forked from unom/punktfunk
Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96f75f4e52 | ||
|
|
4c5b97cfe4 | ||
|
|
4beee17953 | ||
|
|
4ad0055416 | ||
|
|
db9cd40079 | ||
|
|
c63e8cee39 | ||
|
|
b670b5d844 | ||
|
|
064ea3de7d | ||
|
|
ec278c0478 | ||
|
|
5d91176500 | ||
|
|
551d0c3294 | ||
|
|
7f77fa68af | ||
|
|
ece8b16a78 | ||
|
|
2b91339cb8 | ||
|
|
ffa4577793 | ||
|
|
4a32c8fb36 | ||
|
|
9bb8d84f12 | ||
|
|
f42aca690f | ||
|
|
34a02fdac5 | ||
|
|
8670b412c7 | ||
|
|
539ac2f2a5 | ||
|
|
f5a75d9edc | ||
|
|
430499bdab | ||
|
|
92578803c2 | ||
|
|
ca2ff7093a | ||
|
|
a2dc011200 | ||
|
|
48eeae7527 | ||
|
|
0df4ca957f | ||
|
|
13aa11355e | ||
|
|
e989d7457f | ||
|
|
b05bb1dd48 | ||
|
|
2898f6b049 | ||
|
|
4eb4e3465b | ||
|
|
44cd5bfd81 | ||
|
|
8977228a4b | ||
|
|
6a82a602a1 | ||
|
|
fc3b2d0328 | ||
|
|
1c1fd7d9bc | ||
|
|
1c60e641b3 | ||
|
|
eda4b7ebd2 | ||
|
|
730ac43169 | ||
|
|
c9a76287d8 | ||
|
|
cbd3d02817 | ||
|
|
2be444b329 | ||
|
|
669a1bc0ce | ||
|
|
8ff6fe6093 | ||
|
|
1758266bda | ||
|
|
d5fb1e4479 | ||
|
|
ea3c9e1202 | ||
|
|
685c4bd99a | ||
|
|
0519b057d5 | ||
|
|
33b029695f | ||
|
|
1f6f01cb76 | ||
|
|
fade2f7af3 | ||
|
|
6e4cc335c5 | ||
|
|
d9662c010d | ||
|
|
2b0913cf53 | ||
|
|
1280f697be | ||
|
|
3b39710a5a | ||
|
|
19243c30b4 | ||
|
|
dfcffcdd50 | ||
|
|
f2b5b3e567 | ||
|
|
6d7e6f71c0 | ||
|
|
9e3fba10c1 | ||
|
|
fd4f032d20 | ||
|
|
892e683f0e | ||
|
|
7af6c323d0 | ||
|
|
76e6618b84 | ||
|
|
7d2a8778d1 | ||
|
|
6007bc42cd | ||
|
|
16d54b73a1 | ||
|
|
d60b1dda29 | ||
|
|
d7fa5847f1 | ||
|
|
e473a4be7b | ||
|
|
675030935a | ||
|
|
21d9190324 | ||
|
|
7ae8866a5c | ||
|
|
a8099e0f5b | ||
|
|
b4b24f8b57 | ||
|
|
c23fc84bef | ||
|
|
0e5a059098 | ||
|
|
674b16d8eb | ||
|
|
d801cb72f2 | ||
|
|
b03acc9153 | ||
|
|
ace01f06a2 | ||
|
|
e4ec4cec31 | ||
|
|
245173a731 | ||
|
|
230d253b06 | ||
|
|
def215ae8e | ||
|
|
dfcc530ee7 | ||
|
|
6b5307618f | ||
|
|
cdacd5636e | ||
|
|
47f01149bf | ||
|
|
20568d988f | ||
|
|
2b81bd286f |
@@ -248,7 +248,9 @@ jobs:
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: The console must exist (cache hit or fresh build)
|
||||
|
||||
+15
-3
@@ -339,8 +339,19 @@ jobs:
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# RETRIED, like every other single-shot network call in CI (scripts/ci/retry.sh's header
|
||||
# has the why: this box runs many jobs in parallel and drops packets under that load).
|
||||
# `bun install` streams download-and-extract, so a tarball truncated mid-stream surfaces
|
||||
# as `error: Fail extracting tarball for "<pkg>"` — which reads like a corrupt package and
|
||||
# is not one. Measured 2026-08-20: run 19630's docs-site died that way on
|
||||
# @rolldown/binding-linux-x64-gnu (8.3 MB) while the web job installed the same registry
|
||||
# in the same run, and run 19632 installed the identical lockfile seven minutes later. The
|
||||
# tarball's sha512 matches the lockfile and both bun 1.3.13 and 1.3.14 extract it from
|
||||
# disk, so there was never anything wrong with the package. 3 attempts (10s+20s backoff),
|
||||
# not retry.sh's usual 5: a genuinely stale lockfile fails deterministically here, and
|
||||
# 30s is enough to ride out a load burst without making that wait a minute and a half.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
# Build first: it generates the orval API client + paraglide messages that
|
||||
# typechecking imports.
|
||||
- name: Build
|
||||
@@ -368,8 +379,9 @@ jobs:
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
|
||||
- uses: actions/checkout@v4
|
||||
# Retried — see the web job above; this is the job the flake was measured on.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
# Build first: fumadocs-mdx emits the .source typegen the typecheck imports.
|
||||
- name: Build
|
||||
run: bun run build
|
||||
@@ -417,7 +429,7 @@ jobs:
|
||||
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
|
||||
# actions/checkout needs all three (see the web job).
|
||||
- name: Install git + node + CA certs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
|
||||
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
|
||||
|
||||
@@ -231,7 +231,9 @@ jobs:
|
||||
# scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job
|
||||
# neither consumes nor commits, whose only effect here is to make the install depend on
|
||||
# bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless.
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
|
||||
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
|
||||
|
||||
@@ -50,7 +50,10 @@ on:
|
||||
- 'crates/pf-vaadec/**'
|
||||
- 'packaging/flatpak/**'
|
||||
- 'Cargo.lock'
|
||||
# Both halves of this job's correctness, not of the bundle's content: a change to either
|
||||
# can only be proven by a real run, and there is no other trigger that would give it one.
|
||||
- '.gitea/workflows/flatpak.yml'
|
||||
- 'scripts/ci/flatpak-deps-present.sh'
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -270,12 +273,13 @@ jobs:
|
||||
|
||||
- name: Prefetch deps + sources (retried — the network phase, split off the build)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# All of the job's heavy network I/O happens HERE, retried, so a dropped DNS lookup
|
||||
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
|
||||
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
|
||||
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
|
||||
# extensions. (No codec extension: the client links no FFmpeg — see the
|
||||
# manifest header.)
|
||||
# 1) the Flathub deps the manifest declares — the GNOME 50 runtime/SDK + the
|
||||
# rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions — but ONLY the ones
|
||||
# genuinely MISSING; see the block below. (No codec extension: the client links no
|
||||
# FFmpeg — see the manifest header.)
|
||||
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
|
||||
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
|
||||
# after a partial failure is safe and cheap.
|
||||
@@ -288,9 +292,40 @@ jobs:
|
||||
# for the mechanism.
|
||||
# 10 attempts (~9min budget), matching the remote-add bootstrap above — same shared,
|
||||
# load-sensitive runner, same flathub.org resolution path.
|
||||
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--install-deps-from=flathub --install-deps-only \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
#
|
||||
# WHY THIS IS NOT AN UNCONDITIONAL `--install-deps-only` ANY MORE (2026-08-22):
|
||||
# that flag does not install what is missing, it UPDATES what is present.
|
||||
# builder_manifest_install_dep() branches on `flatpak info --show-commit <ref>` succeeding
|
||||
# and runs `flatpak update` for every dep already installed — with no fallback to a
|
||||
# plain install when that update fails — and ci/flatpak-ci.Dockerfile bakes
|
||||
# the entire runtime set, so on a healthy run it was a pure no-op that nonetheless made
|
||||
# every build depend on Flathub being healthy at that minute. It bit on 2026-08-22:
|
||||
# Updating runtime/org.freedesktop.Sdk.Extension.rust-stable/x86_64/25.08
|
||||
# Error: Failed to update org.freedesktop.Sdk.Extension.rust-stable: While pulling …
|
||||
# .filez: Server returned HTTP 404
|
||||
# dl.flathub.org served a 404 for one object of the then-current rust-stable//25.08
|
||||
# commit, deterministically — all 10 retry.sh attempts died on the SAME object over
|
||||
# ~9 min — and flatpak-builder SEGFAULTED on its own error path (rc=139), so retry.sh
|
||||
# saw a crash rather than a clean "this will never work" either. The build never wanted
|
||||
# that newer commit: the manifest pins a runtime VERSION, not a commit, and the baked
|
||||
# one satisfies it. Updating bought nothing and imported an upstream outage.
|
||||
#
|
||||
# So: assert what the image already has, and reach for Flathub only on a real miss —
|
||||
# the same "guard, don't install on top of a stale image" doctrine as the Tooling step.
|
||||
# The check lives in scripts/ci/flatpak-deps-present.sh (run its --self-test after
|
||||
# touching it): a bug in it that reports "satisfied" when it is not would build against
|
||||
# whatever runtime happened to be lying around, which is worth more than an inline
|
||||
# if-statement. It deliberately fails OPEN — anything it cannot parse takes the slow
|
||||
# install path below.
|
||||
if bash scripts/ci/flatpak-deps-present.sh "$MANIFEST"; then
|
||||
echo "deps satisfied by the baked image — not touching Flathub"
|
||||
flatpak list --user --columns=ref
|
||||
else
|
||||
echo "::warning::$MANIFEST declares deps punktfunk-flatpak-ci does not have — pulling from Flathub (~1.5 GB). Bump GNOME_VERSION/FREEDESKTOP_VERSION in ci/flatpak-ci.Dockerfile so this stays off the hot path."
|
||||
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--install-deps-from=flathub --install-deps-only \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
fi
|
||||
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--download-only --disable-updates \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
@@ -298,7 +333,17 @@ jobs:
|
||||
- name: Build the flatpak (offline — deps + sources prefetched above)
|
||||
run: |
|
||||
# Everything is already local (state dir warmed by the prefetch step), so this long
|
||||
# step needs no network; --install-deps-from stays as a no-op safety net.
|
||||
# step needs no network.
|
||||
#
|
||||
# --install-deps-from=flathub USED to sit here, commented as "a no-op safety net". It
|
||||
# was neither. builder-main.c calls builder_manifest_install_deps() whenever that flag
|
||||
# is set — --install-deps-only only decides whether it EXITS afterwards — so this step
|
||||
# re-ran the same `flatpak update` of the runtimes that killed the prefetch step on
|
||||
# 2026-08-22 (Flathub HTTP 404 on a rust-stable//25.08 object; see there). A live pull
|
||||
# of multi-GB runtimes is a strange thing to call a safety net in the step whose whole
|
||||
# design is to be offline, and it could only ever fire if the prefetch step above had
|
||||
# already failed the job. Dropped: the prefetch step is the one place that talks to
|
||||
# Flathub, and it is the one place with retries.
|
||||
#
|
||||
# --disable-updates is LOAD-BEARING, not tidiness: without it this step was never
|
||||
# actually offline. flatpak-builder runs the DOWNLOAD PHASE again as part of every
|
||||
@@ -326,7 +371,6 @@ jobs:
|
||||
flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--default-branch="$FLATPAK_BRANCH" \
|
||||
--disable-updates \
|
||||
--install-deps-from=flathub \
|
||||
--repo="$PWD/repo" \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
|
||||
|
||||
@@ -61,3 +61,9 @@ jobs:
|
||||
punktfunk-host detect-conflicts
|
||||
- name: Re-running is a no-op install
|
||||
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
|
||||
- name: --uninstall takes the packages and the repo off again
|
||||
run: |
|
||||
sh scripts/install.sh --yes --uninstall
|
||||
! command -v punktfunk-host
|
||||
! test -e /etc/apt/sources.list.d/punktfunk.list -o -e /etc/yum.repos.d/punktfunk.repo
|
||||
! grep -q '^\[punktfunk\]' /etc/pacman.conf 2>/dev/null
|
||||
|
||||
@@ -111,3 +111,20 @@ jobs:
|
||||
name: punktfunk-linux-client-screenshots
|
||||
path: clients/linux/screenshots
|
||||
retention-days: 30
|
||||
|
||||
# The artifact above is browser-only (Gitea's API doesn't serve v3 artifacts), which
|
||||
# blocked reusing these shots for the docs. Publish them to the generic package registry
|
||||
# too — fixed version `ci`, delete-then-PUT so each run overwrites, anonymous GET on a
|
||||
# public repo:
|
||||
# https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci/<scene>.png
|
||||
- name: Publish screenshots to the package registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
BASE="https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci"
|
||||
for f in clients/linux/screenshots/*.png; do
|
||||
name=$(basename "$f")
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$name" || true
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" --upload-file "$f" "$BASE/$name"
|
||||
echo "published $BASE/$name"
|
||||
done
|
||||
|
||||
@@ -37,15 +37,18 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Both installs retried: bun's download-and-extract is single-shot, and a truncated tarball
|
||||
# reads as `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job
|
||||
# is the worst place to lose to a dropped packet — the tag is already pushed.
|
||||
- name: Build the SDK (file:../sdk dependency source)
|
||||
working-directory: sdk
|
||||
run: |
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: plugin-kit
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
|
||||
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
|
||||
|
||||
@@ -176,7 +176,9 @@ jobs:
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
|
||||
|
||||
@@ -39,8 +39,11 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job is the
|
||||
# worst place to lose to a dropped packet — the tag is already pushed.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
@@ -40,8 +40,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
# --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen
|
||||
# explicitly since build-storybook has no prebuild hook of its own.
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
- name: Generate API client + i18n messages
|
||||
run: bun run codegen
|
||||
# Pulls the matching Chromium build + the apt libs it needs (root in-container).
|
||||
|
||||
@@ -56,8 +56,12 @@
|
||||
#
|
||||
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
|
||||
#
|
||||
# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
|
||||
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
|
||||
# Publishes THREE artifacts per arch (x64 + ARM64) to Gitea's generic package registry, all packed
|
||||
# from one assembled layout:
|
||||
# punktfunk-client-setup_<arch>.exe — Inno Setup per-user installer, the DEFAULT download
|
||||
# (stable path Steam can launch: overlay + Big Picture work)
|
||||
# punktfunk-client-windows_<arch>-portable.zip — the same file set, no installer
|
||||
# punktfunk-client-windows_<arch>.msix — kept for Microsoft Store compatibility
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
@@ -283,6 +287,28 @@ jobs:
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
|
||||
|
||||
# The DEFAULT download: a per-user Inno Setup exe + a portable zip, packed from the layout
|
||||
# the MSIX step just assembled. The MSIX shape (WindowsApps ACLs, alias-only activation)
|
||||
# breaks Steam's non-Steam-game picker, the Steam overlay injection and Big Picture launch;
|
||||
# the installer's stable %LOCALAPPDATA%\Programs\Punktfunk path is the fix. The MSIX stays
|
||||
# published for Microsoft Store compatibility. Same signing env as the MSIX step above.
|
||||
- name: Pack + sign installer + portable zip
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
env:
|
||||
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
|
||||
AZURE_CODESIGNING_ACCOUNT: unomsigning
|
||||
AZURE_CODESIGNING_PROFILE: unom-io
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
& clients/windows/packaging/pack-client-installer.ps1 `
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-LayoutDir ${{ matrix.td }}\msix\layout -OutDir ${{ matrix.td }}\installer
|
||||
|
||||
- name: Publish to Gitea generic registry
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
@@ -301,7 +327,10 @@ jobs:
|
||||
# on that accident, so removing the quotes can't silently reintroduce it.
|
||||
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
|
||||
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
# The installer + portable zip (the default download; docs point at these alias URLs).
|
||||
if ($env:CLIENT_SETUP_PATH) { $aliasNames[$env:CLIENT_SETUP_PATH] = "punktfunk-client-setup_${{ matrix.arch }}.exe" }
|
||||
if ($env:CLIENT_ZIP_PATH) { $aliasNames[$env:CLIENT_ZIP_PATH] = "$($env:PKG)_${{ matrix.arch }}-portable.zip" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH, $env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||
@@ -324,10 +353,11 @@ jobs:
|
||||
Put $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
|
||||
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
|
||||
# the race, and x64/arm64 filenames differ so the assets don't collide.
|
||||
- name: Attach MSIX to the Gitea release (stable tags only)
|
||||
# On a real release, also attach the installer + portable zip + MSIX (+ its .cer) to the
|
||||
# unified Gitea Release. Both arch legs attach to the same release concurrently — the
|
||||
# helper's create-or-fetch handles the race, and x64/arm64 filenames differ so the assets
|
||||
# don't collide.
|
||||
- name: Attach client artifacts to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
@@ -335,6 +365,6 @@ jobs:
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
foreach ($f in @($env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH, $env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
|
||||
+836
-50
@@ -12,18 +12,486 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.31.2
|
||||
|
||||
10 commits since v0.31.1 (6 non-merge), counted at the tip this was cut from.
|
||||
|
||||
**Nothing versioned moves, and this time nothing versioned even changes shape.** `WIRE_VERSION`
|
||||
stays **2**, the C ABI stays **25**, and so do the driver protocol, the gamepad channel, the plugin
|
||||
index schema and the host event schema. `include/punktfunk_core.h` is **byte-identical to the
|
||||
v0.31.1 tag** — unlike the last two releases, which each added a `#define` — and `pf-driver-proto`
|
||||
shows no diff either. No route is added or removed, no `#[repr(C)]` struct moves, and neither
|
||||
`@punktfunk/host` (0.1.5) nor `@punktfunk/plugin-kit` (0.4.4) is re-cut. An embedder can take this
|
||||
release without recompiling anything, and a packager has one thing to notice: the Windows firewall
|
||||
rules below.
|
||||
|
||||
`api/openapi.json` changes in **documentation only** — two `description` strings on `HostInfo`, no
|
||||
route, schema, field or type — plus the `info.version` stamp. That documentation change is
|
||||
load-bearing, though, because it records a behaviour change: `local_ip` is now read per request.
|
||||
|
||||
The release is entirely fix-shaped. Three of the six non-merge commits are the same class of fault —
|
||||
the host using the wrong local address — reached from three directions: the data socket's source
|
||||
address (#367), the advertised address after a cold boot (#366), and the firewall rules that
|
||||
admitted anyone to the ports those addresses point at (#368). The fourth is an Android regression
|
||||
from v0.31.1 (#365); the remaining two are a refactor and a test in support of #366.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.31.1 | v0.31.2 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged. No message added, removed or re-shaped; `DeliveryReport` (`0x0B`) from v0.31.1 is the most recent addition and is untouched |
|
||||
| C ABI | 25 | **25** | unchanged. `include/punktfunk_core.h` has **no diff at all** against the v0.31.1 tag — not even a constant |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.31.1 tag |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged; no file under the gamepad backends is touched by this release |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
|
||||
| `api/openapi.json` | 0.31.1 | **0.31.2** | **description-only**, plus the stamp (`info.version` is `CARGO_PKG_VERSION`). The two `HostInfo` strings that change are quoted under **`Host::local_ip`** below; no route, schema, required-field or type differs. Re-stamped here, not regenerated — `punktfunk-host` does not build on macOS; the document itself was regenerated in #366 on a runner where `openapi_document_is_complete_and_checked_in` executes. `api/` and `docs-site/public/` are byte-identical to each other |
|
||||
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files, and `packaging/gamescope/PKGBUILD` still declares `pfhdr8` after the v0.31.1 correction |
|
||||
| `@punktfunk/host` (SDK) | 0.1.5 | **0.1.5** | unchanged; nothing under `sdk/` moved |
|
||||
| `@punktfunk/plugin-kit` | 0.4.4 | **0.4.4** | unchanged; nothing under `plugin-kit/` moved. 0.4.4 remains the registry's `latest` |
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None.** No wire change, no ABI change, no driver-protocol change, no plugin-contract change, no
|
||||
API-surface change. Every 0.31.x host, client, driver and plugin keeps interoperating in both
|
||||
directions with no re-pairing and no rebuild.
|
||||
|
||||
Two **behaviour** changes that break no build but change what a machine does:
|
||||
|
||||
- **Windows `service install` now scopes every inbound rule to a program.** The five fixed-port
|
||||
rules gain `program=<exe>` while keeping their `localport=`. If you provision firewall rules
|
||||
yourself rather than letting `service install` do it, the equivalent is `program=` on each; if you
|
||||
do nothing, `service install` re-runs on every upgrade and rewrites them for you. **Externally
|
||||
visible:** 5353 is punktfunk's alone now, so anything else on the machine that was reachable on
|
||||
mDNS through punktfunk's any-program rule needs its own rule.
|
||||
- **`HostInfo.local_ip` is no longer static for the life of the process.** It was a field
|
||||
snapshotted at `Host::detect()`; it is a method that re-reads on every request. A consumer that
|
||||
cached it once at startup was caching a value that could be `127.0.0.1` forever (see below) and
|
||||
should poll instead. The two `description` strings in `api/openapi.json` say so.
|
||||
|
||||
### Windows: the fixed-port firewall rules admitted any program on the machine
|
||||
|
||||
`service install` added `dir=in action=allow` rules carrying only `localport=`. A rule of that shape
|
||||
admits **any process** that binds the port — GameStream (47984/47989/47998-48010/48010), the native
|
||||
plane (9777), mgmt (47990), mDNS (5353) and the console pair (47992/47993). Binding a high port on
|
||||
Windows requires no elevation, so an unprivileged program could take any of them and become
|
||||
LAN-reachable simply by binding first, and **silently**: our rule is precisely what suppresses the
|
||||
"Allow this app to communicate on…" prompt that would otherwise be the only way in.
|
||||
|
||||
Every rule is now scoped to the executable that actually listens on it, keeping the ports — program
|
||||
**and** port is strictly tighter than either alone. The host rules name the host exe (resolved once
|
||||
via `current_exe()` and shared with the data-plane rule, which already worked this way and is the
|
||||
pattern the rest now follow); the console rules name the bundled `<app>/bun/bun.exe` the supervisor
|
||||
spawns. `fw_add_rule_args` is the new single constructor for the whole shape.
|
||||
|
||||
The old argument for leaving them unscoped — "an install whose recorded exe path later moves still
|
||||
has its fixed ports open" — does not hold: `service install` re-runs the whole remove-then-add on
|
||||
every upgrade, so the path is refreshed rather than left stale.
|
||||
|
||||
Fallbacks are deliberate and **asymmetric**. A fixed-port rule whose program cannot be resolved
|
||||
falls back to the old any-program form, because a looser rule still streams and no rule is a black
|
||||
screen. The data-plane rule instead **skips**: it has no `localport=` to fall back to, so a
|
||||
program-less version of it would not be a looser rule but an open host. The installer prints the
|
||||
5353 note only when the scoping actually happened — claiming it while the rules are still wide open
|
||||
would be worse than saying nothing.
|
||||
|
||||
Reported by a user on 2026-08-21, immediately after the source-IP fix below cleared their black
|
||||
screen.
|
||||
|
||||
### The data socket binds the address the control plane arrived on
|
||||
|
||||
`bind_data_socket` bound `0.0.0.0:0`, so the kernel chose the video source address from the routing
|
||||
table, **independently of the address the client's control connection actually arrived on**. The
|
||||
client's data socket is `connect`ed to the host IP it dialled, so its kernel drops every datagram
|
||||
from any other source — in the kernel, before userspace, where nothing counts it.
|
||||
|
||||
On a host with two live paths to the client (Ethernet and Wi-Fi both up on the same LAN; a
|
||||
VPN/overlay adapter claiming the route) that is a permanent black screen with every gauge green: the
|
||||
hole-punch still arrives so the host logs `punched=true`, `loss_ppm` stays 0 because there are no
|
||||
packets to see gaps in, and QUIC — which quinn pins to the right local address — carries control,
|
||||
audio and input perfectly. `from_socket_punch` already documented the mirror of this assumption for
|
||||
the *client's* source IP; the host side was never checked.
|
||||
|
||||
The socket now binds `Connection::local_ip()` (unmapping an IPv4-mapped v6 address so it can still
|
||||
`connect` to a v4 peer), falling back to the wildcard **loudly** when that is unavailable.
|
||||
|
||||
Two diagnostics changed with it, because the field session's log could not answer the question:
|
||||
|
||||
- the `data plane bound` line carries the socket's post-`connect` `local=` address — the source the
|
||||
kernel will actually stamp — and WARNs when it differs from the address the control plane arrived
|
||||
on.
|
||||
- the black-screen ERROR no longer asserts "This is a PATH problem, not decode" and no longer names
|
||||
`punched=false` as *the* fingerprint. It fired with `punched=true` in the field, contradicting its
|
||||
own advice and sending an investigation to the firewall. It now branches on what the bring-up line
|
||||
says, and admits its counter is incremented after decrypt and replay checks, so a session whose
|
||||
every datagram failed to open reports the same zero as one that received nothing.
|
||||
|
||||
### `Host::local_ip` re-reads, and mDNS adverts follow it
|
||||
|
||||
`Host::detect()` snapshotted the LAN address once at process start and every consumer read that
|
||||
frozen field forever. On a cold boot the host wins the race against the network — the Windows
|
||||
service is registered `AutoStart` with no dependencies — so `primary_local_ip()`'s route probe to
|
||||
8.8.8.8 failed with `ENETUNREACH` and the loopback fallback stuck for the life of the process.
|
||||
Restarting the host re-ran `detect()` on a live network, which is the workaround users found.
|
||||
|
||||
Four surfaces broke together off that one field: both mDNS adverts (`_punktfunk._udp`,
|
||||
`_nvstream._tcp`) published `127.0.0.1` as their A record; `session_url_xml()` handed Moonlight
|
||||
`rtsp://127.0.0.1:48010` after `/launch`; `wol::wake_macs()` found no interface for loopback and
|
||||
dropped the `mac` TXT record, silently disabling Wake-on-LAN; and `HostInfo.local_ip` reported
|
||||
loopback to the web console.
|
||||
|
||||
Fixed at the choke point rather than per caller:
|
||||
|
||||
- `primary_local_ip()` never returns loopback or the unspecified address. When the route probe
|
||||
fails it falls back to `first_lan_ipv4` — the first non-loopback interface address, which exists
|
||||
as soon as the NIC is configured even if the default route is not installed yet, the common shape
|
||||
of the boot race. It is split out so a test can assert the one thing that matters: it never hands
|
||||
back the loopback `get_if_addrs` also reports.
|
||||
- `Host::local_ip` becomes a method that re-reads instead of a field that freezes. A `connect(2)` on
|
||||
an unconnected UDP socket sends no packets and costs nothing beside the HTTP response it is
|
||||
serialized into.
|
||||
- mDNS records are **pushed, not polled**: a live advert re-registers when the routed address
|
||||
changes (`discovery::advertise_live`, shared by both service types). It polls the routed address
|
||||
rather than subscribing to the daemon's `IpAdd` events because the boot race usually resolves
|
||||
without one — the NIC often has its address before we register, and only the route lands late.
|
||||
|
||||
This also covers the sibling cases that were never reported: DHCP handing out a different lease, and
|
||||
a host moved between Wi-Fi and Ethernet.
|
||||
|
||||
The re-announce loop's stop signal is now the `mpsc` channel it already sleeps on, rather than an
|
||||
`Arc<AtomicBool>` plus a `Drop` impl: the `Advert` dropping its sender wakes the thread immediately
|
||||
instead of leaving it to notice a flag up to `IP_RECHECK` (10 s) later.
|
||||
|
||||
### Android: the button correction is gated on named triggers, not declared keys
|
||||
|
||||
v0.31.1 corrected button positions for pads Android has no key layout for, and gated it on
|
||||
`hasKeys(BUTTON_C, BUTTON_Z)`. That gate answers for what a device **declares**, not what it
|
||||
reports: `hid-input` allocates `BTN_A + n` straight through for every button in the descriptor, so
|
||||
`BTN_C` (`0x132`) and `BTN_Z` (`0x135`) are set on **any** pad declaring six or more buttons —
|
||||
including a standard-layout pad that never presses either. The signal was therefore identical on the
|
||||
pad that needs correcting and the pad that does not, and no tightening of it could have separated
|
||||
them.
|
||||
|
||||
Two field reports on 2026-08-21 (a GameSir G8+ and an "Xbox Wireless Controller" over Bluetooth) had
|
||||
X answering Y, Y answering LB, and both shoulders answering menu buttons — exactly what
|
||||
`GENERIC_XBOX` does to scancodes `0x133`/`0x134`/`0x136`/`0x137`. It is the same pad model on both
|
||||
sides of the bug: an Elite Series 2 needed the correction on a Fire TV, and another was broken by it
|
||||
here.
|
||||
|
||||
What separates them is the **axes**. A HID gamepad describes its triggers either as the
|
||||
Accelerator/Brake usages — which become `ABS_GAS`/`ABS_BRAKE`, names Android has words for — or as
|
||||
two generic axes on `ABS_Z`/`ABS_RZ`, which it does not. A descriptor well-formed enough to name its
|
||||
triggers puts its buttons at the standard positions too. It is also the firmware line on the pad in
|
||||
the report: an Xbox Wireless Controller reports GAS/BRAKE after its firmware update and Z/Rz before
|
||||
it, and only the older one was ever wrong.
|
||||
|
||||
`padButtons` now takes `namedTriggers` and answers `NATIVE` whenever it is set — no correction of
|
||||
any kind, on buttons or axes, for a pad Android already reads. `padMap` computed that fact one line
|
||||
below and only ever spent it on the axes; it now decides both. `hasKeys` stays for the narrower
|
||||
question it can answer — *which* straight-through order, once the axes have established there is
|
||||
one — where a false positive costs nothing. This is the same discriminator Moonlight uses
|
||||
(`ControllerHandler`, `gasRange == null` beside the `"Xbox Wireless Controller"` name); v0.31.1
|
||||
cited its tables and then replaced its discriminator, which is where this came in.
|
||||
|
||||
`PadButtonsTest` is at 16 cases, 3 new: the gate holds for every vendor/declaration combination, the
|
||||
four reported buttons stay themselves, and the report-order choice past the gate is unchanged.
|
||||
|
||||
**Not fixed here:** a DualSense report filed alongside these, with Triangle dead in both the client
|
||||
UI and the stream. A button reaching neither is one `buttonBit` maps to nothing, which no branch of
|
||||
the correction produces for Triangle.
|
||||
|
||||
---
|
||||
|
||||
## v0.31.1
|
||||
|
||||
30 commits since v0.31.0 (19 non-merge), counted at the tip this was cut from.
|
||||
|
||||
**No versioned surface moves.** `WIRE_VERSION` stays **2**, the C ABI stays **25**, and so do the
|
||||
driver protocol, the gamepad channel, the plugin index schema and the host event schema. No C
|
||||
function changed its signature, no `#[repr(C)]` struct grew a field, and `include/punktfunk_core.h`
|
||||
gains exactly one line: a `#define` for a new control-message type byte. An embedder rebuilds
|
||||
against the new header and is done; a packager has one thing to notice (the Windows firewall rule
|
||||
below) and one thing to be glad of (the Arch compositor package finally declares the level it
|
||||
builds).
|
||||
|
||||
Two surfaces grow, both by pure addition: the **management API** gains
|
||||
`PUT /library/provider/{provider}/running` and its three schemas, and **`@punktfunk/plugin-kit`
|
||||
goes 0.4.3 → 0.4.4** to export the client call for it. A plugin that adopts neither is unaffected,
|
||||
and an older host answers the route with a 404 that means "this host tracks games by scanning".
|
||||
|
||||
One control message is **added** to the wire — `DeliveryReport`, type byte **`0x0B`** — which is a
|
||||
`#define`, not an ABI step, exactly as `PipelineGap` (`0x0A`) was at v0.30.0. It takes a free byte
|
||||
in its block rather than lengthening an existing message, deliberately: see below.
|
||||
|
||||
Two behaviour changes are worth reading before you package or embed this release: on Windows,
|
||||
`service install` now provisions a **program-scoped inbound UDP rule** for the host executable, and
|
||||
the **GameStream plane's default virtual-Xbox backend flips from the XUSB companion to the UMDF HID
|
||||
pad**, matching what the native plane has done since 2026-08-09.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.31.0 | v0.31.1 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged. One additive control message, `DeliveryReport` (`0x0B`), which an older peer does not parse and does not need — see **The wire** below |
|
||||
| C ABI | 25 | **25** | unchanged. `include/punktfunk_core.h` differs from the v0.31.0 tag by one `#define` (`PUNKTFUNK_MSG_DELIVERY_REPORT = 11`, under `PUNKTFUNK_FEATURE_QUIC`), which is a constant, not a declaration. Rust-only addition in `punktfunk-core`: `client::NO_VIDEO_RETRY` is now public beside `client::FLUSH_COOLDOWN` |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.31.0 tag |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged. What changed is which *backend* the GameStream plane picks, not the channel — see **Windows: the GameStream plane builds the pad games can see** |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
|
||||
| `api/openapi.json` | 0.31.0 | **0.31.1** | one route **added** — `PUT /library/provider/{provider}/running` plus its three schemas — and the stamp moved with the crate (`info.version` is `CARGO_PKG_VERSION`). Regenerated in #361 and re-stamped here; nothing else in the document differs. `api/` and `docs-site/public/` are byte-identical to each other |
|
||||
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files. ⚠ **`packaging/gamescope/PKGBUILD` is fixed here**: it declared `pfhdr7` while patch 0010 stamps `+pfhdr8` into the banner, so pacman saw no upgrade at all — see below |
|
||||
| `@punktfunk/host` (SDK) | 0.1.5 | **0.1.5** | unchanged; nothing under `sdk/` moved |
|
||||
| `@punktfunk/plugin-kit` | 0.4.3 | **0.4.4** | cut, for `ProviderClient.reportRunning` and its two types: they were reachable only through the deep `./reconcile.js` path, because `index.ts` re-exports an explicit list rather than a star, so no plugin could import them from the package root. Tagged `plugin-kit-v0.4.4` and **published** — the registry's `latest` (0.4.2 is still skipped there, as it has been since v0.30.0). The playnite plugin deliberately does *not* depend on it, calling the route through the untyped host seam so it was never gated on this publish |
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None.** No wire change, no ABI change, no driver-protocol change, no plugin-contract change. Every
|
||||
0.31.0 host, client, driver and plugin keeps interoperating in both directions with no re-pairing.
|
||||
|
||||
Two **behaviour** changes that break no build but change what a machine does:
|
||||
|
||||
- **Windows `service install` adds a second firewall rule.** `Punktfunk UDP (data plane)` —
|
||||
`dir=in action=allow protocol=UDP program=<host exe>`, on the same profile set the port rules use.
|
||||
`service uninstall` deletes it by name. If you provision firewall rules yourself instead of
|
||||
letting `service install` do it, you need the equivalent, or your hosts keep the black-picture
|
||||
failure below. Program-scoped rather than port-scoped by design: the data plane binds `0.0.0.0:0`,
|
||||
and a pinned port inside 47998-48010 would collide with Sunshine/Apollo.
|
||||
- **`PUNKTFUNK_XBOX_BACKEND` now governs both planes on Windows, and the GameStream plane's default
|
||||
moves to the HID pad.** `PUNKTFUNK_XBOX_BACKEND=xusb` reverts both planes together; it previously
|
||||
reverted only the native one, because `windows_xbox_hid` was `pub(super)` and unreachable from
|
||||
`gamestream/control.rs`. It is `pub(crate)` now, with one definition and one name.
|
||||
|
||||
### A provider plugin can report which of its titles are running
|
||||
|
||||
The host derives liveness by **scanning**, which needs something recognizable on disk. A
|
||||
Playnite-launched emulated game, a manually added one, or a library plugin that records no install
|
||||
directory has none — and its launch is a `playnite://` hand-off, so the host holds no process
|
||||
either. The lease went `Untracked`: the exit was never noticed, `session_on_game_exit` could not
|
||||
fire, and `POST /game/end` had nothing to aim at.
|
||||
|
||||
**`PUT /library/provider/{provider}/running`** takes a provider's *complete* running set (with the
|
||||
pid where it knows one) — declarative and idempotent like the reconcile beside it, so a missed event
|
||||
or a plugin restart self-corrects rather than drifting. `crate::runstate` holds it and **expires it
|
||||
after 90 s unless restated**, which is what makes it safe for a live provider to hold a session open
|
||||
for a game the host cannot see: a plugin that dies stops counting and the host falls back to
|
||||
scanning. The route is the plugin lane's, like the reconcile, and carries **no new authority** — the
|
||||
host maps `external_id` through the catalog, so a provider can only speak about entries it
|
||||
published. An unknown id is *counted, not refused*, because a report legitimately races its own
|
||||
reconcile and 400-ing the batch would discard the liveness of every other running title.
|
||||
|
||||
**`LeaseKind::Reported`** is the lease that follows. `open` reaches it when the spec is empty and a
|
||||
provider speaks for the id, and — load-bearing on Windows, where every launch is a hand-off by
|
||||
construction — the three shim reclassification paths now fall back to it where they fell to
|
||||
`Untracked`. Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the exit.
|
||||
Unlike `procscan::running_hint`, which may only ever *delay* an exit (Steam's registry flag survives
|
||||
an unclean one), a fresh report is decisive in both directions. A reported pid joins the termination
|
||||
ladders on the same terms as a spawned one: re-resolved and start-time-pinned at the moment of use.
|
||||
|
||||
Client side, `ProviderClient.reportRunning` is exported from the plugin-kit root in 0.4.4 (see the
|
||||
table). A **404 from an older host means "this host tracks games by scanning"** — it is not an error
|
||||
a plugin should retry.
|
||||
|
||||
### The wire: `DeliveryReport` (`0x0B`)
|
||||
|
||||
`LossReport` carries `loss_ppm`, which is a ratio over the packets that **arrived** — so a flawless
|
||||
link and a link delivering nothing both report `0`. A host reading total silence as perfection
|
||||
decayed adaptive FEC to its floor and logged confident wording about the client's network.
|
||||
|
||||
Clients now also send `DeliveryReport`, carrying the session's received-packet count. It is a **new
|
||||
type byte, not a field appended to `LossReport`**: that message is length-checked exactly, so
|
||||
lengthening it would make every shipped host reject the loss reports its FEC runs on. Send policy is
|
||||
deliberately sparse — every window while the count is zero, once when the first packets land, then
|
||||
never — because an older host warns per unknown message type and must not be flooded across a good
|
||||
session.
|
||||
|
||||
`client::NO_VIDEO_RETRY` (the client got nothing) and `client::FLUSH_COOLDOWN` (the client is
|
||||
drowning) were both 2000 ms, so the host's cadence classifier could not tell two opposite faults
|
||||
apart and named the wrong one out loud. `NO_VIDEO_RETRY` moves into `punktfunk-core` beside
|
||||
`FLUSH_COOLDOWN` at **2600 ms**, and both sides now compare against the shared constant rather than
|
||||
against a local copy.
|
||||
|
||||
### Windows: the data plane was never open, on any host
|
||||
|
||||
The firewall rules `service install` writes are `localport=`-scoped (47998-48010, 9777, 5353), and
|
||||
the media data plane binds an **ephemeral** port per session. No such rule can cover it, so Windows
|
||||
Firewall dropped the client's hole-punch on **every session on every Windows host** — `punched=false`
|
||||
on the "data plane bound" line, in all six sessions across two field logs, including sessions that
|
||||
appeared to work. Video then fell back to blind-sending at the address the client *reported*; where
|
||||
the path needed the flow opened client-first, the control plane stayed healthy and the picture never
|
||||
arrived. One field host sent 1,919 frames into a black screen while blaming the client.
|
||||
|
||||
Diagnosis changed with it: it now leads with the delivery count (zero is an **error** naming the data
|
||||
plane; a confirmed count keeps the old confident wording; a client that cannot answer gets a warning
|
||||
that says so), and a punch that never arrives is its own warning rather than a debug field on an info
|
||||
line.
|
||||
|
||||
### Windows: the GameStream plane builds the pad games can see
|
||||
|
||||
There are two virtual Xbox backends on Windows and they are not interchangeable to a game. The XUSB
|
||||
companion registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID collection (`pf_xusb.inx`:
|
||||
"a non-HID UMDF2 driver", `Class = System`), so Steam's hidapi enumeration, SDL, RawInput,
|
||||
DirectInput, `joy.cpl` and WGI/GameInput cannot see it at all — only classic `XInputGetState` can.
|
||||
The native plane moved to the real HID pad as its default in `bd5735b8` for exactly that reason.
|
||||
|
||||
`gamestream/control.rs` had bound `crate::inject::gamepad` since the first gamepad commit, when that
|
||||
name meant uinput and Windows had no second backend; Windows later gave the same name the XUSB
|
||||
companion, so this plane inherited it by module-name coincidence rather than by decision. Every
|
||||
Moonlight-compatible session since has presented a pad most games cannot enumerate. A `SessionPads`
|
||||
enum is now the one place this plane picks a backend, reading the same knob the native plane reads.
|
||||
The HID pad's rich-feedback plane is dropped rather than plumbed: an Xbox pad has no lightbar or
|
||||
adaptive triggers, and GameStream's rumble message (`0x010B`) carries the two handle motors only.
|
||||
|
||||
### Android: buttons resolved from the scancode
|
||||
|
||||
Android names a pad's buttons through a **key layout file** matched on VID/PID; a pad with no
|
||||
matching file falls back to AOSP's `Generic.kl`, which assigns keycodes by **scancode position**
|
||||
(`0x130`→`BUTTON_A`, `0x131`→`BUTTON_B`, …). A HID gamepad with no kernel driver numbers its buttons
|
||||
`1..n` in its own report order, so every keycode past the first divergence is somebody else's button.
|
||||
AOSP ships no layout for the Elite Series 2 over Bluetooth (`045e:0b05`) on any version, and the
|
||||
DualSense's (`054c:0ce6`) postdates Fire OS and requires `CONFIG_HID_PLAYSTATION`, which a Fire TV
|
||||
kernel has not.
|
||||
|
||||
`Gamepad.padKeyCode(event)` is a drop-in for `event.keyCode` and **every** pad reader now goes
|
||||
through it — the streaming branch, the Skia console shell's probe, the older Compose navigation, and
|
||||
the Controllers tester. Two guards keep it off pads that already work: the correction applies only
|
||||
where the delivered keycode is what `Generic.kl` would have said, and which report order to read is
|
||||
decided from what the device *declares* (a pad numbering straight through claims `BUTTON_C` and
|
||||
`BUTTON_Z`, keycodes no real controller has a button for) rather than from a model table. Axes get
|
||||
the same treatment, with trigger rest position measured from the device's own range instead of
|
||||
assumed. The Xbox Bluetooth product ids (One S, Elite Series 2 and its Core) join the identity table.
|
||||
|
||||
Also here: `pads()` filters on `looksLikeController` (the source claim **and** hardware behind it)
|
||||
rather than on `isPad`, which kept the console UI pinned on for any device merely claiming the
|
||||
gamepad source class; and the `ASurfaceControl` layer's destination rect is now read per-present from
|
||||
a packed atomic on the session handle (new JNI symbol `nativeVideoSurfaceSize`, fed from every
|
||||
`surfaceChanged`) rather than captured once at `surfaceCreated`, which is why the picture sat at the
|
||||
origin once the bars and cutout grew the view.
|
||||
|
||||
### gamescope and the takeover
|
||||
|
||||
- **`packaging/gamescope/PKGBUILD` moves `pfhdr7` → `pfhdr8`.** The banner has said `+pfhdr8` since
|
||||
patch 0010 (the seat's stub keyboard carrying the compiled `XKB_DEFAULT_*` keymap), and the host
|
||||
probes the banner for `>= 8` on the keymap path — but pacman compares `pkgver-pkgrel`, read
|
||||
`3.16.25.pfhdr7-1` on both v0.30.0 and v0.31.0, and **offered no upgrade at all**. deb and rpm
|
||||
derive their version from the binary banner and moved by themselves; Arch is the only channel that
|
||||
hardcodes it. This is the mismatch the v0.31.0 table flagged as pre-existing.
|
||||
- **The in-stream session-select gate is armed again.** v0.31.0's takeover stopped stopping the
|
||||
display manager and started idling the autologin session (`c2f5e91b`), which also deleted the two
|
||||
lines the old path carried (`record_session_select_baseline()`, `STOPPED_DM = Some(dm)`);
|
||||
`38a0f54b` then removed every remaining writer, leaving `honor_session_select_switch` unreachable.
|
||||
Bazzite/SteamOS never noticed — their `os-session-select` writes no sentinel and
|
||||
`is_steam_htpc_platform()` defaults the mid-stream watcher on. `ID=nobara` matches no HTPC default
|
||||
and its ChimeraOS-layout `os-session-select` **does** write the sentinel, so on Nobara a mid-stream
|
||||
"Switch to Desktop" went entirely unhandled. `takeover_idled()` now reads `IDLE_DROPIN_ARMED`, the
|
||||
idle drop-in re-baselines the sentinel, and `STOPPED_DM` is documented as adoption-only state for a
|
||||
takeover stranded by a pre-0.31.0 host. Both hand-back paths also restore the box's own Game Mode
|
||||
unit, which neither did — a mid-stream switch is not a disconnect, so the disconnect sweep never
|
||||
reached the `ExecStart=/usr/bin/sleep infinity` drop-in.
|
||||
- **Nix shipped a wrapper with no target.** nixpkgs wraps this package: the real ELF is
|
||||
`bin/.gamescope-wrapped` and `bin/gamescope` is a makeWrapper launcher. The prune
|
||||
(`find $out/bin -mindepth 1 ! -name gamescope -delete`) deleted the compositor and kept the
|
||||
launcher — measured at 16 KB. That single line explains the empty `--version` output and the
|
||||
"missing `+pfhdr` marker", both of which had been attributed to the build sandbox and to upstream.
|
||||
The prune keeps the target now and the guard asserts on the **wrapped ELF**. Separately,
|
||||
`packaging/nix/gamescope.nix` now pins `src` to `5fb8dce4` like every other channel — it was the
|
||||
only one patching whatever version nixpkgs happened to carry, which broke `host.gamescopeHdr`
|
||||
(default true) builds outright when nixpkgs shipped 3.16.24.
|
||||
|
||||
### Everything else an integrator might notice
|
||||
|
||||
- **`pf-console-ui`:** `ConsoleOptions.fallback_ui` (new, threaded to `Ctx` beside `deck`) gates the
|
||||
Android-only "Controller-optimized UI" row, written through `extra` under
|
||||
`android.gamepad_ui_enabled`; it is true only for the Android touch shell. Down on the carousel
|
||||
opens Settings (`▼` is a new hint glyph — the `▲` triangle inverted, not a second draw routine),
|
||||
and the host options menu gains a Library row on the same terms `Y` offers it (saved **and**
|
||||
paired), replacing the menu rather than stacking on it. Both exist because a TV remote emits only
|
||||
Move/Confirm/Back.
|
||||
- **`scripts/ci/docs-undocumented-env-baseline.txt`** gains `PUNKTFUNK_MSG_DELIVERY_REPORT`.
|
||||
`check-docs-drift.sh` scans for `PUNKTFUNK_*` identifiers and cannot tell an operator knob from a
|
||||
cbindgen-exported `#define`; every other `PUNKTFUNK_MSG_*` is already baselined beside it.
|
||||
- **`clients/probe`** reads the new delivery counter.
|
||||
- **`trust::Settings::extra` is `#[serde(flatten)]`**, so `android.*` keys are **top-level** keys of
|
||||
the settings document, beside `width` and `codec`. `ConsoleJson` wrote and read them nested under
|
||||
an `"extra"` object, which serde stored under the literal key `"extra"` — so no console row ever
|
||||
found `android.gamepad_ui_enabled`, every Android-only row (low latency, phone rumble/gyro, SC2 and
|
||||
DualSense capture, the console-UI mode picker) read its own default, and the value the console
|
||||
saved came back to Kotlin unchanged, so `applySettings` raised no callback. Fixed in #362, which is
|
||||
what makes the `feat` above work at all. A store written by the nesting build carries the dead
|
||||
wrapper and drops it on the next write. The new test pins the shape from **both sides**: a
|
||||
round-trip alone could not catch this, because both halves agreed on the same wrong nesting.
|
||||
- **`pf-console-ui` focus halo / `panel_highlight` radii.** A rounded rect grown by `d` keeps its
|
||||
corners concentric with the original only if its radius grows by `d` too; both helpers kept the
|
||||
card's own radius, so the halo read as a squared-off outline at the four corners. `drop_shadow`
|
||||
only offsets and was already right; the collections plate uses `RRect::with_outset`, which adjusts
|
||||
radii itself. Every card path goes through the two fixed helpers.
|
||||
|
||||
### Verification status
|
||||
|
||||
Gates run on the release tree (this MacBook, rustc/rustfmt per `rust-toolchain.toml`):
|
||||
`cargo fmt --all --check` clean; `cargo metadata --offline` ok with the `Cargo.lock` diff
|
||||
versions-only (36/36 lines); `cargo test -p punktfunk-core --lib` **273 passed**; the C ABI harness
|
||||
(`tests/c_abi.rs`) **passed**, reporting `abi_version=25` and four frames round-tripped byte-exact
|
||||
through lossy loopback — it did **not** run on the v0.31.0 cut, so this is the first cut since ABI 25
|
||||
where a C compiler has actually built the generated header; `scripts/ci/check-docs-drift.sh` clean;
|
||||
the android.yml Play notes gate run verbatim — 442/500 characters and not byte-identical to any prior
|
||||
release's; both openapi copies `cmp` identical, both stamped 0.31.1; notes voice scan clean.
|
||||
|
||||
⚠ **`api/openapi.json` was re-stamped here, not regenerated.** The document itself was regenerated
|
||||
in #361 (with the new route and its three schemas) on a runner where
|
||||
`openapi_document_is_complete_and_checked_in` actually executes; this commit moves only
|
||||
`info.version`, which utoipa fills from `CARGO_PKG_VERSION`. `punktfunk-host` does not build on
|
||||
macOS, so that test could not be re-run here — but `0.31.0` appears nowhere else in either copy, so
|
||||
regeneration would produce this byte-for-byte. If it ever fails on this commit, regenerate with
|
||||
`cargo run -p punktfunk-host -- openapi > api/openapi.json` and `cp` to `docs-site/public/`.
|
||||
|
||||
⚠ **Verified by reading only** — compiled nowhere available to the cutting host: everything under
|
||||
`crates/punktfunk-host` (Windows and Linux arms alike), `packaging/nix/gamescope.nix`, and the
|
||||
Android/Kotlin half. That includes `crate::runstate` and the new route; its own tests turned up a
|
||||
collision on their first run in an environment that executes them (all three shared the provider id
|
||||
`playnite` and cleared the process-global table between cases, so parallel scheduling flipped their
|
||||
answers) — fixed in #361 by giving each test ids only it uses and retiring the blunt `reset()`. The Windows GameStream pad change was checked on `.133` when it landed
|
||||
(`cargo check` and `cargo clippy -p punktfunk-host -- -D warnings` clean, both compiling arms), and
|
||||
`windows-host.yml` has **no `pull_request` trigger**, so a PR will not re-check that arm.
|
||||
|
||||
⚠ **`audit.yml` is red on main and this release does not fix it.** `cargo audit` reports
|
||||
**RUSTSEC-2026-0258** (`h2` 0.4.15, "unbounded empty DATA frames", published 2026-08-17, fixed in
|
||||
0.4.16); `h2` is transitive through `hyper`. It **predates this cut** — the same job failed on
|
||||
`669a1bc0` and on the v0.31.0 tag commit — so it is not a regression here, and it was deliberately
|
||||
**not** bundled into the release commit: `cargo update -p h2 --precise 0.4.16` bumps h2 in eleven
|
||||
lock lines but also rewrites several `windows-sys` references downward (0.61.2 → 0.59.0/0.52.0) on
|
||||
the pinned 1.96.0 toolchain, and re-resolving the graph for the Windows build is not a change to
|
||||
make inside a version bump that cannot be compiled for Windows on the cutting host. It wants its own
|
||||
commit and its own CI.
|
||||
|
||||
⚠ **Not confirmed on glass:** the Android scancode remap (the reporter's Fire TV Stick 4K Max is the
|
||||
test that settles it), the Windows data-plane firewall rule in a field session, and the
|
||||
Moonlight-compatible HID pad — the log line to look for there is
|
||||
`virtual Xbox pad created (Windows UMDF HID)` where it used to say
|
||||
`virtual Xbox 360 created (Windows XUSB companion)`.
|
||||
|
||||
---
|
||||
|
||||
## v0.31.0
|
||||
|
||||
90 commits since v0.30.0 (65 non-merge).
|
||||
170 commits since v0.30.0 (113 non-merge), counted at the tip this was cut from.
|
||||
|
||||
Nothing versioned moves. `WIRE_VERSION` stays **2**, the C ABI stays **24** — `include/punktfunk_core.h`
|
||||
is byte-identical to the v0.30.0 tag — the driver protocol, gamepad channel and plugin index schema
|
||||
are all unchanged, and no `trust::Settings` field, capability bit or control-message type byte was
|
||||
added. Every 0.30.x host, client, driver and plugin keeps interoperating in both directions, with no
|
||||
re-pairing.
|
||||
One versioned surface moves, additively: the **C ABI goes 24 → 25**, a single new symbol
|
||||
(`punktfunk_set_log_callback`) that lets an embedder hear the core's own log lines. Nothing else
|
||||
does — `WIRE_VERSION` stays **2**, the driver protocol, gamepad channel and plugin index schema are
|
||||
unchanged, and no `trust::Settings` field, capability bit or control-message type byte was added.
|
||||
No existing C function changed its signature or behaviour and no `#[repr(C)]` struct grew a field,
|
||||
so an embedder that adopts nothing rebuilds against the new header and is done. Every 0.30.x host,
|
||||
client, driver and plugin keeps interoperating in both directions, with no re-pairing.
|
||||
|
||||
What did move is beneath the versioned surfaces, and three parts of it are worth a packager's or
|
||||
embedder's attention: the Linux host package installs **three new system files** (a udev rule, a
|
||||
Beneath the versioned surfaces, four things are worth a packager's or embedder's attention: the
|
||||
**Windows client's default download changes** to a per-user installer plus a portable zip, with the
|
||||
MSIX kept for the Store; the Linux host package installs **three new system files** (a udev rule, a
|
||||
WirePlumber policy and an ALSA UCM drop-in) that the DualSense audio path depends on; the Linux
|
||||
desktop-audio capture **flipped topology by default** (`PUNKTFUNK_STREAM_SINK` unset now means a
|
||||
host-owned `null-audio-sink`, with `=stream` a one-release escape hatch to the 0.30 shape); and the
|
||||
@@ -35,7 +503,7 @@ three ABIs, which removes the Compose screenshot scenes.
|
||||
| | v0.30.0 | v0.31.0 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged |
|
||||
| C ABI | 24 | **24** | unchanged — `include/punktfunk_core.h` is byte-identical to the v0.30.0 tag; the only new `pub` items in `punktfunk-core` are three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`), Rust-only, no `pub const` for cbindgen to pick up |
|
||||
| C ABI | 24 | **25** | one additive step: v25 adds `punktfunk_set_log_callback` and the `PunktfunkLogCb` typedef (below). No existing declaration moved and no struct grew a field. Also new in `punktfunk-core`, Rust-only: three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`) |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
|
||||
@@ -43,20 +511,39 @@ three ABIs, which removes the Compose screenshot scenes.
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
|
||||
| `api/openapi.json` | 0.29.0 | **0.29.0** | unchanged — no management-API surface moved this cycle; both copies (`api/` and `docs-site/public/`) are byte-identical to each other and to the tag |
|
||||
| `api/openapi.json` | 0.29.0 | **0.31.0** | **the stamp only** — no management-API surface moved this cycle. The file had been left at 0.29.0 while the crate was already 0.31.0; #337's regenerate-and-diff caught it and it was regenerated, which is a one-line change to both copies. `api/` and `docs-site/public/` are byte-identical to each other |
|
||||
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files. ⚠ `packaging/gamescope/PKGBUILD` still says `pfhdr7` — pre-existing at v0.30.0, not a regression this cycle, but the Arch package builds a binary the host's `>= 8` probe rejects for the keymap path |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged in `package.json` — but `sdk/src/config.ts` and `runner-cli.ts` changed (the `mgmt-endpoint` fix below), so a `sdk-v0.1.5` cut is **owed**; plugins resolve the SDK from the registry and cannot pick the fix up until it ships |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.5** | cut — `sdk/src/config.ts` and `runner-cli.ts` carry the `mgmt-endpoint` fix below, and plugins resolve the SDK from the registry, so it could not reach them until it shipped |
|
||||
| `@punktfunk/plugin-kit` | 0.4.2 | **0.4.3** | cut, for the two `sync-engine.ts` changes that cannot reach a plugin any other way: `minInterval` (below) and the always-apply sync reasons (`startup`/`manual` publish even when the fingerprint matches, so a host-side art drop is recoverable by restarting rather than by deleting the plugin's cache). Note the registry skips 0.4.2: `plugin-kit-v0.4.2` was tagged but its publish never landed, and the tag is left where it is rather than moved |
|
||||
|
||||
⚠ The SDK and plugin-kit version independently of the app (`sdk-v*` / `plugin-kit-v*` tags,
|
||||
`sdk-publish.yml` / `plugin-kit-publish.yml`); this release commit does not bump them. Both have
|
||||
unpublished code changes, called out in the table so they are cut deliberately rather than
|
||||
discovered.
|
||||
`sdk-publish.yml` / `plugin-kit-publish.yml`), so their rows record what the registry holds, not
|
||||
what this tag ships. Both were cut during this cycle rather than left owed — a plugin resolves them
|
||||
from the registry, so a fix that never ships there never reaches one.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None on any versioned surface.** No wire change, no C ABI change, no driver-protocol change, no
|
||||
plugin-contract change. Four things are worth attention anyway; none breaks a build:
|
||||
**None that break a build.** No wire change, no driver-protocol change, no plugin-contract change.
|
||||
The C ABI moves 24 → 25 by **addition only**:
|
||||
|
||||
- **v25 — `punktfunk_set_log_callback(max_level, cb, user)`.** The core logs through `tracing`; an
|
||||
embedder that installs no Rust subscriber hears none of it — transport warnings, connection events,
|
||||
handshake notes — and a client log bundle carries the shell's half alone, which is exactly what an
|
||||
Apple TV field report turned out to be. The call registers a `log::Log` backend behind a C callback
|
||||
(`PunktfunkLogCb`: level, target, message, user), gated by `log::set_max_level` so anything above
|
||||
the ceiling costs no formatting; `NULL` detaches, and it answers `Unsupported` when another log
|
||||
backend already owns the process (`android_logger`). Both strings are borrowed for the call only,
|
||||
and an interior NUL drops the line rather than truncating it. `punktfunk-core` now declares
|
||||
`tracing`'s `log` feature explicitly — it had been on transitively via quinn, which an ABI promise
|
||||
must not rest on. An embedder that never calls it is byte-compatible with v24; see
|
||||
`docs/embedding-the-c-abi.md` §2.6.
|
||||
- **One header comment was wrong and is corrected, with no signature change:**
|
||||
`punktfunk_connect_ex10`'s summary still stated the pre-2026-08-16 rule that only a format other
|
||||
than 48000/16 requests the lossless plane. Any non-zero format at all does, 48000/16 included —
|
||||
which is what its own warning already said and what the code always did. Embedders reading the
|
||||
summary were reading the old rule.
|
||||
|
||||
Five more things are worth attention; none breaks a build:
|
||||
|
||||
- **`refactor(android)!` — the Compose console is deleted.** `pf-console-ui` (the Skia shell the
|
||||
desktop session binary draws) is now Android's console on arm64-v8a, x86_64 **and** armeabi-v7a;
|
||||
@@ -78,6 +565,11 @@ plugin-contract change. Four things are worth attention anyway; none breaks a bu
|
||||
monitors disabled for the session now (closes #284).
|
||||
- **Three new system files in the Linux host package** — the DualSense audio path does not work
|
||||
without them. Downstream repackagers: see the packaging section.
|
||||
- **The Windows client's default download is a per-user installer, not the MSIX.** The MSIX stays,
|
||||
for the Store; the installer and a portable zip are what the download page now offers, and the
|
||||
release carries `punktfunk-client-setup_<arch>.exe` and `..._<arch>-portable.zip` alongside it.
|
||||
Anyone scripting against the MSIX asset name is unaffected; anyone scripting against "the Windows
|
||||
client download" gets a different artifact. See the Windows client section.
|
||||
|
||||
### DualSense audio and haptics on Linux: five faults, and the files they needed
|
||||
|
||||
@@ -295,6 +787,25 @@ gains two direct deps already in the graph.
|
||||
`frameRatePowerSavingsBalanced`) raises the render-range floor — so the ineffective pins were
|
||||
removed again and `pf.present` gained the cadence loop's late-permille / jitter / cushion /
|
||||
re-anchors / qDepth.
|
||||
- **Colour tagging, which the SurfaceView path never had to do.** MediaCodec tags its own window
|
||||
buffers; with `AImageReader` → `ASurfaceControl` the transaction is the only carrier, and a
|
||||
dataspace of 0 means `setBufferDataSpace` is never called. Two consequences, both fixed inside the
|
||||
cycle: **HDR** was seeded from a hardcoded `BT2020_ITU_PQ` guess and then overwritten by whatever
|
||||
the codec echoed on the first output-format change — a decoder that omits color-transfer (common)
|
||||
echoes None, clobbering the dataspace to 0 before the first present, so P010 buffers composited as
|
||||
sRGB, and an HLG stream was mis-seeded PQ. The initial dataspace now derives from `client.color`
|
||||
(PQ vs HLG, range) and a format change only *refines* it when the codec actually reports an HDR
|
||||
transfer, never resets it — the SurfaceView path's semantics. And **SDR** was untagged entirely:
|
||||
a limited-range BT.709 buffer read as full range shows black (16) as grey, so SDR now maps to
|
||||
`ADATASPACE_BT709` and every ASC buffer is tagged.
|
||||
- **One owner for the system bars.** Console → stream rides an `AnimatedContent` cross-fade, so the
|
||||
outgoing console shell stays composed until the fade ends and its
|
||||
`onDispose { show(systemBars()) }` fired *after* `StreamScreen`'s hide — parking the status and
|
||||
gesture bars over the video for the whole session. Hide/show now lives once in `App.kt`, keyed on
|
||||
the resolved intent (streaming or console fronting = immersive, touch shell = bars back), and both
|
||||
screens' per-screen bar management is deleted.
|
||||
- **Idle gates** (from the console-ui sweep): the reachability sweep only probes while the console is
|
||||
attached, and the render thread drops to half rate after 60 s without input.
|
||||
|
||||
### Hyprland / sway: `topology: exclusive` (closes #284)
|
||||
|
||||
@@ -314,20 +825,92 @@ with non-legacy parsers"). `primary` stays extend and warns distinctly. ⚠ **Th
|
||||
exercised on a live sway** — no box in the fleet runs one; both argv shapes are pinned by tests and
|
||||
the read-back turns a wrong guess into a warning naming the outputs. Six new unit tests.
|
||||
|
||||
### Gaming Mode takeover: the mask was the relogin storm
|
||||
### Gaming Mode takeover: it no longer touches the display manager at all
|
||||
|
||||
On an SDDM-autologin box the runtime mask the takeover laid sat in SDDM's relogin path, so every
|
||||
autologin failed in milliseconds and `Relogin=true` has no backoff: 962 logind sessions in 3.7 min,
|
||||
system buttons re-scanned 5,688×, udev `change` at ~20/s, iio-sensor-proxy crash-looping ~16
|
||||
starts/s, load 26 on 12 cores — and Wine's bus driver, re-enumerating udev per event, read the pad at
|
||||
~1.4 Hz. `dm_plan` loses its `mask` input and `dm_survives_masked_unit`; the mask is laid **only after
|
||||
the stop has landed** and every restore path unmasks before restarting; a planned DM stop that does
|
||||
not land now **fails the takeover** and the caller degrades to ATTACH. `skip` is `!any_live` on every
|
||||
flavor; `any_live` now counts `deactivating` and `reloading`. New `DmHelperError::shape()`;
|
||||
`watch_for_relogin_storm()` (two `read_dir`s of `/run/systemd/sessions` 5 s apart, ERROR above 1/s,
|
||||
detect-only); `systemctl_system` captures stderr at DEBUG (the "requires interactive authentication"
|
||||
line was going to the journal on the *successful* path). `cargo test -p pf-vdisplay --lib gamescope`
|
||||
52 passed, 1 ignored.
|
||||
This landed in two steps within the cycle, and the second retired the first — read the end state.
|
||||
|
||||
**The storm.** On an SDDM-autologin box the runtime mask the takeover laid sat in SDDM's relogin
|
||||
path, so every autologin failed in milliseconds and `Relogin=true` has no backoff: 962 logind
|
||||
sessions in 3.7 min, system buttons re-scanned 5,688×, udev `change` at ~20/s, iio-sensor-proxy
|
||||
crash-looping ~16 starts/s, load 26 on 12 cores — and Wine's bus driver, re-enumerating udev per
|
||||
event, read the pad at ~1.4 Hz. Masking without stopping the display manager is not a weaker
|
||||
defence; it is the storm's engine.
|
||||
|
||||
**Then stopping the DM proved wrong too.** With no display manager there is nothing on the box able
|
||||
to start a desktop session, so Steam's own "Switch to Desktop" sat on its modal until a reboot
|
||||
(field report 2026-08-18, `.41`). It could not even be detected and worked around: on a
|
||||
steamos-manager box every trace of that switch is written by the component we had just stopped —
|
||||
the `~/.config/steamos-session-select` sentinel is never written (that is the ChimeraOS/Nobara
|
||||
layout), `/var/lib/sddm/state.conf` only advances when sddm actually *starts* a session,
|
||||
`get-default-login-mode` stays `game` for a non-persistent switch, and `graphical-session.target`
|
||||
going inactive fires at takeover time as well.
|
||||
|
||||
**End state: idle the autologin, leave the display manager alone.** The takeover drops a unit
|
||||
override over the `gamescope-session-plus@` template replacing `ExecStart` with a process that
|
||||
sleeps. The autologin still *succeeds*, so there is no failed unit to relogin against; the session
|
||||
runs nothing, so Steam is free; and the DM is alive, so the box can service its own session switch.
|
||||
No privilege, no DM-flavour matrix, no detection. Measured on `.41` in both directions: takeover
|
||||
leaves `steam` down, `sddm` active, the unit `active (running)` with `NRestarts=0`; the switch that
|
||||
used to hang brings Plasma up in ~10 s; the restore puts Steam back within 5 s. The drop-in lives
|
||||
under `$XDG_RUNTIME_DIR` (a copy outliving the host would be a box whose Game Mode silently does
|
||||
nothing), is swept unconditionally at startup, and its removal sits above every early return in the
|
||||
restore — the desktop-active return is exactly the path that would leak it. The restore *restarts*
|
||||
rather than starts, because `start` on an active-but-idle unit is a no-op that would log success
|
||||
over it.
|
||||
|
||||
With nothing stopping a display manager any more, the whole chain built to survive doing so is
|
||||
deleted: `try_stop_display_manager`, `ensure_host_survives_dm_stop`, `host_is_under_user_manager`,
|
||||
`cgroup_under_user_manager`, `linger_enabled` and `dm_plan`'s mask input — 142 lines out, 17 in.
|
||||
**Two shipped facts became false and are corrected:** the takeover no longer has to stop the display
|
||||
manager, and it no longer needs the `punktfunk` group (the docs and the shipped Bazzite `host.env`
|
||||
both said it did). That group still gates the usbip nodes the virtual Steam Deck pad attaches
|
||||
through, which is what the advice now narrows to. Kept from the first step: `any_live` counts
|
||||
`deactivating` and `reloading` (a unit mid-teardown used to read as a dead leftover, so a box that
|
||||
*is* in gaming mode sampled as idle); `DmHelperError::shape()`; `watch_for_relogin_storm()` (two
|
||||
`read_dir`s of `/run/systemd/sessions` 5 s apart, ERROR above 1/s, detect-only, and it states that
|
||||
no audio, input or PipeWire measurement taken during a storm is valid); and `systemctl_system`
|
||||
capturing stderr at DEBUG, since that verb is *expected* to fail on an unprivileged host and its
|
||||
"requires interactive authentication" line was going to the journal on the successful path.
|
||||
|
||||
### KWin 6.6 creates our virtual output disabled, and refuses to stream it
|
||||
|
||||
On KWin ≥ 6.6 `streamVirtualOutput` creates the output on the backend and then hands
|
||||
`workspace()->findOutput(output)` to the stream — null for an output the workspace does not manage
|
||||
(`wantsToManage` = `isEnabled() && !isNonDesktop()`). An output KWin creates **disabled** is
|
||||
therefore refused with "Could not find output", translated into the session's language and logged
|
||||
nowhere, because disabling an output is a perfectly valid configuration that applies successfully.
|
||||
6.4/6.5 passed the backend output straight through and streamed it either way. It repeats forever:
|
||||
the host asks for a *stable* per-client output name precisely so KWin persists that client's scale
|
||||
and mode against it, so a stored configuration naming it `enabled: false` is reapplied to every
|
||||
future session for that client — and the user cannot fix it in System Settings, because the output
|
||||
only exists for the few milliseconds the request is alive. The host now enables the output and
|
||||
retries. Related, from the same investigation: a **translated** KWin refusal used to burn all 8
|
||||
retries because the match was against KWin's message rather than our own prefix.
|
||||
|
||||
### Windows client: a per-user installer and a portable zip, because Steam must spawn the exe
|
||||
|
||||
A user report — launching through Big Picture does not work and the Steam overlay never appears —
|
||||
turned out to be nothing to do with the app being UWP (it is full-trust Win32 under MSIX too) and
|
||||
everything to do with the MSIX install **shape**: the exe lives under the ACL'd `WindowsApps`
|
||||
directory that Steam's non-Steam-game picker cannot browse, and alias / `shell:AppsFolder`
|
||||
activation defeats the overlay's injection. Steam has to spawn the exe itself, from a normal path.
|
||||
|
||||
- **`punktfunk-client.iss`** — a per-user Inno Setup install (no UAC) to
|
||||
`%LOCALAPPDATA%\Programs\Punktfunk`, re-creating in `HKCU` what the MSIX manifest granted: the
|
||||
`punktfunk://` scheme, the Start entries, and `{app}` on the user PATH for the `punktfunk` CLI. It
|
||||
fetches the Windows App Runtime when missing.
|
||||
- **`pack-client-installer.ps1`** consumes `pack-msix.ps1`'s layout (one assembly, three artifacts),
|
||||
signs the four exes individually and emits `setup.exe` plus a portable zip — same signing backends
|
||||
and fail-closed-on-tags rule as its siblings, and no `.cer`, because an exe runs untrusted.
|
||||
- **`windows-client.yml`** packs after the MSIX and publishes/attaches the new artifacts;
|
||||
canary/latest aliases are `punktfunk-client-setup_<arch>.exe` and `..._<arch>-portable.zip`.
|
||||
- **`deeplink.rs`**: `write_shortcut` targets the app-execution alias only under package identity —
|
||||
an unpackaged install has no alias but does have a stable path, so it targets `current_exe()`.
|
||||
`has_package_identity()` is now shared with `main.rs`'s AppUserModelID probe.
|
||||
- Uninstall is `Settings → Apps → Installed apps` (per-user, no admin prompt) or
|
||||
`unins000.exe /VERYSILENT`; a portable unzip registers nothing and is deleted by hand. Documented
|
||||
in install-client (with a "Launching through Steam" section), channels, clients, uninstall, and
|
||||
both copies of `platforms.json`.
|
||||
|
||||
### Windows host: two session-killers
|
||||
|
||||
@@ -351,6 +934,123 @@ line was going to the journal on the *successful* path). `cargo test -p pf-vdisp
|
||||
instead of `launching` forever. Fixture in `a_pid_only_launch_reports_its_exit` widened 4 → 8 s
|
||||
(it passed only because of the bug); new ignored test drives the field report.
|
||||
|
||||
### `scripts/install.sh`: a guided Linux host install (preview)
|
||||
|
||||
Plain POSIX `sh`, dash-clean, `curl -fsSL https://punktfunk.unom.io/install.sh | sh`. Detect the
|
||||
distro from os-release (apt / dnf / pacman / rpm-ostree→sysext; NixOS, SteamOS, Windows and unknown
|
||||
distros get a one-line pointer and stop; Debian 12 / Ubuntu 24.04 / Mint 22 / Fedora 45 hit the
|
||||
documented floors with the right docs link) → install using the `data/platforms.json` lines
|
||||
**verbatim** (channel and the Fedora group are edited into the string at run time) → run
|
||||
`punktfunk-host detect-conflicts` (exit 1 = an active Sunshine-family host) → offer to keep both by
|
||||
moving the management API port (`PUNKTFUNK_MGMT_BIND`, default 47991, which the firewall step then
|
||||
opens) → input group (`ujust` on Bazzite) → optional `punktfunk` group, GameStream compat and shared
|
||||
clipboard, all defaulting to no → firewalld/ufw profiles → enable host + console (+ the plugin
|
||||
runner where it is not) → optional linger → verify (unit active, UDP 9777 bound) and print the
|
||||
console URL, the password command and the pairing steps.
|
||||
|
||||
`--dry-run` prints every command and changes nothing; `--uninstall` reverses the install and the
|
||||
service enable per family (user units off first, then only the punktfunk packages actually
|
||||
installed, then the repo — config, groups and firewall stay, as `/docs/uninstall` states). Every
|
||||
prompt has a `PUNKTFUNK_INSTALL_*` environment twin so `--yes` (or no terminal) runs unattended, and
|
||||
stdin is never read, because under `curl | sh` stdin *is* the script. Re-running is safe. The
|
||||
end-of-run check catches the two NVIDIA silent failures on every family — no driver at all, and a
|
||||
module the kernel refused to load under Secure Boot — via an `nvidia-smi` probe pointing at the
|
||||
troubleshooting anchor.
|
||||
|
||||
It is labelled **PREVIEW** on purpose: the per-distro docs pages remain the documented default until
|
||||
it has mileage. CI runs it: a new `installer-smoke.yml` exercises install and `--uninstall` per
|
||||
package family, and `check-docs-drift.sh` gate 7 runs the 16-file os-release detection matrix
|
||||
through the real script under `--dry-run` on every push. One bug fixed by the first smoke run: the
|
||||
`/dev/tty` probe used `-r`/`-w`, which answer yes in a container that has the node but no
|
||||
controlling terminal, so the redirect failed — it opens the device instead now.
|
||||
|
||||
### One home per fact: `data/platforms.json`, and CI gates against drift
|
||||
|
||||
Install commands, repo URLs and port numbers had drifted across four surfaces. They now live in
|
||||
`data/platforms.json` and nowhere else: the docs-site install pages quote it through an
|
||||
`<Install platform="…"/>` MDX component reading a byte-identical snapshot at
|
||||
`docs-site/src/data/platforms.json` (the Docker build context is `docs-site/` alone, the same
|
||||
arrangement `openapi.json` uses), `<Ports/>` renders the port table from it, the website download
|
||||
page vendors it, and `install.sh` runs it. `scripts/ci/check-docs-drift.sh` gates the parse, the
|
||||
snapshot sync, undocumented `PUNKTFUNK_*` knobs (against a checked-in baseline) and the detection
|
||||
matrix; `check-docs-links.sh` covers dead links.
|
||||
|
||||
⚠ **Two consequences for whoever cuts this release.** The website vendors `platforms.json` and only
|
||||
refreshes when someone runs `bun run sync-platforms` in punktfunk-website and commits — the release
|
||||
flow in `docs/releases/README.md` gained that step, and `platforms.json` **did** change this cycle
|
||||
(the Windows client download). And the `.gitea/PULL_REQUEST_TEMPLATE.md` now asks the one question
|
||||
CI cannot: did a user-facing fact change, and is the page that owns it updated in the same PR.
|
||||
|
||||
### Clients can send their logs to the host, on every platform that has a console
|
||||
|
||||
0.30 shipped "Send logs to host" on the Gaming Mode console alone and named the Apple and Android
|
||||
legs as follow-ups. Both landed here.
|
||||
|
||||
- **Apple** — a `ClientLog` drop-in for `Logger(subsystem: "io.unom.punktfunk", category:)` with the
|
||||
same call shape, writing os_log *and* a process-global ring bounded at 4096 lines / 768 KiB (under
|
||||
the host's 1 MiB cap), stamped wall-clock ISO-8601 so a bundle lines up with the host log;
|
||||
`.debug` stays out of the ring, which is the Steam Deck DPB lesson applied in advance. 13 `Logger`
|
||||
declarations swapped. `MgmtTransport`/`MgmtConnection` POST a length-framed body on the same
|
||||
pooled, pinned mTLS connection; `SendLogs.toHost` requires identity and pinned fingerprint, the
|
||||
same gates as the library. Reachable from the host card's context menu and the gamepad host
|
||||
options. Paired with ABI v25 above, the Swift client finally hears the core's own lines too
|
||||
(`core.<crate>`, info ceiling by default, `PUNKTFUNK_CORE_LOG_LEVEL` raises it).
|
||||
- **Android** — `pf-client-core`'s logring RING half (note/render/wallclock, std-only) is
|
||||
Android-enabled, with `send_to_host` still desktop-gated alongside the ureq fetches; `wallclock`
|
||||
moves in from the session's ring layer so every feeder stamps lines identically. `JNI_OnLoad`
|
||||
installs a `RingTee`, so every `log` record goes to logcat **and** into the ring in the desktop
|
||||
ring layer's line shape; `nativeRenderLogs(header)` hands Kotlin the rendered bundle, and the
|
||||
upload rides the client's own mTLS.
|
||||
|
||||
### A provider plugin can report which of its titles are **running**
|
||||
|
||||
New: `PUT /api/v1/library/provider/{provider}/running`, body
|
||||
`{"running":[{"external_id":"…","pid":1234}]}` — the **live** counterpart to the static `detect`
|
||||
hints a reconcile carries. `detect` says *how to recognize* a title's process; this says *it is
|
||||
running now*, and carries the pid where the provider knows one. Additive: no existing route,
|
||||
payload or behaviour changes, and a host with no reporting plugin behaves exactly as before.
|
||||
|
||||
It exists because one class of title could never be tracked at all. The host derives liveness by
|
||||
scanning (`procscan` + `DetectSpec`), which needs something recognizable on disk — an install
|
||||
directory, an executable, a Steam reaper. A Playnite-launched emulated game, a manually added one,
|
||||
or a library plugin that records no install directory has none of that, and its launch is a
|
||||
`playnite://` hand-off, so the host holds no process either: the lease went `Untracked`, its exit
|
||||
was never noticed, `session_on_game_exit` could not fire, and `POST /game/end` had nothing to aim
|
||||
at. Playnite knew the whole time — it starts the game, tracks it in the mode the person configured,
|
||||
and fires an event on both edges carrying the pid. That was being thrown away.
|
||||
|
||||
- **Declarative and idempotent**, like the reconcile beside it: the body is the provider's
|
||||
**complete** running set, so a missed event, a plugin restart or an install mid-game self-correct
|
||||
on the next report instead of drifting. Absent from the set = stopped.
|
||||
- **Reports expire** (`crate::runstate::REPORT_TTL`, 90 s; the answer carries `ttl_s`). This is what
|
||||
makes it safe for a live provider to hold a streaming session open for a game the host cannot
|
||||
see: a plugin that dies with a game running stops counting shortly after and the host falls back
|
||||
to scanning. Reporters must restate well inside the window.
|
||||
- **New `gamelease::LeaseKind::Reported`** — a lease with no process signal of its own, tracked by
|
||||
what its provider says. `open` reaches it when the spec is empty and a provider speaks for the id;
|
||||
the shim-reclassification paths (every Windows launch is a hand-off by construction) fall back to
|
||||
it too, where they previously fell to `Untracked`. Phase 1 accepts "running" as the game
|
||||
appearing; phase 2 treats "stopped" as the exit, and — unlike `procscan::running_hint`, which may
|
||||
only ever *delay* an exit because Steam's registry flag survives an unclean exit — a fresh
|
||||
provider report is decisive in both directions. A reported pid joins the termination ladders on
|
||||
the same terms as a spawned one (re-resolved and start-time-pinned at the moment of use).
|
||||
- **Route authority**: the plugin lane, like the reconcile (`mgmt::auth::plugin_may_access`, and its
|
||||
exhaustive classification table). No new authority — the host maps `external_id` through the
|
||||
catalog, so a provider can only ever speak about entries it published; an unknown id is *counted*,
|
||||
not refused, because a report legitimately races its own reconcile and 400-ing the batch would
|
||||
throw away the liveness of every other running title.
|
||||
- **`@punktfunk/plugin-kit`: `ProviderClient.reportRunning(providerId, running)`**, returning
|
||||
`{matched, unknown, ttlS}`; a 404 from an older host means "this host tracks games by scanning".
|
||||
Version bumped to **0.4.4** — **unpublished, `plugin-kit-v0.4.4` owed.**
|
||||
|
||||
The Playnite half lives in `punktfunk-plugin-playnite` (**0.4.5**, exporter **0.4.0**): the C#
|
||||
exporter hooks Playnite's `OnGameStarted`/`OnGameStopped`/`OnGameStartupCancelled` and writes a
|
||||
small `punktfunk-running.json` beside the library export, re-stamped every 30 s and *deleted* when
|
||||
Playnite closes; the plugin polls it and restates the set to this route. It calls the route through
|
||||
the kit's untyped host seam rather than `reportRunning`, deliberately — depending on the method
|
||||
would make that repo unbuildable until the kit publishes, for the same request. Needs a host
|
||||
carrying this route; an older one 404s and the plugin carries on without it.
|
||||
|
||||
### Everything else an integrator might notice
|
||||
|
||||
- **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin,
|
||||
@@ -402,44 +1102,130 @@ line was going to the journal on the *successful* path). `cargo test -p pf-vdisp
|
||||
(screenshot harness only).
|
||||
- **New environment variables:** `PUNKTFUNK_PAD_SINK_VOLUME` (`=0` skips both pad-sink pins),
|
||||
`PUNKTFUNK_DUALSENSE_USBIP_GRACE_MS` (pad-arrival grace), `PUNKTFUNK_USBIP_TRACE` (byte-level
|
||||
USB/IP trace prefix, off by default), and the three Apple screenshot-harness hooks above.
|
||||
USB/IP trace prefix, off by default), `PUNKTFUNK_CORE_LOG_LEVEL` (Apple: raises the ABI v25 log
|
||||
sink's ceiling above its info default), the three Apple screenshot-harness hooks above, and nine
|
||||
`PUNKTFUNK_INSTALL_*` twins for `install.sh`'s prompts (`_YES`, `_CHANNEL`, `_GAMESTREAM`,
|
||||
`_CLIPBOARD`, `_PUNKTFUNK_GROUP`, `_LINGER`, `_MGMT_PORT`, `_DRY_RUN`, `_OS_RELEASE`).
|
||||
`PUNKTFUNK_STREAM_SINK` gained the `stream` value and is documented for the first time.
|
||||
- **A Steam Deck never learned a host's wake MAC, so Wake-on-LAN was skipped there in silence.**
|
||||
Every wake gate reads `!host.mac.is_empty()`, and the MAC only ever reached the store through
|
||||
`trust::learn_mac`, whose two callers were the GTK and WinUI hosts pages — neither of which runs
|
||||
in Gaming Mode. Rather than add the missing call twice, the three per-field learners (`learn_mac`,
|
||||
`learn_os`, `learn_mgmt_port`) collapse into one `learn_from_advert`, called wherever an advert
|
||||
meets a saved record: both desktop hosts pages, the console home, and the CLI's `discover`.
|
||||
Remembering one call is not something a front-end can half-do; remembering three is what produced
|
||||
this (#322).
|
||||
- **`HostRow` gains `clipboard_sync`** (`#[serde(default)]`) and `ConsoleCmd` two variants,
|
||||
`BindProfile` and `SetClipboard` — additive and default-tolerant. From the 2026-08-19 console-ui
|
||||
sweep, which also brought touch deferred-tap and drag-to-scroll to the console (a swipe across the
|
||||
settings list used to cycle whatever value it landed on, because `MenuList` presses focus *and*
|
||||
activate), Controller haptics/speaker rows, and two Android idle gates (the reachability sweep
|
||||
only probes while the console is attached, and the render thread halves its rate after 60 s
|
||||
without input).
|
||||
- **Cancelling a connect returns the console immediately.** The takeover could only be dismissed by
|
||||
a session phase coming back from the embedder and nothing guaranteed one would: Android's shell
|
||||
sent no phase at all on the cancelled path, and the desktop shell waited on a pump parked inside
|
||||
the blocking `NativeClient::connect*`, which had no abort — 15 s on a normal dial, **185 s** on a
|
||||
request-access connect the host holds pending approval. The private `connect_*` inner fn takes a
|
||||
trailing `cancel: Option<Arc<AtomicBool>>`; not exported through the C ABI.
|
||||
- **A portable Playnite's covers survive the art confinement.** A Playnite unzipped outside the
|
||||
users base keeps its library beside the exe, so every cover it exports sits outside every default
|
||||
art root: the games synced and all **70** covers were dropped, with `PUNKTFUNK_LIBRARY_ART_ROOTS`
|
||||
the only way out. The Playnite install dirs are art roots now, exactly as Steam's install root
|
||||
already was, and `playnite_install_dirs` learned to find a portable copy at all — it registers no
|
||||
uninstall entry and sits under no profile, but it does register the `playnite://` handler, which
|
||||
is the very registration the launch path already follows. So a portable install also gets its
|
||||
Fullscreen launcher tile, which it never had. The confinement is not loosened: roots come from the
|
||||
host's own registry and filesystem probes, never from the plugin lane that supplies the art path.
|
||||
Paired with the plugin-kit fix below, a fixed host no longer needs a cache file deleted.
|
||||
- **`plugin-kit`: `startup` and `manual` sync reasons always publish.** The fingerprint says the
|
||||
plugin would compute the same entries again; it does *not* say the host still holds them — and the
|
||||
host may accept a payload and store less of it (an art path outside its roots is stripped and the
|
||||
games kept, deliberately, because a cover must not cost a library). Once that happened the
|
||||
fingerprint was a permanent "no changes", and the only way out was deleting the plugin's cache
|
||||
file, which is exactly the advice a portable-Playnite library with 70 dropped covers was given.
|
||||
The two triggers with a person behind them now always apply.
|
||||
- **Nix:** nixpkgs bumped because its gamescope 3.16.24 no longer took our patch 0009 (the publish
|
||||
tier was red on every build); `enableWsi` is a nixpkgs *function argument* defaulting to false, so
|
||||
the plain derivation shipped a compositor with **no WSI layer at all** and nothing under it could
|
||||
obtain an HDR10 swapchain — our own postInstall assertion caught it. Also: the prune makes `$out`
|
||||
writable first (reshade installs read-only), the bun builds are serialised and the OOM is measured
|
||||
against the real 7 GiB cgroup cap rather than guessed at, and a dispatch opt-in compared against
|
||||
the string `"true"` silently skipped when the API delivered a real JSON boolean — the step was
|
||||
skipped and the job still reported success.
|
||||
- **New packaging payload (Linux host, rpm/deb/arch; nix where noted):** `scripts/60-punktfunk.rules`
|
||||
(+2 sound rules), `scripts/60-punktfunk-dualsense.conf` (WirePlumber, also nix),
|
||||
`scripts/alsa-ucm2/…` (UCM drop-in, **not** nix). Bazzite sysext inherits all three from the RPMs.
|
||||
- **Docs:** `AGENTS.md` + `docs/agents/` (issue tracker is Gitea via the `gitea` MCP server; the
|
||||
five triage labels; single-context domain docs). A host audio-source comment corrected
|
||||
(`pw_impl_node_set_driver` marks props changed but leaves the flush to the next info emission).
|
||||
- **CI:** Nix publish job records `df` after the build as well as before.
|
||||
- **CI:** the Nix publish job records `df` after the build as well as before; the
|
||||
`linux-client-screenshots` run publishes its PNGs to the generic package registry as well as the
|
||||
v3 artifact store (which is browser-only, so nothing could reuse the shots for the docs — that is
|
||||
how the get-started track got its fifth screenshot, a client's host list); and **every Linux
|
||||
`bun install` is now wrapped in `scripts/ci/retry.sh`**. That last one is a real failure, not
|
||||
tidying: `bun install` streams download-and-extract, so a tarball truncated by the runner's
|
||||
packet loss under parallel load surfaces as `error: Fail extracting tarball for "<pkg>"` and
|
||||
names a package that is perfectly intact — measured on run 19630, where docs-site died on
|
||||
`@rolldown/binding-linux-x64-gnu` while the web job installed the same registry in the same run
|
||||
and run 19632 installed the identical lockfile seven minutes later. The tarball's sha512 matches
|
||||
the lockfile and bun 1.3.13 and 1.3.14 both extract it from disk, so neither the package nor the
|
||||
floating `oven/bun:1` bump was ever at fault. `retry.sh`'s header had already diagnosed this
|
||||
class and said to wrap every single-shot network command; `bun install` was the one still
|
||||
unwrapped. Three attempts rather than the usual five, so a genuinely stale lockfile still fails
|
||||
fast under `--frozen-lockfile`.
|
||||
- **The web console's Virtual displays page** put the Streamed-screen and session-lifetime cards
|
||||
below the tab shell, so both rendered on both tabs; they are policy surfaces and now sit inside
|
||||
the Configuration tab, leaving the Live tab as the live list plus arrangement.
|
||||
|
||||
### Verification status
|
||||
|
||||
Gates run on the release tree (this MacBook, rustc/rustfmt 1.96.0 per `rust-toolchain.toml`):
|
||||
`cargo fmt --all --check` clean — **after** a whitespace-only commit on the release branch: two files
|
||||
(`pf-console-ui/src/screens/controllers.rs`, `punktfunk-host/src/audio/linux/pad_card_volume.rs`)
|
||||
had landed on main formatted differently from rustfmt 1.96.0, so `ci.yml`'s Format step was red on
|
||||
the tip this is cut from; `cargo metadata --offline` ok with the `Cargo.lock` diff versions-only
|
||||
(36/36 lines); `cargo test -p punktfunk-core` **272 passed** in the unit suite; the android.yml Play
|
||||
notes gate run verbatim — 498/500 characters and not byte-identical to any prior release's; both
|
||||
openapi copies `cmp` identical and unchanged since the tag; `include/punktfunk_core.h` regenerated
|
||||
by the build and `git diff` clean against the tag.
|
||||
`cargo fmt --all --check` clean; `cargo metadata --offline` ok with the `Cargo.lock` diff
|
||||
versions-only (36/36 lines); `cargo test -p punktfunk-core --lib` **273 passed**; the android.yml
|
||||
Play notes gate run verbatim — 456/500 characters and not byte-identical to any prior release's;
|
||||
both openapi copies `cmp` identical, both stamped 0.31.0; notes voice scan clean outside the
|
||||
For developers section.
|
||||
|
||||
⚠ **The C ABI harness (`tests/c_abi.rs`) did not run on this cut**: it links the staticlib with
|
||||
`-lopus` and this machine has no libopus (`ld: library 'opus' not found`), which is an environment
|
||||
gap, not a code fault. The header it exercises is byte-identical to v0.30.0's, where the harness
|
||||
passed (261 + 1 + 8), and nothing in `punktfunk-core`'s C surface changed. The CI runner is its
|
||||
first execution for this tag.
|
||||
⚠ **This release was cut more than once.** The first cut (`601f040f`, merged as #320) was never
|
||||
tagged, and 41 more non-merge commits landed on top of it — the Windows client installer, the
|
||||
guided Linux installer, the docs overhaul, ABI v25, the KWin 6.6 repair and the takeover's final
|
||||
shape among them; a handful more (the Virtual displays tab fix, the fifth get-started screenshot)
|
||||
arrived while the second cut was being written. This section, the version table and the notes are
|
||||
all re-measured on the latest tip; where the cuts disagreed, the earlier text was **rewritten
|
||||
rather than appended to**, because none of
|
||||
the intervening work ever shipped. Specifically: the "C ABI unchanged / header byte-identical"
|
||||
claim is gone (it is 25 now), the openapi row moved off 0.29.0, the SDK and plugin-kit rows record
|
||||
cuts that have happened rather than cuts that were owed, and the Gaming Mode takeover section
|
||||
describes idling the autologin rather than stopping the display manager — a within-cycle correction
|
||||
no user could have seen.
|
||||
|
||||
⚠ **Verified by reading only** — compiled nowhere available to the cutting host: the Windows runner
|
||||
log redirect (`scripting-run.cmd`), the tray's `Option<u16>` port on Windows, and the sway half of
|
||||
`topology: exclusive` (no live sway in the fleet, as with #283).
|
||||
⚠ **The C ABI harness (`tests/c_abi.rs`) did not run on this cut**, and this time the header *did*
|
||||
change: it links the staticlib with `-lopus` and this machine has no libopus (`ld: library 'opus'
|
||||
not found`), which is an environment gap, not a code fault. `punktfunk_set_log_callback` is
|
||||
therefore compiled by cbindgen and by the Rust unit tests here, but the generated header has not
|
||||
been compiled by a C compiler on this cut — the CI runner is its first. Worth naming because ABI 25
|
||||
is the one versioned surface that moved.
|
||||
|
||||
⚠ **Verified by reading only** — compiled nowhere available to the cutting host: the Windows client
|
||||
installer and portable zip (`punktfunk-client.iss`, `pack-client-installer.ps1` — the pack step is a
|
||||
Windows runner's), the Windows runner log redirect (`scripting-run.cmd`), the tray's `Option<u16>`
|
||||
port on Windows, and the sway half of `topology: exclusive` (no live sway in the fleet, as with
|
||||
#283).
|
||||
|
||||
⚠ **Not verified on hardware by this cut**, named rather than left to be discovered: the null-sink
|
||||
capture topology's on-glass validation (pw-top showing our sink at the top of its own group, 5 min
|
||||
of loud audio at `delivered_pct=100 gaps=0` on a box where a hardware sink also runs) was still owed
|
||||
when it landed; the 96 kbps speaker lane was judged on glass by ear only; and the Android
|
||||
`ASurfaceControl` path was verified on one device (Nothing Phone 3) — the fallback presenter is
|
||||
byte-for-byte the 0.30 one.
|
||||
when it landed; the 96 kbps speaker lane was judged on glass by ear only; the Android
|
||||
`ASurfaceControl` path was verified on one device (Nothing Phone 3), with the fallback presenter
|
||||
byte-for-byte the 0.30 one; the Mac Accessibility intercept (the tap ahead of Spotlight, inside the
|
||||
sandbox) needs a granted Accessibility switch the dev machine does not have; and `install.sh` is
|
||||
smoke-tested per package family in CI containers but is shipped **preview** precisely because it has
|
||||
no real-box mileage, Bazzite above all.
|
||||
|
||||
⚠ **Owed outside this repository:** `data/platforms.json` changed this cycle (the Windows client
|
||||
download), and the website's download page vendors a copy that only refreshes when someone runs
|
||||
`bun run sync-platforms` in punktfunk-website and commits — step 1 of `docs/releases/README.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+36
-36
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
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.31.0"
|
||||
version = "0.31.2"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3032,7 +3032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3050,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3073,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3083,7 +3083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3109,7 +3109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3122,7 +3122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3136,11 +3136,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3169,14 +3169,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3191,7 +3191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3199,7 +3199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3211,7 +3211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3220,7 +3220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3253,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3264,7 +3264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3275,7 +3275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3487,7 +3487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3497,7 +3497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"anyhow",
|
||||
@@ -3521,7 +3521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pf-client-core",
|
||||
@@ -3554,7 +3554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3572,7 +3572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3605,7 +3605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3614,7 +3614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3684,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3698,7 +3698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3722,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+222
-4
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.31.0"
|
||||
"version": "0.31.2"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
@@ -364,6 +364,77 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"clients"
|
||||
],
|
||||
"summary": "Rename a paired client",
|
||||
"description": "Sets or clears the operator-visible display name for one paired Moonlight client. This is\npurely cosmetic — it touches no certificate and no trust decision — but it is the only way to\ntell paired devices apart: every moonlight-common-c client self-signs with the identical\nsubject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable\nonly by fingerprint. The name is stored beside the pairing store and survives host restarts;\nunpairing the device forgets it.",
|
||||
"operationId": "renameClient",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "fingerprint",
|
||||
"in": "path",
|
||||
"description": "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RenameClient"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The client as it now reads",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairedClient"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Malformed fingerprint",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No paired client with that fingerprint",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/compositors": {
|
||||
@@ -1860,6 +1931,69 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/provider/{provider}/running": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Report which of a provider's titles are running",
|
||||
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
|
||||
"operationId": "reportProviderRunning",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "path",
|
||||
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The report was accepted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningAccepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider id or payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/scanners": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -6625,7 +6759,7 @@
|
||||
},
|
||||
"HostInfo": {
|
||||
"type": "object",
|
||||
"description": "Host identity and advertised capabilities (static for the life of the process).",
|
||||
"description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
|
||||
"required": [
|
||||
"hostname",
|
||||
"uniqueid",
|
||||
@@ -6671,7 +6805,7 @@
|
||||
},
|
||||
"local_ip": {
|
||||
"type": "string",
|
||||
"description": "Best-effort primary LAN IP."
|
||||
"description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
|
||||
},
|
||||
"os": {
|
||||
"type": "string",
|
||||
@@ -7312,6 +7446,14 @@
|
||||
"description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.",
|
||||
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||
},
|
||||
"label": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).\n\nThis is the ONLY thing that can tell two paired Moonlight devices apart in a list, because\ntheir certificates cannot: see [`Self::subject`]. Absent until somebody names the device.",
|
||||
"example": "Living Room TV"
|
||||
},
|
||||
"not_after_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
@@ -7333,7 +7475,7 @@
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses."
|
||||
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.\n\nDo not display this as a device name. Every moonlight-common-c client self-signs with that\nsame fixed subject, so it identifies the *protocol*, not the device — a list of paired\nphones, TVs and handhelds all read identically. [`Self::label`] is the field to show."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7792,6 +7934,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningAccepted": {
|
||||
"type": "object",
|
||||
"description": "The result of a liveness report.",
|
||||
"required": [
|
||||
"matched",
|
||||
"unknown",
|
||||
"ttl_s"
|
||||
],
|
||||
"properties": {
|
||||
"matched": {
|
||||
"type": "integer",
|
||||
"description": "How many reported titles matched an entry this provider currently publishes.",
|
||||
"minimum": 0
|
||||
},
|
||||
"ttl_s": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
|
||||
"minimum": 0
|
||||
},
|
||||
"unknown": {
|
||||
"type": "integer",
|
||||
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningInput": {
|
||||
"type": "object",
|
||||
"description": "Request body for `reportProviderRunning`.",
|
||||
"properties": {
|
||||
"running": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RunningTitle"
|
||||
},
|
||||
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReleaseDisplayRequest": {
|
||||
"type": "object",
|
||||
"description": "Request body for `releaseDisplay`.",
|
||||
@@ -7846,6 +8028,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RenameClient": {
|
||||
"type": "object",
|
||||
"description": "Body of `PATCH /clients/{fingerprint}` — the device's display name.",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The name to show for this device. `null` (or an empty/whitespace-only string) clears it and\nthe device goes back to being listed by fingerprint alone.\n\nScrubbed before storage by the same sanitizer the native plane runs on device names:\ncontrol characters and Unicode bidi overrides are stripped (they could make one paired\ndevice impersonate another in this very list), whitespace collapsed, and the result capped\nat 64 characters.",
|
||||
"example": "Living Room TV"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RunningTitle": {
|
||||
"type": "object",
|
||||
"description": "One running title in a provider's liveness report.",
|
||||
"required": [
|
||||
"external_id"
|
||||
],
|
||||
"properties": {
|
||||
"external_id": {
|
||||
"type": "string",
|
||||
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
|
||||
},
|
||||
"pid": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuntimeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -96,7 +96,11 @@ internal fun ControllersScreen(
|
||||
InputDevice.getDeviceIds()
|
||||
.toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
.filter { !it.isVirtual && !Gamepad.isPad(it) }
|
||||
// Everything real that is NOT counted as a controller — including a device that claims
|
||||
// a pad source with no pad hardware behind it, which the Gamepads list above now
|
||||
// rejects. One list or the other, never neither: this screen is where someone looks
|
||||
// when the client's idea of "a pad is attached" disagrees with the room.
|
||||
.filter { !it.isVirtual && !Gamepad.looksLikeController(it) }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val im = context.getSystemService(InputManager::class.java)
|
||||
@@ -136,14 +140,19 @@ internal fun ControllersScreen(
|
||||
// Read ONCE, up front: the test can end inside this very event, and the release that
|
||||
// ended it still has to be swallowed here — see the B branch below.
|
||||
val consume = consuming
|
||||
// The CORRECTED keycode, so this screen shows the button the stream will send and not
|
||||
// the one Android guessed for a pad it has no key layout for — the two differ on every
|
||||
// controller [Gamepad.padKeyCode] exists for, and a tester that disagrees with the
|
||||
// stream is worse than no tester. The raw pair is still reported in "Last input".
|
||||
val code = Gamepad.padKeyCode(event)
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
held[event.keyCode] = true
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
|
||||
held[code] = true
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
held[event.keyCode] = false
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
held[code] = false
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
bHeld = false
|
||||
if (consume) {
|
||||
if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) {
|
||||
@@ -167,23 +176,43 @@ internal fun ControllersScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
|
||||
// Raw scancode AND keycode, plus the correction when one fired: this line is what a
|
||||
// field report needs to pin an unmapped pad's report order without the device in hand.
|
||||
val raw = KeyEvent.keyCodeToString(event.keyCode).removePrefix("KEYCODE_")
|
||||
val fixed = KeyEvent.keyCodeToString(code).removePrefix("KEYCODE_")
|
||||
lastInput = "${event.device?.name}: scan 0x%X · %s%s".format(
|
||||
event.scanCode,
|
||||
raw,
|
||||
if (code != event.keyCode) " → $fixed" else "",
|
||||
)
|
||||
consume
|
||||
}
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
// Through the device's resolved map, exactly as `Gamepad.AxisMapper` reads it while
|
||||
// streaming — on a pad Android has no key layout for, the right stick and the triggers
|
||||
// are not on the axes their names suggest.
|
||||
val map = Gamepad.padMap(event.device)
|
||||
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
|
||||
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
|
||||
axes["RX"] = event.getAxisValue(MotionEvent.AXIS_Z)
|
||||
axes["RY"] = event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||
axes["LT"] = maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
)
|
||||
axes["RT"] = maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
)
|
||||
axes["RX"] = event.getAxisValue(map.rightStickX)
|
||||
axes["RY"] = event.getAxisValue(map.rightStickY)
|
||||
axes["LT"] = if (map.leftTrigger == Gamepad.AXIS_NONE) {
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
)
|
||||
} else {
|
||||
map.level(event.getAxisValue(map.leftTrigger))
|
||||
}
|
||||
axes["RT"] = if (map.rightTrigger == Gamepad.AXIS_NONE) {
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
)
|
||||
} else {
|
||||
map.level(event.getAxisValue(map.rightTrigger))
|
||||
}
|
||||
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
consuming
|
||||
@@ -689,6 +718,16 @@ private fun PadRow(info: PadInfo, gamepadSetting: Int) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Only when a correction is actually in force: on a pad Android has a key layout for
|
||||
// there is nothing to say, and a line that says "normal" on every device teaches
|
||||
// nobody anything. Named rather than merely flagged, so a field report can quote it.
|
||||
padButtonsNote(info.buttons)?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (info.canRumble) {
|
||||
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
|
||||
} else {
|
||||
@@ -784,6 +823,12 @@ internal data class PadInfo(
|
||||
val controllerNumber: Int,
|
||||
val resolvedPref: Int,
|
||||
val canRumble: Boolean,
|
||||
/**
|
||||
* The report order this pad's buttons were resolved to ([Gamepad.padButtons]). Defaults to
|
||||
* the pad Android already knows, which is what a screenshot scene wants and what the note
|
||||
* under the card stays silent about.
|
||||
*/
|
||||
val buttons: Gamepad.PadButtons = Gamepad.PadButtons.NATIVE,
|
||||
val dev: InputDevice? = null,
|
||||
)
|
||||
|
||||
@@ -793,6 +838,7 @@ internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
|
||||
forwarded = isForwarded(dev),
|
||||
controllerNumber = dev.controllerNumber,
|
||||
resolvedPref = Gamepad.prefFor(dev),
|
||||
buttons = Gamepad.padMap(dev).buttons, // via padMap so the list refresh reuses the cache
|
||||
canRumble = deviceHasVibrator(dev),
|
||||
dev = dev,
|
||||
)
|
||||
@@ -823,6 +869,20 @@ internal fun testRumble(dev: InputDevice) {
|
||||
}
|
||||
|
||||
/** Identity line: VID:PID + the source classes Android assigned. */
|
||||
/**
|
||||
* What to say about a pad whose buttons had to be resolved from their scancodes because Android
|
||||
* has no key layout for it — null for a pad it does know, which needs no explanation.
|
||||
*/
|
||||
private fun padButtonsNote(buttons: Gamepad.PadButtons): String? = when (buttons) {
|
||||
Gamepad.PadButtons.NATIVE -> null
|
||||
Gamepad.PadButtons.GENERIC_SONY ->
|
||||
"Android has no button layout for this controller — read as a PlayStation pad"
|
||||
Gamepad.PadButtons.GENERIC_XBOX ->
|
||||
"Android has no button layout for this controller — read as an Xbox pad"
|
||||
Gamepad.PadButtons.SONY_MODERN ->
|
||||
"Android has no button layout for this controller — face buttons corrected"
|
||||
}
|
||||
|
||||
private fun deviceDetail(dev: InputDevice): String =
|
||||
"%04X:%04X · %s".format(dev.vendorId, dev.productId, sourcesLabel(dev.sources))
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -96,7 +97,7 @@ fun GamepadNavEffect(
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
when (Gamepad.padKeyCode(ev)) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
|
||||
@@ -202,7 +203,7 @@ fun GamepadNavEffect2D(
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
when (Gamepad.padKeyCode(ev)) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
|
||||
|
||||
@@ -515,8 +515,21 @@ class MainActivity : ComponentActivity() {
|
||||
else -> KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
}
|
||||
|
||||
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
||||
/**
|
||||
* Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate].
|
||||
*
|
||||
* NEVER on a TV, which leaves the id at `0` and makes every [setConsoleHighRefreshRate] call a
|
||||
* no-op. The pin exists for phone refresh governors that cap third-party apps at 60 Hz; a TV has
|
||||
* no such governor, and there it does active harm. `display.mode` is what [nativeDisplayMode]
|
||||
* reads to resolve "Native" refresh at connect, so a menu-time pin makes the session negotiate
|
||||
* the PINNED rate rather than the TV's real HDMI output — and [StreamScreen] then releases the
|
||||
* pin on TV (the decoder's own mode switch governs there), dropping the panel back to 60 while
|
||||
* the host is already serving 120. Every frame then waits out that mismatch, which is the
|
||||
* "latency explodes unless I set the refresh by hand" field report: picking a refresh explicitly
|
||||
* is precisely what bypasses the corrupted `nativeDisplayMode` answer.
|
||||
*/
|
||||
private fun resolveHighRefreshMode() {
|
||||
if (isTvDevice(this)) return
|
||||
@Suppress("DEPRECATION")
|
||||
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||
highRefreshModeId = disp?.supportedModes?.maxWithOrNull(
|
||||
@@ -616,7 +629,7 @@ class MainActivity : ComponentActivity() {
|
||||
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
|
||||
// why this asks it rather than `buttonBit`).
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
|
||||
val bit = Gamepad.padButtonBit(Gamepad.padKeyCode(event), event.flags)
|
||||
if (bit != 0) {
|
||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||
@@ -708,8 +721,10 @@ class MainActivity : ComponentActivity() {
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
||||
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
|
||||
// already move focus on their own, so they fall through to super untouched.
|
||||
when (event.keyCode) {
|
||||
// already move focus on their own, so they fall through to super untouched. Read
|
||||
// through [Gamepad.padKeyCode] so a pad Android has no key layout for reaches the
|
||||
// menus on the right buttons too, not only the stream.
|
||||
when (Gamepad.padKeyCode(event)) {
|
||||
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
|
||||
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
|
||||
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
|
||||
|
||||
@@ -478,7 +478,12 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
val mode = display.mode
|
||||
val w = mode.physicalWidth
|
||||
val h = mode.physicalHeight
|
||||
val hz = mode.refreshRate.toInt().coerceAtLeast(1)
|
||||
// ROUNDED, not truncated: TVs report the fractional NTSC rates over HDMI (59.94, 29.97,
|
||||
// 23.976), and `toInt()` turns 59.94 into 59 — a rate no display mode anywhere has, which the
|
||||
// host then serves by clamping DOWN to the highest mode it advertises at or below it. Rounding
|
||||
// also keeps this agreeing with `MainActivity.streamPanelFps`, which already rounds; the two
|
||||
// describe the same panel and must not disagree.
|
||||
val hz = kotlin.math.round(mode.refreshRate).toInt().coerceAtLeast(1)
|
||||
return Triple(maxOf(w, h), minOf(w, h), hz)
|
||||
}
|
||||
|
||||
|
||||
@@ -940,6 +940,17 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
// The view's CURRENT pixel size, for the ASurfaceControl layer's
|
||||
// destination rect. It is reported here and not only at
|
||||
// surfaceCreated because the view grows a frame or two after the
|
||||
// stream screen appears — hiding the system bars and switching on
|
||||
// cutout drawing both resize it, and neither recreates the surface.
|
||||
// A layer left on the start-up rect paints the picture small, in the
|
||||
// top-left corner. The view's own size, not the buffer geometry in
|
||||
// `width`/`height`: the layer composites in the view's space.
|
||||
NativeBridge.nativeVideoSurfaceSize(
|
||||
handle, this@apply.width, this@apply.height,
|
||||
)
|
||||
// Re-assert the frame-rate vote: a buffer-geometry change can reset
|
||||
// the surface's frame-rate setting on some OEM builds, silently
|
||||
// dropping the 120 Hz pin mid-stream. Mirrors the native hint's
|
||||
|
||||
@@ -317,15 +317,20 @@ internal object ConsoleJson {
|
||||
j.put("invert_scroll", s.invertScroll)
|
||||
j.put("pad_haptics", s.padHaptics)
|
||||
j.put("pad_speaker", if (s.padSpeaker) "pad" else "off")
|
||||
// Android-only rows ride `extra` (WP5 gives them RowIds); nothing on the desktop reads them.
|
||||
val extra = j.optJSONObject("extra") ?: JSONObject()
|
||||
extra.put("android.low_latency", s.lowLatencyMode)
|
||||
extra.put("android.rumble_on_phone", s.rumbleOnPhone)
|
||||
extra.put("android.gyro_on_phone", s.gyroOnPhone)
|
||||
extra.put("android.sc2_capture", s.sc2Capture)
|
||||
extra.put("android.ds_capture", s.dsCapture)
|
||||
extra.put("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
j.put("extra", extra)
|
||||
// Android-only rows ride `Settings::extra`, which is `#[serde(flatten)]` — so they are
|
||||
// TOP-LEVEL keys of this document, not a nested `extra` object. Nesting them put the
|
||||
// whole object into the map under the literal key "extra", where no console row could
|
||||
// read it and every value the console wrote came straight back as the one we had sent.
|
||||
j.put("android.low_latency", s.lowLatencyMode)
|
||||
j.put("android.rumble_on_phone", s.rumbleOnPhone)
|
||||
j.put("android.gyro_on_phone", s.gyroOnPhone)
|
||||
j.put("android.sc2_capture", s.sc2Capture)
|
||||
j.put("android.ds_capture", s.dsCapture)
|
||||
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
|
||||
// A store written by the nesting build carries the stale wrapper; drop it rather than
|
||||
// round-trip a copy of these keys that nothing reads for the life of the install.
|
||||
j.remove("extra")
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -335,7 +340,8 @@ internal object ConsoleJson {
|
||||
*/
|
||||
fun applySettings(s: Settings, j: JSONObject): Settings {
|
||||
fun str(k: String, cur: String) = j.optString(k, cur).ifEmpty { cur }
|
||||
val extra = j.optJSONObject("extra") ?: JSONObject()
|
||||
// The `android.*` keys are TOP-LEVEL here, not nested: `Settings::extra` is
|
||||
// `#[serde(flatten)]`, so the console writes them beside `width` and `codec`.
|
||||
return s.copy(
|
||||
width = j.optInt("width", s.width),
|
||||
height = j.optInt("height", s.height),
|
||||
@@ -372,13 +378,14 @@ internal object ConsoleJson {
|
||||
"off" -> false
|
||||
else -> s.padSpeaker
|
||||
},
|
||||
lowLatencyMode = extra.optBoolean("android.low_latency", s.lowLatencyMode),
|
||||
rumbleOnPhone = extra.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
|
||||
gyroOnPhone = extra.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
|
||||
sc2Capture = extra.optBoolean("android.sc2_capture", s.sc2Capture),
|
||||
dsCapture = extra.optBoolean("android.ds_capture", s.dsCapture),
|
||||
gamepadUiMode = extra.optString("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
lowLatencyMode = j.optBoolean("android.low_latency", s.lowLatencyMode),
|
||||
rumbleOnPhone = j.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
|
||||
gyroOnPhone = j.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
|
||||
sc2Capture = j.optBoolean("android.sc2_capture", s.sc2Capture),
|
||||
dsCapture = j.optBoolean("android.ds_capture", s.dsCapture),
|
||||
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
.ifEmpty { s.gamepadUiMode },
|
||||
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,9 @@ object SkiaConsole {
|
||||
val opts = JSONObject()
|
||||
.put("device_name", deviceName(app))
|
||||
.put("gpu_cache_bytes", gpuCacheBytes(app))
|
||||
// The touch shell exists as a fallback on phones/tablets but not on a TV —
|
||||
// gates the console's own "Controller-optimized UI" off switch.
|
||||
.put("fallback_ui", !io.unom.punktfunk.isTvDevice(app))
|
||||
.put("settings", ConsoleJson.settings(initial, base))
|
||||
.put("profiles", JSONArray(ConsoleJson.profiles(profiles)))
|
||||
.put("known_hosts", JSONObject(ConsoleJson.knownHosts(knownHostStore.all())))
|
||||
|
||||
@@ -159,7 +159,12 @@ fun SkiaConsoleShell(
|
||||
if (ev.action != KeyEvent.ACTION_DOWN && ev.action != KeyEvent.ACTION_UP) return@probe false
|
||||
val fromPad = ev.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
if (fromPad) {
|
||||
val bit = when (ev.keyCode) {
|
||||
// The CORRECTED keycode: a pad Android has no key layout for delivers its buttons
|
||||
// under other buttons' names, so read raw this console answered ✕ with whatever
|
||||
// sat in BUTTON_A's scancode slot. Same resolution the stream uses — the console
|
||||
// and the game must not disagree about which button a user pressed.
|
||||
val code = Gamepad.padKeyCode(ev)
|
||||
val bit = when (code) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> 0
|
||||
KeyEvent.KEYCODE_BUTTON_B -> 1
|
||||
KeyEvent.KEYCODE_BUTTON_X -> 2
|
||||
@@ -179,7 +184,7 @@ fun SkiaConsoleShell(
|
||||
}
|
||||
return@probe true
|
||||
}
|
||||
val dbit = when (ev.keyCode) {
|
||||
val dbit = when (code) {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> 0
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> 1
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> 2
|
||||
@@ -191,7 +196,7 @@ fun SkiaConsoleShell(
|
||||
padState.push(handle)
|
||||
return@probe true
|
||||
}
|
||||
if (ev.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
|
||||
NativeBridge.nativeConsoleMenu(handle, 0) // ▲ opens the tile's options on Home
|
||||
return@probe true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.console.ConsoleJson
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Android-only console settings ride `trust::Settings::extra`, which is `#[serde(flatten)]`:
|
||||
* they are TOP-LEVEL keys of the settings document, beside `width` and `codec`.
|
||||
*
|
||||
* They were written and read nested under an `"extra"` object instead. Serde put that whole
|
||||
* object into the map under the literal key `"extra"`, so no console row ever found
|
||||
* `android.gamepad_ui_enabled` — and the value the console saved came back to Kotlin as the one
|
||||
* Kotlin had just sent. On glass that was a "Controller-optimized UI" switch you could turn off
|
||||
* with nothing happening: the console stayed up, because the setting never moved.
|
||||
*/
|
||||
class ConsoleSettingsExtraTest {
|
||||
@Test
|
||||
fun androidKeysAreWrittenFlat() {
|
||||
val j = ConsoleJson.settings(Settings(gamepadUiEnabled = false, lowLatencyMode = false), null)
|
||||
assertTrue("the console reads this key at the top level", j.has("android.gamepad_ui_enabled"))
|
||||
assertFalse(j.getBoolean("android.gamepad_ui_enabled"))
|
||||
assertFalse(j.getBoolean("android.low_latency"))
|
||||
assertFalse("a nested wrapper is what serde swallows whole", j.has("extra"))
|
||||
}
|
||||
|
||||
/** A store written by the nesting build must not keep echoing its dead wrapper. */
|
||||
@Test
|
||||
fun aStaleNestedWrapperIsDropped() {
|
||||
val base = JSONObject().put(
|
||||
"extra",
|
||||
JSONObject().put("android.gamepad_ui_enabled", true),
|
||||
)
|
||||
assertFalse(ConsoleJson.settings(Settings(gamepadUiEnabled = false), base).has("extra"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theConsolesOwnSaveIsReadBack() {
|
||||
val saved = JSONObject()
|
||||
.put("android.gamepad_ui_enabled", false)
|
||||
.put("android.gamepad_ui_mode", GAMEPAD_UI_ALWAYS)
|
||||
.put("android.ds_capture", false)
|
||||
val next = ConsoleJson.applySettings(Settings(), saved)
|
||||
assertFalse("turning the console off must reach the store", next.gamepadUiEnabled)
|
||||
assertEquals(GAMEPAD_UI_ALWAYS, next.gamepadUiMode)
|
||||
assertFalse(next.dsCapture)
|
||||
}
|
||||
|
||||
/** Both halves against each other — the shape only holds if they agree. */
|
||||
@Test
|
||||
fun theRoundTripKeepsEveryAndroidRow() {
|
||||
val want = Settings(
|
||||
gamepadUiEnabled = false,
|
||||
gamepadUiMode = GAMEPAD_UI_ALWAYS,
|
||||
lowLatencyMode = false,
|
||||
rumbleOnPhone = true,
|
||||
gyroOnPhone = true,
|
||||
sc2Capture = false,
|
||||
dsCapture = false,
|
||||
)
|
||||
val got = ConsoleJson.applySettings(Settings(), ConsoleJson.settings(want, null))
|
||||
assertEquals(want.gamepadUiEnabled, got.gamepadUiEnabled)
|
||||
assertEquals(want.gamepadUiMode, got.gamepadUiMode)
|
||||
assertEquals(want.lowLatencyMode, got.lowLatencyMode)
|
||||
assertEquals(want.rumbleOnPhone, got.rumbleOnPhone)
|
||||
assertEquals(want.gyroOnPhone, got.gyroOnPhone)
|
||||
assertEquals(want.sc2Capture, got.sc2Capture)
|
||||
assertEquals(want.dsCapture, got.dsCapture)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package io.unom.punktfunk.kit
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -127,8 +128,12 @@ object Gamepad {
|
||||
|
||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
// The Bluetooth revisions (0x02E0/0x02FD Xbox One S, 0x0B05/0x0B22 Elite Series 2 and its
|
||||
// Core) are here for the same reason as the wired ones: they are the pads a couch actually
|
||||
// pairs to a TV box, and without them an Elite streams under the Xbox 360 identity.
|
||||
private val PID_XBOXONE = setOf(
|
||||
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
|
||||
0x02D1, 0x02DD, 0x02E0, 0x02E3, 0x02EA, 0x02FD,
|
||||
0x0B00, 0x0B05, 0x0B12, 0x0B13, 0x0B20, 0x0B22,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -188,9 +193,53 @@ object Gamepad {
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
}
|
||||
|
||||
/** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */
|
||||
fun pads(): List<InputDevice> =
|
||||
InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) }
|
||||
/**
|
||||
* True when [dev] is a controller someone can actually hold: a pad source ([isPad]) that is a
|
||||
* REAL device carrying real pad hardware — a stick, a HAT, or the A/B face buttons.
|
||||
*
|
||||
* [isPad] alone answers "did this event come from a pad source", which is the right question
|
||||
* for ROUTING an event and the wrong one for "is a controller attached". Devices publish
|
||||
* inputs that claim `SOURCE_GAMEPAD`/`SOURCE_JOYSTICK` while being no such thing — OEM
|
||||
* game-mode overlays and the gaming-phone shoulder triggers among them — and one of those is
|
||||
* enough to pin the console UI on forever: a pad that was never there cannot disconnect, so
|
||||
* "With a controller" has no way back to the touch UI.
|
||||
*
|
||||
* The capability probe is what separates them: a source class is a claim, a stick or a face
|
||||
* button is hardware. It is not a complete defence — an OEM device that declares `BTN_GAMEPAD`
|
||||
* and a pair of axes is indistinguishable from a pad at this layer — so the master switch stays
|
||||
* the guaranteed way out. `isVirtual` only means "device id < 0" (the platform's own synthetic
|
||||
* device), which is worth excluding but catches none of the above.
|
||||
*/
|
||||
fun looksLikeController(dev: InputDevice?): Boolean {
|
||||
val d = dev ?: return false
|
||||
return looksLikeController(
|
||||
padSource = isPad(d),
|
||||
virtual = d.isVirtual,
|
||||
hasStick = d.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_JOYSTICK) != null ||
|
||||
d.getMotionRange(MotionEvent.AXIS_HAT_X, InputDevice.SOURCE_JOYSTICK) != null,
|
||||
// `hasKeys` answers for the DEVICE, so a pad with no sticks at all (an arcade stick,
|
||||
// a d-pad-only pad) still counts.
|
||||
hasFaceButtons = d.hasKeys(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B)
|
||||
.any { it },
|
||||
)
|
||||
}
|
||||
|
||||
/** [looksLikeController]'s decision, over plain facts — the seam its truth table is tested at
|
||||
* (an [InputDevice] cannot be built off a device). */
|
||||
fun looksLikeController(
|
||||
padSource: Boolean,
|
||||
virtual: Boolean,
|
||||
hasStick: Boolean,
|
||||
hasFaceButtons: Boolean,
|
||||
): Boolean = padSource && !virtual && (hasStick || hasFaceButtons)
|
||||
|
||||
/**
|
||||
* All connected controllers, in system enumeration order — the devices that answer "is a pad
|
||||
* attached", so the filter is [looksLikeController] rather than the looser [isPad].
|
||||
*/
|
||||
fun pads(): List<InputDevice> = InputDevice.getDeviceIds().toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
.filter { looksLikeController(it) }
|
||||
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? = pads().firstOrNull()
|
||||
@@ -293,6 +342,334 @@ object Gamepad {
|
||||
else -> BTN_BACK
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Controllers Android has no key layout for
|
||||
//
|
||||
// Android turns a pad's raw evdev scancode into a `KeyEvent.keyCode` through a KEY LAYOUT
|
||||
// file matched on USB VID/PID (`Vendor_054c_Product_0ce6.kl` & co.). A pad with no matching
|
||||
// file falls back to AOSP's `Generic.kl`, which assigns keycodes by SCANCODE POSITION —
|
||||
// `0x130`→BUTTON_A, `0x131`→BUTTON_B, `0x132`→BUTTON_C, and so on up. That is only right if
|
||||
// the pad's buttons happen to sit at the positions the file assumes, and a HID gamepad with
|
||||
// no kernel driver behind it numbers its buttons 1..n straight through IN ITS OWN REPORT
|
||||
// ORDER — so every keycode after the first divergence is somebody else's button.
|
||||
//
|
||||
// Reported from a Fire TV Stick 4K Max (2026-08-20): a DualSense and an Xbox Elite Series 2,
|
||||
// both over Bluetooth, both identified correctly but with buttons landing on the wrong
|
||||
// actions ("L1 being L2"). Neither has a layout there — AOSP ships none for the Elite
|
||||
// Series 2 over Bluetooth (`045e:0b05`) on ANY version, and the DualSense's
|
||||
// (`054c:0ce6`) both postdates Fire OS and carries `requires_kernel_config
|
||||
// CONFIG_HID_PLAYSTATION`, which a Fire TV kernel does not have. A DualSense reporting
|
||||
// straight through puts L2 on `0x136`, which `Generic.kl` calls BUTTON_L1: the reported
|
||||
// symptom exactly.
|
||||
//
|
||||
// The fix is to resolve buttons from the SCANCODE, which is the pad's own report position and
|
||||
// is immune to the layout file — the same reason [Keymap.toVk] reads `scanCode` for keyboards.
|
||||
// Two things keep it from breaking a pad that already works:
|
||||
//
|
||||
// 1. Nothing is corrected on a pad that names its triggers ([padButtons]). A descriptor
|
||||
// well-formed enough to call them Accelerator/Brake puts its buttons at the standard
|
||||
// positions too, and that is the fact — not the model — that separates the two firmwares
|
||||
// of the SAME Xbox pad, only the older of which needs any of this.
|
||||
// 2. Past that gate the correction still applies ONLY where the delivered keycode is what
|
||||
// `Generic.kl` would have said ([genericKeyCode]). A different keycode means a
|
||||
// device-specific layout IS in force and knows this pad better than we do.
|
||||
//
|
||||
// Moonlight carries the same two tables AND the same gate (`ControllerHandler`'s
|
||||
// `isNonStandardDualShock4` / `isNonStandardXboxBtController`, the latter on `gasRange == null`),
|
||||
// which is why both pads work there on the same box.
|
||||
//
|
||||
// The first cut of this asked `hasKeys(BUTTON_C, BUTTON_Z)` on its own, on the reasoning that a
|
||||
// pad numbering straight through reaches keycodes no controller has a button for. It does — but
|
||||
// so does every pad that merely DECLARES six buttons, because `hid-input` allocates `BTN_A + n`
|
||||
// straight through for the whole descriptor whether or not the pad ever presses them. That fired
|
||||
// the correction on pads Android was already reading correctly (2026-08-21: an Xbox pad
|
||||
// answering X with Y, Y with LB, and both shoulders with a menu button), and it could not have
|
||||
// done otherwise: the signal is identical on the firmware that needs correcting and the one that
|
||||
// does not. Declaration is not report order. Only the axes tell them apart.
|
||||
|
||||
/** [MotionEvent] axis id meaning "this pad has no such axis" — see [PadMap]. */
|
||||
const val AXIS_NONE = -1
|
||||
|
||||
/**
|
||||
* The report order a controller's buttons are numbered in, and with it which scancode carries
|
||||
* which physical button. Resolved once per device by [padButtons] from what the device
|
||||
* declares; [correct] then maps one scancode to the keycode it should have produced.
|
||||
*/
|
||||
enum class PadButtons {
|
||||
/**
|
||||
* The keycode Android delivered is already right — a device-specific key layout is in
|
||||
* force, or the generic one happens to agree. [correct] changes nothing.
|
||||
*/
|
||||
NATIVE,
|
||||
|
||||
/**
|
||||
* A Sony pad numbering straight through with no kernel driver behind it: □ ✕ ○ △ L1 R1
|
||||
* L2 R2 Create Options L3 R3 PS, i.e. `0x130`..`0x13c` in that order. The analog trigger
|
||||
* value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold to keycodes
|
||||
* [buttonBit] deliberately drops — the wire carries the axis, never both.
|
||||
*/
|
||||
GENERIC_SONY,
|
||||
|
||||
/**
|
||||
* An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS, i.e.
|
||||
* `0x130`..`0x139`. Also the fallback for an unbranded pad, which near-universally
|
||||
* clones the Xbox layout — the same assumption [styleFor] makes for its glyphs.
|
||||
*/
|
||||
GENERIC_XBOX,
|
||||
|
||||
/**
|
||||
* A Sony pad WITH a kernel driver (`hid-playstation` / `hid-sony`) but still no key
|
||||
* layout — the combination an Android 11 box on a 5.10 kernel lands in. Such a driver
|
||||
* emits the modern Linux gamepad codes, where `0x133` is BTN_NORTH (△) and `0x134` is
|
||||
* BTN_WEST (□); `Generic.kl` reads those two as BUTTON_X and BUTTON_Y, so exactly the
|
||||
* face pair comes out swapped and nothing else is wrong.
|
||||
*/
|
||||
SONY_MODERN,
|
||||
;
|
||||
|
||||
/**
|
||||
* The keycode scancode [scan] should have produced, given Android delivered [keyCode].
|
||||
*
|
||||
* Returns [keyCode] untouched unless it is precisely what [genericKeyCode] would have
|
||||
* said for [scan] — anything else is a device-specific layout's answer, which outranks
|
||||
* this table. That guard is what makes the correction idempotent and safe to run on
|
||||
* every pad: it can only ever fire where Android was guessing in the first place.
|
||||
*/
|
||||
fun correct(scan: Int, keyCode: Int): Int {
|
||||
if (this == NATIVE) return keyCode
|
||||
if (keyCode != genericKeyCode(scan)) return keyCode
|
||||
val fixed = when (this) {
|
||||
GENERIC_SONY -> when (scan) {
|
||||
0x130 -> KeyEvent.KEYCODE_BUTTON_X // □
|
||||
0x131 -> KeyEvent.KEYCODE_BUTTON_A // ✕
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_B // ○
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // △
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_L2 // analog: AXIS_RX
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_R2 // analog: AXIS_RY
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_SELECT // Create / Share
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_START // Options
|
||||
0x13a -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x13b -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE // PS
|
||||
// 0x13d touchpad click / 0x13e mute: no wire button, dropped as before.
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
GENERIC_XBOX -> when (scan) {
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_X
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_SELECT // View
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_START // Menu
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
else -> keyCode // 0x130 A / 0x131 B already agree
|
||||
}
|
||||
// Only the face pair; every other row of Generic.kl is right for these codes.
|
||||
SONY_MODERN -> when (scan) {
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // BTN_NORTH = △
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_X // BTN_WEST = □
|
||||
else -> keyCode
|
||||
}
|
||||
NATIVE -> keyCode
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AOSP `Generic.kl`'s gamepad rows — the layout Android falls back to when no device-specific
|
||||
* key layout matches the pad's VID/PID. Scancodes outside it answer [KeyEvent.KEYCODE_UNKNOWN],
|
||||
* which never equals a real delivered keycode, so [PadButtons.correct]'s guard leaves those
|
||||
* events alone.
|
||||
*/
|
||||
fun genericKeyCode(scan: Int): Int = when (scan) {
|
||||
0x130 -> KeyEvent.KEYCODE_BUTTON_A
|
||||
0x131 -> KeyEvent.KEYCODE_BUTTON_B
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_C
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_X
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_Y
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_Z
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_L2
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_R2
|
||||
0x13a -> KeyEvent.KEYCODE_BUTTON_SELECT
|
||||
0x13b -> KeyEvent.KEYCODE_BUTTON_START
|
||||
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE
|
||||
0x13d -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x13e -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* How one controller must be read: its button report order plus the axes its right stick and
|
||||
* analog triggers actually arrive on. Resolved once per device by [padMap].
|
||||
*/
|
||||
class PadMap(
|
||||
val buttons: PadButtons,
|
||||
val rightStickX: Int = MotionEvent.AXIS_Z,
|
||||
val rightStickY: Int = MotionEvent.AXIS_RZ,
|
||||
/**
|
||||
* The trigger axes, or [AXIS_NONE] for a pad Android already names them on — that case
|
||||
* keeps folding LTRIGGER with BRAKE and RTRIGGER with GAS by max, which is what pads that
|
||||
* report one pair, the other, or both have always needed.
|
||||
*/
|
||||
val leftTrigger: Int = AXIS_NONE,
|
||||
val rightTrigger: Int = AXIS_NONE,
|
||||
/** Those trigger axes rest at −1 rather than 0, measured off the device's own range. */
|
||||
val triggersSigned: Boolean = false,
|
||||
) {
|
||||
/** One resolved trigger axis value, folded to the 0..1 the wire scale expects. */
|
||||
fun level(v: Float): Float = if (triggersSigned) (v + 1f) / 2f else v
|
||||
}
|
||||
|
||||
/** The map every pad with a key layout uses: Android's own names, unchanged. */
|
||||
private val NATIVE_MAP = PadMap(PadButtons.NATIVE)
|
||||
|
||||
/**
|
||||
* Resolved [PadMap]s, keyed by [InputDevice.getDescriptor] — the device's stable identity
|
||||
* hash, so a pad that reconnects is recognised and a model resolves once for the process.
|
||||
* Nothing here depends on a live connection, so entries never need evicting.
|
||||
*/
|
||||
private val padMaps = ConcurrentHashMap<String, PadMap>()
|
||||
|
||||
/**
|
||||
* Which report order [dev]'s buttons follow — [namedTriggers] is whether the pad reports its
|
||||
* triggers under a name Android knows (see [padMap]), and [declaresCZ] whether it declares
|
||||
* BUTTON_C and BUTTON_Z.
|
||||
*
|
||||
* `namedTriggers` decides it, and a pad that has them is [PadButtons.NATIVE] whatever else it
|
||||
* says. A HID gamepad describes its triggers either as the Accelerator/Brake usages, which
|
||||
* become `ABS_GAS`/`ABS_BRAKE` and axis names Android has words for, or as two more generic
|
||||
* axes on `ABS_Z`/`ABS_RZ`, which it does not — and a report descriptor well-formed enough to
|
||||
* name its triggers puts its buttons at the standard positions too, the ones `Generic.kl`
|
||||
* already reads correctly. It is the same fact Moonlight decides this on (`gasRange == null`
|
||||
* beside the `"Xbox Wireless Controller"` name), and it is the one that separates the two
|
||||
* firmwares of the SAME pad: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE
|
||||
* after its firmware update and Z/Rz before it, and only the older one needs correcting.
|
||||
*
|
||||
* `declaresCZ` cannot make that call and must never be asked to. `hasKeys` answers for what a
|
||||
* device DECLARES, not what it reports: `hid-input` allocates `BTN_A + n` straight through for
|
||||
* every button in the descriptor, so BTN_C (`0x132`) and BTN_Z (`0x135`) are set on any pad
|
||||
* declaring six or more — a standard-layout pad that never presses either included. Read alone
|
||||
* it fired the correction on pads whose buttons were already right, which is how an Xbox pad
|
||||
* came to answer X with Y and Y with LB (field reports, 2026-08-21). It stays as the narrower
|
||||
* question it can answer — WHICH straight-through order, once `namedTriggers` has established
|
||||
* there is one — where a false positive costs nothing.
|
||||
*/
|
||||
fun padButtons(dev: InputDevice, namedTriggers: Boolean): PadButtons {
|
||||
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
|
||||
return padButtons(namedTriggers, dev.vendorId == VID_SONY, declaresCZ = has[0] && has[1])
|
||||
}
|
||||
|
||||
/** [padButtons]'s choice over plain facts — the seam its truth table is tested at (an
|
||||
* [InputDevice] cannot be built off a device). */
|
||||
fun padButtons(namedTriggers: Boolean, sony: Boolean, declaresCZ: Boolean): PadButtons = when {
|
||||
namedTriggers -> PadButtons.NATIVE
|
||||
declaresCZ && sony -> PadButtons.GENERIC_SONY
|
||||
declaresCZ -> PadButtons.GENERIC_XBOX
|
||||
sony -> PadButtons.SONY_MODERN
|
||||
else -> PadButtons.NATIVE
|
||||
}
|
||||
|
||||
/**
|
||||
* The [PadMap] for [dev] — its button report order and the axes its right stick and triggers
|
||||
* arrive on, resolved once per device model and cached.
|
||||
*
|
||||
* Axes get the same treatment as buttons: a pad Android has a layout for names its triggers
|
||||
* LTRIGGER/RTRIGGER (or BRAKE/GAS, or BRAKE/THROTTLE) and is left exactly as it was. A pad
|
||||
* with NONE of those names is one Android never mapped, and its triggers are sitting on two
|
||||
* raw axes under the names the HID report gave them. Which two depends on the same report
|
||||
* order the buttons did:
|
||||
*
|
||||
* - a Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry = left stick, right
|
||||
* stick, then the triggers — so the right stick is already right and only the triggers
|
||||
* (`AXIS_RX`/`AXIS_RY`) are missed;
|
||||
* - every other such pad puts the right stick on Rx/Ry and the triggers on Z/Rz, which is
|
||||
* the shape that makes pulling a trigger swing the right stick.
|
||||
*
|
||||
* Whether those axes idle at −1 is MEASURED from the device's own range rather than assumed,
|
||||
* so a pad that reports an honest 0..1 is not rescaled to a permanent half-pull.
|
||||
*/
|
||||
fun padMap(dev: InputDevice?): PadMap {
|
||||
if (dev == null) return NATIVE_MAP
|
||||
padMaps[dev.descriptor]?.let { return it }
|
||||
fun has(a: Int) = axis(dev, a) != null
|
||||
val named = (has(MotionEvent.AXIS_LTRIGGER) && has(MotionEvent.AXIS_RTRIGGER)) ||
|
||||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_GAS)) ||
|
||||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_THROTTLE))
|
||||
val buttons = padButtons(dev, namedTriggers = named)
|
||||
val rx = axis(dev, MotionEvent.AXIS_RX)
|
||||
val hasRxRy = rx != null && has(MotionEvent.AXIS_RY)
|
||||
// Whichever pair the fallback is about to pick, ask THAT one where it rests.
|
||||
val restsNegative = if (buttons == PadButtons.GENERIC_SONY) {
|
||||
(rx?.min ?: 0f) < -0.5f
|
||||
} else {
|
||||
(axis(dev, MotionEvent.AXIS_Z)?.min ?: 0f) < -0.5f
|
||||
}
|
||||
val map = padMap(buttons, namedTriggers = named, hasRxRy = hasRxRy, restsNegative = restsNegative)
|
||||
padMaps[dev.descriptor] = map
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* The axis half of [padMap], decided from four facts about the device so it can be pinned
|
||||
* without one — see `PadButtonsTest`. [namedTriggers] is whether the pad calls its triggers
|
||||
* anything Android knows (LTRIGGER/RTRIGGER, BRAKE/GAS, BRAKE/THROTTLE); if it does, nothing
|
||||
* here applies and the pad is read exactly as it always was. [restsNegative] is measured off
|
||||
* whichever axis pair the fallback picks, never assumed.
|
||||
*/
|
||||
fun padMap(
|
||||
buttons: PadButtons,
|
||||
namedTriggers: Boolean,
|
||||
hasRxRy: Boolean,
|
||||
restsNegative: Boolean,
|
||||
): PadMap = when {
|
||||
namedTriggers || !hasRxRy -> PadMap(buttons)
|
||||
// X, Y, Z, Rz, Rx, Ry = left stick, right stick, triggers. The sticks already read right.
|
||||
buttons == PadButtons.GENERIC_SONY -> PadMap(
|
||||
buttons,
|
||||
leftTrigger = MotionEvent.AXIS_RX,
|
||||
rightTrigger = MotionEvent.AXIS_RY,
|
||||
triggersSigned = restsNegative,
|
||||
)
|
||||
// Right stick on Rx/Ry and triggers on Z/Rz — the shape in which reading Z/Rz as the
|
||||
// right stick makes pulling a trigger swing it.
|
||||
else -> PadMap(
|
||||
buttons,
|
||||
rightStickX = MotionEvent.AXIS_RX,
|
||||
rightStickY = MotionEvent.AXIS_RY,
|
||||
leftTrigger = MotionEvent.AXIS_Z,
|
||||
rightTrigger = MotionEvent.AXIS_RZ,
|
||||
triggersSigned = restsNegative,
|
||||
)
|
||||
}
|
||||
|
||||
/** [dev]'s range for one joystick [axis], under either source class a pad reports on. */
|
||||
private fun axis(dev: InputDevice, axis: Int): InputDevice.MotionRange? =
|
||||
dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK)
|
||||
?: dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD)
|
||||
|
||||
/**
|
||||
* The keycode [event] should have carried, given the controller it came from — [event]'s own
|
||||
* keycode for every pad Android has a key layout for, and the scancode's true button for one
|
||||
* it does not (see the block comment above [PadButtons]).
|
||||
*
|
||||
* A drop-in for `event.keyCode` at every gamepad reader: the console UI's navigation, the
|
||||
* Controllers screen's tester, and the streaming branch all route through it, so a mis-mapped
|
||||
* pad is fixed in the menus and in the game at once. Events from anything that is not a
|
||||
* controller, and events with no scancode (soft keyboards, synthetic events), pass through
|
||||
* untouched.
|
||||
*/
|
||||
fun padKeyCode(event: KeyEvent): Int {
|
||||
val dev = event.device ?: return event.keyCode
|
||||
if (event.scanCode == 0 || !isPad(dev)) return event.keyCode
|
||||
return padMap(dev).buttons.correct(event.scanCode, event.keyCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
|
||||
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
|
||||
@@ -306,7 +683,12 @@ object Gamepad {
|
||||
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
|
||||
* folds the event straight in without re-qualifying it.
|
||||
*/
|
||||
class AxisMapper(private val handle: Long, private val pad: Int) {
|
||||
class AxisMapper(
|
||||
private val handle: Long,
|
||||
private val pad: Int,
|
||||
/** Which axes this controller's right stick and triggers arrive on — see [padMap]. */
|
||||
private val map: PadMap = NATIVE_MAP,
|
||||
) {
|
||||
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
||||
private val last = IntArray(6) { Int.MIN_VALUE }
|
||||
private var hatX = 0 // -1 / 0 / +1
|
||||
@@ -317,30 +699,18 @@ object Gamepad {
|
||||
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
||||
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
||||
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
|
||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
|
||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
|
||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(map.rightStickX)))
|
||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(map.rightStickY)))
|
||||
|
||||
// Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
|
||||
// with max, the same fold as the Controllers screen probe, so a pad that reports
|
||||
// only one pair and a pad that reports both behave identically; 0..1 → 0..255.
|
||||
sendAxis(
|
||||
AXIS_LT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
),
|
||||
),
|
||||
)
|
||||
sendAxis(
|
||||
AXIS_RT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
),
|
||||
),
|
||||
)
|
||||
// only one pair and a pad that reports both behave identically; 0..1 → 0..255. A pad
|
||||
// reporting NONE of those names is one Android has no key layout for, and [map]
|
||||
// carries the raw axes its triggers really landed on instead.
|
||||
val lt = resolved(event, map.leftTrigger, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)
|
||||
val rt = resolved(event, map.rightTrigger, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)
|
||||
sendAxis(AXIS_LT, trigger(lt))
|
||||
sendAxis(AXIS_RT, trigger(rt))
|
||||
|
||||
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
|
||||
// tap (press+release inside one batch window) lives only in the historical samples — the
|
||||
@@ -383,6 +753,17 @@ object Gamepad {
|
||||
hatY = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One trigger's 0..1 value: [resolvedAxis] when this pad needed one resolved for it,
|
||||
* else the max of the two names Android gives a trigger it does know.
|
||||
*/
|
||||
private fun resolved(event: MotionEvent, resolvedAxis: Int, named: Int, alias: Int): Float =
|
||||
if (resolvedAxis == AXIS_NONE) {
|
||||
maxOf(event.getAxisValue(named), event.getAxisValue(alias))
|
||||
} else {
|
||||
map.level(event.getAxisValue(resolvedAxis))
|
||||
}
|
||||
|
||||
private fun sendAxis(id: Int, v: Int) {
|
||||
if (last[id] == v) return
|
||||
last[id] = v
|
||||
|
||||
@@ -605,7 +605,7 @@ class GamepadRouter(
|
||||
// for the slot's life; the sensor path reads it on every sample.
|
||||
val slot = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index),
|
||||
Gamepad.AxisMapper(handle, index, Gamepad.padMap(dev)),
|
||||
NativeBridge.nativePadMotionReaches(handle, pref),
|
||||
)
|
||||
slots[dev.id] = slot
|
||||
|
||||
@@ -298,6 +298,18 @@ object NativeBridge {
|
||||
surfaceH: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Re-report the video SurfaceView's on-screen pixel size — call it from every `surfaceChanged`.
|
||||
*
|
||||
* The ASurfaceControl present backend composites the picture into exactly this rectangle, and
|
||||
* the view grows AFTER [nativeStartVideo] has run: the stream screen hides the system bars and
|
||||
* switches the window to draw into the display cutout a frame or two later, and neither
|
||||
* recreates the surface. Without this the layer keeps painting at its start-up size in the
|
||||
* corner of a now-bigger surface. Non-positive values are ignored. No-op on a `0` handle;
|
||||
* cheap (one atomic store), UI-safe.
|
||||
*/
|
||||
external fun nativeVideoSurfaceSize(handle: Long, width: Int, height: Int)
|
||||
|
||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||
external fun nativeStopVideo(handle: Long)
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of [Gamepad.PadButtons.correct] — the scancode resolution for controllers Android
|
||||
* has no key layout for. Only `KeyEvent`'s compile-time-inlined keycode constants are involved, so
|
||||
* no Android runtime is needed. Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*
|
||||
* The regression it pins is a field report from a Fire TV Stick 4K Max (2026-08-20): a DualSense
|
||||
* and an Xbox Elite Series 2, both over Bluetooth, both identified correctly but with buttons
|
||||
* landing on the wrong actions — "L1 being L2". Neither pad has a key layout on that box (AOSP
|
||||
* ships none for `045e:0b05` at all, and the DualSense's requires `CONFIG_HID_PLAYSTATION`), so
|
||||
* both fall back to `Generic.kl`, which names keycodes by scancode POSITION. A pad with no kernel
|
||||
* driver numbers its HID buttons 1..n straight through in its own report order, so every keycode
|
||||
* after the first divergence belongs to a different button.
|
||||
*
|
||||
* The table below is the pad's physical button on the left and where `Generic.kl` put it on the
|
||||
* right; the assertions read it back the other way.
|
||||
*/
|
||||
class PadButtonsTest {
|
||||
|
||||
private fun sony(scan: Int) =
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
|
||||
private fun xbox(scan: Int) =
|
||||
Gamepad.PadButtons.GENERIC_XBOX.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
|
||||
/**
|
||||
* The exact report: a DualSense's L2 sits at scancode `0x136`, which `Generic.kl` calls
|
||||
* BUTTON_L1 — so pulling L2 read as a shoulder press, and L1 (at `0x134`, read as BUTTON_Y)
|
||||
* read as a face button.
|
||||
*/
|
||||
@Test
|
||||
fun `a DualSense's shoulders stop being each other's buttons`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, sony(0x134)) // L1, delivered as BUTTON_Y
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, sony(0x135)) // R1, delivered as BUTTON_Z
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L2, sony(0x136)) // L2, delivered as BUTTON_L1
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R2, sony(0x137)) // R2, delivered as BUTTON_R1
|
||||
}
|
||||
|
||||
/** ✕ is the bottom button — the one A means everywhere else — and □ is the left one. */
|
||||
@Test
|
||||
fun `a DualSense's face buttons land on their Xbox positions`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, sony(0x130)) // □
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_A, sony(0x131)) // ✕
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_B, sony(0x132)) // ○
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, sony(0x133)) // △
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/Options/L3/R3/PS. Select in particular: without this it arrived as BUTTON_THUMBL,
|
||||
* which took the exit, mic and stats chords with it — every one of them is built on Select.
|
||||
*/
|
||||
@Test
|
||||
fun `a DualSense's menu buttons and stick clicks are themselves`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, sony(0x138)) // Create
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, sony(0x139)) // Options
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, sony(0x13a)) // L3
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, sony(0x13b)) // R3
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_MODE, sony(0x13c)) // PS
|
||||
}
|
||||
|
||||
/** The touchpad click and mute have no wire button; they must resolve to nothing, not to R3. */
|
||||
@Test
|
||||
fun `a DualSense's touchpad and mute are dropped rather than mistaken`() {
|
||||
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13d))
|
||||
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13e))
|
||||
assertEquals(0, Gamepad.buttonBit(sony(0x13d)))
|
||||
}
|
||||
|
||||
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
|
||||
@Test
|
||||
fun `an Xbox pad numbering straight through keeps its own layout`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_A, xbox(0x130))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_B, xbox(0x131))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, xbox(0x132))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, xbox(0x133))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, xbox(0x134))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, xbox(0x135))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, xbox(0x136)) // View
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, xbox(0x137)) // Menu
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, xbox(0x138))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, xbox(0x139))
|
||||
}
|
||||
|
||||
/** `hid-playstation` emits the modern Linux codes, where only the face pair reads swapped. */
|
||||
@Test
|
||||
fun `a driver-backed Sony pad has only its face pair corrected`() {
|
||||
val m = Gamepad.PadButtons.SONY_MODERN
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, m.correct(0x133, KeyEvent.KEYCODE_BUTTON_X)) // △
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, m.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y)) // □
|
||||
for (scan in listOf(0x130, 0x131, 0x136, 0x137, 0x13a, 0x13b, 0x13c)) {
|
||||
assertEquals(Gamepad.genericKeyCode(scan), m.correct(scan, Gamepad.genericKeyCode(scan)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard that makes all of this safe to run on every pad: a keycode that is NOT what
|
||||
* `Generic.kl` would have said came from a device-specific key layout, which knows this
|
||||
* controller better than any table here. Correcting it would break a pad that works.
|
||||
*/
|
||||
@Test
|
||||
fun `a keycode a device layout already resolved is never second-guessed`() {
|
||||
// AOSP's DualSense layout puts △ on BUTTON_Y itself. Every profile must leave it be.
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, p.correct(0x133, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
}
|
||||
// Same for a scancode outside the generic gamepad block entirely — a pad's Back key.
|
||||
assertEquals(
|
||||
KeyEvent.KEYCODE_BACK,
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(158, KeyEvent.KEYCODE_BACK),
|
||||
)
|
||||
}
|
||||
|
||||
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
|
||||
@Test
|
||||
fun `correction is idempotent`() {
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
for (scan in 0x130..0x13e) {
|
||||
val once = p.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
assertEquals(once, p.correct(scan, once))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The axis half. A pad that names its triggers something Android knows is read exactly as it
|
||||
* always was — this is the branch that must NOT fire on the pads that already work.
|
||||
*/
|
||||
@Test
|
||||
fun `a pad that names its triggers is read unchanged`() {
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
val map = Gamepad.padMap(p, namedTriggers = true, hasRxRy = true, restsNegative = true)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
|
||||
assertEquals(Gamepad.AXIS_NONE, map.leftTrigger)
|
||||
assertEquals(Gamepad.AXIS_NONE, map.rightTrigger)
|
||||
}
|
||||
// Same when there is no Rx/Ry to fall back to in the first place.
|
||||
val none = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = false, restsNegative = false)
|
||||
assertEquals(Gamepad.AXIS_NONE, none.leftTrigger)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry — left stick, right
|
||||
* stick, then the triggers. Only the triggers were being missed; the sticks already read
|
||||
* right and must be left alone.
|
||||
*/
|
||||
@Test
|
||||
fun `an unmapped Sony pad keeps its sticks and gains its triggers`() {
|
||||
val map = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
|
||||
assertEquals(MotionEvent.AXIS_RX, map.leftTrigger)
|
||||
assertEquals(MotionEvent.AXIS_RY, map.rightTrigger)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every other unmapped pad is the opposite way round: right stick on Rx/Ry, triggers on Z/Rz.
|
||||
* Reading Z/Rz as the right stick there is what makes pulling a trigger swing it — so the two
|
||||
* pairs must never be mixed up, which is the whole point of pinning them.
|
||||
*/
|
||||
@Test
|
||||
fun `an unmapped Xbox-layout pad has its stick and triggers the other way round`() {
|
||||
for (p in listOf(Gamepad.PadButtons.GENERIC_XBOX, Gamepad.PadButtons.SONY_MODERN)) {
|
||||
val map = Gamepad.padMap(p, namedTriggers = false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(MotionEvent.AXIS_RX, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RY, map.rightStickY)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.leftTrigger)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightTrigger)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A trigger axis that idles at −1 is rescaled; one that idles at 0 must NOT be, or it would
|
||||
* read as a permanent half-pull. Which it is gets measured off the device, never assumed —
|
||||
* both the DualSense's raw RX/RY and the Xbox pad's Z/Rz report an honest 0..1.
|
||||
*/
|
||||
@Test
|
||||
fun `only a trigger that idles negative is rescaled`() {
|
||||
val signed = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = true)
|
||||
assertEquals(0f, signed.level(-1f), 1e-6f)
|
||||
assertEquals(0.5f, signed.level(0f), 1e-6f)
|
||||
assertEquals(1f, signed.level(1f), 1e-6f)
|
||||
|
||||
val unsigned = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(0f, unsigned.level(0f), 1e-6f)
|
||||
assertEquals(1f, unsigned.level(1f), 1e-6f)
|
||||
}
|
||||
|
||||
/** A pad Android does know is untouched, which is most of them. */
|
||||
@Test
|
||||
fun `a pad with a key layout is left alone`() {
|
||||
for (scan in 0x130..0x13e) {
|
||||
val generic = Gamepad.genericKeyCode(scan)
|
||||
assertEquals(generic, Gamepad.PadButtons.NATIVE.correct(scan, generic))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression that made this gate necessary (field reports, 2026-08-21): an Xbox Wireless
|
||||
* Controller and a GameSir G8+, both with their buttons at the standard positions and both
|
||||
* corrected anyway, because `hasKeys` says BUTTON_C and BUTTON_Z for any pad that DECLARES six
|
||||
* buttons — `hid-input` allocates the whole descriptor `BTN_A + n` straight through whether the
|
||||
* pad ever presses them or not. Naming the triggers is what tells the two apart.
|
||||
*/
|
||||
@Test
|
||||
fun `a pad that names its triggers is never corrected, whatever it declares`() {
|
||||
for (sony in listOf(false, true)) {
|
||||
for (declaresCZ in listOf(false, true)) {
|
||||
assertEquals(
|
||||
Gamepad.PadButtons.NATIVE,
|
||||
Gamepad.padButtons(namedTriggers = true, sony = sony, declaresCZ = declaresCZ),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The four buttons the field reports named, on a pad whose report order is already standard:
|
||||
* X answering Y, Y answering LB, and both shoulders answering a menu button. NATIVE is what
|
||||
* keeps them themselves — the correction tables are right for the pads they are for, and this
|
||||
* is about not reaching one of them.
|
||||
*/
|
||||
@Test
|
||||
fun `an Xbox pad at the standard positions keeps X, Y and its shoulders`() {
|
||||
val native = Gamepad.PadButtons.NATIVE
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, native.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, native.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, native.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, native.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
|
||||
// What the old heuristic did to each of them, kept here so the difference stays visible.
|
||||
val wrong = Gamepad.PadButtons.GENERIC_XBOX
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, wrong.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, wrong.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, wrong.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, wrong.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
|
||||
}
|
||||
|
||||
/** Past the gate, which straight-through order to read is still the question it always was. */
|
||||
@Test
|
||||
fun `an unnamed-trigger pad still resolves its report order`() {
|
||||
fun order(sony: Boolean, declaresCZ: Boolean) =
|
||||
Gamepad.padButtons(namedTriggers = false, sony = sony, declaresCZ = declaresCZ)
|
||||
assertEquals(Gamepad.PadButtons.GENERIC_SONY, order(sony = true, declaresCZ = true))
|
||||
assertEquals(Gamepad.PadButtons.GENERIC_XBOX, order(sony = false, declaresCZ = true))
|
||||
assertEquals(Gamepad.PadButtons.SONY_MODERN, order(sony = true, declaresCZ = false))
|
||||
assertEquals(Gamepad.PadButtons.NATIVE, order(sony = false, declaresCZ = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The truth table behind "is a controller attached" — the question the console UI's
|
||||
* "With a controller" mode is answered by. A false positive here is not cosmetic: it pins the
|
||||
* console UI on with no pad in the room, and no setting short of turning the whole thing off can
|
||||
* dismiss it, because the phantom pad never disconnects.
|
||||
*/
|
||||
class PadPresenceTest {
|
||||
|
||||
/** A real pad: the source class plus hardware behind it, in either of the two shapes. */
|
||||
@Test
|
||||
fun realPadsCount() {
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
// An arcade stick / d-pad-only pad — buttons, no analog stick.
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = false, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
// A wheel or flight stick — axes, no A/B.
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = true, hasFaceButtons = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The gaming-phone shoulder triggers and OEM game-mode overlays: a virtual device wearing the
|
||||
* gamepad source class. This is the field report — the console UI that could not be dismissed. */
|
||||
@Test
|
||||
fun virtualDevicesAreNotControllers() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = true, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A device that claims a pad source with nothing behind it is not a pad either. */
|
||||
@Test
|
||||
fun aSourceClaimWithoutHardwareIsNotAController() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = false, hasFaceButtons = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** And a keyboard/mouse with sticks it never reports on the joystick source stays out. */
|
||||
@Test
|
||||
fun nonPadSourcesNeverCount() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = false, virtual = false, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ struct CreateOptions {
|
||||
device_name: String,
|
||||
/// Skia's resource budget, bytes (Kotlin sizes it from `ActivityManager.memoryClass`).
|
||||
gpu_cache_bytes: usize,
|
||||
/// Whether the touch shell exists as a fallback (phones/tablets; false on a TV) —
|
||||
/// gates the console-off settings row. Default false: absent means don't offer it.
|
||||
#[serde(default)]
|
||||
fallback_ui: bool,
|
||||
/// The settings snapshot the shell starts from (`pf_client_core::trust::Settings` JSON).
|
||||
settings: pf_client_core::trust::Settings,
|
||||
/// The profile catalog as `[[id, name], …]`.
|
||||
@@ -150,6 +154,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleCrea
|
||||
let console_opts = ConsoleOptions {
|
||||
device_name: opts.device_name,
|
||||
deck: false,
|
||||
fallback_ui: opts.fallback_ui,
|
||||
store: Some(store.clone()),
|
||||
platform: Platform::Android,
|
||||
gpu_cache_bytes: opts.gpu_cache_bytes.max(16 << 20),
|
||||
|
||||
@@ -142,21 +142,21 @@ pub(super) struct AscBackend {
|
||||
impl AscBackend {
|
||||
/// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then
|
||||
/// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the
|
||||
/// negotiated decode size; `panel_hz` the mode-table panel rate (seeds the learner);
|
||||
/// negotiated decode size; `surface_size` the LIVE view size the layer composites into;
|
||||
/// `panel_hz` the mode-table panel rate (seeds the learner);
|
||||
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn create(
|
||||
window: &NativeWindow,
|
||||
src_w: i32,
|
||||
src_h: i32,
|
||||
surface_w: i32,
|
||||
surface_h: i32,
|
||||
surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
panel_hz: i32,
|
||||
dataspace: i32,
|
||||
source_hz: u32,
|
||||
priority: PresentPriority,
|
||||
) -> Option<AscBackend> {
|
||||
let layer = Layer::create(window, surface_w, surface_h)?;
|
||||
let layer = Layer::create(window, surface_size)?;
|
||||
let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE
|
||||
| ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
|
||||
let reader = match ImageReader::new_with_usage(
|
||||
|
||||
@@ -96,8 +96,7 @@ pub(super) fn run_async(
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
@@ -199,8 +198,7 @@ pub(super) fn run_async(
|
||||
&window,
|
||||
mode.width as i32,
|
||||
mode.height as i32,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size,
|
||||
panel_hz,
|
||||
initial_ds,
|
||||
mode.refresh_hz,
|
||||
|
||||
@@ -91,7 +91,14 @@ const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(
|
||||
|
||||
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
|
||||
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
|
||||
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
|
||||
///
|
||||
/// ⚠ Taken from core, NOT a local number. `FLUSH_COOLDOWN` (the jump-to-live rate limit) is 2000 ms,
|
||||
/// and the host classifies a keyframe-recovery cadence by matching a cooldown's period ±10 % to
|
||||
/// decide WHICH client failure it is looking at. The two are opposites — "I have received nothing"
|
||||
/// versus "I am drowning in frames I cannot drain" — so while this was also 2000 ms the host
|
||||
/// confidently reported the wrong one, and a black-screen field case was diagnosed as a slow decoder
|
||||
/// for days (2026-08-20). Keeping the value in core is what stops the two drifting back together.
|
||||
const NO_VIDEO_RETRY: std::time::Duration = punktfunk_core::client::NO_VIDEO_RETRY;
|
||||
|
||||
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
||||
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
||||
@@ -133,12 +140,12 @@ pub(crate) struct DecodeOptions {
|
||||
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
|
||||
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
|
||||
pub panel_hz: i32,
|
||||
/// The video `SurfaceView`'s on-screen pixel size (the aspect-fitted display footprint), from
|
||||
/// Kotlin at `surfaceCreated`. The ASurfaceControl backend composites its layer in this
|
||||
/// coordinate space — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin
|
||||
/// couldn't read it yet, and the backend falls back to the window buffer size.
|
||||
pub surface_w: i32,
|
||||
pub surface_h: i32,
|
||||
/// The video `SurfaceView`'s LIVE on-screen pixel size (the aspect-fitted display footprint),
|
||||
/// packed by [`crate::session::pack_surface_size`] and re-reported by Kotlin on every
|
||||
/// `surfaceChanged`. The ASurfaceControl backend composites its layer in this coordinate space
|
||||
/// — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin couldn't read it
|
||||
/// yet, and the backend falls back to the window buffer size.
|
||||
pub surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||
|
||||
@@ -24,6 +24,7 @@ use ndk::hardware_buffer::HardwareBuffer;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use std::ffi::c_void;
|
||||
use std::os::fd::{FromRawFd, OwnedFd, RawFd};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{mpsc, Arc};
|
||||
|
||||
use super::async_loop::DecodeEvent;
|
||||
@@ -276,9 +277,14 @@ unsafe extern "C" fn on_complete(context: *mut c_void, stats: *mut ASurfaceTrans
|
||||
pub(super) struct Layer {
|
||||
api: Api,
|
||||
sc: Arc<ScHandle>,
|
||||
/// Destination rectangle (the SurfaceView's pixel size) — the buffer is scaled to fill it.
|
||||
dest_w: i32,
|
||||
dest_h: i32,
|
||||
/// The SurfaceView's LIVE pixel size, packed by `pack_surface_size` and re-read before every
|
||||
/// present — the destination rectangle the buffer is scaled to fill. Live rather than captured
|
||||
/// because the view resizes under a surface that is never recreated (see `dest`).
|
||||
surface_size: Arc<AtomicU64>,
|
||||
/// Fallback destination for as long as `surface_size` is still `0` (Kotlin hadn't measured the
|
||||
/// view when video started): the window's own buffer geometry, the best remaining guess.
|
||||
fallback_w: i32,
|
||||
fallback_h: i32,
|
||||
/// `true` once the first transaction has made the layer visible + set its z-order + frame rate.
|
||||
configured: bool,
|
||||
}
|
||||
@@ -287,13 +293,16 @@ impl Layer {
|
||||
/// Create the compositor layer over `window` (the SurfaceView's `ANativeWindow`), or `None` on
|
||||
/// API < 29 / a null layer — the caller then uses the SurfaceView presenter.
|
||||
///
|
||||
/// `dest_w/h` are the SurfaceView's **on-screen pixel size** — the coordinate space the child
|
||||
/// layer is composited into, which is the display footprint of the (aspect-fitted) video view,
|
||||
/// NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer geometry in a
|
||||
/// rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) — using it shrank
|
||||
/// the picture to the top-left corner. A non-positive `dest_w/h` (Kotlin couldn't read the view
|
||||
/// yet) falls back to that buffer size as the best remaining guess.
|
||||
pub(super) fn create(window: &NativeWindow, dest_w: i32, dest_h: i32) -> Option<Layer> {
|
||||
/// `surface_size` carries the SurfaceView's **on-screen pixel size** — the coordinate space the
|
||||
/// child layer is composited into, which is the display footprint of the (aspect-fitted) video
|
||||
/// view, NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer
|
||||
/// geometry in a rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) —
|
||||
/// using it shrank the picture to the top-left corner. It is read fresh on every present
|
||||
/// because that view RESIZES mid-stream under a surface that is never recreated: the stream
|
||||
/// screen hides the system bars and switches on cutout drawing a frame or two after
|
||||
/// `surfaceCreated`, and each one grows it. An empty `surface_size` (Kotlin hadn't measured the
|
||||
/// view yet) falls back to the buffer size as the best remaining guess.
|
||||
pub(super) fn create(window: &NativeWindow, surface_size: Arc<AtomicU64>) -> Option<Layer> {
|
||||
let api = Api::resolve()?;
|
||||
// SAFETY: `window.ptr()` is the live `ANativeWindow` the decode thread owns; the name is a
|
||||
// static NUL-terminated string; the call returns null on failure (checked).
|
||||
@@ -303,20 +312,11 @@ impl Layer {
|
||||
log::warn!("asc: createFromWindow returned null — falling back to SurfaceView");
|
||||
return None;
|
||||
}
|
||||
let dest_w = if dest_w > 0 {
|
||||
dest_w
|
||||
} else {
|
||||
window.width().max(1)
|
||||
};
|
||||
let dest_h = if dest_h > 0 {
|
||||
dest_h
|
||||
} else {
|
||||
window.height().max(1)
|
||||
};
|
||||
let fallback_w = window.width().max(1);
|
||||
let fallback_h = window.height().max(1);
|
||||
log::info!(
|
||||
"asc: layer created, dest {dest_w}x{dest_h} (window buffer {}x{})",
|
||||
window.width(),
|
||||
window.height(),
|
||||
"asc: layer created, dest {:?} (window buffer {fallback_w}x{fallback_h})",
|
||||
crate::session::unpack_surface_size(surface_size.load(Ordering::Relaxed)),
|
||||
);
|
||||
Some(Layer {
|
||||
sc: Arc::new(ScHandle {
|
||||
@@ -324,12 +324,20 @@ impl Layer {
|
||||
release: api.ac_release,
|
||||
}),
|
||||
api,
|
||||
dest_w,
|
||||
dest_h,
|
||||
surface_size,
|
||||
fallback_w,
|
||||
fallback_h,
|
||||
configured: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The destination rectangle for this present: the live view size, or the window's buffer
|
||||
/// geometry while Kotlin has reported nothing.
|
||||
fn dest(&self) -> (i32, i32) {
|
||||
crate::session::unpack_surface_size(self.surface_size.load(Ordering::Relaxed))
|
||||
.unwrap_or((self.fallback_w, self.fallback_h))
|
||||
}
|
||||
|
||||
/// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes
|
||||
/// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot
|
||||
/// completion that reports the real latch + the previous buffer's release fence on `ev_tx`,
|
||||
@@ -370,11 +378,12 @@ impl Layer {
|
||||
right: src_w.max(1),
|
||||
bottom: src_h.max(1),
|
||||
};
|
||||
let (dest_w, dest_h) = self.dest();
|
||||
let dst = ARect {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: self.dest_w,
|
||||
bottom: self.dest_h,
|
||||
right: dest_w,
|
||||
bottom: dest_h,
|
||||
};
|
||||
(self.api.txn_set_geometry)(txn, sc, &src, &dst, TRANSFORM_IDENTITY);
|
||||
if dataspace != 0 {
|
||||
|
||||
@@ -50,8 +50,7 @@ pub(super) fn run_sync(
|
||||
panel_hz: _,
|
||||
// The ASurfaceControl backend is async-loop only; the sync loop renders straight to the
|
||||
// SurfaceView, so it never needs the view's on-screen size.
|
||||
surface_w: _,
|
||||
surface_h: _,
|
||||
surface_size: _,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
|
||||
@@ -470,6 +470,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
access_seq: std::sync::atomic::AtomicU32::new(0),
|
||||
// Reported by Kotlin at `surfaceCreated` and on every resize after it.
|
||||
surface_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
};
|
||||
Box::into_raw(Box::new(handle)) as jlong
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ mod probe;
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -87,6 +87,37 @@ pub(crate) struct SessionHandle {
|
||||
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
|
||||
/// (the host's expiry warnings) arrived without holding a blocking event thread.
|
||||
pub(crate) access_seq: AtomicU32,
|
||||
/// The video `SurfaceView`'s LIVE on-screen pixel size ([`pack_surface_size`]), written by
|
||||
/// `nativeStartVideo` and by every `nativeVideoSurfaceSize` the `surfaceChanged` callback
|
||||
/// sends, read by the ASurfaceControl presenter before each present.
|
||||
///
|
||||
/// Shared and live rather than a start-time parameter because the view RESIZES under a surface
|
||||
/// that is never recreated: hiding the system bars and switching the window to
|
||||
/// `LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` both happen a frame or two AFTER `surfaceCreated`,
|
||||
/// and each one grows the video view. A destination rect captured once at creation then keeps
|
||||
/// compositing the picture at its old, smaller size anchored at the layer's origin — the
|
||||
/// "stream in the top-left corner" field report. `0` = nothing reported yet, and the layer
|
||||
/// falls back to the window's buffer geometry.
|
||||
pub surface_size: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// Pack a surface's pixel size into one `u64` — so the presenter reads width and height as a
|
||||
/// single atomic load and can never see a torn pair (a new width against an old height).
|
||||
/// Non-positive values pack as `0`, the "not reported yet" sentinel.
|
||||
pub(crate) fn pack_surface_size(w: i32, h: i32) -> u64 {
|
||||
if w <= 0 || h <= 0 {
|
||||
return 0;
|
||||
}
|
||||
((w as u64) << 32) | (h as u64 & 0xffff_ffff)
|
||||
}
|
||||
|
||||
/// The inverse of [`pack_surface_size`]: `None` for the `0` sentinel.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub(crate) fn unpack_surface_size(packed: u64) -> Option<(i32, i32)> {
|
||||
if packed == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((((packed >> 32) as u32) as i32, (packed as u32) as i32))
|
||||
}
|
||||
|
||||
struct VideoThread {
|
||||
@@ -160,3 +191,29 @@ fn parse_hex32(s: &str) -> Option<[u8; 32]> {
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pack_surface_size, unpack_surface_size};
|
||||
|
||||
/// The pair the presenter reads as one atomic load must survive the round trip — including a
|
||||
/// size wider than a signed 16-bit value, which every panel this runs on now is.
|
||||
#[test]
|
||||
fn surface_size_round_trips() {
|
||||
assert_eq!(
|
||||
unpack_surface_size(pack_surface_size(2800, 1260)),
|
||||
Some((2800, 1260))
|
||||
);
|
||||
assert_eq!(unpack_surface_size(pack_surface_size(1, 1)), Some((1, 1)));
|
||||
}
|
||||
|
||||
/// "Not reported yet" — and anything nonsensical — is the one sentinel, so the layer falls back
|
||||
/// to the window's buffer geometry rather than composing into an empty rectangle.
|
||||
#[test]
|
||||
fn non_positive_sizes_are_the_sentinel() {
|
||||
assert_eq!(pack_surface_size(0, 0), 0);
|
||||
assert_eq!(pack_surface_size(1920, 0), 0);
|
||||
assert_eq!(pack_surface_size(-1, 1080), 0);
|
||||
assert_eq!(unpack_surface_size(0), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
let client = h.client.clone();
|
||||
let sd = shutdown.clone();
|
||||
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
|
||||
|
||||
// Seed the live view size with what the view measures right now; `surfaceChanged` keeps it
|
||||
// current from here on (the bars hide and the cutout mode changes AFTER this call).
|
||||
h.surface_size.store(
|
||||
super::pack_surface_size(surface_w, surface_h),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
let opts = crate::decode::DecodeOptions {
|
||||
decoder_name: decoder,
|
||||
ll_feature,
|
||||
@@ -80,8 +87,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz: panel_fps,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size: h.surface_size.clone(),
|
||||
};
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-decode".into())
|
||||
@@ -93,6 +99,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoSurfaceSize(handle, width, height)` — the video `SurfaceView`'s
|
||||
/// on-screen pixel size, re-reported on every `surfaceChanged`.
|
||||
///
|
||||
/// The ASurfaceControl presenter composites its child layer into exactly this rectangle, and the
|
||||
/// view resizes UNDER a surface that is never recreated: the stream screen hides the system bars
|
||||
/// and asks to draw into the display cutout a frame or two after `surfaceCreated`, both of which
|
||||
/// grow it. Without this the layer would keep painting the picture at its start-up size, in the
|
||||
/// corner of a bigger surface. Non-positive values are ignored (they'd blank the picture).
|
||||
/// No-op on a `0` handle. Stored whether or not video is running — the next `nativeStartVideo`
|
||||
/// then starts from a measured view rather than the window's guess. Not android-gated: pure `jni`
|
||||
/// + an atomic store, so it links on the host build too.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSurfaceSize(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
width: jni::sys::jint,
|
||||
height: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
let packed = super::pack_surface_size(width, height);
|
||||
if handle == 0 || packed == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.surface_size
|
||||
.store(packed, std::sync::atomic::Ordering::Relaxed);
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
|
||||
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
|
||||
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
|
||||
|
||||
+18
-5
@@ -370,15 +370,26 @@ from the config directory for a true factory reset."
|
||||
.unwrap_or(DISCOVER_DEFAULT_SECS)
|
||||
.min(DISCOVER_MAX_SECS);
|
||||
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
|
||||
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
|
||||
// never hands their ids back. `load` would mint ids for a pre-mint store and save them —
|
||||
// a write from a read-only verb, and one that races the `hosts list` a caller is very
|
||||
// likely running at the same moment (the Decky panel issues both together).
|
||||
// `read`, not `load`: this verb never hands a record's id back, so it has no business
|
||||
// MINTING one. `load` would mint ids for a pre-mint store and save them, racing the
|
||||
// `hosts list` a caller is very likely running at the same moment (the Decky panel issues
|
||||
// both together) — after which the ids one of them already handed out no longer resolve.
|
||||
let known = KnownHosts::read();
|
||||
let rows: Vec<(
|
||||
&pf_client_core::discovery::DiscoveredHost,
|
||||
Option<&KnownHost>,
|
||||
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
|
||||
// The one write this verb does make, and why it doesn't contradict the above: an advert
|
||||
// is the only place a host's wake MAC is ever published, and this verb is the only one
|
||||
// the Decky panel runs that ever sees one. Without it a Deck in Gaming Mode never learns
|
||||
// a MAC at all and Wake-on-LAN cannot fire, with nothing to show for it (#322).
|
||||
// `learn_from_advert` mints nothing either, and writes only when an advert genuinely
|
||||
// taught the record something new — so a steady-state panel refresh touches no disk.
|
||||
for (d, saved) in &rows {
|
||||
if let Some(k) = saved {
|
||||
trust::learn_from_advert(&k.fp_hex, &k.addr, k.port, &d.mac, &d.os, d.mgmt_port);
|
||||
}
|
||||
}
|
||||
if has(args, "--json") {
|
||||
let hosts: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
@@ -733,7 +744,9 @@ from the config directory for a true factory reset."
|
||||
};
|
||||
let host = &known.hosts[i];
|
||||
if host.mac.is_empty() {
|
||||
eprintln!("no Wake-on-LAN address known for {} — connect to it once while it's awake so the client can learn it", host.name);
|
||||
// A MAC is learned from the host's mDNS advert, never from a connect — say so, since
|
||||
// "connect to it once" sent at least one Deck owner looking in the wrong place (#322).
|
||||
eprintln!("no Wake-on-LAN address known for {} — run `punktfunk discover` while it's awake (the Deck panel does this every time it opens) so the client learns it from the host's advert", host.name);
|
||||
return UNRESOLVED;
|
||||
}
|
||||
if !has(args, "--wait") {
|
||||
|
||||
@@ -1087,33 +1087,20 @@ impl HostsPage {
|
||||
// Online = advertising on mDNS OR proven reachable by the last probe sweep.
|
||||
let online = self.adverts.values().any(|a| matches(k, a))
|
||||
|| self.probed.get(&saved_key(k)).copied().unwrap_or(false);
|
||||
// Learn this host's wake MAC(s) from its live advert while it's online.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && !a.mac.is_empty())
|
||||
{
|
||||
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||
}
|
||||
// Same for its OS chain — the icon then survives the host going offline.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && !a.os.is_empty())
|
||||
{
|
||||
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
|
||||
}
|
||||
// Same for its management port — and this one is not cosmetic: without it a host
|
||||
// that moved off 47990 loses its library the moment mDNS is unavailable, because
|
||||
// the advert was the only place the real port ever lived.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && a.mgmt_port.is_some())
|
||||
{
|
||||
if let Some(p) = a.mgmt_port {
|
||||
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
|
||||
}
|
||||
// Learn what this host's live advert teaches while it's online: its wake MAC(s),
|
||||
// its OS chain (so the icon survives it going offline), and its management port
|
||||
// — the last one not cosmetic, since a host that moved off 47990 loses its
|
||||
// library the moment mDNS is unavailable and the advert is the only place the
|
||||
// real port ever lived.
|
||||
if let Some(a) = self.adverts.values().find(|a| matches(k, a)) {
|
||||
crate::trust::learn_from_advert(
|
||||
&k.fp_hex,
|
||||
&k.addr,
|
||||
k.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
saved.push_back(HostCard {
|
||||
connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()),
|
||||
|
||||
@@ -53,9 +53,9 @@ use punktfunk_core::config::Role;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::packet::FLAG_PROBE;
|
||||
use punktfunk_core::quic::{
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, Hello, LossReport,
|
||||
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start,
|
||||
Welcome,
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, DeliveryReport, Hello,
|
||||
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate,
|
||||
Start, Welcome,
|
||||
};
|
||||
use punktfunk_core::transport::UdpTransport;
|
||||
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
|
||||
@@ -987,10 +987,18 @@ async fn session(args: Args) -> Result<()> {
|
||||
let mut ls = send;
|
||||
let lp = loss_ppm.clone();
|
||||
let df = dropped_frames.clone();
|
||||
// Delivery truth for the host's dead-data-plane check: report what actually landed on the
|
||||
// wire, so the probe reproduces a real client's answer rather than the "cannot answer"
|
||||
// sentinel — which is exactly what makes it usable for testing that path.
|
||||
let rxp = rx_wire_packets.clone();
|
||||
tokio::spawn(async move {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut last_report = std::time::Instant::now();
|
||||
let mut last_dropped = 0u64;
|
||||
// Mirrors the real clients' rule (see `pump/data.rs`): report the delivery count every
|
||||
// window while it is zero, once when the first packets land, then stop — so a host that
|
||||
// predates the message is not flooded with "unknown control message" on a good session.
|
||||
let mut delivery_confirmed = false;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let d = df.load(Relaxed);
|
||||
@@ -1007,6 +1015,25 @@ async fn session(args: Args) -> Result<()> {
|
||||
if last_report.elapsed() >= std::time::Duration::from_millis(750) {
|
||||
last_report = std::time::Instant::now();
|
||||
let v = lp.swap(u32::MAX, Relaxed);
|
||||
// Independent of whether there is a fresh loss sample: "no fresh sample" is
|
||||
// exactly the shape a dead data plane has, so gating it on one would silence
|
||||
// it in the state it exists to report.
|
||||
let received = rxp.load(Relaxed);
|
||||
if received == 0 || !delivery_confirmed {
|
||||
delivery_confirmed = received > 0;
|
||||
if io::write_msg(
|
||||
&mut ls,
|
||||
&DeliveryReport {
|
||||
packets_received: received,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break; // control stream gone
|
||||
}
|
||||
}
|
||||
if v != u32::MAX
|
||||
&& io::write_msg(&mut ls, &LossReport { loss_ppm: v }.encode())
|
||||
.await
|
||||
|
||||
@@ -800,11 +800,21 @@ impl ServiceState {
|
||||
|| (d.addr == h.addr && d.port == h.port)
|
||||
});
|
||||
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
|
||||
// Write the advertised mgmt port down while the host is visible, so this console
|
||||
// keeps working against a moved port once it is not. No-op (and no disk write)
|
||||
// Write down everything the advert teaches while the host is visible: the mgmt
|
||||
// port (so this console keeps working against a moved one once it is not), the
|
||||
// OS chain, and the wake MAC — which matters most here, because this console and
|
||||
// the Decky panel are the only surfaces a Deck in Gaming Mode ever runs, and a
|
||||
// record that never learned a MAC can never be woken. No-op (and no disk write)
|
||||
// when unchanged, so this is safe on every refresh tick.
|
||||
if let Some(p) = advert.and_then(|d| d.mgmt_port) {
|
||||
pf_client_core::trust::learn_mgmt_port(&h.fp_hex, &h.addr, h.port, p);
|
||||
if let Some(a) = advert {
|
||||
pf_client_core::trust::learn_from_advert(
|
||||
&h.fp_hex,
|
||||
&h.addr,
|
||||
h.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
let row = HostRow {
|
||||
key: key.clone(),
|
||||
|
||||
@@ -33,11 +33,14 @@ the fast **`punktfunk/1`** protocol.
|
||||
hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream
|
||||
re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host.
|
||||
|
||||
Builds and ships for both **x64** and **ARM64** as a signed **MSIX**.
|
||||
Builds and ships for both **x64** and **ARM64**, three ways from one layout: a signed **installer**
|
||||
(the default — a per-user setup.exe whose stable install path Steam can launch, so the Steam
|
||||
overlay and Big Picture work), a **portable zip**, and a signed **MSIX** (kept for Microsoft Store
|
||||
compatibility).
|
||||
|
||||
## Get it
|
||||
|
||||
Install the signed MSIX from the package registry — see
|
||||
Install the signed installer from the package registry — see
|
||||
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
|
||||
A stock [Moonlight](https://moonlight-stream.org/) client also works over GameStream if you prefer.
|
||||
|
||||
@@ -58,7 +61,7 @@ punktfunk-client --headless --speed-test --connect host[:port] # probe burst
|
||||
```
|
||||
|
||||
> `CARGO_HOME` must be an ASCII path — non-ASCII characters break SDL3's MSVC precompiled-header
|
||||
> build. Packaging (MSIX manifest, signing) lives in [`packaging/`](packaging/).
|
||||
> build. Packaging (MSIX manifest, the Inno Setup installer, signing) lives in [`packaging/`](packaging/).
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -79,7 +82,7 @@ src/
|
||||
trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse
|
||||
probe.rs · wol.rs speed probe · Wake-on-LAN
|
||||
logfile.rs log tee to %LOCALAPPDATA%
|
||||
packaging/ MSIX manifest, signing, pack script
|
||||
packaging/ MSIX manifest + Inno Setup installer, signing, pack scripts
|
||||
```
|
||||
|
||||
## Manual smoke checklist
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
# punktfunk Windows client — MSIX packaging
|
||||
# punktfunk Windows client — packaging
|
||||
|
||||
The Windows client ships as **signed MSIX** packages so Windows boxes get a real package (Start
|
||||
tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from
|
||||
[`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml) to Gitea's
|
||||
The Windows client ships **three ways, packed from one assembled layout** by CI
|
||||
([`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml)) to Gitea's
|
||||
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
|
||||
touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels).
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels):
|
||||
|
||||
1. **Inno Setup installer** (`punktfunk-client-setup_<arch>.exe`) — the **default download**. A
|
||||
per-user, no-UAC install to `%LOCALAPPDATA%\Programs\Punktfunk`. It exists because the MSIX
|
||||
install shape breaks the top user-reported flows: the exe lands under the ACL'd
|
||||
`C:\Program Files\WindowsApps`, which Steam's *Add a Non-Steam Game* picker can't browse, and
|
||||
the alias/`shell:AppsFolder` activation defeats the Steam overlay's injection and Big Picture
|
||||
launch — Steam must spawn the exe itself from a normal path. `punktfunk-client.iss` +
|
||||
`pack-client-installer.ps1`; it re-creates the manifest's declarative grants per-user
|
||||
(`punktfunk://` in HKCU Classes, Start shortcuts, `{app}` on the user PATH for the
|
||||
`punktfunk` CLI) and fetches the Windows App Runtime when missing.
|
||||
2. **Portable zip** (`punktfunk-client-windows_<arch>-portable.zip`) — the same signed file set,
|
||||
nothing registered.
|
||||
3. **Signed MSIX** (`punktfunk-client-windows_<arch>.msix`) — kept for **Microsoft Store**
|
||||
compatibility. Everything below the fold documents this path.
|
||||
|
||||
`pack-msix.ps1` assembles the layout and packs the MSIX; `pack-client-installer.ps1` then consumes
|
||||
that same `layout/` for the installer + zip (and signs the four exes individually — the MSIX only
|
||||
signs its container).
|
||||
|
||||
# MSIX packaging
|
||||
|
||||
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
|
||||
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Pack + sign the punktfunk Windows client as an Inno Setup setup.exe (the default download) and a
|
||||
portable .zip, from the layout pack-msix.ps1 already assembled.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs AFTER pack-msix.ps1 in the same job and consumes its $OutDir\layout verbatim — one assembly,
|
||||
three artifacts (.msix, setup.exe, portable .zip). Why the installer exists at all: the MSIX
|
||||
install shape (WindowsApps ACLs + alias-only activation) breaks Steam's non-Steam-game picker,
|
||||
the Steam overlay's injection, and Big Picture launching — see punktfunk-client.iss's header.
|
||||
|
||||
Steps:
|
||||
1. stage the runtime file set from -LayoutDir (drops AppxManifest.xml + the tile Assets),
|
||||
2. sign the four exes individually (the MSIX only signs its container),
|
||||
3. zip the stage -> the portable build,
|
||||
4. ISCC punktfunk-client.iss over the same stage, sign the setup.exe,
|
||||
5. emit CLIENT_SETUP_PATH / CLIENT_ZIP_PATH to GITHUB_ENV for the publish step.
|
||||
|
||||
Signing backend precedence is identical to pack-msix.ps1 / pack-host-installer.ps1 (Azure
|
||||
Artifact Signing -> supplied .pfx -> ephemeral self-signed; fail closed on v* tags). No .cer is
|
||||
exported here: unlike an MSIX, a plain exe RUNS regardless of signer trust — an untrusted
|
||||
signature only costs a SmartScreen warning, so canary self-signed builds need nothing imported.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File pack-client-installer.ps1 -Version 0.2.137.0 -Arch x64 `
|
||||
-LayoutDir C:\t\msix\layout -OutDir C:\t\installer
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, same as the MSIX
|
||||
[Parameter(Mandatory = $true)][string]$LayoutDir, # pack-msix.ps1's $OutDir\layout
|
||||
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64',
|
||||
[string]$OutDir = (Join-Path (Split-Path -Parent $LayoutDir) 'installer'),
|
||||
# Subject for the EPHEMERAL self-signed fallback only; Azure/pfx carry their own subjects.
|
||||
[string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE",
|
||||
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret
|
||||
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
|
||||
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT,
|
||||
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT,
|
||||
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE,
|
||||
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB,
|
||||
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto',
|
||||
[switch]$NoSign # skip signing (local debug)
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
# Keep the "check $LASTEXITCODE myself" model (see pack-host-installer.ps1): pwsh 7.4 must not
|
||||
# turn a non-zero native exit into a terminating error before Sign-File's timestamp retry runs.
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
if ($Version -notmatch '^\d+\.\d+\.\d+\.\d+$') {
|
||||
throw "Version must be 4-part numeric (Major.Minor.Build.Revision); got '$Version'."
|
||||
}
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$iss = Join-Path $here 'punktfunk-client.iss'
|
||||
|
||||
# --- locate ISCC (Inno Setup) + signtool (Windows SDK) — same finders as the sibling scripts ---
|
||||
function Find-Iscc {
|
||||
foreach ($p in @(
|
||||
'C:\Program Files (x86)\Inno Setup 6\ISCC.exe',
|
||||
'C:\Program Files\Inno Setup 6\ISCC.exe')) {
|
||||
if (Test-Path $p) { return $p }
|
||||
}
|
||||
$c = Get-Command iscc -ErrorAction SilentlyContinue
|
||||
if ($c) { return $c.Source }
|
||||
throw "ISCC.exe (Inno Setup 6, any 6.x) not found - install it (choco install innosetup -y)."
|
||||
}
|
||||
function Find-SdkTool([string]$name) {
|
||||
$root = 'C:\Program Files (x86)\Windows Kits\10\bin'
|
||||
$hit = Get-ChildItem -Path $root -Recurse -Filter $name -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -match '\\(10\.0\.\d+\.\d+)\\x64\\' } |
|
||||
Sort-Object { [version]([regex]::Match($_.FullName, '\\(10\.0\.\d+\.\d+)\\x64\\').Groups[1].Value) } |
|
||||
Select-Object -Last 1
|
||||
if (-not $hit) { throw "$name not found under $root - install the Windows 10/11 SDK." }
|
||||
$hit.FullName
|
||||
}
|
||||
function Find-AzureDlib([string]$Explicit) {
|
||||
if ($Explicit) {
|
||||
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
|
||||
return (Resolve-Path $Explicit).Path
|
||||
}
|
||||
$roots = @(
|
||||
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
|
||||
'C:\trusted-signing\microsoft.trusted.signing.client'
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
|
||||
Sort-Object LastWriteTime | Select-Object -Last 1
|
||||
if (-not $hit) {
|
||||
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
|
||||
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
|
||||
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
|
||||
}
|
||||
$hit.FullName
|
||||
}
|
||||
$iscc = Find-Iscc
|
||||
Write-Host "ISCC: $iscc"
|
||||
|
||||
# --- stage the runtime file set (the portable layout = what the installer lays down) ----------
|
||||
# Explicit list, not a wildcard copy: the MSIX layout also holds AppxManifest.xml and the tile
|
||||
# Assets, which mean nothing outside a package (the exes embed their icons via build.rs).
|
||||
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'punktfunk.exe',
|
||||
'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
||||
$stage = Join-Path $OutDir 'portable'
|
||||
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $stage | Out-Null
|
||||
foreach ($f in $required) {
|
||||
$src = Join-Path $LayoutDir $f
|
||||
if (-not (Test-Path $src)) { throw "missing '$f' in $LayoutDir (did pack-msix.ps1 run first?)" }
|
||||
Copy-Item $src (Join-Path $stage $f) -Force
|
||||
}
|
||||
$licSrc = Join-Path $LayoutDir 'licenses'
|
||||
if (-not (Test-Path $licSrc)) { throw "missing licenses\ in $LayoutDir (did pack-msix.ps1 run first?)" }
|
||||
Copy-Item $licSrc (Join-Path $stage 'licenses') -Recurse -Force
|
||||
|
||||
# --- signing backend, same precedence + fail-closed rule as pack-msix.ps1 ---------------------
|
||||
$requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/tags/v*' }
|
||||
else { [Convert]::ToBoolean($RequireSignedCert) }
|
||||
if ($NoSign -and $requireCert) {
|
||||
throw "release build ($env:GITHUB_REF) with -NoSign - refusing to publish an unsigned installer."
|
||||
}
|
||||
$pfxPath = Join-Path $OutDir 'signing.pfx'
|
||||
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
|
||||
$signMode = 'none'
|
||||
$signtool = $null
|
||||
if (-not $NoSign) {
|
||||
$signtool = Find-SdkTool 'signtool.exe'
|
||||
Write-Host "signtool: $signtool"
|
||||
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
|
||||
$signMode = 'azure'
|
||||
$AzureDlib = Find-AzureDlib $AzureDlib
|
||||
@{
|
||||
Endpoint = $AzureEndpoint
|
||||
CodeSigningAccountName = $AzureAccount
|
||||
CertificateProfileName = $AzureProfile
|
||||
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
|
||||
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
|
||||
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
|
||||
if (-not [Environment]::GetEnvironmentVariable($v)) {
|
||||
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
|
||||
"DefaultAzureCredential; without the service-principal trio it falls through to " +
|
||||
"an interactive login that cannot complete on a runner and hangs the build.")
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($PfxBase64) {
|
||||
$signMode = 'pfx'
|
||||
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
|
||||
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
|
||||
}
|
||||
elseif ($requireCert) {
|
||||
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 - " +
|
||||
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
|
||||
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
|
||||
}
|
||||
else {
|
||||
$signMode = 'selfsigned'
|
||||
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
|
||||
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
|
||||
$tmp = New-SelfSignedCertificate -Type Custom -Subject $Publisher `
|
||||
-KeyUsage DigitalSignature -FriendlyName 'punktfunk client installer (self-signed)' `
|
||||
-CertStoreLocation 'Cert:\CurrentUser\My' `
|
||||
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}')
|
||||
$sec = ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText
|
||||
Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null
|
||||
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
|
||||
}
|
||||
}
|
||||
|
||||
# Timestamp policy matches the sibling scripts: best-effort for a long-lived .pfx, MANDATORY under
|
||||
# Azure signing (those leaf certs expire in ~3 days; untimestamped signatures die with them).
|
||||
function Sign-File([string]$Path) {
|
||||
if ($NoSign) { return }
|
||||
if ($signMode -eq 'azure') {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
|
||||
$ts = 'http://timestamp.acs.microsoft.com'
|
||||
}
|
||||
else {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
$ts = 'http://timestamp.digicert.com'
|
||||
}
|
||||
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $Path))
|
||||
if ($LASTEXITCODE -eq 0) { return }
|
||||
if ($signMode -eq 'azure') {
|
||||
throw ("timestamped sign failed for $Path ($LASTEXITCODE) - NOT retrying without a timestamp. " +
|
||||
"An Azure signing cert is valid for ~3 days; an untimestamped signature would go " +
|
||||
"untrusted within days of release.")
|
||||
}
|
||||
Write-Warning "timestamped sign failed for $Path - retrying without a timestamp"
|
||||
& $signtool ($signArgs + @($Path))
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
# --- sign the inner exes, zip the stage (portable build), then build + sign the installer ------
|
||||
foreach ($f in $required | Where-Object { $_ -like '*.exe' }) {
|
||||
Sign-File (Join-Path $stage $f)
|
||||
}
|
||||
|
||||
$zip = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}-portable.zip"
|
||||
if (Test-Path $zip) { Remove-Item $zip -Force }
|
||||
Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip
|
||||
Write-Host "==> portable zip: $zip"
|
||||
|
||||
# Stage the .iss + branding next to each other under $OutDir: ISCC is a 32-bit process, and on the
|
||||
# SYSTEM-profile runner WOW64 redirection breaks reads from the checkout path (see
|
||||
# pack-host-installer.ps1's staging note) — everything ISCC touches must live under C:\t.
|
||||
$issLocal = Join-Path $OutDir 'punktfunk-client.iss'
|
||||
Copy-Item -LiteralPath $iss -Destination $issLocal -Force
|
||||
$brandSrc = (Resolve-Path (Join-Path $here '..\..\..\packaging\windows\branding')).Path
|
||||
$brandStage = Join-Path $OutDir 'branding'
|
||||
if (Test-Path $brandStage) { Remove-Item $brandStage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $brandStage | Out-Null
|
||||
Copy-Item (Join-Path $brandSrc '*.bmp') $brandStage -Force
|
||||
Copy-Item (Join-Path $brandSrc 'punktfunk.ico') $brandStage -Force
|
||||
|
||||
$defines = @(
|
||||
"/DMyAppVersion=$Version",
|
||||
"/DArch=$Arch",
|
||||
"/DLayoutDir=$stage",
|
||||
"/DBrandingDir=$brandStage",
|
||||
"/DOutputDir=$OutDir"
|
||||
)
|
||||
Write-Host "==> ISCC $($defines -join ' ') $issLocal"
|
||||
& $iscc @defines $issLocal
|
||||
if ($LASTEXITCODE -ne 0) { throw "ISCC failed ($LASTEXITCODE)" }
|
||||
|
||||
$setup = Join-Path $OutDir "punktfunk-client-setup-${Version}_${Arch}.exe"
|
||||
if (-not (Test-Path $setup)) { throw "expected installer not produced: $setup" }
|
||||
Sign-File $setup
|
||||
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==> installer: $setup"
|
||||
if ($signMode -eq 'azure') {
|
||||
Write-Host "==> signed by a publicly trusted CA."
|
||||
}
|
||||
elseif ($signMode -ne 'none') {
|
||||
Write-Host "==> $signMode-signed: the exe still runs everywhere; expect a SmartScreen prompt on canary builds."
|
||||
}
|
||||
if ($env:GITHUB_ENV) {
|
||||
"CLIENT_SETUP_PATH=$setup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CLIENT_ZIP_PATH=$zip" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
; punktfunk Windows CLIENT installer (Inno Setup 6) — the default download.
|
||||
;
|
||||
; A classic per-user setup.exe, NOT because MSIX failed technically (the app is full-trust Win32
|
||||
; either way) but because the MSIX install SHAPE breaks the most-reported use case: the exe lands
|
||||
; under the ACL'd C:\Program Files\WindowsApps, which Steam's "Add a Non-Steam Game" picker cannot
|
||||
; browse and whose activation path defeats the overlay's GameOverlayRenderer64.dll injection —
|
||||
; Steam has to spawn the process itself from a normal path for the overlay (and a Big Picture
|
||||
; launch) to work. This installs to {userpf}\Punktfunk: user-writable-visible, no UAC, and a
|
||||
; stable path Steam can target. The MSIX is kept for Microsoft Store compatibility
|
||||
; (clients/windows/packaging/pack-msix.ps1 — both are packed from the same layout every build).
|
||||
;
|
||||
; Built by pack-client-installer.ps1, e.g.:
|
||||
; ISCC.exe /DMyAppVersion=0.2.137.0 /DArch=x64 /DLayoutDir=C:\t\installer\portable \
|
||||
; /DBrandingDir=C:\t\installer\branding /DOutputDir=C:\t\installer punktfunk-client.iss
|
||||
;
|
||||
; What the MSIX manifest granted declaratively is re-created here per-user (all HKCU, so no
|
||||
; elevation and uninstall leaves nothing behind):
|
||||
; punktfunk:// protocol -> HKCU\Software\Classes\punktfunk (deeplink.rs positional parse)
|
||||
; Start entries -> {userprograms} shortcuts (Punktfunk + Punktfunk Console)
|
||||
; punktfunk.exe CLI alias -> {app} appended to the HKCU PATH (Playnite importer shells to it)
|
||||
; punktfunk-client.exe alias -> unnecessary: deeplink.rs targets current_exe() when unpackaged
|
||||
; Microsoft.WindowsAppRuntime.2 PackageDependency
|
||||
; -> download + run the runtime installer when missing ([Code])
|
||||
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "0.0.0.0"
|
||||
#endif
|
||||
#ifndef Arch
|
||||
#define Arch "x64"
|
||||
#endif
|
||||
#ifndef LayoutDir
|
||||
#define LayoutDir "."
|
||||
#endif
|
||||
#ifndef BrandingDir
|
||||
#define BrandingDir "..\..\..\packaging\windows\branding"
|
||||
#endif
|
||||
#ifndef OutputDir
|
||||
#define OutputDir "."
|
||||
#endif
|
||||
; The unpackaged app resolves an INSTALLED Windows App SDK runtime via the bootstrap DLL
|
||||
; (windows-reactor pins WINDOWSAPPSDK_RELEASE_MAJORMINOR = 0x20000; the MSIX manifest's
|
||||
; PackageDependency floor is 2.2 — keep the two in sync with packaging/AppxManifest.xml).
|
||||
#define AppRuntimeUrl "https://aka.ms/windowsappsdk/2.2/latest/windowsappruntimeinstall-" + Arch + ".exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{52464E61-68A1-4621-B6B3-5B8BBB823D1A}
|
||||
AppName=Punktfunk
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher=unom
|
||||
AppPublisherURL=https://git.unom.io/unom/punktfunk
|
||||
; Per-user, no UAC: {userpf} = %LOCALAPPDATA%\Programs. A browsable, stable path is the point —
|
||||
; see the header (Steam overlay / Big Picture).
|
||||
DefaultDirName={userpf}\Punktfunk
|
||||
PrivilegesRequired=lowest
|
||||
DisableProgramGroupPage=yes
|
||||
UsePreviousAppDir=yes
|
||||
; Same floor as the MSIX manifest's TargetDeviceFamily MinVersion (10.0.17763).
|
||||
MinVersion=10.0.17763
|
||||
#if Arch == "arm64"
|
||||
ArchitecturesAllowed=arm64
|
||||
ArchitecturesInstallIn64BitMode=arm64
|
||||
#else
|
||||
ArchitecturesAllowed=x64
|
||||
ArchitecturesInstallIn64BitMode=x64
|
||||
#endif
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename=punktfunk-client-setup-{#MyAppVersion}_{#Arch}
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
; Modern branded wizard, same version gate as the host installer (punktfunk-host.iss).
|
||||
#if VER >= EncodeVer(6,6,0)
|
||||
WizardStyle=modern dynamic windows11
|
||||
#else
|
||||
WizardStyle=modern
|
||||
#endif
|
||||
SetupIconFile={#BrandingDir}\punktfunk.ico
|
||||
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
|
||||
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
|
||||
UninstallDisplayName=Punktfunk {#MyAppVersion}
|
||||
UninstallDisplayIcon={app}\punktfunk-client.exe
|
||||
; {app} goes on the USER PATH (see [Registry] + PathNeedsAdd/RemoveAppFromPath below) so the
|
||||
; documented `punktfunk hosts list` / `punktfunk launch` one-liners work by name — same contract
|
||||
; the MSIX's punktfunk.exe app-execution alias provided. Broadcasts WM_SETTINGCHANGE.
|
||||
ChangesEnvironment=yes
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Create a Desktop shortcut"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
; The staged MSIX layout, minus the package-only bits (AppxManifest.xml, the tile Assets — the
|
||||
; exes embed their own icons via build.rs winresource). pack-client-installer.ps1 signs the four
|
||||
; exes individually before ISCC runs; the .msix signs only its container, so this cannot be
|
||||
; skipped by "the MSIX build already signed them".
|
||||
Source: "{#LayoutDir}\punktfunk-client.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk-session.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk-console.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\Microsoft.WindowsAppRuntime.Bootstrap.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\SDL3.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\resources.pri"; DestDir: "{app}"; Flags: ignoreversion
|
||||
; MIT/Apache + the client-scoped THIRD-PARTY-NOTICES — same payload the MSIX carries.
|
||||
Source: "{#LayoutDir}\licenses\*"; DestDir: "{app}\licenses"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
; Flat Start-menu entries, mirroring the MSIX's two Application tiles.
|
||||
Name: "{userprograms}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"
|
||||
Name: "{userprograms}\Punktfunk Console"; Filename: "{app}\punktfunk-console.exe"; \
|
||||
Comment: "Controller-driven couch interface for TVs and HTPCs"
|
||||
Name: "{userdesktop}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"; Tasks: desktopicon
|
||||
|
||||
[Registry]
|
||||
; The punktfunk:// scheme (design/client-deep-links.md §4.2) — the registry twin of the MSIX
|
||||
; manifest's windows.protocol extension. Protocol activation delivers the URI as "%1" on the
|
||||
; command line, so this lands in the same positional URL parse in main() that the packaged
|
||||
; activation does. HKCU + uninsdeletekey: nothing survives uninstall.
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; \
|
||||
ValueData: "URL:Punktfunk stream link"; Flags: uninsdeletekey
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk\DefaultIcon"; ValueType: string; \
|
||||
ValueData: "{app}\punktfunk-client.exe,0"
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk\shell\open\command"; ValueType: string; \
|
||||
ValueData: """{app}\punktfunk-client.exe"" ""%1"""
|
||||
; Put {app} on the USER PATH so `punktfunk` (the headless CLI) is runnable by name. Appended to
|
||||
; {olddata} and guarded by PathNeedsAdd so a repair/upgrade never appends a duplicate. NOT
|
||||
; uninsdeletevalue — that would delete the whole Path value; the uninstaller surgically removes
|
||||
; just our entry (RemoveAppFromPath). expandsz preserves %VAR%-style entries other software put here.
|
||||
Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; \
|
||||
ValueData: "{olddata};{app}"; Check: PathNeedsAdd(ExpandConstant('{app}'))
|
||||
|
||||
[Code]
|
||||
const
|
||||
EnvKey = 'Environment'; { the HKCU per-user environment key }
|
||||
|
||||
{ Is the install dir missing from the user PATH? Guards the [Registry] append so a repair or
|
||||
upgrade can't add a second copy. Semicolon-delimited, case-insensitive — a path that merely
|
||||
CONTAINS ours as a substring doesn't count as a match. (Same helper as punktfunk-host.iss,
|
||||
retargeted from the HKLM machine key to HKCU.) }
|
||||
function PathNeedsAdd(Param: String): Boolean;
|
||||
var
|
||||
OrigPath: String;
|
||||
begin
|
||||
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
|
||||
begin
|
||||
Result := True; { no Path value at all - the append creates it }
|
||||
exit;
|
||||
end;
|
||||
Result := Pos(';' + Uppercase(Param) + ';', ';' + Uppercase(OrigPath) + ';') = 0;
|
||||
end;
|
||||
|
||||
{ Remove exactly our install-dir entry from the user PATH on uninstall, leaving every other entry
|
||||
(and their order) intact. Entry-by-entry rebuild, never a substring delete. }
|
||||
procedure RemoveAppFromPath;
|
||||
var
|
||||
OrigPath, NewPath, Entry: String;
|
||||
Target: String;
|
||||
P: Integer;
|
||||
begin
|
||||
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
|
||||
exit;
|
||||
Target := Uppercase(ExpandConstant('{app}'));
|
||||
NewPath := '';
|
||||
OrigPath := OrigPath + ';';
|
||||
repeat
|
||||
P := Pos(';', OrigPath);
|
||||
Entry := Trim(Copy(OrigPath, 1, P - 1));
|
||||
OrigPath := Copy(OrigPath, P + 1, Length(OrigPath));
|
||||
if (Entry <> '') and (Uppercase(Entry) <> Target) then
|
||||
begin
|
||||
if NewPath <> '' then NewPath := NewPath + ';';
|
||||
NewPath := NewPath + Entry;
|
||||
end;
|
||||
until OrigPath = '';
|
||||
RegWriteExpandStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', NewPath);
|
||||
end;
|
||||
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
begin
|
||||
if CurUninstallStep = usPostUninstall then
|
||||
RemoveAppFromPath;
|
||||
end;
|
||||
|
||||
{ The Windows App SDK runtime the bootstrap DLL resolves at launch (the unpackaged twin of the
|
||||
MSIX's PackageDependency). Probe per-user via Get-AppxPackage; when missing, fetch Microsoft's
|
||||
runtime installer and run it quietly — it registers Store-signed framework packages, which
|
||||
needs no elevation. Every failure path is NON-FATAL and ends in the same message the docs
|
||||
carry, because the app itself reports the missing runtime on first launch too. }
|
||||
function AppRuntimeMissing(): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
{ exit 0 = found, 1 = missing; a powershell failure (rc <> 0/1) counts as missing - the
|
||||
download below is idempotent and the runtime installer no-ops when it is present. }
|
||||
if not Exec('powershell.exe',
|
||||
'-NoProfile -ExecutionPolicy Bypass -Command "if (Get-AppxPackage -Name Microsoft.WindowsAppRuntime.2*) { exit 0 } else { exit 1 }"',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||
begin
|
||||
Result := True;
|
||||
exit;
|
||||
end;
|
||||
Result := ResultCode <> 0;
|
||||
end;
|
||||
|
||||
procedure EnsureAppRuntime;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
Installer: String;
|
||||
begin
|
||||
if not AppRuntimeMissing() then
|
||||
exit;
|
||||
Installer := 'windowsappruntimeinstall.exe';
|
||||
try
|
||||
DownloadTemporaryFile('{#AppRuntimeUrl}', Installer, '', nil);
|
||||
if not Exec(ExpandConstant('{tmp}\' + Installer), '--quiet', '',
|
||||
SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
|
||||
RaiseException('runtime installer exit code ' + IntToStr(ResultCode));
|
||||
except
|
||||
SuppressibleMsgBox(
|
||||
'The Windows App Runtime 2.x could not be installed automatically.' + #13#10 + #13#10 +
|
||||
'Punktfunk needs it to start. Install it from ' + #13#10 +
|
||||
'https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads' + #13#10 +
|
||||
'and then launch Punktfunk normally.',
|
||||
mbInformation, MB_OK, IDOK);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
{ On upgrade a running shell/stream locks the exes; kill them best-effort so the copy succeeds.
|
||||
taskkill matches the image NAME, so "punktfunk.exe" hits only the CLI, not the host service. }
|
||||
if CurStep = ssInstall then
|
||||
Exec(ExpandConstant('{sys}\taskkill.exe'),
|
||||
'/F /IM punktfunk-client.exe /IM punktfunk-session.exe /IM punktfunk-console.exe /IM punktfunk.exe',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
{ ssPostInstall, NOT a wizard-page hook: silent installs (winget-style /VERYSILENT) show no
|
||||
pages, and skipping the runtime there would ship an app that cannot start. This step runs on
|
||||
every install mode, and SuppressibleMsgBox keeps the failure path unattended-safe. }
|
||||
if CurStep = ssPostInstall then
|
||||
EnsureAppRuntime;
|
||||
end;
|
||||
@@ -700,31 +700,23 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.iter()
|
||||
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false);
|
||||
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
|
||||
// it once it sleeps (no-op / no disk write when unchanged).
|
||||
if let Some(a) = hosts.iter().find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
&& !h.mac.is_empty()
|
||||
}) {
|
||||
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||
}
|
||||
// Same for its OS chain — the tile's mark then survives the host going offline.
|
||||
if let Some(a) = hosts.iter().find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) && !h.os.is_empty()
|
||||
}) {
|
||||
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
|
||||
}
|
||||
// Same for its management port — load-bearing, unlike the two above: a host moved off
|
||||
// 47990 loses its library entirely once mDNS is gone unless we write the port down.
|
||||
if let Some(p) = hosts
|
||||
// Learn what this host's live advert teaches while it's online: its wake MAC(s) (so we
|
||||
// can wake it once it sleeps), its OS chain (so the tile's mark survives it going
|
||||
// offline), and its management port — the last load-bearing rather than cosmetic, as
|
||||
// a host moved off 47990 loses its library entirely once mDNS is gone unless we write
|
||||
// the port down. No-op, and no disk write, when unchanged.
|
||||
if let Some(a) = hosts
|
||||
.iter()
|
||||
.find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
&& h.mgmt_port.is_some()
|
||||
})
|
||||
.and_then(|h| h.mgmt_port)
|
||||
.find(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
{
|
||||
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
|
||||
crate::trust::learn_from_advert(
|
||||
&k.fp_hex,
|
||||
&k.addr,
|
||||
k.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
let can_wake = !online && !k.mac.is_empty();
|
||||
let menu = {
|
||||
|
||||
@@ -203,14 +203,30 @@ pub(crate) fn queue(url: String) {
|
||||
INBOX.lock().unwrap().push(url);
|
||||
}
|
||||
|
||||
/// Whether this process runs with MSIX package identity. Decides how a shortcut must target us
|
||||
/// (`write_shortcut` below) and whether the process may stamp its own AppUserModelID
|
||||
/// (`set_app_user_model_id` in main.rs).
|
||||
pub(crate) fn has_package_identity() -> bool {
|
||||
use windows::Win32::appmodel::GetCurrentPackageFullName;
|
||||
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
|
||||
// SAFETY: `GetCurrentPackageFullName` with `len = 0` and no buffer is the documented identity
|
||||
// PROBE — it writes nothing and only reports whether this process is packaged.
|
||||
unsafe {
|
||||
let mut len: u32 = 0;
|
||||
GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a `.lnk` on the Desktop that launches this URL, and return its path.
|
||||
///
|
||||
/// The shortcut targets the app execution alias with the URL as an ARGUMENT, rather than being
|
||||
/// a `.url` internet shortcut. Both would work while the scheme is registered; only this one
|
||||
/// still works if it isn't, because it invokes the client directly — which is the whole point
|
||||
/// of a shortcut being a container for a URL rather than a second launch mechanism
|
||||
/// (design/client-deep-links.md §5). Targeting the alias (not the package path) is what keeps
|
||||
/// it valid across updates, since the install path changes and the alias doesn't.
|
||||
/// The shortcut targets the client exe with the URL as an ARGUMENT, rather than being a `.url`
|
||||
/// internet shortcut. Both would work while the scheme is registered; only this one still works
|
||||
/// if it isn't, because it invokes the client directly — which is the whole point of a shortcut
|
||||
/// being a container for a URL rather than a second launch mechanism
|
||||
/// (design/client-deep-links.md §5). Which exe reference is durable depends on how we were
|
||||
/// installed: under MSIX the install path changes on every update but the app execution alias
|
||||
/// doesn't, so packaged runs target the alias; the Inno Setup / portable installs have no alias
|
||||
/// but a stable install dir, so unpackaged runs target the absolute exe path.
|
||||
pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBuf, String> {
|
||||
use windows::core::{Interface, HSTRING};
|
||||
use windows::Win32::combaseapi::{CoCreateInstance, CoInitializeEx};
|
||||
@@ -223,6 +239,15 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
|
||||
.map(|p| std::path::PathBuf::from(p).join("Desktop"))
|
||||
.map_err(|_| "USERPROFILE isn't set".to_string())?;
|
||||
let path = desktop.join(format!("{}.lnk", file_name(label)));
|
||||
// Alias when packaged, absolute path when not — see the doc comment above.
|
||||
let target = if has_package_identity() {
|
||||
"punktfunk-client.exe".to_string()
|
||||
} else {
|
||||
std::env::current_exe()
|
||||
.map_err(|e| format!("current exe: {e}"))?
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
// SAFETY: COM calls on this thread's apartment. `CoCreateInstance` returns an owned interface
|
||||
// checked by `?`, and every setter below takes a borrowed `HSTRING`/`PCWSTR` that outlives its
|
||||
// synchronous call; nothing here dereferences a pointer the caller supplied.
|
||||
@@ -233,7 +258,7 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
|
||||
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED as u32);
|
||||
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)
|
||||
.map_err(|e| format!("shell link: {e}"))?;
|
||||
link.SetPath(&HSTRING::from("punktfunk-client.exe"))
|
||||
link.SetPath(&HSTRING::from(target.as_str()))
|
||||
.ok()
|
||||
.map_err(|e| format!("shortcut target: {e}"))?;
|
||||
link.SetArguments(&HSTRING::from(url))
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct DiscoveredHost {
|
||||
/// persisted like `mac`. Empty if absent (older host).
|
||||
pub os: String,
|
||||
/// The management API's port from the mDNS `mgmt` TXT — where the game library is served.
|
||||
/// Persisted like `mac` (`trust::learn_mgmt_port`), and load-bearing rather than cosmetic:
|
||||
/// Persisted like `mac` (`trust::learn_from_advert`), and load-bearing rather than cosmetic:
|
||||
/// a host moved off 47990 loses its library once mDNS is gone unless we write this down.
|
||||
/// `None` if absent (older host) — resolve via `library::DEFAULT_MGMT_PORT`.
|
||||
pub mgmt_port: Option<u16>,
|
||||
|
||||
@@ -173,18 +173,12 @@ fn main() {
|
||||
/// processes are left alone. Must run before any window exists.
|
||||
#[cfg(windows)]
|
||||
fn set_app_user_model_id() {
|
||||
use windows::Win32::appmodel::GetCurrentPackageFullName;
|
||||
use windows::Win32::shobjidl_core::SetCurrentProcessExplicitAppUserModelID;
|
||||
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
|
||||
// SAFETY: `GetCurrentPackageFullName` is called with `len = 0` and no buffer, which is the
|
||||
// documented identity PROBE — it writes nothing and only reports whether this process is
|
||||
// packaged; `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
|
||||
if deeplink::has_package_identity() {
|
||||
return; // packaged (or indeterminate) — leave the identity alone
|
||||
}
|
||||
// SAFETY: `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
|
||||
unsafe {
|
||||
let mut len: u32 = 0;
|
||||
// No buffer: just probe whether the process has package identity.
|
||||
if GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE {
|
||||
return; // packaged (or indeterminate) — leave the identity alone
|
||||
}
|
||||
// Must stay in sync with pf-presenter's win32.rs, or the windows stop grouping.
|
||||
let _ = SetCurrentProcessExplicitAppUserModelID(windows::core::w!("unom.punktfunk.client"));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
//! still load via a serde alias in core.
|
||||
|
||||
pub use pf_client_core::trust::{
|
||||
hex, learn_mac, learn_mgmt_port, learn_os, load_or_create_identity, pair_error_message,
|
||||
parse_hex32, KnownHost, KnownHosts, Settings,
|
||||
hex, learn_from_advert, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
|
||||
KnownHosts, Settings,
|
||||
};
|
||||
|
||||
@@ -675,10 +675,10 @@ pub fn forget_placeholder(addr: &str, port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The record [`learn_mac`]/[`learn_os`] should write what an advert taught them onto:
|
||||
/// the fingerprint match if there is one, else whatever the address resolves to. Fingerprint
|
||||
/// FIRST — a single pass that took "either" would hand a stale record at the same address the
|
||||
/// data the live host advertised, purely because it came earlier in the file.
|
||||
/// The record an advert's lesson should land on: the fingerprint match if there is one, else
|
||||
/// whatever the address resolves to. Fingerprint FIRST — a single pass that took "either" would
|
||||
/// hand a stale record at the same address the data the live host advertised, purely because it
|
||||
/// came earlier in the file.
|
||||
fn learn_target<'a>(
|
||||
known: &'a mut KnownHosts,
|
||||
fp_hex: &str,
|
||||
@@ -692,61 +692,62 @@ fn learn_target<'a>(
|
||||
known.hosts.get_mut(i)
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
|
||||
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
|
||||
/// the hosts page can call it on every discovery tick without churning the store.
|
||||
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
||||
if mac.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.mac == mac {
|
||||
return;
|
||||
}
|
||||
h.mac = mac.to_vec();
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's OS-identity chain from its live advert (mDNS `os` TXT), matched
|
||||
/// like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when unchanged, so
|
||||
/// the hosts page can call it on every discovery tick without churning the store.
|
||||
pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) {
|
||||
if os.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.os == os {
|
||||
return;
|
||||
}
|
||||
h.os = os.to_string();
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's management-API port from its live advert (mDNS `mgmt` TXT),
|
||||
/// matched like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when
|
||||
/// unchanged, so the hosts page can call it on every discovery tick without churning the store.
|
||||
/// Copy everything an advert can teach onto a saved record — wake MAC(s), OS-identity chain,
|
||||
/// management port — and report whether anything actually moved, so the caller writes only when
|
||||
/// there is something to write. Pure (no disk, no clock), which is what makes it testable.
|
||||
///
|
||||
/// This is what makes a moved mgmt port outlive mDNS. Until it existed the port was read straight
|
||||
/// off the live advert and thrown away, so the library worked on the LAN and went blank over a VPN.
|
||||
pub fn learn_mgmt_port(fp_hex: &str, addr: &str, port: u16, mgmt_port: u16) {
|
||||
if mgmt_port == 0 {
|
||||
return;
|
||||
/// A field the advert does not carry is left alone, never cleared: an older host simply omits the
|
||||
/// TXT, and forgetting a MAC already learned would cost the user their wake.
|
||||
fn apply_advert(h: &mut KnownHost, mac: &[String], os: &str, mgmt_port: Option<u16>) -> bool {
|
||||
let mut changed = false;
|
||||
if !mac.is_empty() && h.mac != mac {
|
||||
h.mac = mac.to_vec();
|
||||
changed = true;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
if !os.is_empty() && h.os != os {
|
||||
h.os = os.to_string();
|
||||
changed = true;
|
||||
}
|
||||
// 0 is how "not advertised" reaches us from a caller whose own type has no `Option`.
|
||||
if mgmt_port.is_some_and(|p| p != 0 && h.mgmt_port != Some(p)) {
|
||||
h.mgmt_port = mgmt_port;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Write down everything a live advert teaches the saved record it matched — wake MAC(s), OS
|
||||
/// chain, management port — matched by fingerprint or address. No-op, and no disk write, when
|
||||
/// the record already says all three, so a surface can call this on every discovery tick.
|
||||
///
|
||||
/// ONE call rather than three. Each field used to be learned by its own function, which meant
|
||||
/// every front-end had to remember all three, and only the two desktop hosts pages ever did:
|
||||
/// the console home and the headless CLI learned the management port alone. On a Steam Deck,
|
||||
/// whose Gaming Mode runs nothing but those two, that left every saved host with no MAC forever
|
||||
/// — and every wake gate in the codebase reads `!mac.is_empty()` against this record, so
|
||||
/// Wake-on-LAN there could not fire at all, with no error to show for it (#322).
|
||||
///
|
||||
/// [`KnownHosts::read`], not [`KnownHosts::load`]: `punktfunk discover` calls this, and that verb
|
||||
/// is deliberately not an id-minter (see [`KnownHosts::read`] for the race that avoids). Learning
|
||||
/// a MAC is no reason to become one.
|
||||
///
|
||||
/// Takes the three learned fields rather than a `DiscoveredHost` because there are two of those
|
||||
/// — core's and the WinUI shell's verbatim port — and this has to serve both.
|
||||
pub fn learn_from_advert(
|
||||
fp_hex: &str,
|
||||
addr: &str,
|
||||
port: u16,
|
||||
mac: &[String],
|
||||
os: &str,
|
||||
mgmt_port: Option<u16>,
|
||||
) {
|
||||
let mut known = KnownHosts::read();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.mgmt_port == Some(mgmt_port) {
|
||||
return;
|
||||
if apply_advert(h, mac, os, mgmt_port) {
|
||||
let _ = known.save();
|
||||
}
|
||||
h.mgmt_port = Some(mgmt_port);
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
|
||||
@@ -785,7 +786,7 @@ pub fn touch_last_used(fp_hex: &str) {
|
||||
/// Save a host's management-API port learned from the **session's own `Welcome`**, keyed by
|
||||
/// fingerprint alone — the identity a just-connected client is certain of.
|
||||
///
|
||||
/// This is the mDNS-free path, and the one that matters most: [`learn_mgmt_port`] can only fire
|
||||
/// This is the mDNS-free path, and the one that matters most: [`learn_from_advert`] can only fire
|
||||
/// where an advert is visible, whereas this fires on any successful connect, including a host
|
||||
/// added by IP on a network where discovery has never worked. No-op — and no disk write — when
|
||||
/// the fingerprint isn't stored or the value is unchanged, so it is safe on every connect.
|
||||
@@ -2293,6 +2294,33 @@ mod tests {
|
||||
assert!(learn_target(&mut k, &fp('e'), "10.0.0.9", 9777).is_none());
|
||||
}
|
||||
|
||||
/// What an advert carries lands on the record; what it omits is left alone; and a repeat of
|
||||
/// the same advert reports no change — which is what lets every surface call this on every
|
||||
/// discovery tick without churning the store.
|
||||
#[test]
|
||||
fn apply_advert_learns_what_it_carries_and_keeps_what_it_omits() {
|
||||
let mut h = KnownHost::default();
|
||||
let mac = vec!["aa:bb:cc:dd:ee:ff".to_string()];
|
||||
assert!(apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
|
||||
assert_eq!(h.mac, mac);
|
||||
assert_eq!(h.os, "linux/arch");
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// The same advert a tick later: nothing moved, so there is nothing to persist.
|
||||
assert!(!apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
|
||||
// An older host advertises none of the three. Clearing a learned MAC here is exactly what
|
||||
// would cost the user their wake, so an absent field must never overwrite a known one.
|
||||
assert!(!apply_advert(&mut h, &[], "", None));
|
||||
assert_eq!(h.mac, mac);
|
||||
assert_eq!(h.os, "linux/arch");
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// 0 is how "not advertised" reaches us from a consumer that has no Option — not a port.
|
||||
assert!(!apply_advert(&mut h, &[], "", Some(0)));
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// A host that genuinely moved: the new value wins.
|
||||
assert!(apply_advert(&mut h, &[], "", Some(47992)));
|
||||
assert_eq!(h.mgmt_port, Some(47992));
|
||||
}
|
||||
|
||||
/// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is
|
||||
/// presentation state, so a dangling one is never an error surface.
|
||||
#[test]
|
||||
|
||||
@@ -148,6 +148,10 @@ pub(crate) enum HintKey {
|
||||
/// there isn't (the library grid spends up on rows) the same menu hangs off
|
||||
/// [`HintKey::Tertiary`] instead; the button differs, the word "Options" does not.
|
||||
Up,
|
||||
/// ▼ — the home carousel's other spare direction, which opens Settings. Advertised in
|
||||
/// place of [`HintKey::Tertiary`] where no pad is attached, because that is exactly the
|
||||
/// device that has no X to press: a TV remote is a D-pad, OK and Back.
|
||||
Down,
|
||||
Key(&'static str),
|
||||
}
|
||||
|
||||
@@ -272,7 +276,7 @@ fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
|
||||
match resolved(key, style) {
|
||||
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
|
||||
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k,
|
||||
Resolved::Up => BADGE_D * k,
|
||||
Resolved::Up | Resolved::Down => BADGE_D * k,
|
||||
Resolved::Key(text) => keycap_w(fonts, text, k),
|
||||
}
|
||||
}
|
||||
@@ -294,6 +298,9 @@ enum Resolved {
|
||||
/// The d-pad's up — drawn the same in every style, because it is a direction rather
|
||||
/// than a button whose label changes with the pad.
|
||||
Up,
|
||||
/// The d-pad's down — the same triangle stood on its head, and style-free for the
|
||||
/// same reason [`Resolved::Up`] is.
|
||||
Down,
|
||||
Key(&'static str),
|
||||
}
|
||||
|
||||
@@ -317,6 +324,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
HintKey::Shoulders => Resolved::Key("Tab"),
|
||||
HintKey::Adjust => Resolved::Adjust,
|
||||
HintKey::Up => Resolved::Up,
|
||||
HintKey::Down => Resolved::Down,
|
||||
HintKey::Key(t) => Resolved::Key(t),
|
||||
};
|
||||
}
|
||||
@@ -327,6 +335,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
HintKey::Secondary => Resolved::Badge(Face::Y),
|
||||
HintKey::Shoulders => Resolved::Shoulders,
|
||||
HintKey::Adjust => Resolved::Adjust,
|
||||
HintKey::Down => Resolved::Down,
|
||||
HintKey::Up => Resolved::Up,
|
||||
HintKey::Key(t) => Resolved::Key(t),
|
||||
}
|
||||
@@ -394,17 +403,23 @@ fn draw_glyph(
|
||||
pen += w + 3.0 * k;
|
||||
}
|
||||
}
|
||||
Resolved::Up => {
|
||||
// ▲ — one solid triangle in a badge-sized slot.
|
||||
g @ (Resolved::Up | Resolved::Down) => {
|
||||
// ▲ / ▼ — one solid triangle in a badge-sized slot, the same triangle either
|
||||
// way up: apex toward the direction it names, base at the other end.
|
||||
let r = BADGE_D * k / 2.0;
|
||||
let (cx, cyf) = ((x + r) as f32, cy as f32);
|
||||
let (tw, th) = ((5.5 * k) as f32, (4.5 * k) as f32);
|
||||
let mut up = PathBuilder::new();
|
||||
up.move_to((cx, cyf - th));
|
||||
up.line_to((cx - tw, cyf + th));
|
||||
up.line_to((cx + tw, cyf + th));
|
||||
up.close();
|
||||
canvas.draw_path(&up.detach(), &fill(fg(0.85)));
|
||||
let (apex, base) = if matches!(g, Resolved::Down) {
|
||||
(cyf + th, cyf - th)
|
||||
} else {
|
||||
(cyf - th, cyf + th)
|
||||
};
|
||||
let mut tri = PathBuilder::new();
|
||||
tri.move_to((cx, apex));
|
||||
tri.line_to((cx - tw, base));
|
||||
tri.line_to((cx + tw, base));
|
||||
tri.close();
|
||||
canvas.draw_path(&tri.detach(), &fill(fg(0.85)));
|
||||
}
|
||||
Resolved::Adjust => {
|
||||
// ◀ ▶ — two small solid triangles.
|
||||
|
||||
@@ -63,6 +63,10 @@ pub(crate) struct Ctx<'a> {
|
||||
pub pads: &'a [PadInfo],
|
||||
/// Steam Deck: never draw our keyboard — Steam's types via SDL text input.
|
||||
pub deck: bool,
|
||||
/// The host app has another interface to fall back to when the console is switched
|
||||
/// off (an Android phone/tablet's touch shell) — see
|
||||
/// [`crate::shell::ConsoleOptions::fallback_ui`]. Gates the console-off settings row.
|
||||
pub fallback_ui: bool,
|
||||
/// The name the HOST stores this client under when pairing (the machine's
|
||||
/// hostname, resolved by the binary).
|
||||
pub device_name: &'a str,
|
||||
|
||||
@@ -396,6 +396,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads,
|
||||
deck,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -301,6 +302,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -323,6 +325,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -941,6 +941,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -378,6 +378,7 @@ mod tests {
|
||||
platform,
|
||||
pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -429,6 +430,7 @@ mod tests {
|
||||
platform,
|
||||
pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
}
|
||||
|
||||
@@ -206,7 +206,17 @@ impl HomeScreen {
|
||||
}
|
||||
_ => Some(MenuPulse::Boundary),
|
||||
},
|
||||
MenuEvent::Move(_) => None,
|
||||
// Down is Settings — the same screen X opens. The carousel is horizontal, so
|
||||
// down is the other free direction, and it is the only route to Settings on a
|
||||
// device whose input has no face buttons: an Android TV remote is a D-pad, OK
|
||||
// and Back, and X never arrives. (Apple hit this on the Siri Remote too, and
|
||||
// answered it by moving rows out to the ordinary Settings app.)
|
||||
MenuEvent::Move(MenuDir::Down) => {
|
||||
fx.push(Screen::Settings(super::settings::SettingsScreen::new(
|
||||
ctx.store,
|
||||
)));
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +289,15 @@ impl HomeScreen {
|
||||
{
|
||||
hints.push(Hint::new(HintKey::Up, "Options"));
|
||||
}
|
||||
hints.push(Hint::new(HintKey::Tertiary, "Settings"));
|
||||
// Name the route this device actually has. With no pad attached the legend is
|
||||
// already speaking keyboard, and the one input that reaches here with neither a
|
||||
// pad NOR letter keys is a TV remote — for which X is not a button that exists.
|
||||
// Down opens Settings for everyone; only the advertisement changes.
|
||||
hints.push(if ctx.pads.is_empty() {
|
||||
Hint::new(HintKey::Down, "Settings")
|
||||
} else {
|
||||
Hint::new(HintKey::Tertiary, "Settings")
|
||||
});
|
||||
hints.push(Hint::new(HintKey::Back, "Quit"));
|
||||
hints
|
||||
}
|
||||
@@ -859,6 +877,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -885,6 +904,67 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// Everything this screen offers must be reachable from a D-pad, OK and Back alone —
|
||||
/// an Android TV remote has no face buttons, so Settings (X) and the options menu
|
||||
/// would otherwise be unreachable there. Up is the menu, down is Settings, and the
|
||||
/// legend names the direction rather than X when nothing is plugged in.
|
||||
#[test]
|
||||
fn a_remote_reaches_settings_and_options_without_face_buttons() {
|
||||
let mut settings = ctx_settings();
|
||||
let hosts = [host("paired", true, true, false)];
|
||||
let pads: Vec<pf_client_core::menu_nav::PadInfo> = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Android,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: true,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = HomeScreen::new();
|
||||
|
||||
// Down opens the same screen X opens.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::Settings(_))),
|
||||
"down must open Settings"
|
||||
);
|
||||
// Up still opens the host's own menu — the library hangs off that menu now.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::HostOptions(_))),
|
||||
"up must open the host options menu"
|
||||
);
|
||||
// With no pad the legend advertises the direction, not a button that isn't there.
|
||||
assert!(
|
||||
s.hints(&ctx).iter().any(|h| h.key == HintKey::Down),
|
||||
"a padless device is told about down"
|
||||
);
|
||||
// With a pad it goes back to naming X, which is faster to press.
|
||||
let pads = vec![pf_client_core::menu_nav::PadInfo {
|
||||
name: "Pad".into(),
|
||||
key: "045e:028e:Pad".into(),
|
||||
pref: punktfunk_core::config::GamepadPref::Xbox360,
|
||||
steam_virtual: false,
|
||||
battery: None,
|
||||
detail: "045E:028E · gamepad".into(),
|
||||
forwarded: true,
|
||||
rumble: false,
|
||||
}];
|
||||
ctx.pads = &pads;
|
||||
assert!(
|
||||
s.hints(&ctx).iter().any(|h| h.key == HintKey::Tertiary),
|
||||
"a pad is told about X"
|
||||
);
|
||||
}
|
||||
|
||||
/// A pinned card's A-press is a connect WITH its profile (one-off), titled so the
|
||||
/// connecting takeover says which settings are coming (§5.2a).
|
||||
#[test]
|
||||
@@ -908,6 +988,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -932,6 +1013,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -2218,6 +2218,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ use skia_safe::{Canvas, Rect};
|
||||
enum Action {
|
||||
Wake,
|
||||
SendLogs,
|
||||
/// Open this host's game library — the same shelf the home carousel's Y opens, offered
|
||||
/// here because Y is a face button and a TV remote has none. Saved-and-paired only,
|
||||
/// exactly like that Y (an unpaired host has no shelf to fetch).
|
||||
Library,
|
||||
CopyLink,
|
||||
Edit,
|
||||
/// Choose the profile the host's primary tile connects with (opens the
|
||||
@@ -154,6 +158,12 @@ impl OptionsScreen {
|
||||
if host.paired && host.online {
|
||||
a.push(Action::SendLogs);
|
||||
}
|
||||
// The shelf, on the same terms the carousel's Y offers it. Ahead of Copy link
|
||||
// because it is the one row here that goes somewhere rather than acting on the
|
||||
// host — and on a remote-only device it is the ONLY way to the library.
|
||||
if host.paired && host.saved {
|
||||
a.push(Action::Library);
|
||||
}
|
||||
a.extend([
|
||||
Action::CopyLink,
|
||||
Action::Edit,
|
||||
@@ -171,6 +181,7 @@ impl OptionsScreen {
|
||||
match a {
|
||||
Action::Wake => "Wake host".into(),
|
||||
Action::SendLogs => "Send logs to host".into(),
|
||||
Action::Library => "Library".into(),
|
||||
Action::CopyLink => "Copy link".into(),
|
||||
Action::Edit => "Edit\u{2026}".into(),
|
||||
Action::BindProfile => "Default profile\u{2026}".into(),
|
||||
@@ -234,7 +245,7 @@ impl OptionsScreen {
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
ListMsg::Activate => {
|
||||
self.run(action, ctx.store, fx);
|
||||
self.run(action, ctx, fx);
|
||||
pulse
|
||||
}
|
||||
}
|
||||
@@ -257,7 +268,8 @@ impl OptionsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self, action: Action, store: &dyn crate::store::SettingsStore, fx: &mut Outbox) {
|
||||
fn run(&mut self, action: Action, ctx: &Ctx, fx: &mut Outbox) {
|
||||
let store = ctx.store;
|
||||
let key = self.host_key().to_string();
|
||||
match action {
|
||||
Action::Wake => {
|
||||
@@ -289,6 +301,24 @@ impl OptionsScreen {
|
||||
}
|
||||
fx.pop();
|
||||
}
|
||||
// Same two steps the home carousel's Y takes: ask for the shelf, then open it
|
||||
// on the epoch read BEFORE the command drains, so the screen can tell its own
|
||||
// fetch's titles from the ones already in the model. `replace`, not push — the
|
||||
// menu has said its piece, and Back from the shelf belongs on the carousel
|
||||
// rather than on a menu about the host you just left.
|
||||
Action::Library => {
|
||||
let host = self.host();
|
||||
fx.cmds.push(ConsoleCmd::FetchLibrary {
|
||||
addr: host.addr.clone(),
|
||||
mgmt: host.mgmt_port,
|
||||
fp_hex: host.fp_hex.clone(),
|
||||
});
|
||||
let epoch = ctx.library.fetch_epoch();
|
||||
fx.replace(Screen::Library(super::library::LibraryScreen::new(
|
||||
self.host(),
|
||||
epoch,
|
||||
)));
|
||||
}
|
||||
Action::Edit => fx.replace(Screen::AddHost(super::add_host::AddHostScreen::edit(
|
||||
self.host(),
|
||||
))),
|
||||
@@ -407,6 +437,27 @@ mod tests {
|
||||
use crate::model::ProfileChip;
|
||||
use crate::screens::Nav;
|
||||
|
||||
/// Activate one row. `run` reads the store, and — for Library — the shared library's
|
||||
/// fetch epoch; nothing else in this menu touches the context, so one throwaway is
|
||||
/// enough for every action test here.
|
||||
fn run_action(s: &mut OptionsScreen, action: Action, fx: &mut Outbox) {
|
||||
let mut settings = pf_client_core::trust::Settings::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
s.run(action, &ctx, fx);
|
||||
}
|
||||
|
||||
fn host() -> HostRow {
|
||||
HostRow {
|
||||
key: "aa".into(),
|
||||
@@ -510,6 +561,38 @@ mod tests {
|
||||
assert_eq!(s.host_key(), "aa");
|
||||
}
|
||||
|
||||
/// The shelf is on this menu, which is the only route to it that survives a device with
|
||||
/// no face buttons: home's Y opens it too, but an Android TV remote has no Y. Offered on
|
||||
/// the same terms that Y is (saved AND paired), and it REPLACES the menu, so Back from
|
||||
/// the shelf lands on the carousel rather than on a menu about the host just left.
|
||||
#[test]
|
||||
fn the_library_hangs_off_the_menu_for_a_padless_device() {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
assert!(s
|
||||
.actions(crate::platform::Platform::Android)
|
||||
.contains(&Action::Library));
|
||||
|
||||
let mut fx = Outbox::default();
|
||||
run_action(&mut s, Action::Library, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.cmds.first(), Some(ConsoleCmd::FetchLibrary { .. })),
|
||||
"opening the shelf asks for it first"
|
||||
);
|
||||
match fx.nav {
|
||||
Some(Nav::Replace(screen)) => assert!(matches!(*screen, Screen::Library(_))),
|
||||
_ => panic!("expected the shelf to replace the menu"),
|
||||
}
|
||||
|
||||
// An unpaired host has no shelf to fetch — the row is absent, not inert.
|
||||
let unpaired = OptionsScreen::for_host(&HostRow {
|
||||
paired: false,
|
||||
..host()
|
||||
});
|
||||
assert!(!unpaired
|
||||
.actions(crate::platform::Platform::Android)
|
||||
.contains(&Action::Library));
|
||||
}
|
||||
|
||||
/// "Default profile…" swaps the menu for the chooser — a Replace like Edit's, and for
|
||||
/// the same reason — addressed to the HOST's plain key even from rows that carry a
|
||||
/// composite one.
|
||||
@@ -520,7 +603,7 @@ mod tests {
|
||||
.actions(crate::platform::Platform::Desktop)
|
||||
.contains(&Action::BindProfile));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::BindProfile, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::BindProfile, &mut fx);
|
||||
match fx.nav {
|
||||
Some(crate::screens::Nav::Replace(screen)) => match *screen {
|
||||
Screen::BindProfile(b) => assert_eq!(b.host_name(), "Desk"),
|
||||
@@ -537,7 +620,7 @@ mod tests {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
assert!(s.label(Action::Clipboard).ends_with("Off"));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Clipboard, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetClipboard {
|
||||
@@ -551,7 +634,7 @@ mod tests {
|
||||
});
|
||||
assert!(s.label(Action::Clipboard).ends_with("On"));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Clipboard, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetClipboard {
|
||||
@@ -569,12 +652,12 @@ mod tests {
|
||||
s.list.cursor = i;
|
||||
let mut fx = Outbox::default();
|
||||
|
||||
s.run(Action::Forget, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Forget, &mut fx);
|
||||
assert!(fx.cmds.is_empty(), "the first press only arms");
|
||||
assert!(s.armed);
|
||||
assert!(s.label(Action::Forget).contains("press again"));
|
||||
|
||||
s.run(Action::Forget, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Forget, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::ForgetHost { key: "aa".into() }],
|
||||
@@ -597,6 +680,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -659,7 +743,7 @@ mod tests {
|
||||
OptionsScreen::for_game(&host(), &game()),
|
||||
] {
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::CopyLink, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::CopyLink, &mut fx);
|
||||
assert!(matches!(fx.nav, Some(Nav::Pop)));
|
||||
assert!(fx.toast.is_some());
|
||||
}
|
||||
|
||||
@@ -497,6 +497,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "living-room-deck",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -537,6 +538,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "deck",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -233,6 +233,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -274,6 +275,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -104,6 +104,12 @@ enum RowId {
|
||||
Sc2Passthrough,
|
||||
/// DualSense raw-USB capture (touchpad, motion, adaptive triggers).
|
||||
DsCapture,
|
||||
/// Whether the console UI fronts the app at all — the touch settings' switch
|
||||
/// (`Settings.gamepadUiEnabled`), reachable from inside the console it turns off.
|
||||
/// Only offered where there is another interface to fall back to
|
||||
/// ([`Ctx::fallback_ui`]): on a TV or the desktop session this console is the only
|
||||
/// UI, and an off switch would strand the user in nothing.
|
||||
GamepadUi,
|
||||
/// When the console UI fronts the app: with a controller attached, or always.
|
||||
GamepadUiMode,
|
||||
/// The platform's connected-controllers view (an action row — opens a native screen).
|
||||
@@ -121,6 +127,7 @@ mod android_keys {
|
||||
pub const SC2: &str = "android.sc2_capture";
|
||||
pub const DS_CAPTURE: &str = "android.ds_capture";
|
||||
pub const GAMEPAD_UI_MODE: &str = "android.gamepad_ui_mode";
|
||||
pub const GAMEPAD_UI: &str = "android.gamepad_ui_enabled";
|
||||
}
|
||||
|
||||
/// The Android console-UI mode's stored values (`GamepadUi.kt`).
|
||||
@@ -245,6 +252,7 @@ const TABS: [(&str, &[RowId]); 7] = [
|
||||
RowId::Stats,
|
||||
RowId::Fullscreen,
|
||||
RowId::AutoWake,
|
||||
RowId::GamepadUi,
|
||||
RowId::GamepadUiMode,
|
||||
RowId::Licenses,
|
||||
],
|
||||
@@ -384,7 +392,7 @@ impl SettingsScreen {
|
||||
.1
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| row_on(*id, ctx.platform) && row_applies(*id, ctx.settings))
|
||||
.filter(|id| row_on(*id, ctx.platform) && row_applies(*id, ctx))
|
||||
.collect();
|
||||
}
|
||||
if self.profiles.is_empty() {
|
||||
@@ -675,6 +683,7 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
|
||||
| RowId::PhoneGyro
|
||||
| RowId::Sc2Passthrough
|
||||
| RowId::DsCapture
|
||||
| RowId::GamepadUi
|
||||
| RowId::GamepadUiMode
|
||||
| RowId::Controllers
|
||||
| RowId::Licenses
|
||||
@@ -694,9 +703,22 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
|
||||
fn row_applies(id: RowId, ctx: &Ctx) -> bool {
|
||||
match id {
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
RowId::SmoothBuffer => ctx.settings.present_priority == "smooth",
|
||||
// The console-off switch needs somewhere for "off" to land: only clients with a
|
||||
// fallback interface (an Android phone/tablet's touch shell) get the row — on a TV
|
||||
// this console is the only UI, and off would strand the user (the touch settings'
|
||||
// subtitle even promises "A TV always uses it").
|
||||
RowId::GamepadUi => ctx.fallback_ui,
|
||||
// The same two conditions the mode decides anything under: a TV is in console mode
|
||||
// whatever the mode says (`GamepadUi.kt`: the tv term alone satisfies the OR), and
|
||||
// while the switch above is off nothing fronts the console at all. Hidden rather
|
||||
// than dimmed, like the touch screen's picker, and it sits directly below the row
|
||||
// that drops it so the cursor is never under anything that moves.
|
||||
RowId::GamepadUiMode => {
|
||||
ctx.fallback_ui && extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -962,9 +984,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"DualSense over USB",
|
||||
on_off(extra_bool(s, android_keys::DS_CAPTURE, true)).into(),
|
||||
),
|
||||
RowId::GamepadUi => (
|
||||
None,
|
||||
"Controller-optimized UI",
|
||||
on_off(extra_bool(s, android_keys::GAMEPAD_UI, true)).into(),
|
||||
),
|
||||
RowId::GamepadUiMode => (
|
||||
None,
|
||||
"Controller UI",
|
||||
// The touch screen's word for the same picker, which now sits under the same
|
||||
// switch it does there — "Controller UI" beside "Controller-optimized UI"
|
||||
// would be two rows a reader has to tell apart by their tails.
|
||||
"Show it",
|
||||
label_for(
|
||||
&GAMEPAD_UI_MODES,
|
||||
extra_str(s, android_keys::GAMEPAD_UI_MODE, "connected"),
|
||||
@@ -1145,9 +1175,14 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
"Capture a wired DualSense directly (touchpad, motion, adaptive triggers). \
|
||||
Needs the USB grant when the pad is plugged in."
|
||||
}
|
||||
RowId::GamepadUi => {
|
||||
"Front the app with this console instead of the touch interface. Off returns \
|
||||
to the touch home immediately — switch it back on there."
|
||||
}
|
||||
RowId::GamepadUiMode => {
|
||||
"When this console fronts the app: whenever a controller is attached, or \
|
||||
always. The touch settings' \"Controller-optimized UI\" switch turns it off."
|
||||
always — for a device that lives docked to a TV. The switch above turns it \
|
||||
off altogether."
|
||||
}
|
||||
RowId::Controllers => "Connected controllers, their grants and a rumble/haptics test.",
|
||||
RowId::Licenses => "The open-source licences this app ships under.",
|
||||
@@ -1375,6 +1410,7 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
RowId::PhoneGyro => toggle_extra(s, android_keys::PHONE_GYRO, false, delta, wrap),
|
||||
RowId::Sc2Passthrough => toggle_extra(s, android_keys::SC2, true, delta, wrap),
|
||||
RowId::DsCapture => toggle_extra(s, android_keys::DS_CAPTURE, true, delta, wrap),
|
||||
RowId::GamepadUi => toggle_extra(s, android_keys::GAMEPAD_UI, true, delta, wrap),
|
||||
RowId::GamepadUiMode => {
|
||||
let mut v = extra_str(s, android_keys::GAMEPAD_UI_MODE, "connected").to_string();
|
||||
step_str(&GAMEPAD_UI_MODES, &mut v, delta, wrap).map(|()| {
|
||||
@@ -1504,6 +1540,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1538,6 +1575,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1603,6 +1641,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1652,6 +1691,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1686,6 +1726,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1727,6 +1768,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1757,6 +1799,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1793,6 +1836,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1867,6 +1911,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1900,6 +1945,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1931,6 +1977,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1960,6 +2007,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2009,6 +2057,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2060,6 +2109,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2105,6 +2155,7 @@ pub(super) mod tests {
|
||||
RowId::Sc2Passthrough,
|
||||
RowId::DsCapture,
|
||||
RowId::Controllers,
|
||||
RowId::GamepadUi,
|
||||
RowId::GamepadUiMode,
|
||||
RowId::Licenses,
|
||||
]
|
||||
@@ -2147,6 +2198,9 @@ pub(super) mod tests {
|
||||
extra_str(ctx.settings, android_keys::GAMEPAD_UI_MODE, "connected"),
|
||||
"always"
|
||||
);
|
||||
assert!(extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true));
|
||||
assert!(adjust(RowId::GamepadUi, 1, true, ctx));
|
||||
assert!(!extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true));
|
||||
// Only `extra` moved.
|
||||
let mut after = ctx.settings.clone();
|
||||
after.extra = before.extra.clone();
|
||||
@@ -2154,6 +2208,34 @@ pub(super) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// The console-off switch exists only where there is a fallback interface for "off"
|
||||
/// to land in, and the mode row under it only where the mode decides anything: not on
|
||||
/// a TV (always console, whatever the mode says) and not while the switch is off.
|
||||
#[test]
|
||||
fn console_off_switch_needs_a_fallback_ui() {
|
||||
with_ctx(|ctx| {
|
||||
ctx.platform = crate::platform::Platform::Android;
|
||||
// A TV: no off switch (it would strand the user), and no mode row either —
|
||||
// `gamepadUiActive`'s tv term satisfies the OR on its own.
|
||||
assert!(
|
||||
!row_applies(RowId::GamepadUi, ctx),
|
||||
"a TV offers no off switch"
|
||||
);
|
||||
assert!(!row_applies(RowId::GamepadUiMode, ctx));
|
||||
// A phone or tablet with the console on: both rows.
|
||||
ctx.fallback_ui = true;
|
||||
assert!(row_applies(RowId::GamepadUi, ctx));
|
||||
assert!(row_applies(RowId::GamepadUiMode, ctx));
|
||||
// Switched off: the switch stays (it is the way back), the mode row goes.
|
||||
set_extra_bool(ctx.settings, android_keys::GAMEPAD_UI, false);
|
||||
assert!(row_applies(RowId::GamepadUi, ctx));
|
||||
assert!(
|
||||
!row_applies(RowId::GamepadUiMode, ctx),
|
||||
"the mode row decides nothing while the switch above it is off"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_row_has_exactly_one_tab() {
|
||||
let mut seen: Vec<RowId> = Vec::new();
|
||||
@@ -2168,9 +2250,9 @@ pub(super) mod tests {
|
||||
// 2026-08 sweep found them bridged but unreachable) later passes added, minus the
|
||||
// game-library toggle: this screen never read it, and the library is offered on any
|
||||
// paired host now.
|
||||
// 35 desktop rows + the eight Android-only ones (design android-skia-console-port.md
|
||||
// D3): six `extra`-backed settings and two platform-screen action rows.
|
||||
assert_eq!(seen.len(), 43, "{seen:?}");
|
||||
// 35 desktop rows + the nine Android-only ones (design android-skia-console-port.md
|
||||
// D3): seven `extra`-backed settings and two platform-screen action rows.
|
||||
assert_eq!(seen.len(), 44, "{seen:?}");
|
||||
assert!(seen.contains(&RowId::Palette));
|
||||
assert!(seen.contains(&RowId::ReduceMotion));
|
||||
assert!(seen.contains(&RowId::AudioFormat));
|
||||
@@ -2209,6 +2291,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2250,6 +2333,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2295,6 +2379,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2373,6 +2458,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -183,6 +183,11 @@ pub struct ConsoleOptions {
|
||||
pub device_name: String,
|
||||
/// Steam Deck: Steam's keyboard types (SDL text input); ours never draws.
|
||||
pub deck: bool,
|
||||
/// Whether the host app has another interface to fall back to when the console is
|
||||
/// switched off — an Android phone/tablet's touch shell. Shows the console-off switch
|
||||
/// on the settings screen; false where this console is the only UI there is (the
|
||||
/// desktop session, an Android TV), where offering "off" would strand the user.
|
||||
pub fallback_ui: bool,
|
||||
/// Where settings persist and the profile catalog comes from. `None` = the desktop
|
||||
/// file store (`pf_client_core::trust`), which is what the Vulkan session wants and the
|
||||
/// only store there is on Linux/Windows; every other host must supply one.
|
||||
@@ -203,6 +208,7 @@ impl ConsoleOptions {
|
||||
ConsoleOptions {
|
||||
device_name,
|
||||
deck,
|
||||
fallback_ui: false,
|
||||
store: None,
|
||||
platform: Platform::Desktop,
|
||||
gpu_cache_bytes: DEFAULT_GPU_CACHE_BYTES,
|
||||
@@ -247,6 +253,8 @@ pub(crate) struct Shell {
|
||||
hosts_gen: u64,
|
||||
device_name: String,
|
||||
deck: bool,
|
||||
/// See [`ConsoleOptions::fallback_ui`].
|
||||
fallback_ui: bool,
|
||||
pub(crate) in_stream: bool,
|
||||
connecting: Option<Connecting>,
|
||||
/// The last host title a connect was raised for, kept past the connect itself so
|
||||
@@ -352,6 +360,7 @@ impl Shell {
|
||||
hosts_gen: u64::MAX,
|
||||
device_name: opts.device_name,
|
||||
deck: opts.deck,
|
||||
fallback_ui: opts.fallback_ui,
|
||||
in_stream: false,
|
||||
connecting: None,
|
||||
last_connect_title: None,
|
||||
@@ -888,6 +897,7 @@ impl Shell {
|
||||
platform: self.platform,
|
||||
pads: &self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: &self.device_name,
|
||||
t: self.t0.elapsed().as_secs_f64(),
|
||||
};
|
||||
@@ -951,6 +961,10 @@ impl Shell {
|
||||
// navigation but "open this tile's menu". Without this the context menu —
|
||||
// and with it the only way to copy a host's link — is pad-only.
|
||||
crate::glyphs::HintKey::Up => Some(MenuEvent::Move(MenuDir::Up)),
|
||||
// ▼ is the same kind of hint: a direction that steers nothing, because
|
||||
// the only screen publishing it is the home carousel, where down means
|
||||
// "open Settings". A finger must be able to press what it advertises.
|
||||
crate::glyphs::HintKey::Down => Some(MenuEvent::Move(MenuDir::Down)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ev) = ev {
|
||||
@@ -970,6 +984,7 @@ impl Shell {
|
||||
platform: self.platform,
|
||||
pads: &self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: &self.device_name,
|
||||
t: self.t0.elapsed().as_secs_f64(),
|
||||
};
|
||||
|
||||
@@ -172,6 +172,7 @@ impl Shell {
|
||||
platform: self.platform,
|
||||
pads: &self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: &self.device_name,
|
||||
t,
|
||||
glyphs: self.glyphs,
|
||||
@@ -331,6 +332,8 @@ struct LayerEnv<'a> {
|
||||
platform: crate::platform::Platform,
|
||||
pads: &'a [PadInfo],
|
||||
deck: bool,
|
||||
/// See [`crate::shell::ConsoleOptions::fallback_ui`] — a screen's row set can ask.
|
||||
fallback_ui: bool,
|
||||
device_name: &'a str,
|
||||
t: f64,
|
||||
glyphs: GlyphStyle,
|
||||
@@ -365,6 +368,7 @@ impl LayerEnv<'_> {
|
||||
platform: self.platform,
|
||||
pads: self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: self.device_name,
|
||||
t: self.t,
|
||||
};
|
||||
|
||||
@@ -402,10 +402,11 @@ fn a_replace_carries_the_screen_it_replaced() {
|
||||
assert!(matches!(s.stack.last(), Some(Screen::HostOptions(_))));
|
||||
finish_motion(&mut s);
|
||||
|
||||
// Walk to "Edit…" and take it. The first fixture host is paired and online and cannot
|
||||
// wake, so its menu is [Send logs, Copy link, Edit…, Forget, Cancel] — Edit is two down.
|
||||
// Pressed exactly rather than searched, so that reordering the menu fails HERE instead of
|
||||
// quietly landing this test's Confirm on "Forget".
|
||||
// Walk to "Edit…" and take it. The first fixture host is paired, saved and online and
|
||||
// cannot wake, so its menu is [Send logs, Library, Copy link, Edit…, …] — Edit is three
|
||||
// down. Pressed exactly rather than searched, so that reordering the menu fails HERE
|
||||
// instead of quietly landing this test's Confirm on something destructive.
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
|
||||
@@ -396,9 +396,16 @@ pub(crate) fn panel_highlight(canvas: &Canvas, rect: Rect, corner: f32, k: f32)
|
||||
),
|
||||
None,
|
||||
));
|
||||
canvas.draw_rrect(RRect::new_rect_xy(inset, corner * k, corner * k), &p);
|
||||
// Concentric, the same rule the halo states: pulled in by half a unit, so the radius
|
||||
// comes in by half a unit too or the lit edge crosses the panel's own corner arc.
|
||||
let r = ((corner - 0.5) * k).max(0.0);
|
||||
canvas.draw_rrect(RRect::new_rect_xy(inset, r, r), &p);
|
||||
}
|
||||
|
||||
/// How far [`focus_halo`] is grown past the card on every side, in design units. Both the
|
||||
/// rect AND the corner radius take it — see the draw there.
|
||||
const HALO_OUTSET: f32 = 4.0;
|
||||
|
||||
/// An accent-tinted glow under the focused card — the palette-aware mark that says "this
|
||||
/// one" from across a room, where a 2 % scale difference says nothing at all. Drawn behind
|
||||
/// [`drop_shadow`], and only ever for the ONE focused tile, so it costs a single extra
|
||||
@@ -439,8 +446,13 @@ pub(crate) fn focus_halo(canvas: &Canvas, rect: Rect, corner: f32, k: f32, f: f3
|
||||
// it overran the coverflow's 58 dp focused-to-neighbour gap, and since the strip paints
|
||||
// farthest-first the focused card's corona landed on top of its neighbours — which is
|
||||
// what made every card look like it was glowing.
|
||||
let spread = rect.with_outset((4.0 * k, 4.0 * k));
|
||||
canvas.draw_rrect(RRect::new_rect_xy(spread, corner * k, corner * k), &p);
|
||||
let spread = rect.with_outset((HALO_OUTSET * k, HALO_OUTSET * k));
|
||||
// Concentric: a shape grown by `d` on every side keeps its corners parallel to the
|
||||
// original's only if its radius grows by `d` too (the two arcs then share a centre).
|
||||
// Reusing the card's own radius left the halo squarer than the card it sits under, so
|
||||
// it read as a misaligned outline at the four corners and a clean glow along the edges.
|
||||
let r = (corner + HALO_OUTSET) * k;
|
||||
canvas.draw_rrect(RRect::new_rect_xy(spread, r, r), &p);
|
||||
}
|
||||
|
||||
pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alpha: f32) {
|
||||
|
||||
@@ -116,11 +116,16 @@ static MANAGED_LAUNCH: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
/// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects.
|
||||
static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// The display-manager unit we stopped for the takeover (any DM that drove a LIVE gaming session
|
||||
/// is stopped for the stream — see [`dm_plan`]), so the restore brings the box back via
|
||||
/// A display-manager unit stopped for a takeover, so the restore brings the box back via
|
||||
/// `reset-failed` + `restart` of the DM instead of a `--user start` of the gamescope unit (which
|
||||
/// cannot work on a mask-fragile flavor: without a DM login session there is no seat, so gamescope
|
||||
/// never gets DRM master — live-proven on the Nobara repro VM 2026-07-24).
|
||||
///
|
||||
/// ⚠ **Adoption-only since 0.31.0**: the takeover idles the box's autologin session
|
||||
/// ([`install_idle_dropin`]) and leaves the DM up, so nothing in this process ever writes this any
|
||||
/// more — only [`restore_takeover_on_startup`], for a takeover stranded by a host old enough to
|
||||
/// have stopped one. It is therefore NOT the marker of a live takeover; [`takeover_idled`] is.
|
||||
/// Reading it as that marker is what silently unreachable-d the in-stream switch gate in 0.31.0.
|
||||
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Whether this takeover runtime-masked the [`STOPPED_AUTOLOGIN`] units ([`mask_unit`]) — i.e.
|
||||
@@ -136,12 +141,16 @@ static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None
|
||||
static AUTOLOGIN_MASKED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
|
||||
/// "Switch to Desktop" detector compares against. Steam's session-select script writes
|
||||
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
|
||||
/// display-manager checks — so it advances even under a DM-stop takeover, where the script's
|
||||
/// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*;
|
||||
/// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is
|
||||
/// therefore the one durable trace of the user's switch request.
|
||||
/// "Switch to Desktop" detector compares against. The ChimeraOS-layout `os-session-select`
|
||||
/// (Nobara, ChimeraOS) writes `~/.config/steamos-session-select` unconditionally in its USER pass,
|
||||
/// before any of its display-manager checks, so an advanced mtime after a capture loss is the one
|
||||
/// durable trace of the user's switch request — the switch itself leaves nothing else behind that
|
||||
/// this host can see.
|
||||
///
|
||||
/// ⚠ Bazzite/SteamOS write NO sentinel: their `os-session-select` is a thin wrapper over
|
||||
/// `steamosctl` D-Bus calls. The detector is therefore inert there by construction, which is
|
||||
/// exactly right — those platforms default the mid-stream session watcher ON
|
||||
/// ([`is_steam_htpc_platform`]) and follow the switch with it instead.
|
||||
///
|
||||
/// Two levels of `Option`, because "no baseline" and "no sentinel" mean opposite things:
|
||||
/// * **outer `None`** — never baselined (no takeover this host lifetime). Nothing can read as an
|
||||
@@ -358,6 +367,38 @@ fn takeover_state_is_live(state: &TakeoverState) -> bool {
|
||||
|| state.forced_screen_env
|
||||
}
|
||||
|
||||
/// Restart the box's own autologin gaming session(s) after a leftover idle drop-in was swept off
|
||||
/// a host that died holding one ([`restore_takeover_on_startup`]).
|
||||
///
|
||||
/// Gated on the box actually being dark ([`box_session_live`]): if the user is already in game mode
|
||||
/// or on a desktop, the drop-in we removed was inert and bouncing their session would be the bug.
|
||||
/// Only an ACTIVE instance is restarted — under a just-removed idle drop-in, active means "running
|
||||
/// the sleep"; an inactive one is a leftover the display manager will handle on its own.
|
||||
fn hand_back_idled_units_after_crash() {
|
||||
if box_session_live() {
|
||||
return; // something is already drawing — the drop-in was inert
|
||||
}
|
||||
let units: Vec<String> = listed_autologin_units()
|
||||
.into_iter()
|
||||
.filter(|(_, active)| active == "active")
|
||||
.map(|(unit, _)| unit)
|
||||
.collect();
|
||||
if units.is_empty() {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
?units,
|
||||
"gamescope: the box's Game Mode is running the dead host's idle placeholder and its panel \
|
||||
is dark — restarting it"
|
||||
);
|
||||
for unit in &units {
|
||||
if let RestoreVerb::Failed(why) = issue_restore_verb(&["restart", unit]) {
|
||||
tracing::error!(unit, status = %why, "gamescope: could not restart it");
|
||||
}
|
||||
}
|
||||
ensure_box_session_or_escalate(&units);
|
||||
}
|
||||
|
||||
/// On host startup, restore the TV's gaming session if a previous host instance took it over and
|
||||
/// crashed before restoring (`design/gamemode-and-dedicated-sessions.md` A3). Loads the persisted
|
||||
/// [`TakeoverState`] into the statics and schedules a restore after a short reconnect grace (so a
|
||||
@@ -390,6 +431,13 @@ pub fn restore_takeover_on_startup() {
|
||||
"gamescope: removed a leftover idle drop-in from a previous host instance — the box's \
|
||||
own Game Mode session would have started and then done nothing"
|
||||
);
|
||||
// Removing the FILE does not touch the unit RUNNING under it. That unit's `ExecStart` was
|
||||
// replaced with a sleep, so it is `active` and drawing nothing, and nothing below will
|
||||
// restart it: the takeover file may be absent, unparseable, or not `takeover_state_is_live`
|
||||
// — and all three of those exits used to leave the box sitting on a dark panel with its
|
||||
// Game Mode "running". A host killed mid-stream (SIGKILL, OOM, a yanked update) lands
|
||||
// exactly there, and on glass it is indistinguishable from broken hardware. Hand it back.
|
||||
hand_back_idled_units_after_crash();
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(takeover_state_path()) else {
|
||||
return; // no takeover file — clean start
|
||||
@@ -674,21 +722,28 @@ fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<Virtual
|
||||
if steamos_session_present() {
|
||||
return create_managed_session_steamos(mode, hdr);
|
||||
}
|
||||
// In-stream "Switch to Desktop" under a DM-stop takeover: the user's session-select inside
|
||||
// the streamed game mode advanced the sentinel, but its config rewrite was a silent no-op
|
||||
// (every write branch needs the DM running, and the takeover stopped it) — so without this,
|
||||
// the capture loss it caused would just relaunch game mode ("thrown back in", field-tested
|
||||
// 2026-07-24). Honor the request instead: restore the DM and replay the switch.
|
||||
let dm_takeover = STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if let Some(dm) = dm_takeover {
|
||||
if session_select_requested() {
|
||||
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
honor_session_select_switch(dm);
|
||||
return Err(anyhow!(
|
||||
"the user switched the box to the desktop session — display manager restored; \
|
||||
re-detection follows the desktop compositor as it comes up"
|
||||
));
|
||||
}
|
||||
// In-stream "Switch to Desktop": the user's session-select inside the streamed game mode
|
||||
// advanced the sentinel, so the box is on its way to a desktop session. Without this, the
|
||||
// capture loss that switch causes just relaunches game mode over the booting desktop — the
|
||||
// "thrown back in" field report of 2026-07-24, and again on Nobara 2026-08-20.
|
||||
//
|
||||
// ⚠ Gated on the IDLED takeover, not on [`STOPPED_DM`]. Until 0.31.0 the takeover stopped the
|
||||
// display manager, and setting that static was what armed this gate; the idled takeover
|
||||
// replaced both the stop and the static ([`install_idle_dropin`]) and nothing re-armed the
|
||||
// gate, so this branch became unreachable on every box. Bazzite did not notice — its
|
||||
// `os-session-select` is a `steamosctl` D-Bus call that writes no sentinel, and its session
|
||||
// watcher is on by default ([`is_steam_htpc_platform`]) so the stream follows the switch
|
||||
// anyway. The ChimeraOS-layout distros are the ones that lost their handling: their
|
||||
// `os-session-select` DOES write the sentinel, and `ID=nobara` matches no HTPC default.
|
||||
if takeover_idled() && session_select_requested() {
|
||||
// `take`, so an adopted DM stop is consumed exactly once — see
|
||||
// [`honor_session_select_switch`] for why a 0.31.0 takeover has none to consume.
|
||||
let adopted_dm = std::mem::take(&mut *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
honor_session_select_switch(adopted_dm);
|
||||
return Err(anyhow!(
|
||||
"the user switched the box to the desktop session — the box's own game mode is handed \
|
||||
back; re-detection follows the desktop compositor as it comes up"
|
||||
));
|
||||
}
|
||||
// Post-honor grace: while the selected desktop boots, a managed relaunch would win the race
|
||||
// (gamescope+Steam start faster than KWin) and a delivering pipeline ends the rebuild's
|
||||
@@ -1349,19 +1404,34 @@ fn install_idle_dropin() -> Result<()> {
|
||||
.parent()
|
||||
.context("the idle drop-in path has no parent directory")?;
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"[Service]\nExecStart=\nExecStart={} infinity\n",
|
||||
sleep_binary()
|
||||
),
|
||||
)
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
std::fs::write(&path, idle_dropin_body(sleep_binary()))
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The idle drop-in's body (the unit-testable core of [`install_idle_dropin`]).
|
||||
///
|
||||
/// The **empty `ExecStart=` comes first and is load-bearing**: `ExecStart` is a list-valued
|
||||
/// directive, so a drop-in that only adds a line APPENDS to the box's own — which would run the
|
||||
/// real gamescope session *and* the sleep, i.e. exactly the Steam-fighting session the takeover
|
||||
/// exists to get out of the way, with no symptom pointing here. The reset is what replaces it.
|
||||
fn idle_dropin_body(sleep_bin: &str) -> String {
|
||||
format!("[Service]\nExecStart=\nExecStart={sleep_bin} infinity\n")
|
||||
}
|
||||
|
||||
/// Does THIS host hold the box's game mode idled right now? The successor to "did we stop the
|
||||
/// display manager" as the marker of a live managed takeover, and so what arms the in-stream
|
||||
/// switch gate in [`create_managed_session`].
|
||||
///
|
||||
/// Reads [`IDLE_DROPIN_ARMED`] — this process's own memory — deliberately, unlike
|
||||
/// [`remove_idle_dropin`]: a drop-in on disk that we did not write belongs to a dead host, and
|
||||
/// honoring a "switch" against someone else's takeover would hand back a box we never took.
|
||||
fn takeover_idled() -> bool {
|
||||
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Remove the idle drop-in so the box's own Game Mode runs for real again; reports whether one was
|
||||
/// there. Deliberately NOT gated on [`IDLE_DROPIN_ARMED`] — the flag is this process's memory, and
|
||||
/// the drop-in outliving a host that died is exactly the case that has to be swept.
|
||||
@@ -2282,10 +2352,36 @@ fn switch_ends_mask_window(kind: super::ActiveKind) -> bool {
|
||||
}
|
||||
|
||||
/// The host's mid-stream session watcher calls this on every switch it confirms; see
|
||||
/// [`switch_ends_mask_window`] for which ones actually lift the mask.
|
||||
/// [`switch_ends_mask_window`] for which ones end the takeover's hold on the box's own game mode.
|
||||
///
|
||||
/// This is the SECOND of the two ways a box can leave our takeover mid-stream — the sentinel
|
||||
/// detector in [`create_managed_session`] is the other — and both owe the box the same hand-back.
|
||||
/// The watcher is the one that covers Bazzite/SteamOS, where it is on by default
|
||||
/// ([`is_steam_htpc_platform`]) and no sentinel is ever written; the detector covers the
|
||||
/// ChimeraOS-layout distros, which are the reverse. Fixing only one leaves the other's boxes
|
||||
/// holding an idled game mode.
|
||||
pub fn release_autologin_mask(switched_to: super::ActiveKind) {
|
||||
if switch_ends_mask_window(switched_to) {
|
||||
lift_autologin_mask();
|
||||
if !switch_ends_mask_window(switched_to) {
|
||||
return;
|
||||
}
|
||||
lift_autologin_mask();
|
||||
// The idle drop-in is the mask's successor and inherits its whole hazard: it replaces the
|
||||
// box's game-mode `ExecStart` with a sleep, and a switch to a desktop is exactly where that
|
||||
// stops being ours to hold. Left on, the user's "Return to Gaming Mode" starts a unit that
|
||||
// only sleeps — the same barred way back this function's mask lift exists to prevent, and
|
||||
// measured in that state on Bazzite `.41` 2026-08-20 (`ExecStart=/usr/bin/sleep infinity`
|
||||
// still on the unit after a completed switch to KDE).
|
||||
//
|
||||
// Deliberately NOT a full [`clear_takeover`]: the takeover outlives this window, exactly as
|
||||
// the mask lift's own note says. The box may come back to game mode, and the disconnect
|
||||
// restore still owes [`STOPPED_AUTOLOGIN`] a start. All this says is "the box's own game mode
|
||||
// runs for real again".
|
||||
if remove_idle_dropin() {
|
||||
tracing::info!(
|
||||
switched_to = ?switched_to,
|
||||
"gamescope: the box left our game session for a desktop — removed the takeover's idle \
|
||||
drop-in so its own Game Mode runs for real again"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2754,11 +2850,12 @@ fn session_select_mtime() -> Option<std::time::SystemTime> {
|
||||
|
||||
/// Record the sentinel baseline, so a LATER write (the user's in-stream "Switch to Desktop") is
|
||||
/// distinguishable from the switch that led into this session. Taken at **takeover** (the moment
|
||||
/// [`STOPPED_DM`] is set, which is what arms the honor gate) and again at a successful launch: the
|
||||
/// switch INTO game mode writes the sentinel on its way in, and that write must never read as a
|
||||
/// request to go back out. Baselining only at launch left the window in between — a takeover whose
|
||||
/// launch failed, then a client retry inside the restore debounce — reading a months-old sentinel
|
||||
/// as a live request and pushing the box to the desktop the user never asked for.
|
||||
/// the idle drop-in goes in, which is what arms the honor gate — see [`takeover_idled`]) and again
|
||||
/// at a successful launch: the switch INTO game mode writes the sentinel on its way in, and that
|
||||
/// write must never read as a request to go back out. Baselining only at launch left the window in
|
||||
/// between — a takeover whose launch failed, then a client retry inside the restore debounce —
|
||||
/// reading a months-old sentinel as a live request and pushing the box to the desktop the user
|
||||
/// never asked for.
|
||||
fn record_session_select_baseline() {
|
||||
*SESSION_SELECT_BASELINE
|
||||
.lock()
|
||||
@@ -2801,11 +2898,11 @@ fn sentinel_advanced(
|
||||
///
|
||||
/// The caller then refuses managed relaunches for [`SWITCH_HONOR_GRACE`] so the capture-loss
|
||||
/// re-detection follows the desktop compositor once it's up instead of racing it.
|
||||
fn honor_session_select_switch(dm: String) {
|
||||
fn honor_session_select_switch(adopted_dm: Option<String>) {
|
||||
tracing::info!(
|
||||
%dm,
|
||||
"gamescope: in-stream session-select detected — restoring the display manager and \
|
||||
switching the box to the desktop session"
|
||||
adopted_dm = ?adopted_dm,
|
||||
"gamescope: in-stream session-select detected — handing the box's own game mode back and \
|
||||
following the desktop session the user selected"
|
||||
);
|
||||
// Consume the takeover state up front: from here on the box is the DM's again. The mask goes
|
||||
// FIRST and while the unit list still exists — this path discards that list, and it is the only
|
||||
@@ -2818,7 +2915,43 @@ fn honor_session_select_switch(dm: String) {
|
||||
clear_takeover();
|
||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
|
||||
if let Err(e) = restore_display_manager(&dm) {
|
||||
// Give the box its own Game Mode back before anything else can ask for it. The takeover
|
||||
// replaced that session's `ExecStart` with a sleep ([`install_idle_dropin`]), and a switch is
|
||||
// the one exit that used to leave it behind: the disconnect restore sweeps it, but a switch is
|
||||
// not a disconnect. Without this the user's next "Return to Gaming Mode" starts a unit that
|
||||
// does nothing at all — measured on the Nobara VM 2026-08-20, and on glass it is
|
||||
// indistinguishable from broken hardware.
|
||||
if remove_idle_dropin() {
|
||||
tracing::info!(
|
||||
"gamescope: removed the takeover's idle drop-in — the box's own Game Mode runs for \
|
||||
real again"
|
||||
);
|
||||
}
|
||||
// Only an ADOPTED takeover still owes a display-manager restore. 0.31.0 leaves the DM up for
|
||||
// exactly this reason, so by the time we get here the OS's own switch has already done the
|
||||
// whole job — config rewrite and relogin (measured end to end on the Nobara VM). A takeover
|
||||
// inherited from a host old enough to have STOPPED the DM has not: for it the switch really
|
||||
// was the silent no-op that every write branch of `os-session-select` becomes without a
|
||||
// running DM, so that one still has to be replayed.
|
||||
if let Some(dm) = adopted_dm {
|
||||
replay_switch_under_restored_dm(&dm);
|
||||
}
|
||||
record_session_select_baseline();
|
||||
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Restore a display manager an ADOPTED takeover stopped, then replay the user's switch under it —
|
||||
/// every verb live-validated on the Nobara repro VM:
|
||||
/// 1. start the DM (its autologin heads back into game mode briefly — the config still names it);
|
||||
/// 2. run the distro's own `os-session-select desktop` as the user (its internal pkexec is
|
||||
/// `allow_any`-authorized), which rewrites the DM autologin config to the desktop session;
|
||||
/// 3. stop the autologin gamescope unit — the login session exits, and `Relogin=true` relogs
|
||||
/// into the now-selected desktop.
|
||||
///
|
||||
/// Reachable only from [`honor_session_select_switch`], and only for a takeover inherited from a
|
||||
/// pre-0.31.0 host: nothing stops a display manager any more.
|
||||
fn replay_switch_under_restored_dm(dm: &str) {
|
||||
if let Err(e) = restore_display_manager(dm) {
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
reason = %e,
|
||||
@@ -2831,7 +2964,7 @@ fn honor_session_select_switch(dm: String) {
|
||||
// Budgeted: this is a 10 s loop, and a single unbounded `is-active` against a system
|
||||
// manager that is itself mid-restart would consume the whole window in one tick.
|
||||
let active = crate::proc::output_within(
|
||||
Command::new("systemctl").args(["is-active", &dm]),
|
||||
Command::new("systemctl").args(["is-active", dm]),
|
||||
UNIT_STATE_BUDGET,
|
||||
)
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
|
||||
@@ -2888,8 +3021,48 @@ fn honor_session_select_switch(dm: String) {
|
||||
session instead of switching to the desktop"
|
||||
);
|
||||
}
|
||||
record_session_select_baseline();
|
||||
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// The box's autologin gaming instances and their ACTIVE state, as `(unit, active)` pairs — the
|
||||
/// `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION, so the state is the third.
|
||||
///
|
||||
/// An unanswered query reads as "none listed", which is the safe direction for both callers: the
|
||||
/// takeover then frees nothing rather than killing a session it could not see properly, and the
|
||||
/// crash hand-back restarts nothing rather than bouncing one.
|
||||
fn listed_autologin_units() -> Vec<(String, String)> {
|
||||
let Ok(out) = crate::proc::output_within(
|
||||
Command::new("systemctl").args([
|
||||
"--user",
|
||||
"list-units",
|
||||
"--type=service",
|
||||
"--all",
|
||||
"--no-legend",
|
||||
"--plain",
|
||||
"gamescope-session-plus@*.service",
|
||||
]),
|
||||
UNIT_QUERY_BUDGET,
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
parse_listed_units(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// [`listed_autologin_units`]'s parser (the unit-testable core). Which column the ACTIVE state is
|
||||
/// in decides whether the takeover can tell a live gaming session from a dead leftover, and
|
||||
/// getting that wrong is silent in both directions — a live session read as dead leaves Steam
|
||||
/// holding the instance our own launch then collides with, and a dead one read as live idles a
|
||||
/// session nobody was in.
|
||||
fn parse_listed_units(stdout: &str) -> Vec<(String, String)> {
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut cols = l.split_whitespace();
|
||||
let unit = cols.next()?;
|
||||
let active = cols.nth(1).unwrap_or("");
|
||||
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
|
||||
.then(|| (unit.to_string(), active.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
||||
@@ -2919,33 +3092,9 @@ fn honor_session_select_switch(dm: String) {
|
||||
/// The ORDER is therefore load-bearing and not a style choice: stop the DM, bail if it did not
|
||||
/// land, and only then mask. A mask laid before a stop that never arrives is the storm.
|
||||
fn stop_autologin_sessions() -> Result<()> {
|
||||
let Ok(out) = crate::proc::output_within(
|
||||
Command::new("systemctl").args([
|
||||
"--user",
|
||||
"list-units",
|
||||
"--type=service",
|
||||
"--all",
|
||||
"--no-legend",
|
||||
"--plain",
|
||||
"gamescope-session-plus@*.service",
|
||||
]),
|
||||
UNIT_QUERY_BUDGET,
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
// `(unit, ACTIVE state)` — the `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION.
|
||||
let listed: Vec<(String, String)> = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut cols = l.split_whitespace();
|
||||
let unit = cols.next()?;
|
||||
let active = cols.nth(1).unwrap_or("");
|
||||
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
|
||||
.then(|| (unit.to_string(), active.to_string()))
|
||||
})
|
||||
.collect();
|
||||
let listed = listed_autologin_units();
|
||||
if listed.is_empty() {
|
||||
return Ok(()); // nothing autologged in — Steam is already free
|
||||
return Ok(()); // nothing autologged in (or the query failed) — Steam is already free
|
||||
}
|
||||
let dm = display_manager_unit();
|
||||
// Only a LIVE instance holds Steam / justifies touching the DM. A loaded-but-inactive
|
||||
@@ -2979,6 +3128,13 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
// switch.
|
||||
if plan.dm_relogins {
|
||||
install_idle_dropin().context("idling the box's autologin game session for the stream")?;
|
||||
// Baseline the switch sentinel HERE, not only at a successful launch: arming the idle
|
||||
// drop-in is what arms the honor gate in [`create_managed_session`], so from this instant
|
||||
// an unbaselined sentinel would read as an in-stream "Switch to Desktop" — including the
|
||||
// write left by the switch that just brought the box INTO game mode. A successful launch
|
||||
// re-baselines (tighter still). This moved here from the display-manager stop that 0.31.0
|
||||
// retired; losing it with that stop is what left the gate unarmed.
|
||||
record_session_select_baseline();
|
||||
}
|
||||
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
|
||||
let mut stopped = Vec::new();
|
||||
@@ -3340,7 +3496,13 @@ pub fn restore_takeover_now() {
|
||||
}
|
||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None; // doing it right here
|
||||
tracing::info!("gamescope: host is shutting down — restoring the box's own session first");
|
||||
do_restore_tv_session();
|
||||
// `verify: false` — the escalation ladder waits up to a minute, and this runs inside
|
||||
// `native.rs`'s 20 s `SHUTDOWN_RESTORE_GRACE`, after which `exit(0)` runs no destructors.
|
||||
// Spending that grace watching instead of restoring would COST the hand-back, not check it.
|
||||
// The next host start is what covers a shutdown that left the box dark
|
||||
// ([`restore_takeover_on_startup`], which now hands the box back rather than only sweeping the
|
||||
// drop-in off it).
|
||||
do_restore_tv_session(false);
|
||||
}
|
||||
|
||||
/// What a bounded `systemctl --user` lifecycle verb on the RESTORE path actually did. Three states,
|
||||
@@ -3404,11 +3566,168 @@ fn connected_connector_under(base: &std::path::Path) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// How long a hand-back waits for the box to show something on its own panel before it starts
|
||||
/// escalating. Generous on purpose: the unit's `ExecStart` is a whole gamescope + Steam start, and
|
||||
/// on a cold box that is not quick — while a false escalation costs the user a session bounce.
|
||||
const HANDBACK_GRACE: Duration = Duration::from_secs(25);
|
||||
|
||||
/// How long each escalation rung gets. Shorter than [`HANDBACK_GRACE`]: by the time a rung runs,
|
||||
/// the ordinary start has already had its full grace and not delivered.
|
||||
const HANDBACK_RUNG_GRACE: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Poll slice for the two waits above.
|
||||
const HANDBACK_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Is ANYTHING driving the box's own panel right now — its game mode, or a desktop it switched to?
|
||||
///
|
||||
/// [`super::detect_active_session`] answers precisely the question the symptom asks: it reports the
|
||||
/// running compositor of our uid, and [`super::ActiveKind::None`] means nothing is drawing
|
||||
/// anywhere. Only sound AFTER `stop_session(SESSION_UNIT)` has killed our own managed session —
|
||||
/// that kill is a synchronous SIGKILL ([`kill_unit`]), so by the restore's escalation point our
|
||||
/// gamescope cannot still be answering for the box.
|
||||
fn box_session_live() -> bool {
|
||||
super::detect_active_session().kind != super::ActiveKind::None
|
||||
}
|
||||
|
||||
/// Poll [`box_session_live`] until it is true or `grace` runs out. [`HandbackWait::Superseded`]
|
||||
/// means a client reconnected and took the box over again — the hand-back we were checking is moot,
|
||||
/// and every remedy below would now be fighting a live stream for the box's session.
|
||||
enum HandbackWait {
|
||||
Live,
|
||||
Superseded,
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
fn wait_for_box_session(grace: Duration) -> HandbackWait {
|
||||
let deadline = Instant::now() + grace;
|
||||
loop {
|
||||
if takeover_live() {
|
||||
return HandbackWait::Superseded;
|
||||
}
|
||||
if box_session_live() {
|
||||
return HandbackWait::Live;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return HandbackWait::TimedOut;
|
||||
}
|
||||
std::thread::sleep(HANDBACK_POLL);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The hand-back's last line of defence for a dark panel**, and the only part of this file that
|
||||
/// checks whether the restore it just performed actually WORKED.
|
||||
///
|
||||
/// Everything above issues a lifecycle verb and reports what systemd said about the JOB. That is
|
||||
/// not the same question as "does the box show a picture again", and the gap between the two is
|
||||
/// where every "my screen stays black after disconnecting" report lives — including ones whose
|
||||
/// trigger nobody has reproduced. So stop inferring the outcome and measure it: if nothing is
|
||||
/// driving the panel a full [`HANDBACK_GRACE`] after the hand-back, climb a ladder of remedies,
|
||||
/// each of which is a mechanism measured on both distro families (Bazzite `44.20260818`, Nobara
|
||||
/// f44, 2026-08-22), and say loudly at every rung what is happening.
|
||||
///
|
||||
/// 1. **`stop` the autologin unit.** Its login session's script is parked on
|
||||
/// `systemctl --user --wait start <unit>` (verified on both images), so stopping the unit
|
||||
/// releases that wait, the session exits, and `Relogin=true` logs straight back in — starting
|
||||
/// the unit inside a fresh login session with a seat. `stop`, never `restart`: a restart does
|
||||
/// NOT release the parked waiter (measured), which is exactly why it cannot rescue a box the
|
||||
/// ordinary restart already failed to bring back.
|
||||
/// 2. **Restart the display manager.** What the pre-0.31.0 takeover did on every disconnect, and
|
||||
/// proven to return the box to game mode. Needs privilege, so it can honestly fail.
|
||||
/// 3. **`PUNKTFUNK_RECOVER_SESSION_CMD`**, then an ERROR naming the command a human must run.
|
||||
///
|
||||
/// **Detached**, and that is not incidental. The restore runs under [`RESTORE_FLIGHT`], which a
|
||||
/// reconnecting client must take before it can re-take the box; watching for up to a minute while
|
||||
/// holding it would put that whole wait in front of every reconnect. So the caller fires this and
|
||||
/// returns, and the watcher stands down by itself the moment [`takeover_live`] says a new takeover
|
||||
/// armed — the box belongs to that stream now, and a remedy fired into it would be the bug.
|
||||
/// Call it AFTER `clear_takeover()`, or the very first poll reads our own finished takeover as a
|
||||
/// new one and stands down immediately.
|
||||
///
|
||||
/// A box that was already fine costs one [`box_session_live`] call and the thread exits.
|
||||
fn ensure_box_session_or_escalate(units: &[String]) {
|
||||
let units: Vec<String> = units.to_vec();
|
||||
std::thread::spawn(move || handback_watch(&units));
|
||||
}
|
||||
|
||||
fn handback_watch(units: &[String]) {
|
||||
match wait_for_box_session(HANDBACK_GRACE) {
|
||||
HandbackWait::Live => {
|
||||
tracing::info!(
|
||||
"gamescope: the box is driving its own panel again — hand-back complete"
|
||||
);
|
||||
return;
|
||||
}
|
||||
HandbackWait::Superseded => return,
|
||||
HandbackWait::TimedOut => {}
|
||||
}
|
||||
tracing::warn!(
|
||||
secs = HANDBACK_GRACE.as_secs(),
|
||||
units = ?units,
|
||||
"gamescope: NOTHING is driving the box's panel {}s after the hand-back — its screen is \
|
||||
dark. Escalating: stopping the autologin unit so the display manager relogins into a \
|
||||
session with a seat",
|
||||
HANDBACK_GRACE.as_secs()
|
||||
);
|
||||
// Rung 1 — release the login session's parked `--wait start` and let the DM relogin.
|
||||
for unit in units {
|
||||
if let RestoreVerb::Failed(why) = issue_restore_verb(&["stop", unit]) {
|
||||
tracing::warn!(unit, status = %why, "gamescope: could not stop the autologin unit");
|
||||
}
|
||||
}
|
||||
match wait_for_box_session(HANDBACK_RUNG_GRACE) {
|
||||
HandbackWait::Live => {
|
||||
tracing::info!(
|
||||
"gamescope: the display manager relogged the box into its own session — panel back"
|
||||
);
|
||||
return;
|
||||
}
|
||||
HandbackWait::Superseded => return,
|
||||
HandbackWait::TimedOut => {}
|
||||
}
|
||||
// Rung 2 — put the display manager itself through a restart.
|
||||
if let Some(dm) = display_manager_unit() {
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
"gamescope: the box is still dark — restarting its display manager"
|
||||
);
|
||||
match restore_display_manager(&dm) {
|
||||
Ok(()) => match wait_for_box_session(HANDBACK_RUNG_GRACE) {
|
||||
HandbackWait::Live => {
|
||||
tracing::info!(%dm, "gamescope: the display manager brought the box back");
|
||||
return;
|
||||
}
|
||||
HandbackWait::Superseded => return,
|
||||
HandbackWait::TimedOut => {}
|
||||
},
|
||||
Err(why) => tracing::warn!(
|
||||
%dm,
|
||||
shape = why.shape(),
|
||||
reason = %why,
|
||||
"gamescope: could not restart the display manager"
|
||||
),
|
||||
}
|
||||
}
|
||||
// Rung 3 — the operator's own escape hatch, then say what is left to do by hand.
|
||||
if crate::try_recover_session() {
|
||||
tracing::warn!(
|
||||
"gamescope: fired PUNKTFUNK_RECOVER_SESSION_CMD to bring the box's session back"
|
||||
);
|
||||
return;
|
||||
}
|
||||
tracing::error!(
|
||||
units = ?units,
|
||||
"gamescope: the box has NO session driving its panel and every automatic remedy failed — \
|
||||
its screen stays dark until someone runs `systemctl --user restart <unit>` for one of \
|
||||
these, or `sudo systemctl restart display-manager.service`. Set \
|
||||
PUNKTFUNK_RECOVER_SESSION_CMD to let the host do this itself"
|
||||
);
|
||||
}
|
||||
|
||||
/// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s)
|
||||
/// we stopped on connect — so the TV returns to gaming mode when no one is streaming. Invoked by
|
||||
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
|
||||
/// cancelled+reconnected window keeps the list for a later real restore.
|
||||
fn do_restore_tv_session() {
|
||||
fn do_restore_tv_session(verify: bool) {
|
||||
// SteamOS: we reconfigured `gamescope-session.target` headless via a drop-in. Restore = remove
|
||||
// the drop-in + restart the target (back to the physical panel) — unless the user switched to a
|
||||
// desktop session meanwhile, in which case drop the override and leave the desktop alone.
|
||||
@@ -3475,6 +3794,9 @@ fn do_restore_tv_session() {
|
||||
),
|
||||
}
|
||||
clear_takeover(); // A3: consumed — after the restart, not before it
|
||||
if verify {
|
||||
ensure_box_session_or_escalate(&[STEAMOS_SESSION_TARGET.to_string()]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3600,14 +3922,14 @@ fn do_restore_tv_session() {
|
||||
}
|
||||
// (The idle drop-in is already gone — removed above every early return, so the restarts
|
||||
// below bring the box's real session back rather than another idle one.)
|
||||
for unit in units {
|
||||
for unit in &units {
|
||||
// Checked, not discarded: this call and the SteamOS `restart` above were the two places
|
||||
// that logged an unconditional success over a thrown-away exit status. A `--user start`
|
||||
// fails for reasons an operator can act on (the unit is masked, its start limit tripped),
|
||||
// and the DM branch thirty lines up already shows the shape — say what happened.
|
||||
// `restart`, not `start`: the idle takeover leaves the unit ACTIVE, and `start` on an
|
||||
// active unit is a no-op that would report success over a session still running nothing.
|
||||
match issue_restore_verb(&["restart", &unit]) {
|
||||
match issue_restore_verb(&["restart", unit]) {
|
||||
RestoreVerb::Done => tracing::info!(
|
||||
unit,
|
||||
"restored the TV's autologin gaming session (debounce elapsed, no client)"
|
||||
@@ -3632,6 +3954,12 @@ fn do_restore_tv_session() {
|
||||
}
|
||||
}
|
||||
clear_takeover(); // A3: consumed — and only now, with the restarts actually issued
|
||||
// …and CHECK that the restart above actually put a picture back on the box's panel, rather
|
||||
// than trusting the job status to mean that. AFTER `clear_takeover`, which is what makes a
|
||||
// later `takeover_live()` mean "a client reconnected" — see [`ensure_box_session_or_escalate`].
|
||||
if verify {
|
||||
ensure_box_session_or_escalate(&units);
|
||||
}
|
||||
}
|
||||
|
||||
/// Host-lifetime worker that fires a pending [`schedule_restore_tv_session`] once its debounce
|
||||
@@ -3668,7 +3996,10 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||
}
|
||||
};
|
||||
if still_due {
|
||||
do_restore_tv_session();
|
||||
// The disconnect restore: verified. This is the path the field reports
|
||||
// are about, it is on a worker thread with no deadline over it, and a box
|
||||
// left dark here stays dark until someone walks up to it.
|
||||
do_restore_tv_session(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5233,13 +5564,14 @@ mod tests {
|
||||
use super::{
|
||||
any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned,
|
||||
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
|
||||
game_hz, gamescope_output_size, hdr_args, is_steam_launch, 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, xwayland_refusal_marker,
|
||||
BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan,
|
||||
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT,
|
||||
STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
|
||||
game_hz, gamescope_output_size, hdr_args, idle_dropin_body, idle_dropin_path,
|
||||
install_idle_dropin, is_steam_launch, mask_unit, missing_flags, mode_mismatch,
|
||||
nested_wrapper_script, our_wsi_layer_dir, parse_listed_units, plan_bind,
|
||||
release_autologin_mask, remove_idle_dropin, script_hardcodes_gamescope, sentinel_advanced,
|
||||
shape_dedicated_command, switch_ends_mask_window, takeover_state_is_live, unmask_unit,
|
||||
xwayland_refusal_marker, BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind,
|
||||
TakeoverState, WsiPlan, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE,
|
||||
RESTORE_FLIGHT, STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -5434,6 +5766,58 @@ mod tests {
|
||||
assert!(!sentinel_advanced(Some(Some(t0)), None));
|
||||
}
|
||||
|
||||
/// `ExecStart` is list-valued, so the reset line is the whole mechanism: without it the
|
||||
/// drop-in APPENDS the sleep to the box's own session command and both run — the takeover
|
||||
/// would then be fighting the very Steam it set out to free, and nothing on the box would say
|
||||
/// why. Pins the reset, its order, and that the resolved `sleep` is the one that gets run.
|
||||
/// The `--plain` column the ACTIVE state lives in, pinned against real `systemctl --user
|
||||
/// list-units` output from both distro families. Read the wrong column and a live gaming
|
||||
/// session looks dead (Steam stays held, and our launch collides with it) or a dead leftover
|
||||
/// looks live (the takeover idles a session nobody was in) — both silent on glass.
|
||||
#[test]
|
||||
fn listed_units_take_the_active_column_not_the_load_column() {
|
||||
// Bazzite 44.20260818 and Nobara f44, verbatim (unit / LOAD / ACTIVE / SUB / description).
|
||||
let out = "gamescope-session-plus@ogui-steam.service loaded active running Gamescope Session Plus\n\
|
||||
gamescope-session-plus@steam.service loaded inactive dead Gamescope Session Plus\n";
|
||||
assert_eq!(
|
||||
parse_listed_units(out),
|
||||
vec![
|
||||
(
|
||||
"gamescope-session-plus@ogui-steam.service".to_string(),
|
||||
"active".to_string()
|
||||
),
|
||||
(
|
||||
"gamescope-session-plus@steam.service".to_string(),
|
||||
"inactive".to_string()
|
||||
),
|
||||
]
|
||||
);
|
||||
// `loaded` is the LOAD column and must never be mistaken for the state — that is the
|
||||
// off-by-one this pins.
|
||||
assert!(parse_listed_units(out).iter().all(|(_, a)| a != "loaded"));
|
||||
// Anything that is not one of our template's instances is not ours to touch.
|
||||
assert!(
|
||||
parse_listed_units("plasma-plasmashell.service loaded active running Shell\n")
|
||||
.is_empty()
|
||||
);
|
||||
assert!(parse_listed_units("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_dropin_replaces_exec_start_rather_than_appending() {
|
||||
let body = idle_dropin_body("/usr/bin/sleep");
|
||||
assert_eq!(
|
||||
body, "[Service]\nExecStart=\nExecStart=/usr/bin/sleep infinity\n",
|
||||
"{body}"
|
||||
);
|
||||
let lines: Vec<&str> = body.lines().collect();
|
||||
assert_eq!(lines[1], "ExecStart=", "the reset must come first: {body}");
|
||||
// The path is resolved per box ([`sleep_binary`]) and must reach the unit verbatim — a
|
||||
// bare `sleep` would depend on the unit's PATH, and an ExecStart that fails to EXECUTE is
|
||||
// the failing unit the display manager relogin-loops against.
|
||||
assert!(idle_dropin_body("/bin/sleep").contains("ExecStart=/bin/sleep infinity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_wrapper_script_shapes() {
|
||||
let relay = std::path::Path::new("/run/user/1000/pf-ei");
|
||||
@@ -5661,10 +6045,32 @@ mod tests {
|
||||
release_autologin_mask(crate::ActiveKind::None);
|
||||
assert_eq!(is_enabled(), "masked-runtime");
|
||||
|
||||
// The idle drop-in is the mask's successor and shares this exact window, so it has to come
|
||||
// off with it. A takeover that leaves it on has replaced the box's game-mode `ExecStart`
|
||||
// with a sleep — a "Return to Gaming Mode" that starts and does nothing, which is the same
|
||||
// barred way back, measured on Bazzite `.41` 2026-08-20.
|
||||
install_idle_dropin().expect("arm the takeover's idle drop-in");
|
||||
assert!(idle_dropin_path().exists());
|
||||
|
||||
// Mid-stream, with the box still ours: the mask is doing its job and must stay. `Gaming` is
|
||||
// what our own managed session reads as, and `None` is one momentarily down between
|
||||
// relaunches — lifting on either would void the mask for the whole stream.
|
||||
release_autologin_mask(crate::ActiveKind::Gaming);
|
||||
release_autologin_mask(crate::ActiveKind::None);
|
||||
assert_eq!(is_enabled(), "masked-runtime");
|
||||
assert!(
|
||||
idle_dropin_path().exists(),
|
||||
"the idle drop-in must survive a switch that is not to a desktop"
|
||||
);
|
||||
|
||||
// The user switched the box to its own desktop mid-stream: the window is over, and the way
|
||||
// back into game mode has to be clear before they ask for it.
|
||||
release_autologin_mask(crate::ActiveKind::DesktopKde);
|
||||
assert_ne!(is_enabled(), "masked-runtime");
|
||||
assert!(
|
||||
!idle_dropin_path().exists(),
|
||||
"the idle drop-in outlived the switch — the box's Game Mode is a sleep now"
|
||||
);
|
||||
// The restart list SURVIVES the lift: the mask's lifetime is shorter than the takeover's,
|
||||
// and the disconnect restore still owes these units a `start`.
|
||||
assert_eq!(STOPPED_AUTOLOGIN.lock().unwrap().as_slice(), [PROBE]);
|
||||
@@ -5673,6 +6079,7 @@ mod tests {
|
||||
assert_ne!(is_enabled(), "masked-runtime");
|
||||
|
||||
unmask_unit(PROBE);
|
||||
remove_idle_dropin();
|
||||
STOPPED_AUTOLOGIN.lock().unwrap().clear();
|
||||
*AUTOLOGIN_MASKED.lock().unwrap() = false;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,16 @@ use zkde::zkde_screencast_unstable_v1::ZkdeScreencastUnstableV1 as Screencast;
|
||||
const POINTER_METADATA: u32 = 4;
|
||||
const POINTER_EMBEDDED: u32 = 2;
|
||||
|
||||
/// Marks the one KWin refusal a retry can clear: the disabled-output repair ran and changed the
|
||||
/// box between attempts ([`kwin_output_mgmt::enable_disabled_output`]).
|
||||
///
|
||||
/// It is load-bearing in TWO places and both are easy to break. The opener keys on it to skip the
|
||||
/// `KWin virtual output failed` wrapper below — and that wrapper's prefix is exactly what the
|
||||
/// host's `is_permanent_build_error` matches to short-circuit the retry loop, so a repaired
|
||||
/// refusal carrying it would be classified permanent and the retry that consumes the repair would
|
||||
/// never run. It is also the human-readable half of the message; keep it a phrase, not a code.
|
||||
const REPAIRED_HINT: &str = "enabled it over output management";
|
||||
|
||||
/// The name we give the created output; KWin exposes it to output-management as `Virtual-<name>`.
|
||||
const VOUT_NAME: &str = "punktfunk";
|
||||
|
||||
@@ -268,6 +278,10 @@ impl VirtualDisplay for KwinDisplay {
|
||||
.context("spawn KWin virtual-output thread")?;
|
||||
match setup_rx.recv_timeout(OPENER_BUDGET) {
|
||||
Ok(Ok(v)) => Ok((v, stop)),
|
||||
// Repaired: report it as-is. The wrapper below would prepend the phrase the host
|
||||
// reads as "permanent, do not retry", and this is the one refusal whose retry is
|
||||
// the entire point — the repair only fixes the NEXT request.
|
||||
Ok(Err(e)) if e.contains(REPAIRED_HINT) => bail!("{e}"),
|
||||
// KWin's reason is TRANSLATED into the session's language, so it is often
|
||||
// unsearchable for the person reading the log. Say what it means once, here.
|
||||
Ok(Err(e)) => bail!(
|
||||
@@ -1793,14 +1807,41 @@ fn run(
|
||||
);
|
||||
|
||||
// Pump events until KWin reports the node id (or an error, or the budget).
|
||||
let node_id = await_created(
|
||||
//
|
||||
// A refusal here is where the KWin >= 6.6 disabled-output trap lands, and it is repairable
|
||||
// FROM INSIDE THIS SCOPE and nowhere else: KWin destroys the output when our stream is
|
||||
// destroyed, so the connection has to stay up while we enable it (see
|
||||
// [`kwin_output_mgmt::enable_disabled_output`] for why the output is still alive at all, and
|
||||
// why enabling it fixes the NEXT request rather than this one).
|
||||
let node_id = match await_created(
|
||||
&conn,
|
||||
&mut queue,
|
||||
&mut state,
|
||||
stop,
|
||||
"stream_virtual_output",
|
||||
started,
|
||||
)?;
|
||||
) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
// `Virtual-<name>` is the address KWin exposes our output under (the same prefix the
|
||||
// topology path resolves against).
|
||||
match crate::kwin_output_mgmt::enable_disabled_output(&format!("Virtual-{name}")) {
|
||||
// Deliberately does NOT carry the "KWin virtual output failed" prefix: that string
|
||||
// is what marks a KWin refusal PERMANENT for the session's retry loop, and this is
|
||||
// the one refusal where something DID change between attempts. Retrying is the
|
||||
// whole point of repairing.
|
||||
Some(repaired) => bail!(
|
||||
"KWin created the virtual output disabled and refused to stream it ({e}); \
|
||||
{REPAIRED_HINT} (head {repaired}) — the retry picks up the configuration \
|
||||
KWin just persisted"
|
||||
),
|
||||
// Nothing to repair (no such head, already enabled, or the apply was refused):
|
||||
// the refusal stands, and its own prefix keeps it permanent so the session fails
|
||||
// fast instead of burning the retry budget on an unchanged box.
|
||||
None => return Err(e),
|
||||
}
|
||||
}
|
||||
};
|
||||
setup_tx
|
||||
.send(Ok(node_id))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
@@ -1274,6 +1274,76 @@ pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
|
||||
complete
|
||||
}
|
||||
|
||||
/// Enable a virtual output KWin created but left DISABLED, addressed by the `Virtual-<name>`
|
||||
/// prefix it exposes ours under. Returns the head's name when one matched, was disabled, and the
|
||||
/// enable applied.
|
||||
///
|
||||
/// This is the repair for the KWin ≥ 6.6 refusal (`"Could not find output"`, translated into the
|
||||
/// session's language). `streamVirtualOutput` there creates the output on the backend and then
|
||||
/// hands `workspace()->findOutput(output)` to the stream — and that returns null for an output the
|
||||
/// workspace does not manage, which `wantsToManage` defines as `isEnabled() && !isNonDesktop()`.
|
||||
/// KWin 6.4/6.5 passed the backend output straight through, so a disabled one streamed anyway;
|
||||
/// from 6.6 it is a hard refusal, and one that repeats forever: the host asks for a STABLE
|
||||
/// per-client name so KWin persists that client's scale and mode, and a stored setup naming it
|
||||
/// `enabled: false` is therefore reapplied to every future session.
|
||||
///
|
||||
/// Two properties of KWin make the repair possible, both verified against Plasma/6.7:
|
||||
///
|
||||
/// * `sendFailed` only sends the event — it does not emit `finished`, and `removeVirtualOutput` is
|
||||
/// wired to `finished`. So the disabled output stays alive for exactly as long as the caller
|
||||
/// holds its (failed) stream open, which is the window this runs in.
|
||||
/// * `WaylandServer::handleOutputAdded` offers EVERY backend output to the output-device registry,
|
||||
/// gating only placeholders and non-desktop ones. A disabled output has no `wl_output` — that
|
||||
/// side is gated on the workspace — but it is addressable over `kde_output_management_v2`.
|
||||
///
|
||||
/// Enabling it through output management is a user-applied configuration, so KWin persists it
|
||||
/// against that output's identity: the caller's next `stream_virtual_output` under the same name
|
||||
/// finds a stored setup that enables it. Which is why the caller must RETRY after this returns
|
||||
/// `Some` — the request that failed cannot be salvaged, only the one after it.
|
||||
pub(crate) fn enable_disabled_output(prefix: &str) -> Option<String> {
|
||||
let mut sess = Session::open("enable_disabled").ok()?;
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
// Newest-wins, exactly as the supersede resolve elsewhere in this file: a reconnect can leave
|
||||
// a predecessor of the same name briefly announced, and enabling THAT one repairs an output
|
||||
// that is already going away.
|
||||
let dev = sess
|
||||
.state
|
||||
.devices
|
||||
.values()
|
||||
.filter(|d| d.name.as_deref().is_some_and(|n| n.starts_with(prefix)) && d.proxy.is_some())
|
||||
.max_by_key(|d| (d.global, d.seq))
|
||||
.cloned()?;
|
||||
let name = dev.name.clone()?;
|
||||
if dev.enabled {
|
||||
// Not the shape we repair. Say so rather than applying a no-op config that would `applied`
|
||||
// successfully and read as a fix — the caller decides whether to retry on this.
|
||||
tracing::debug!(
|
||||
%name,
|
||||
"KWin output management: our virtual output is already enabled — nothing to repair"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let proxy = dev.proxy.as_ref()?;
|
||||
let config = sess.new_config();
|
||||
config.enable(proxy, 1);
|
||||
let ok = sess.apply(&config, deadline);
|
||||
config.destroy();
|
||||
if !ok {
|
||||
tracing::warn!(
|
||||
%name,
|
||||
reason = ?sess.state.failure_reason,
|
||||
"KWin output management: could not enable the virtual output KWin created disabled"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
tracing::info!(
|
||||
%name,
|
||||
"KWin output management: KWin created our virtual output DISABLED and refused to stream \
|
||||
it; enabled it — KWin persists that, so the retry's request comes back enabled"
|
||||
);
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// Position the output identified by `uuid` at `(x, y)` in the desktop layout, in-process. Returns
|
||||
/// `true` if applied; `false` tells the caller to fall back to `kscreen-doctor`.
|
||||
pub(crate) fn set_position(uuid: &str, x: i32, y: i32) -> bool {
|
||||
|
||||
@@ -215,6 +215,7 @@ include = ["PunktfunkEndReason"]
|
||||
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
|
||||
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
|
||||
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
|
||||
"MSG_DELIVERY_REPORT" = "PUNKTFUNK_MSG_DELIVERY_REPORT"
|
||||
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
|
||||
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
|
||||
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! `CtrlRequest` (the embedder's control-stream requests) and `Negotiated` (the handshake result).
|
||||
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::quic::{ClipControl, ClipOffer, ColorInfo, LossReport, ProbeRequest, RfiRequest};
|
||||
use crate::quic::{
|
||||
ClipControl, ClipOffer, ColorInfo, DeliveryReport, LossReport, ProbeRequest, RfiRequest,
|
||||
};
|
||||
|
||||
/// A control-stream request the embedder makes on the open handshake stream: a mode switch or a
|
||||
/// speed test. One outbound channel carries both so the worker's `select!` has a single writer
|
||||
@@ -15,6 +17,10 @@ pub(crate) enum CtrlRequest {
|
||||
/// forcing a full IDR. See [`RfiRequest`].
|
||||
Rfi(RfiRequest),
|
||||
Loss(LossReport),
|
||||
/// How many data-plane packets have reached us all session — sent straight after every
|
||||
/// [`CtrlRequest::Loss`], because `loss_ppm` is ambiguous at zero (no loss and no packets look
|
||||
/// identical) and only this separates them. See [`DeliveryReport`].
|
||||
Delivery(DeliveryReport),
|
||||
/// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's
|
||||
/// [`BitrateController`] when the user's bitrate setting is Automatic.
|
||||
SetBitrate(u32),
|
||||
|
||||
@@ -57,6 +57,21 @@ pub(crate) const FLUSH_AFTER: Duration = Duration::from_millis(250);
|
||||
/// the number, so the two can never drift apart.
|
||||
pub const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Spacing of a client's keyframe re-asks while it has received **no video at all** — the other
|
||||
/// reason a client asks on a perfectly fixed cadence, and the OPPOSITE fault to [`FLUSH_COOLDOWN`]'s
|
||||
/// (nothing arriving, versus more arriving than it can drain).
|
||||
///
|
||||
/// **Public, and deliberately a different value, for the same reason [`FLUSH_COOLDOWN`] is public.**
|
||||
/// While both were 2000 ms the host's recovery-cadence detector could not tell which failure it was
|
||||
/// looking at, and reported the confident wrong one: a 2026-08-20 field case where not one byte of
|
||||
/// video ever reached the client was diagnosed for days as a client too slow to keep up. Embedders
|
||||
/// own the no-video timer (it lives in each decode loop), so this is the value they must use — a
|
||||
/// local copy is exactly the drift that made the two indistinguishable in the first place.
|
||||
///
|
||||
/// The delivery count on [`crate::quic::LossReport`] settles it outright for clients new enough to
|
||||
/// send one; this keeps the period itself informative for those that are not.
|
||||
pub const NO_VIDEO_RETRY: Duration = Duration::from_millis(2600);
|
||||
|
||||
/// 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
|
||||
/// causes, and flushing helps neither: a **wall-clock step** (NTP mid-session on either end)
|
||||
|
||||
@@ -42,7 +42,7 @@ mod recovery;
|
||||
mod rumble;
|
||||
mod worker;
|
||||
|
||||
pub use self::frame_channel::FLUSH_COOLDOWN;
|
||||
pub use self::frame_channel::{FLUSH_COOLDOWN, NO_VIDEO_RETRY};
|
||||
pub use self::planes::AudioPacket;
|
||||
pub use self::probe::ProbeOutcome;
|
||||
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
|
||||
|
||||
@@ -11,9 +11,9 @@ use crate::abr::BitrateController;
|
||||
use crate::config::Role;
|
||||
use crate::packet::FLAG_PROBE;
|
||||
use crate::quic::{
|
||||
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync, Hello,
|
||||
LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncAdmit, ResyncGuard,
|
||||
ResyncStep, SetBitrate, Start, Welcome,
|
||||
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync,
|
||||
DeliveryReport, Hello, LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe,
|
||||
ResyncAdmit, ResyncGuard, ResyncStep, SetBitrate, Start, Welcome,
|
||||
};
|
||||
use crate::session::Session;
|
||||
use crate::transport::UdpTransport;
|
||||
|
||||
@@ -107,6 +107,7 @@ impl ControlTask {
|
||||
}
|
||||
CtrlRequest::Rfi(r) => r.encode(),
|
||||
CtrlRequest::Loss(r) => r.encode(),
|
||||
CtrlRequest::Delivery(r) => r.encode(),
|
||||
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
|
||||
CtrlRequest::ClockResync => {
|
||||
if clock_rtt_ns.is_none() {
|
||||
|
||||
@@ -77,6 +77,12 @@ impl DataPump {
|
||||
// size FEC to the link. Suppressed during a speed test (its FLAG_PROBE filler would skew it).
|
||||
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
||||
let mut last_report = Instant::now();
|
||||
// Has the host been told, once, that data-plane packets are reaching us? See the send site:
|
||||
// the delivery count is reported every window while it is ZERO (the state the host acts on)
|
||||
// and once more when the first packets land, then never again. A host that predates the
|
||||
// message logs "unknown control message" for each one, so a healthy session must not stream
|
||||
// them — one line per session is a fair price on an old host, eighty a minute is not.
|
||||
let mut delivery_confirmed = false;
|
||||
let (
|
||||
mut last_recovered,
|
||||
mut last_late,
|
||||
@@ -415,6 +421,27 @@ impl DataPump {
|
||||
);
|
||||
} else {
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
|
||||
// Rides with the loss report — it is what makes `loss_ppm = 0` readable at the
|
||||
// host, which cannot otherwise tell a flawless link from one delivering
|
||||
// nothing. The session TOTAL, not this window's, so one message stands on its
|
||||
// own. Deliberately inside the same arm: a discarded window is discarded
|
||||
// because the host was rebuilding or a probe distorted it, and staying silent
|
||||
// there keeps that contract exact. Nothing is lost — the state this reports
|
||||
// (no packets at all) produces no discards, so its windows always send.
|
||||
//
|
||||
// Sent every window while the count is ZERO, then ONCE when the first packets
|
||||
// land (so the host stops guessing and can name the other failure confidently),
|
||||
// then never again: a healthy session must not stream a message that older
|
||||
// hosts log as unknown on every arrival.
|
||||
// ponytail: only start-of-session death is covered. A path that dies MID-stream
|
||||
// leaves the count frozen above zero and silent, which the host still reads as
|
||||
// healthy — detecting that needs a stalled-counter check with its own timing,
|
||||
// worth adding if a mid-session case is ever reported.
|
||||
if should_report_delivery(st.packets_received, &mut delivery_confirmed) {
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Delivery(DeliveryReport {
|
||||
packets_received: st.packets_received,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Standing-latency bleed: close the detector's window with this report's loss
|
||||
// verdict and run its escalation ladder — re-sync first (free; a stale offset
|
||||
@@ -757,10 +784,58 @@ fn take_pipeline_gap(slot: &AtomicU32) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this report window owe the host a [`DeliveryReport`], and record that it has been told?
|
||||
///
|
||||
/// Every window while `packets_received` is ZERO — that is the state the host escalates on, and it
|
||||
/// must keep hearing it — then exactly ONCE more when the first packets land, so the host learns
|
||||
/// delivery works and can stop hedging its stall diagnosis. Silent after that: a host that predates
|
||||
/// the message logs every unknown control message, and a healthy hours-long session must not fill
|
||||
/// its log with them.
|
||||
fn should_report_delivery(packets_received: u64, confirmed: &mut bool) -> bool {
|
||||
let owed = packets_received == 0 || !*confirmed;
|
||||
*confirmed = packets_received > 0;
|
||||
owed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The host must keep hearing "zero" for as long as it is true (that is the black-screen
|
||||
/// signal), get exactly one confirmation when video starts, and then silence — the noise budget
|
||||
/// on an older host, which warns per unknown message, is what pays for the first two.
|
||||
#[test]
|
||||
fn the_delivery_count_is_reported_while_zero_then_once_more_and_never_again() {
|
||||
let mut confirmed = false;
|
||||
// Nothing arriving: reported every window, for as long as it stays true.
|
||||
for _ in 0..5 {
|
||||
assert!(
|
||||
should_report_delivery(0, &mut confirmed),
|
||||
"a dead data plane must be re-reported every window"
|
||||
);
|
||||
}
|
||||
// First packets land: one confirmation, so the host can name the other failure confidently.
|
||||
assert!(should_report_delivery(500, &mut confirmed));
|
||||
// Healthy from here: silent.
|
||||
for n in [900, 1_200, 90_000] {
|
||||
assert!(
|
||||
!should_report_delivery(n, &mut confirmed),
|
||||
"a healthy session must not stream delivery reports"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A session that never receives anything must never look confirmed, no matter how long it runs
|
||||
/// — the whole point is that the host keeps being told.
|
||||
#[test]
|
||||
fn a_session_that_receives_nothing_never_reports_itself_healthy() {
|
||||
let mut confirmed = false;
|
||||
for _ in 0..100 {
|
||||
assert!(should_report_delivery(0, &mut confirmed));
|
||||
assert!(!confirmed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pipeline_gap_is_taken_exactly_once() {
|
||||
let slot = AtomicU32::new(0);
|
||||
@@ -935,8 +1010,8 @@ mod tests {
|
||||
.expect("the window after the gap reports on schedule");
|
||||
assert!(
|
||||
matches!(reported, Some(CtrlRequest::Loss(_))),
|
||||
"the window after the gap must produce a loss report — an idle session's only \
|
||||
outbound request"
|
||||
"the window after the gap must produce a loss report — the first of the two requests \
|
||||
an idle session makes (the delivery count follows it)"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() >= Duration::from_millis(1_400),
|
||||
|
||||
@@ -97,6 +97,33 @@ pub struct LossReport {
|
||||
pub loss_ppm: u32,
|
||||
}
|
||||
|
||||
/// `client → host`, sent immediately after each [`LossReport`]: data-plane packets this client has
|
||||
/// received all session, cumulative.
|
||||
///
|
||||
/// ⚠ Exists because `loss_ppm` alone is **ambiguous at zero**: a client receiving a flawless stream
|
||||
/// and a client receiving *nothing at all* both report `loss_ppm = 0` — loss is a ratio over a
|
||||
/// window whose denominator is the packets that arrived, so no-packets is indistinguishable from
|
||||
/// no-loss. That ambiguity let a host decay adaptive FEC to its floor while the client sat behind a
|
||||
/// black screen having received zero bytes, and the host's own stall diagnosis blamed the client for
|
||||
/// "not sustaining the stream" it had never been sent (field 2026-08-20: a Windows host whose
|
||||
/// per-session data port was closed inbound, so the client's hole-punch never opened the return
|
||||
/// path). `0` while the host has sent frames is the one unambiguous statement of "the video data
|
||||
/// plane is not reaching me" — the control plane carrying this report is, by construction, healthy.
|
||||
///
|
||||
/// ⚠ A SEPARATE MESSAGE rather than a field appended to [`LossReport`], and that is load-bearing:
|
||||
/// `LossReport::decode` length-checks EXACTLY, so a longer report is rejected outright by every host
|
||||
/// already shipped — a new client would silently lose adaptive FEC against them. Mixed versions are
|
||||
/// normal here (the field case that motivated this ran a current host against a months-old client),
|
||||
/// so the compatible shape is a new type byte an older host simply ignores, exactly as it already
|
||||
/// ignores every other control message it predates.
|
||||
///
|
||||
/// Cumulative, not per-window, so a single message is self-contained; `u64` to match the counter it
|
||||
/// mirrors, with no saturation to reason about.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DeliveryReport {
|
||||
pub packets_received: u64,
|
||||
}
|
||||
|
||||
/// `client → host`, any time after [`Start`]: reconfigure the encoder to a new target bitrate
|
||||
/// without reconnecting — the mid-stream lever of adaptive bitrate. The host clamps the request
|
||||
/// exactly like [`Hello::bitrate_kbps`] (its `[MIN, MAX]` band; `0` → host default), answers with
|
||||
@@ -270,6 +297,8 @@ pub const MSG_SHARD_PAYLOAD_ACK: u8 = 0x09;
|
||||
/// and [`BitrateChanged`] already feed. Deliberately NOT in the 0x30 clock block — it carries a
|
||||
/// duration precisely so that no clock domain is involved.
|
||||
pub const MSG_PIPELINE_GAP: u8 = 0x0A;
|
||||
/// Type byte of [`DeliveryReport`].
|
||||
pub const MSG_DELIVERY_REPORT: u8 = 0x0B;
|
||||
/// Type byte of [`ProbeRequest`].
|
||||
pub const MSG_PROBE_REQUEST: u8 = 0x20;
|
||||
/// Type byte of [`ProbeResult`].
|
||||
@@ -436,6 +465,26 @@ impl LossReport {
|
||||
}
|
||||
}
|
||||
|
||||
impl DeliveryReport {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] packets_received[5..13]
|
||||
let mut b = Vec::with_capacity(13);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_DELIVERY_REPORT);
|
||||
b.extend_from_slice(&self.packets_received.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<DeliveryReport> {
|
||||
if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_DELIVERY_REPORT {
|
||||
return Err(PunktfunkError::InvalidArg("bad DeliveryReport"));
|
||||
}
|
||||
Ok(DeliveryReport {
|
||||
packets_received: u64::from_le_bytes(b[5..13].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SetBitrate {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] bitrate_kbps[5..9]
|
||||
@@ -1291,6 +1340,41 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_report_roundtrip() {
|
||||
for packets_received in [0u64, 1, 9_999, u32::MAX as u64 + 1, u64::MAX] {
|
||||
let r = DeliveryReport { packets_received };
|
||||
assert_eq!(DeliveryReport::decode(&r.encode()).unwrap(), r);
|
||||
}
|
||||
assert!(DeliveryReport::decode(&RequestKeyframe.encode()).is_err());
|
||||
assert!(DeliveryReport::decode(&LossReport { loss_ppm: 0 }.encode()).is_err());
|
||||
}
|
||||
|
||||
/// The delivery count MUST NOT ride on [`LossReport`]: that message is length-checked EXACTLY,
|
||||
/// so lengthening it would make every already-shipped host reject the loss reports its adaptive
|
||||
/// FEC runs on — a silent regression for a new client against an old host, which is the normal
|
||||
/// mixed-version case here (the field report that motivated this ran a current host against a
|
||||
/// months-old client). Its own type byte keeps `LossReport` byte-identical while an older host
|
||||
/// simply ignores the message it does not know.
|
||||
#[test]
|
||||
fn the_delivery_count_does_not_disturb_the_loss_report_wire_form() {
|
||||
let loss = LossReport { loss_ppm: 42 }.encode();
|
||||
assert_eq!(loss.len(), 9, "LossReport must stay the 9-byte wire form");
|
||||
assert_eq!(loss[4], MSG_LOSS_REPORT);
|
||||
|
||||
let delivery = DeliveryReport {
|
||||
packets_received: 0,
|
||||
}
|
||||
.encode();
|
||||
assert_ne!(
|
||||
delivery[4], MSG_LOSS_REPORT,
|
||||
"a distinct type byte is what makes an old host ignore it instead of failing"
|
||||
);
|
||||
// Neither can be silently mis-parsed as the other.
|
||||
assert!(LossReport::decode(&delivery).is_err());
|
||||
assert!(DeliveryReport::decode(&loss).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_loss_ppm_estimates_and_caps() {
|
||||
// No traffic → 0. A clean window (nothing recovered) → 0.
|
||||
|
||||
@@ -26,17 +26,26 @@
|
||||
|
||||
use super::{audio_control, audio_probe, minted, pad_endpoint as pe};
|
||||
use anyhow::Result;
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo;
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
|
||||
};
|
||||
|
||||
/// The `Device Parameters` REG_DWORD each punktfunk-minted devnode family stamps on itself. The
|
||||
/// VALUE is what differs per family; presence of the NAME is "this one is ours", which is all a
|
||||
/// sweep needs.
|
||||
const OWNER_MARKERS: [&str; 3] = [
|
||||
pub(crate) const OWNER_MARKERS: [&str; 3] = [
|
||||
pe::PAD_INDEX_VALUE,
|
||||
minted::ROLE_MARKER,
|
||||
audio_probe::PROBE_MARKER,
|
||||
];
|
||||
|
||||
/// The Steam streaming hardware ids every audio devnode this product mints is created with —
|
||||
/// the second half of the ABANDONED-devnode test in [`owned_devnodes`].
|
||||
const MINTED_HWIDS: [&str; 2] = [
|
||||
"ROOT\\SteamStreamingSpeakers",
|
||||
"ROOT\\SteamStreamingMicrophone",
|
||||
];
|
||||
|
||||
/// What one sweep removed. `endpoint_records` is counted separately from `devnodes` because the
|
||||
/// registry half is best-effort by design — see [`delete_endpoint_record`].
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -117,11 +126,91 @@ fn owned_devnodes() -> Result<Vec<String>> {
|
||||
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
|
||||
{
|
||||
out.push(inst);
|
||||
continue;
|
||||
}
|
||||
// ABANDONED: `ROOT\MEDIA\NNNN` carrying one of our minting hardware ids but no marker at
|
||||
// all — a devnode registered by a host that died before the marker write landed. It is
|
||||
// still bound and still serving endpoints, so leaving it behind is the "uninstalling
|
||||
// punktfunk left Sound settings full of Punktfunk devices forever" report all over again.
|
||||
//
|
||||
// The instance prefix is what makes this safe, and it is NOT redundant with
|
||||
// [`is_removable_instance`]: Steam's own devnodes carry these very hardware ids and are
|
||||
// ROOT-enumerated too, but live under `ROOT\SteamStreamingSpeakers\*` /
|
||||
// `ROOT\SteamStreamingMicrophone\*`. Only `ROOT\MEDIA\*` can have come from our
|
||||
// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)`.
|
||||
if is_abandoned_mint(
|
||||
&inst,
|
||||
&pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID),
|
||||
) {
|
||||
out.push(inst);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The ABANDONED-devnode test, split out from the PnP enumeration so the rule that keeps this
|
||||
/// sweep off VALVE'S OWN devices is checkable without a live devinfo set. See [`owned_devnodes`].
|
||||
fn is_abandoned_mint(instance_id: &str, hwids: &[String]) -> bool {
|
||||
instance_id
|
||||
.to_ascii_uppercase()
|
||||
.starts_with("ROOT\\MEDIA\\")
|
||||
&& MINTED_HWIDS
|
||||
.iter()
|
||||
.any(|want| hwids.iter().any(|h| h.eq_ignore_ascii_case(want)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod abandoned_tests {
|
||||
use super::is_abandoned_mint;
|
||||
|
||||
fn hw(s: &str) -> Vec<String> {
|
||||
vec![s.to_string()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopts_our_own_unmarked_devnodes() {
|
||||
// What a host that died mid-mint leaves behind, either role.
|
||||
assert!(is_abandoned_mint(
|
||||
r"ROOT\MEDIA\0004",
|
||||
&hw(r"ROOT\SteamStreamingMicrophone")
|
||||
));
|
||||
assert!(is_abandoned_mint(
|
||||
r"ROOT\MEDIA\0002",
|
||||
&hw(r"ROOT\SteamStreamingSpeakers")
|
||||
));
|
||||
// PnP casing is not guaranteed on either half.
|
||||
assert!(is_abandoned_mint(
|
||||
r"root\media\0009",
|
||||
&hw(r"root\steamstreamingspeakers")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_matches_valves_own_devices() {
|
||||
// THE safety rule: Steam's devnodes carry the very same hardware ids and are ROOT-
|
||||
// enumerated too — only the instance prefix separates them from ours.
|
||||
assert!(!is_abandoned_mint(
|
||||
r"ROOT\STEAMSTREAMINGMICROPHONE\0000",
|
||||
&hw(r"ROOT\SteamStreamingMicrophone")
|
||||
));
|
||||
assert!(!is_abandoned_mint(
|
||||
r"ROOT\STEAMSTREAMINGSPEAKERS\0000",
|
||||
&hw(r"ROOT\SteamStreamingSpeakers")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_matches_other_vendors_or_real_hardware() {
|
||||
// VB-Cable mints ROOT\MEDIA devnodes too — a different hardware id is all that saves it.
|
||||
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0000", &hw("VBAudioVACWDM")));
|
||||
assert!(!is_abandoned_mint(
|
||||
r"HDAUDIO\FUNC_01&VEN_10EC&DEV_0897",
|
||||
&hw(r"ROOT\SteamStreamingSpeakers")
|
||||
));
|
||||
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0001", &[]));
|
||||
}
|
||||
}
|
||||
|
||||
/// A devnode this sweep is allowed to remove: ROOT-enumerated, i.e. software-created.
|
||||
///
|
||||
/// Every devnode we mint comes from `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA
|
||||
|
||||
@@ -275,13 +275,22 @@ fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
|
||||
let (hwid, inf) = discover_driver(role.needle(), role.inf_name())?;
|
||||
let devnode = match find_role_devnode(role)? {
|
||||
Some(inst) => inst,
|
||||
None => {
|
||||
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
|
||||
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
|
||||
})?;
|
||||
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
|
||||
inst
|
||||
}
|
||||
// Before minting a SECOND devnode, reclaim an abandoned one. Minting is two PnP steps
|
||||
// (register, then mark), and a host that dies between them — the 0.30.0 teardown abort
|
||||
// did exactly this, five times on one box — leaves a registered, driver-bound, endpoint-
|
||||
// serving devnode that carries no marker. Nothing then resolves it: the next pass mints
|
||||
// a fresh one and the orphan lingers as a duplicate "Punktfunk Speakers"/"Punktfunk
|
||||
// Microphone" in the Sound zoo, invisible to the marker-matched uninstall sweep.
|
||||
None => match adopt_orphan_devnode(role, &hwid)? {
|
||||
Some(inst) => inst,
|
||||
None => {
|
||||
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
|
||||
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
|
||||
})?;
|
||||
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
|
||||
inst
|
||||
}
|
||||
},
|
||||
};
|
||||
pe::bind_driver(&hwid, &inf)?;
|
||||
|
||||
@@ -531,6 +540,61 @@ fn find_role_devnode(role: Role) -> Result<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Reclaim an ABANDONED punktfunk devnode for `role`, re-marking it so it resolves normally from
|
||||
/// here on; `None` when there is nothing to adopt (the ordinary first-mint path).
|
||||
///
|
||||
/// The shape adopted is `ROOT\MEDIA\NNNN` + the role's Steam hardware id + NO owner marker.
|
||||
/// That triple can only be ours: `ROOT\MEDIA\NNNN` is what
|
||||
/// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA class yields, and STEAM'S OWN
|
||||
/// devnodes are enumerated under `ROOT\SteamStreamingSpeakers\*` /
|
||||
/// `ROOT\SteamStreamingMicrophone\*` — they carry the same hardware id but never that instance
|
||||
/// prefix, which is precisely what keeps this from adopting (and later sweeping) Steam's devices.
|
||||
/// A marker of ANY family is left alone: it is a live devnode, ours but spoken for.
|
||||
///
|
||||
/// Which family the orphan came from does not matter. Every one is a plain instance of the same
|
||||
/// Valve driver; roles are ours to assign, and re-marking it here is what makes the assignment
|
||||
/// stick across restarts.
|
||||
fn adopt_orphan_devnode(role: Role, hwid: &str) -> Result<Option<String>> {
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
|
||||
};
|
||||
let set = pe::media_class_devs()?;
|
||||
for i in 0.. {
|
||||
let mut did = pe::devinfo_data();
|
||||
// SAFETY: live set; `did` is a live out-param with cbSize set.
|
||||
if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() {
|
||||
break; // ERROR_NO_MORE_ITEMS
|
||||
}
|
||||
let Some(inst) = pe::instance_id(&set, &did) else {
|
||||
continue;
|
||||
};
|
||||
if !inst.to_ascii_uppercase().starts_with("ROOT\\MEDIA\\") {
|
||||
continue;
|
||||
}
|
||||
if !pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID)
|
||||
.iter()
|
||||
.any(|h| h.eq_ignore_ascii_case(hwid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if super::devnode_cleanup::OWNER_MARKERS
|
||||
.iter()
|
||||
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
pe::write_devparam_dword(&set, &mut did, ROLE_MARKER, role.value())?;
|
||||
tracing::warn!(
|
||||
role = role.label(),
|
||||
devnode = %inst,
|
||||
"adopted an abandoned audio devnode — one of ours whose owner marker never landed \
|
||||
(a host that died mid-mint). Re-marked and reused instead of minting a duplicate"
|
||||
);
|
||||
return Ok(Some(inst));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Find the (exact hardware id, INF path) for one of Steam's streaming drivers: prefer any
|
||||
/// installed devnode whose hardware-id list contains `needle` (its `oemNN.inf` is the driver
|
||||
/// Windows already trusts), else fall back to Steam's driver directory. Shared with the
|
||||
|
||||
@@ -1192,6 +1192,17 @@ fn grant_system_full_control(subkey_path: &str) -> Result<()> {
|
||||
result
|
||||
}
|
||||
|
||||
/// The MMDevices hive an endpoint's record lives in, chosen by the direction its id encodes
|
||||
/// (`{0.0.1.…}` = capture, anything else = render). Render is the safe default: it is what every
|
||||
/// non-capture id resolves to, and the pad program only ever has render endpoints.
|
||||
fn mmdev_path_for(endpoint_id: &str) -> &'static str {
|
||||
if endpoint_id.starts_with(CAPTURE_ENDPOINT_ID_PREFIX) {
|
||||
MMDEV_CAPTURE_PATH
|
||||
} else {
|
||||
MMDEV_RENDER_PATH
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw-registry stamp route: repair the Properties key ACL, then write the serialized
|
||||
/// values (see [`reg_registry_value`]). Values written here are STORED but possibly not
|
||||
/// SERVED until an AudioEndpointBuilder restart — the caller's read-back decides.
|
||||
@@ -1199,7 +1210,14 @@ fn registry_stamp(endpoint_id: &str, stamps: &[&Stamp]) -> Result<()> {
|
||||
use winreg::enums::HKEY_LOCAL_MACHINE;
|
||||
use winreg::RegKey;
|
||||
let guid = endpoint_guid_part(endpoint_id)?;
|
||||
let path = format!(r"{MMDEV_RENDER_PATH}\{guid}\Properties");
|
||||
// The hive follows the endpoint's DIRECTION. This was hardcoded to Render, which is
|
||||
// invisible for the pad program (its endpoints are render-only) but wrong for the minted
|
||||
// provider, which stamps the virtual microphone's CAPTURE endpoint through the same
|
||||
// writer: the fallback then reached for `…\Render\{capture-guid}\Properties`, a key that
|
||||
// cannot exist, so every registry-route stamp of a capture endpoint failed on a box where
|
||||
// the property store was denied — silently, since the caller degrades to "keeps the
|
||||
// driver's default name".
|
||||
let path = format!(r"{}\{guid}\Properties", mmdev_path_for(endpoint_id));
|
||||
grant_system_full_control(&path)
|
||||
.with_context(|| format!("make {path} writable (registry stamp route)"))?;
|
||||
let key = RegKey::predef(HKEY_LOCAL_MACHINE)
|
||||
@@ -2109,6 +2127,23 @@ fn pad_capture_thread(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The registry stamp route must reach for the hive matching the endpoint's DIRECTION —
|
||||
/// it was hardcoded to Render, so a capture endpoint's fallback stamp could never land.
|
||||
#[test]
|
||||
fn registry_stamp_hive_follows_the_endpoint_direction() {
|
||||
assert_eq!(
|
||||
mmdev_path_for("{0.0.1.00000000}.{2753f927-2093-4ab4-aa90-9d880e959128}"),
|
||||
MMDEV_CAPTURE_PATH,
|
||||
"the minted microphone's capture endpoint records under Capture"
|
||||
);
|
||||
assert_eq!(
|
||||
mmdev_path_for("{0.0.0.00000000}.{5da9b5c9-8a10-4b54-8cf6-ce02b8354f16}"),
|
||||
MMDEV_RENDER_PATH,
|
||||
);
|
||||
// Anything unrecognised keeps the old behaviour rather than inventing a hive.
|
||||
assert_eq!(mmdev_path_for("nonsense"), MMDEV_RENDER_PATH);
|
||||
}
|
||||
|
||||
/// The serialized container blob for pad 0 must be byte-for-byte the on-glass-measured
|
||||
/// value, and byte 23 must be the pad index.
|
||||
#[test]
|
||||
|
||||
@@ -573,6 +573,29 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live.
|
||||
let edge = args.iter().any(|a| a == "--edge");
|
||||
let deck = args.iter().any(|a| a == "--deck");
|
||||
// `--idle-after N` drives normally for N seconds, then STOPS sending state frames while still
|
||||
// pumping. That is Moonlight's cadence: moonlight-common-c sends a controller packet only on
|
||||
// CHANGE, so an untouched pad produces no wire events at all. The native plane never sees this
|
||||
// because punktfunk's own client re-sends every live pad's snapshot every 100 ms (the
|
||||
// `input_task.rs` refresh tick) — which is exactly why a manager that needs a periodic re-emit
|
||||
// can look healthy on one plane and die on the other.
|
||||
let idle_after: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--idle-after")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
// `--resume-after M` ends the silence at M seconds and drives again. That is the half that
|
||||
// actually answers the question: enumeration surviving a silence proves nothing, because a pad
|
||||
// can stay listed and still deliver no input. What matters is whether a report written AFTER
|
||||
// the silence still reaches a consumer — check it with `win-input-matrix --watch` while this
|
||||
// runs, and watch whether the timestamps start advancing again.
|
||||
let resume_after: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--resume-after")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let extra_buttons: u32 = if edge || deck {
|
||||
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
|
||||
} else {
|
||||
@@ -612,6 +635,9 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
$label
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let started = Instant::now();
|
||||
let mut announced_silence = false;
|
||||
let mut announced_resume = false;
|
||||
let (mut i, mut last) = (0i32, Instant::now());
|
||||
while Instant::now() < deadline {
|
||||
mgr.pump(
|
||||
@@ -620,7 +646,27 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
),
|
||||
|o| println!(" hid output from game: {o:?}"),
|
||||
);
|
||||
if last.elapsed() >= Duration::from_millis(400) {
|
||||
let el = started.elapsed();
|
||||
let resumed =
|
||||
resume_after != 0 && el >= Duration::from_secs(resume_after.max(idle_after));
|
||||
let silent =
|
||||
idle_after != 0 && el >= Duration::from_secs(idle_after) && !resumed;
|
||||
if silent && !announced_silence {
|
||||
announced_silence = true;
|
||||
println!(
|
||||
" --- going SILENT (no more state frames, still pumping) at {}s ---",
|
||||
idle_after
|
||||
);
|
||||
}
|
||||
if resumed && !announced_resume {
|
||||
announced_resume = true;
|
||||
println!(
|
||||
" --- RESUMING state frames at {}s (after {}s of silence) ---",
|
||||
resume_after,
|
||||
resume_after.saturating_sub(idle_after)
|
||||
);
|
||||
}
|
||||
if !silent && last.elapsed() >= Duration::from_millis(400) {
|
||||
last = Instant::now();
|
||||
i += 1;
|
||||
let buttons = if i % 2 == 0 {
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
use anyhow::{Context, Result};
|
||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The native-protocol mDNS service type. Clients browse this to find punktfunk/1 hosts.
|
||||
pub const NATIVE_SERVICE: &str = "_punktfunk._udp.local.";
|
||||
@@ -81,9 +83,78 @@ pub(crate) fn dns_label(name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the mDNS daemon; dropping it unregisters the service.
|
||||
/// Holds the mDNS daemon; dropping it unregisters the service and stops the re-announce loop.
|
||||
pub struct Advert {
|
||||
_daemon: ServiceDaemon,
|
||||
/// Never sent on. Dropping it disconnects the channel the re-announce thread waits on, which
|
||||
/// wakes that thread immediately and ends it — so an `Advert` takes its loop with it instead
|
||||
/// of leaving one behind polling for a service nobody advertises.
|
||||
_stop: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
/// How often a live advert re-checks the address it is announcing.
|
||||
const IP_RECHECK: Duration = Duration::from_secs(10);
|
||||
|
||||
/// The address to advertise right now — loopback only while the machine still has none.
|
||||
fn current_ip() -> IpAddr {
|
||||
crate::gamestream::primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
|
||||
/// Register `build(ip)` for the host's current address, and re-register it whenever that address
|
||||
/// changes. Shared by both adverts ([`advertise_native`] and [`crate::gamestream::mdns`]).
|
||||
///
|
||||
/// mDNS records are PUSHED, not polled: whatever address was true at `register()` keeps being
|
||||
/// announced until something registers a newer one. The host process comes up during boot, which
|
||||
/// on a cold start is before the machine has an address — so the first registration could be
|
||||
/// `127.0.0.1`, and it stayed that way until the host was restarted by hand. `mdns-sd` documents a
|
||||
/// second `register()` of the same fullname as an update, so re-announcing is just calling it
|
||||
/// again.
|
||||
///
|
||||
/// Polls the *routed* address rather than subscribing to the daemon's `IpAdd` events, because the
|
||||
/// boot race usually resolves without one: the NIC often has its address before we register and
|
||||
/// only the default route lands late, so no interface event ever fires.
|
||||
pub(crate) fn advertise_live(
|
||||
service: &'static str,
|
||||
build: impl Fn(IpAddr) -> Result<ServiceInfo> + Send + 'static,
|
||||
) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
let registered = current_ip();
|
||||
daemon
|
||||
.register(build(registered)?)
|
||||
.with_context(|| format!("register {service} mDNS service"))?;
|
||||
|
||||
let (stop_tx, stop_rx) = mpsc::channel::<()>();
|
||||
let bg_daemon = daemon.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut announced = registered;
|
||||
// Doubles as the sleep: times out every `IP_RECHECK` to re-check, and returns
|
||||
// `Disconnected` the moment the `Advert` drops its sender, which ends the loop.
|
||||
while matches!(
|
||||
stop_rx.recv_timeout(IP_RECHECK),
|
||||
Err(mpsc::RecvTimeoutError::Timeout)
|
||||
) {
|
||||
let now = current_ip();
|
||||
if now == announced {
|
||||
continue;
|
||||
}
|
||||
match build(now)
|
||||
.and_then(|info| bg_daemon.register(info).context("re-register mDNS service"))
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::info!(service, from = %announced, to = %now, "host address changed — re-announced");
|
||||
announced = now;
|
||||
}
|
||||
// Leave the previous record standing and retry next tick rather than going dark.
|
||||
Err(e) => {
|
||||
tracing::warn!(service, error = %format!("{e:#}"), "mDNS re-announce failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Advert {
|
||||
_daemon: daemon,
|
||||
_stop: stop_tx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex);
|
||||
@@ -95,7 +166,6 @@ pub struct Advert {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn advertise_native(
|
||||
hostname: &str,
|
||||
ip: IpAddr,
|
||||
port: u16,
|
||||
fingerprint: &str,
|
||||
require_pairing: bool,
|
||||
@@ -103,14 +173,17 @@ pub fn advertise_native(
|
||||
mgmt_port: Option<u16>,
|
||||
os_chain: &str,
|
||||
) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
// `hostname` is the DISPLAY name (the instance label clients read back); the A-record target
|
||||
// has to be a legal DNS name, hence the separate sanitized label.
|
||||
let host_name = format!("{}.local.", dns_label(hostname));
|
||||
let mut props: HashMap<String, String> = HashMap::new();
|
||||
props.insert("proto".into(), NATIVE_PROTO.into());
|
||||
props.insert("fp".into(), fingerprint.to_string());
|
||||
props.insert(
|
||||
// Owned, because the record is rebuilt whenever the host's address changes — see
|
||||
// [`advertise_live`]. Everything except the address (and the MACs derived from it) is fixed,
|
||||
// so it is computed once here and moved into the builder.
|
||||
let instance = hostname.to_string();
|
||||
let mut fixed: HashMap<String, String> = HashMap::new();
|
||||
fixed.insert("proto".into(), NATIVE_PROTO.into());
|
||||
fixed.insert("fp".into(), fingerprint.to_string());
|
||||
fixed.insert(
|
||||
"pair".into(),
|
||||
if require_pairing {
|
||||
"required"
|
||||
@@ -119,31 +192,14 @@ pub fn advertise_native(
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
props.insert("id".into(), uniqueid.to_string());
|
||||
fixed.insert("id".into(), uniqueid.to_string());
|
||||
if let Some(mgmt) = mgmt_port {
|
||||
props.insert("mgmt".into(), mgmt.to_string());
|
||||
fixed.insert("mgmt".into(), mgmt.to_string());
|
||||
}
|
||||
// `os` — advisory OS-identity chain for the client's host-card icon (see module doc).
|
||||
if !os_chain.is_empty() {
|
||||
props.insert("os".into(), os_chain.to_string());
|
||||
fixed.insert("os".into(), os_chain.to_string());
|
||||
}
|
||||
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed NIC
|
||||
// first. A client persists these while the host is awake so it can send a Wake-on-LAN magic
|
||||
// packet to wake it later (when it's asleep and no longer advertising). Unauthenticated like
|
||||
// the rest of the advert, but a wrong MAC only makes a wake fail — the magic packet is inert
|
||||
// and the cert fingerprint still gates the actual connection. Omitted when none can be read.
|
||||
let macs = crate::wol::wake_macs(ip);
|
||||
if !macs.is_empty() {
|
||||
props.insert("mac".into(), macs.join(","));
|
||||
}
|
||||
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason WoL
|
||||
// silently fails.
|
||||
crate::wol::warn_if_not_armed(ip);
|
||||
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
|
||||
.context("build native mDNS ServiceInfo")?;
|
||||
daemon
|
||||
.register(service)
|
||||
.context("register native mDNS service")?;
|
||||
tracing::info!(
|
||||
service = "_punktfunk._udp",
|
||||
port,
|
||||
@@ -151,7 +207,26 @@ pub fn advertise_native(
|
||||
pair = if require_pairing { "required" } else { "optional" },
|
||||
"native punktfunk/1 mDNS advertising"
|
||||
);
|
||||
Ok(Advert { _daemon: daemon })
|
||||
advertise_live(NATIVE_SERVICE, move |ip| {
|
||||
let mut props = fixed.clone();
|
||||
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed
|
||||
// NIC first. A client persists these while the host is awake so it can send a
|
||||
// Wake-on-LAN magic packet to wake it later (when it's asleep and no longer advertising).
|
||||
// Unauthenticated like the rest of the advert, but a wrong MAC only makes a wake fail —
|
||||
// the magic packet is inert and the cert fingerprint still gates the actual connection.
|
||||
// Omitted when none can be read, which is what a host that came up before its network did
|
||||
// used to report forever.
|
||||
let macs = crate::wol::wake_macs(ip);
|
||||
if !macs.is_empty() {
|
||||
props.insert("mac".into(), macs.join(","));
|
||||
}
|
||||
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason
|
||||
// WoL silently fails. Re-checked on an address change because the routed NIC may be a
|
||||
// different one now.
|
||||
crate::wol::warn_if_not_armed(ip);
|
||||
ServiceInfo::new(NATIVE_SERVICE, &instance, &host_name, ip, port, props)
|
||||
.context("build native mDNS ServiceInfo")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -114,9 +114,18 @@ pub enum LeaseKind {
|
||||
Child,
|
||||
/// A launcher owns the game; it is recognized by its [`DetectSpec`].
|
||||
Matched,
|
||||
/// Nothing identifies this title's process — no detect signals and no child we own. Both
|
||||
/// lifetime behaviors stay inert for it, and the host says so once in the log rather than
|
||||
/// guessing.
|
||||
/// A launcher owns the game and **tells us** when it starts and stops
|
||||
/// ([`crate::runstate`]) — no process signal of our own.
|
||||
///
|
||||
/// The one lease kind whose liveness the host does not determine for itself, and the answer to
|
||||
/// a title that has nothing to scan for: Playnite launches an emulated or manually-added game
|
||||
/// through its own tracking and reports the edges, where the host could see only a
|
||||
/// `playnite://` forwarder exiting. Before this such a title was [`Untracked`](Self::Untracked)
|
||||
/// — the honest answer at the time, and a dead end.
|
||||
Reported,
|
||||
/// Nothing identifies this title's process — no detect signals, no child we own, and no
|
||||
/// provider reporting on it. Both lifetime behaviors stay inert for it, and the host says so
|
||||
/// once in the log rather than guessing.
|
||||
Untracked,
|
||||
}
|
||||
|
||||
@@ -126,6 +135,7 @@ impl LeaseKind {
|
||||
Self::Nested => "nested",
|
||||
Self::Child => "child",
|
||||
Self::Matched => "matched",
|
||||
Self::Reported => "reported",
|
||||
Self::Untracked => "untracked",
|
||||
}
|
||||
}
|
||||
@@ -387,6 +397,12 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
|
||||
LeaseKind::Child
|
||||
} else if !spec.is_empty() {
|
||||
LeaseKind::Matched
|
||||
} else if crate::runstate::speaks_for(game.id.as_deref()) {
|
||||
// Nothing to scan for, but the provider that published this title is reporting liveness for
|
||||
// it — so it is tracked after all. Asked once, here, rather than every poll: a lease's kind
|
||||
// is what decides whether it is watched at all, and a title that flipped kind mid-flight
|
||||
// would make both lifetime behaviors depend on a plugin's uptime.
|
||||
LeaseKind::Reported
|
||||
} else {
|
||||
LeaseKind::Untracked
|
||||
};
|
||||
@@ -551,8 +567,32 @@ fn watch(
|
||||
s.is_some_and(|p| !scanner.alive(&[p]).is_empty())
|
||||
};
|
||||
|
||||
// What this title's provider says about it, when one reports at all ([`crate::runstate`]) —
|
||||
// `None` on every host with no reporting plugin, which is what keeps all of this inert until
|
||||
// someone opts in. Re-read each poll rather than captured: the whole value of it is that it
|
||||
// changes while the lease is alive.
|
||||
let reported = || shared.game.id.as_deref().and_then(crate::runstate::opinion);
|
||||
|
||||
// What a `Child` lease falls back to once its child turns out to be a shim: the store's own
|
||||
// signals, else the provider's reporting, else nothing. The same ladder [`open`] walks, minus
|
||||
// the child that has just gone away — and the reason a hint-less Playnite title is tracked at
|
||||
// all on Windows, where the launch is `explorer.exe "playnite://…"` and therefore ALWAYS a
|
||||
// hand-off, so every such lease arrives here.
|
||||
let fallback_kind = || {
|
||||
if !shared.spec.is_empty() {
|
||||
LeaseKind::Matched
|
||||
} else if crate::runstate::speaks_for(shared.game.id.as_deref()) {
|
||||
LeaseKind::Reported
|
||||
} else {
|
||||
LeaseKind::Untracked
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Phase 1: wait for the game to show up. ----
|
||||
let start_deadline = spawned_at + START_GRACE;
|
||||
// How long the scan has *continuously* seen something for this title — the scan-side twin of
|
||||
// [`SHIM_WINDOW`]. See `scan_settled` below for what it is protecting against.
|
||||
let mut seen_since: Option<Instant> = None;
|
||||
loop {
|
||||
if cancelled() {
|
||||
return;
|
||||
@@ -567,8 +607,10 @@ fn watch(
|
||||
&& !spawned_up(&spawned)
|
||||
{
|
||||
spawned = None;
|
||||
if spawned_at.elapsed() < SHIM_WINDOW {
|
||||
if shared.spec.is_empty() {
|
||||
let quick = spawned_at.elapsed() < SHIM_WINDOW;
|
||||
kind = fallback_kind();
|
||||
if quick {
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
tracing::info!(
|
||||
title = %shared.game.title,
|
||||
"the launch command exited immediately (a launcher handing off) and this \
|
||||
@@ -582,11 +624,10 @@ fn watch(
|
||||
}
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
"the launch command handed off and exited — recognizing the game by its store \
|
||||
signals instead"
|
||||
kind = kind.as_str(),
|
||||
"the launch command handed off and exited — recognizing the game another way"
|
||||
);
|
||||
kind = LeaseKind::Matched;
|
||||
} else if shared.spec.is_empty() {
|
||||
} else if matches!(kind, LeaseKind::Untracked) {
|
||||
// It ran long enough to have BEEN the game, and nothing else identifies it.
|
||||
shared.was_running.store(true, Ordering::Relaxed);
|
||||
finish(&shared, &on_exit, "the launched process exited");
|
||||
@@ -604,31 +645,30 @@ fn watch(
|
||||
shared.forget_child();
|
||||
if quick && status.success() {
|
||||
// A launcher that handed the game off and exited. Fall back to recognizing
|
||||
// the game by its store's signals; with none, stop tracking entirely rather
|
||||
// than pretend the shim's exit was the game's.
|
||||
kind = if shared.spec.is_empty() {
|
||||
// the game by its store's signals (or its provider's reporting); with
|
||||
// neither, stop tracking entirely rather than pretend the shim's exit was
|
||||
// the game's.
|
||||
kind = fallback_kind();
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
tracing::info!(
|
||||
title = %shared.game.title,
|
||||
"the launch command exited immediately (a launcher handing off) and \
|
||||
this title has no detect signals — stopping game tracking for it"
|
||||
);
|
||||
LeaseKind::Untracked
|
||||
} else {
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
"the launch command handed off and exited — recognizing the game by \
|
||||
its store signals instead"
|
||||
);
|
||||
LeaseKind::Matched
|
||||
};
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
shared.set_state(GameState::Untracked);
|
||||
return;
|
||||
}
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
kind = kind.as_str(),
|
||||
"the launch command handed off and exited — recognizing the game \
|
||||
another way"
|
||||
);
|
||||
} else {
|
||||
// It ran long enough to have BEEN the game (or failed outright). Either way
|
||||
// the game is gone; only a success after a real run counts as "played".
|
||||
if shared.spec.is_empty() {
|
||||
kind = fallback_kind();
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
if spawned_at.elapsed() >= SHIM_WINDOW {
|
||||
shared.was_running.store(true, Ordering::Relaxed);
|
||||
finish(&shared, &on_exit, "the launched process exited");
|
||||
@@ -642,11 +682,7 @@ fn watch(
|
||||
Some(Err(e)) => {
|
||||
tracing::debug!(error = %e, "could not poll the launched child — falling back to scanning");
|
||||
child = None;
|
||||
kind = if shared.spec.is_empty() {
|
||||
LeaseKind::Untracked
|
||||
} else {
|
||||
LeaseKind::Matched
|
||||
};
|
||||
kind = fallback_kind();
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
shared.set_state(GameState::Untracked);
|
||||
return;
|
||||
@@ -680,7 +716,39 @@ fn watch(
|
||||
&& (child.is_some() || spawned.is_some())
|
||||
&& spawned_at.elapsed() >= SHIM_WINDOW;
|
||||
let live = scanner.find(&shared.spec, shared.launch_stamp);
|
||||
if !live.is_empty() || child_alive {
|
||||
// The same rule for what the *scan* finds, and for the same reason. A store's launch is a
|
||||
// chain of process trees, and the ones that run before the game carry the signals the game
|
||||
// carries: Steam wraps its shader pre-caching and its Proton prefix work in the very
|
||||
// `reaper SteamLaunch AppId=<appid>` the game gets, so the first poll of a launch can match
|
||||
// a tree that was never the game.
|
||||
//
|
||||
// Latching on one poll is what costs, because the two phases are patient in opposite ways.
|
||||
// This one waits [`START_GRACE`] — five minutes — and ending it never ends the session.
|
||||
// Phase 2 waits [`EXIT_CONFIRM`] — three seconds — and ending it *does*. A single sighting
|
||||
// flips the lease from the first to the second, permanently; when that tree then exits with
|
||||
// the real game not yet started, the stream drops mid-launch. On Linux that ended a Rocket
|
||||
// League session 10 s after launch, while Steam was still compiling its shaders, and the
|
||||
// player had to launch a second time to get one that stayed up (field report 2026-08-22).
|
||||
//
|
||||
// Requiring the sighting to persist buys that back for a few seconds of `GameRunning`
|
||||
// latency and nothing else — exit detection is untouched. ⚠ It is a window, not a proof: a
|
||||
// pre-launch tree that outlives the window still latches. Signals sharp enough to tell one
|
||||
// from the other belong in [`crate::procscan`] (where Steam's shader job is already excluded
|
||||
// by name); this bounds what no signal caught.
|
||||
let scan_settled = if live.is_empty() {
|
||||
seen_since = None;
|
||||
false
|
||||
} else {
|
||||
seen_since.get_or_insert_with(Instant::now).elapsed() >= SHIM_WINDOW
|
||||
};
|
||||
// A provider saying so is as good as seeing it — better, for a title there is nothing to
|
||||
// see: it is the launcher that started the game telling us it did. This is the only way a
|
||||
// [`LeaseKind::Reported`] lease ever leaves this phase, and for a `Matched` one it just
|
||||
// gets there sooner than the scan would. Not gated by the window above: a report is the
|
||||
// launcher's own statement about the game, not an inference from a process that resembles
|
||||
// it, so there is nothing to wait out.
|
||||
let said_running = reported().is_some_and(|l| l.running);
|
||||
if scan_settled || child_alive || said_running {
|
||||
known = live.clone();
|
||||
publish(&live);
|
||||
shared.was_running.store(true, Ordering::Relaxed);
|
||||
@@ -693,6 +761,8 @@ fn watch(
|
||||
title = %shared.game.title,
|
||||
kind = kind.as_str(),
|
||||
procs = live.len(),
|
||||
// Which processes, not just how many: see [`crate::procscan::names`].
|
||||
names = ?crate::procscan::names(&live),
|
||||
"the launched game is running"
|
||||
);
|
||||
break;
|
||||
@@ -754,6 +824,27 @@ fn watch(
|
||||
gone_since = None;
|
||||
vetoed = false;
|
||||
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
|
||||
} else if let Some(said) = reported() {
|
||||
// Nothing of the game is visible to us, but its provider is still reporting on it — and
|
||||
// that report is decisive in BOTH directions, where `running_hint` below may only ever
|
||||
// delay an exit.
|
||||
//
|
||||
// The difference is what backs each claim. Steam's registry flag is a leftover that
|
||||
// survives an unclean exit, so believing it indefinitely produces a session that never
|
||||
// ends; a provider report is an event from the launcher that started the game, restated
|
||||
// continuously, and it stops counting the moment it goes stale
|
||||
// ([`crate::runstate::REPORT_TTL`]) — after which this branch simply stops being taken
|
||||
// and the scan-only path below resumes. So a *live* provider is allowed to hold the
|
||||
// session open for a game the host cannot see at all, which is the entire point for a
|
||||
// title with no detect signals, and a dead one costs at most one TTL.
|
||||
if said.running {
|
||||
gone_since = None;
|
||||
vetoed = false;
|
||||
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
|
||||
} else {
|
||||
finish(&shared, &on_exit, "its provider reported the game stopped");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by
|
||||
// the veto below — letting it run on is exactly what bounds the veto.
|
||||
@@ -909,7 +1000,7 @@ fn terminate_blocking(shared: &LeaseShared) {
|
||||
"released the nested session's kept display to end its game"
|
||||
);
|
||||
}
|
||||
LeaseKind::Child | LeaseKind::Matched => {
|
||||
LeaseKind::Child | LeaseKind::Matched | LeaseKind::Reported => {
|
||||
#[cfg(target_os = "linux")]
|
||||
unix_term_ladder(shared);
|
||||
#[cfg(windows)]
|
||||
@@ -919,6 +1010,26 @@ fn terminate_blocking(shared: &LeaseShared) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The process this lease's provider reports for its game, re-resolved and pinned to its start
|
||||
/// time, or `None`.
|
||||
///
|
||||
/// The reason the wire carries a pid at all: for a [`LeaseKind::Reported`] title the matcher finds
|
||||
/// nothing by construction, so without this "End" would have no target and would silently do
|
||||
/// nothing — the exact failure a spawned pid was folded into the Windows ladder to fix. Resolved at
|
||||
/// the moment of use rather than stored on the lease, so a report that has since gone stale, or a
|
||||
/// pid the kernel has since recycled, contributes nothing.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn reported_proc(shared: &LeaseShared) -> Option<crate::procscan::ProcRef> {
|
||||
let pid = shared
|
||||
.game
|
||||
.id
|
||||
.as_deref()
|
||||
.and_then(crate::runstate::opinion)
|
||||
.filter(|l| l.running)?
|
||||
.pid?;
|
||||
crate::procscan::resolve(pid)
|
||||
}
|
||||
|
||||
/// SIGTERM everything that belongs to the game, wait, then SIGKILL whatever ignored it.
|
||||
///
|
||||
/// Every pid is re-verified against its recorded start time immediately before each signal, so a pid
|
||||
@@ -942,11 +1053,22 @@ fn unix_term_ladder(shared: &LeaseShared) {
|
||||
// `OwnedChild::group_leader`) — never for a child sharing the host's own group.
|
||||
unsafe { libc::kill(target, sig) == 0 }
|
||||
};
|
||||
// Everything the matcher can find, plus the pid the provider reported (see `reported_proc`) —
|
||||
// which for a `Reported` lease is the only member of this set.
|
||||
let targets = || {
|
||||
let mut procs = scanner.find(&shared.spec, shared.launch_stamp);
|
||||
if let Some(p) = reported_proc(shared) {
|
||||
if !procs.iter().any(|q| q.pid == p.pid) {
|
||||
procs.push(p);
|
||||
}
|
||||
}
|
||||
procs
|
||||
};
|
||||
let signal_matched = |sig: i32| -> usize {
|
||||
// Re-scan and re-verify immediately before signalling, so a pid recycled since the last
|
||||
// sweep is never hit.
|
||||
scanner
|
||||
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
|
||||
.alive(&targets())
|
||||
.into_iter()
|
||||
// SAFETY: as above, for a single pid just re-verified to be the process we adopted.
|
||||
.filter(|p| unsafe { libc::kill(p.pid as i32, sig) == 0 })
|
||||
@@ -965,9 +1087,7 @@ fn unix_term_ladder(shared: &LeaseShared) {
|
||||
let deadline = Instant::now() + TERM_GRACE;
|
||||
while Instant::now() < deadline {
|
||||
std::thread::sleep(POLL);
|
||||
let still = scanner
|
||||
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
|
||||
.len();
|
||||
let still = scanner.alive(&targets()).len();
|
||||
// Signal 0 only probes for existence — the child (or its group) is gone once it fails.
|
||||
let child_gone = !signal_child(0);
|
||||
if still == 0 && child_gone {
|
||||
@@ -1000,11 +1120,19 @@ fn windows_term_ladder(shared: &LeaseShared) {
|
||||
let live = || {
|
||||
let mut procs = scanner.alive(&scanner.find(&shared.spec, shared.launch_stamp));
|
||||
// Re-verified like everything else, so a dead or recycled pid contributes nothing, and
|
||||
// de-duplicated: the matcher may well have found this same process by its image.
|
||||
if let Some(p) = shared.spawned {
|
||||
// de-duplicated: the matcher may well have found this same process by its image. The
|
||||
// provider's reported pid joins on the same terms, and for a `Reported` lease it is the
|
||||
// only thing here (see `reported_proc`).
|
||||
let mut fold = |p: crate::procscan::ProcRef| {
|
||||
if !scanner.alive(&[p]).is_empty() && !procs.iter().any(|q| q.pid == p.pid) {
|
||||
procs.push(p);
|
||||
}
|
||||
};
|
||||
if let Some(p) = shared.spawned {
|
||||
fold(p);
|
||||
}
|
||||
if let Some(p) = reported_proc(shared) {
|
||||
fold(p);
|
||||
}
|
||||
procs
|
||||
};
|
||||
@@ -1570,6 +1698,54 @@ mod tests {
|
||||
assert!(!l.shared().is_trackable());
|
||||
}
|
||||
|
||||
/// A title with nothing to scan for is tracked after all when its provider reports on it.
|
||||
///
|
||||
/// This is the Playnite case the static `detect` hints could never reach: an emulated game, a
|
||||
/// manually added one, a library plugin that records no install directory. The launch is a
|
||||
/// `playnite://` hand-off, so the host holds nothing; the spec is empty, so the matcher finds
|
||||
/// nothing; and the honest verdict used to be [`LeaseKind::Untracked`] — no exit detection, and
|
||||
/// `POST /game/end` with nothing to aim at. Playnite knew the whole time.
|
||||
#[test]
|
||||
fn a_reported_title_is_tracked_where_it_used_to_be_untracked() {
|
||||
// The same request with no provider reporting: unchanged, and the control for what follows.
|
||||
let l = open(
|
||||
req("playnite:lease-test", DetectSpec::default(), false),
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
|
||||
assert!(!l.shared().is_trackable());
|
||||
drop(l);
|
||||
|
||||
// A provider that speaks for the title — while reporting it NOT running, which is exactly
|
||||
// what a report looks like at the moment a game is launched. Trackability follows from the
|
||||
// provider *reporting*, not from what it currently says; a lease whose kind flipped with
|
||||
// the answer would make both lifetime behaviours depend on a plugin's timing.
|
||||
crate::runstate::report(
|
||||
"playnite-lease-test",
|
||||
["playnite:lease-test".to_string()].into_iter().collect(),
|
||||
std::collections::HashMap::new(),
|
||||
);
|
||||
let l = open(
|
||||
req("playnite:lease-test", DetectSpec::default(), false),
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Reported));
|
||||
assert!(
|
||||
l.shared().is_trackable(),
|
||||
"so its exit is noticed and `POST /game/end` has a target"
|
||||
);
|
||||
drop(l);
|
||||
crate::runstate::forget("playnite-lease-test");
|
||||
|
||||
// …and once the provider is gone, so is the tracking. Pinned because a report that outlived
|
||||
// its plugin is the one way this could hold a session open forever.
|
||||
let l = open(
|
||||
req("playnite:lease-test", DetectSpec::default(), false),
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untracked_lease_is_never_terminated() {
|
||||
let l = open(
|
||||
@@ -1875,6 +2051,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 🛑 The 2026-08-22 field report: a **pre-launch** process tree must not be mistaken for the
|
||||
/// game.
|
||||
///
|
||||
/// Steam wraps its shader pre-caching in the same `SteamLaunch AppId=` reaper the game itself
|
||||
/// gets, so the first poll of a launch matches a tree that was never the game. What shipped
|
||||
/// latched on that single sighting: the lease left the start phase immediately, and when the
|
||||
/// compile finished and that tree exited — with Rocket League still starting — the exit watch
|
||||
/// called it the game exiting and closed the session with `APP_EXITED`, 10 s after launch. On
|
||||
/// the player's screen the stream dropped mid-"Processing Vulkan shaders"; their workaround was
|
||||
/// to launch the game twice.
|
||||
///
|
||||
/// The scanner now knows Steam's replayer by name ([`crate::procscan`]). This pins the bound
|
||||
/// behind that: a matched process that does not outlive [`SHIM_WINDOW`] never arms the exit
|
||||
/// watch, whatever it was — which is what covers the pre-launch trees nobody has named yet.
|
||||
///
|
||||
/// Ignored by default: it outlives the shim window and then waits out [`EXIT_CONFIRM`], ~11 s.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "drives a real process for ~11s (shim window + exit confirmation)"]
|
||||
fn a_pre_launch_tree_that_exits_never_ends_the_session() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
// The stand-in has to keep the name `sleep`: coreutils is a multi-call binary that
|
||||
// dispatches on `argv[0]`, and under any other name it exits instantly — which would pass
|
||||
// this test for entirely the wrong reason. (Same trap as the live matcher test in
|
||||
// [`crate::procscan`].)
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let stand_in = td.path().join("sleep");
|
||||
std::fs::copy("/bin/sleep", &stand_in).expect("copy a stand-in pre-launch binary");
|
||||
let launch_stamp = launch_clock();
|
||||
|
||||
// Alive for less than the shim window — Steam's shader job, in miniature.
|
||||
let mut child = std::process::Command::new(&stand_in)
|
||||
.arg("3")
|
||||
.spawn()
|
||||
.expect("spawn the fake pre-launch tree");
|
||||
// Reaped on its own thread: a zombie keeps its `/proc` entry with an unchanged start time,
|
||||
// so the scan would call it alive forever and the exit under test never happen.
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
|
||||
static PRE_EXITS: AtomicUsize = AtomicUsize::new(0);
|
||||
PRE_EXITS.store(0, Ordering::SeqCst);
|
||||
let lease = open(
|
||||
LeaseRequest {
|
||||
launch_stamp,
|
||||
// No child and no pid: the scan is the only signal, which is the field-report shape
|
||||
// (`steam steam://rungameid/…` had already handed off and exited).
|
||||
..req("steam:pre-launch", DetectSpec::dir(td.path()), false)
|
||||
},
|
||||
Box::new(|| {
|
||||
PRE_EXITS.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
let shared = lease.shared();
|
||||
assert!(matches!(shared.kind(), LeaseKind::Matched));
|
||||
|
||||
std::thread::sleep(SHIM_WINDOW + EXIT_CONFIRM + Duration::from_secs(3));
|
||||
assert_eq!(
|
||||
PRE_EXITS.load(Ordering::SeqCst),
|
||||
0,
|
||||
"a tree that ran before the game must not end the session when it exits — this is the \
|
||||
field report"
|
||||
);
|
||||
assert_ne!(
|
||||
shared.state(),
|
||||
GameState::Exited,
|
||||
"the game never started, so nothing of it can have exited"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole point of the module, against a real process: a `Child` lease sees its game running,
|
||||
/// notices when it exits, and reports that exit exactly once.
|
||||
///
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
use super::{AppState, CONTROL_PORT};
|
||||
use crate::inject::gamepad::GamepadManager;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::input::InputEvent;
|
||||
use punktfunk_core::input::{GamepadEvent, InputEvent};
|
||||
use punktfunk_core::quic::{classify, GrantClass, HdrMeta, GRANT_ALL};
|
||||
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
|
||||
use std::net::UdpSocket;
|
||||
@@ -229,6 +229,65 @@ fn permitted(mask: u32, class: GrantClass, drops: &mut GrantDrops) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The virtual Xbox pad a Moonlight session presents, and the one place this plane decides which
|
||||
/// backend builds it.
|
||||
///
|
||||
/// On Windows there are two, and they are not interchangeable to a game: the XUSB companion
|
||||
/// registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID collection, so Steam's hidapi
|
||||
/// enumeration, SDL, RawInput, DirectInput, `joy.cpl` and WGI/GameInput cannot see it at all —
|
||||
/// only classic `XInputGetState` can. The native plane made the HID pad its default on
|
||||
/// 2026-08-09 for exactly that reason; this plane kept constructing
|
||||
/// [`GamepadManager`](crate::inject::gamepad::GamepadManager) directly and so kept handing
|
||||
/// Moonlight clients a pad most games cannot enumerate. Both planes now read the same knob —
|
||||
/// `native::gamepad::windows_xbox_hid` (not an intra-doc link: it is `cfg(windows)`, so the link
|
||||
/// would not resolve on any other target) — so `PUNKTFUNK_XBOX_BACKEND=xusb` reverts both
|
||||
/// together and neither can drift again.
|
||||
///
|
||||
/// Everywhere else the choice does not exist: Linux has one uinput X-Box pad, and the stub
|
||||
/// backend on other platforms drops events.
|
||||
enum SessionPads {
|
||||
/// Linux uinput / the Windows XUSB companion — `crate::inject::gamepad`.
|
||||
Xusb(GamepadManager),
|
||||
/// The Windows UMDF HID Xbox pad, what the native plane builds by default.
|
||||
#[cfg(target_os = "windows")]
|
||||
Hid(crate::inject::xbox_windows::XboxWindowsManager),
|
||||
}
|
||||
|
||||
impl SessionPads {
|
||||
/// Build this session's pad manager, honoring the shared Windows backend knob.
|
||||
fn new() -> SessionPads {
|
||||
#[cfg(target_os = "windows")]
|
||||
if crate::native::gamepad::windows_xbox_hid() {
|
||||
return SessionPads::Hid(crate::inject::xbox_windows::XboxWindowsManager::new());
|
||||
}
|
||||
SessionPads::Xusb(GamepadManager::new())
|
||||
}
|
||||
|
||||
/// Apply one decoded controller event (create/destroy by mask, then state).
|
||||
fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match self {
|
||||
SessionPads::Xusb(m) => m.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
SessionPads::Hid(m) => m.handle(ev),
|
||||
}
|
||||
}
|
||||
|
||||
/// Service the pads' feedback protocol and relay changed rumble levels. Games block inside the
|
||||
/// kernel/driver handshake until answered, so call this every tick.
|
||||
///
|
||||
/// The HID pad's rich-feedback plane is discarded rather than plumbed: an Xbox pad has no
|
||||
/// lightbar or adaptive triggers to report, and GameStream has no vocabulary for one either —
|
||||
/// its rumble message (`0x010B`, [`super::gamepad::rumble_plaintext`]) carries the two handle
|
||||
/// motors and nothing else, which is also why the trigger levels are dropped at the call site.
|
||||
fn pump_rumble(&mut self, rumble: impl FnMut(u16, u16, u16, u16, u16)) {
|
||||
match self {
|
||||
SessionPads::Xusb(m) => m.pump_rumble(rumble),
|
||||
#[cfg(target_os = "windows")]
|
||||
SessionPads::Hid(m) => m.pump(rumble, |_| {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconcile the control port to the paired-client list: bound while at least one pairing
|
||||
/// exists, closed when none remain. Idempotent and race-free (see [`Gate::running`]); call it
|
||||
/// wherever the paired list changes — startup, pairing phase 4, unpair.
|
||||
@@ -362,7 +421,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
// by every outbound message (rumble + the HDR-mode signal): the GCM nonce is derived
|
||||
// from `seq`, so a per-message-type counter would reuse (key, nonce) pairs across
|
||||
// message types in the host direction.
|
||||
let mut pads = GamepadManager::new();
|
||||
let mut pads = SessionPads::new();
|
||||
// Pen/touch translator (SS_PEN/SS_TOUCH → virtual tablet / wire touch). Sent only
|
||||
// by clients that saw our SS_FF_PEN_TOUCH_EVENTS feature flag (rtsp.rs).
|
||||
let mut pointer = super::pen::GsPointer::new();
|
||||
@@ -480,7 +539,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
hdr_sent = false;
|
||||
// Unplug the session's virtual pads + tablet (destroying the
|
||||
// uinput pen releases any held tool/tip kernel-side).
|
||||
pads = GamepadManager::new();
|
||||
pads = SessionPads::new();
|
||||
pointer = super::pen::GsPointer::new();
|
||||
// Surface the session's enforcement-drop totals (WP13).
|
||||
drops.end_of_session();
|
||||
@@ -583,7 +642,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
detected = None;
|
||||
decrypt_fails = 0;
|
||||
hdr_sent = false;
|
||||
pads = GamepadManager::new();
|
||||
pads = SessionPads::new();
|
||||
pointer = super::pen::GsPointer::new();
|
||||
drops.end_of_session();
|
||||
}
|
||||
@@ -689,7 +748,7 @@ fn on_receive(
|
||||
detected: &mut Option<Scheme>,
|
||||
decrypt_fails: &mut u64,
|
||||
inj_tx: &Sender<InputEvent>,
|
||||
pads: &mut GamepadManager,
|
||||
pads: &mut SessionPads,
|
||||
pointer: &mut super::pen::GsPointer,
|
||||
grants: u32,
|
||||
drops: &mut GrantDrops,
|
||||
|
||||
@@ -3,37 +3,34 @@
|
||||
|
||||
use super::Host;
|
||||
use anyhow::{Context, Result};
|
||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||
use mdns_sd::ServiceInfo;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Holds the mDNS daemon; dropping it unregisters the service.
|
||||
pub struct Advert {
|
||||
_daemon: ServiceDaemon,
|
||||
}
|
||||
// One `Advert` for both service types: holds the mDNS daemon plus the re-announce loop that
|
||||
// keeps the record pointed at the host's current address.
|
||||
use crate::discovery::Advert;
|
||||
|
||||
const SERVICE: &str = "_nvstream._tcp.local.";
|
||||
|
||||
pub fn advertise(host: &Host) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
// Instance name = the display name (what Moonlight lists); A-record target = the sanitized
|
||||
// DNS label, so a free-text `PUNKTFUNK_HOST_NAME` can't produce an illegal record.
|
||||
let host_name = format!("{}.local.", crate::discovery::dns_label(&host.hostname));
|
||||
// No TXT records are required for Moonlight discovery; it resolves the A record and then
|
||||
// GETs /serverinfo for capabilities.
|
||||
let props: HashMap<String, String> = HashMap::new();
|
||||
let service = ServiceInfo::new(
|
||||
"_nvstream._tcp.local.",
|
||||
&host.hostname,
|
||||
&host_name,
|
||||
host.local_ip,
|
||||
host.http_port,
|
||||
props,
|
||||
)
|
||||
.context("build mDNS ServiceInfo")?;
|
||||
daemon.register(service).context("register mDNS service")?;
|
||||
let instance = host.hostname.clone();
|
||||
let port = host.http_port;
|
||||
tracing::info!(
|
||||
service = "_nvstream._tcp",
|
||||
port = host.http_port,
|
||||
port,
|
||||
host = %host_name,
|
||||
"mDNS advertising"
|
||||
);
|
||||
Ok(Advert { _daemon: daemon })
|
||||
// The advertised address is supplied per-registration so the record follows the host onto a
|
||||
// network that only came up after boot — see [`crate::discovery::advertise_live`].
|
||||
crate::discovery::advertise_live(SERVICE, move |ip| {
|
||||
// No TXT records are required for Moonlight discovery; it resolves the A record and then
|
||||
// GETs /serverinfo for capabilities.
|
||||
let props: HashMap<String, String> = HashMap::new();
|
||||
ServiceInfo::new(SERVICE, &instance, &host_name, ip, port, props)
|
||||
.context("build mDNS ServiceInfo")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,7 +138,6 @@ pub struct Host {
|
||||
pub hostname: String,
|
||||
/// Stable per-host id (persisted), echoed in serverinfo + matched on pairing.
|
||||
pub uniqueid: String,
|
||||
pub local_ip: IpAddr,
|
||||
pub http_port: u16,
|
||||
pub https_port: u16,
|
||||
/// OS identity chain (`windows` | `macos` | `linux[/<family>][/<id>]`), advertised in the
|
||||
@@ -155,13 +154,25 @@ impl Host {
|
||||
Ok(Host {
|
||||
hostname: hostname_string(),
|
||||
uniqueid: load_or_create_uniqueid()?,
|
||||
local_ip: primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
os_chain: os.chain.clone(),
|
||||
os_name: os.pretty.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Best-effort primary LAN IP, re-read on every call.
|
||||
///
|
||||
/// Deliberately NOT a field: [`Host::detect`] runs as the host process starts, which on a cold
|
||||
/// boot is before the machine has an address at all, and a snapshot taken there used to stick
|
||||
/// for the life of the process — the host then advertised itself over mDNS as `127.0.0.1`,
|
||||
/// handed Moonlight an `rtsp://127.0.0.1` session URL, and dropped its Wake-on-LAN MAC record,
|
||||
/// until someone restarted it by hand. Reading live costs a `connect(2)` on an unconnected UDP
|
||||
/// socket (no packets are sent), which is nothing beside the HTTP responses it is serialized
|
||||
/// into. Loopback here means "still no LAN address", not a stale one.
|
||||
pub fn local_ip(&self) -> IpAddr {
|
||||
primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
}
|
||||
|
||||
/// The stream parameters a client passes at `/launch`, shared with the RTSP + media stages.
|
||||
@@ -432,7 +443,7 @@ pub fn serve(
|
||||
tracing::info!(
|
||||
hostname = %state.host.hostname,
|
||||
uniqueid = %state.host.uniqueid,
|
||||
ip = %state.host.local_ip,
|
||||
ip = %state.host.local_ip(),
|
||||
native_port = native.port,
|
||||
require_pairing = native.require_pairing,
|
||||
gamestream,
|
||||
@@ -656,10 +667,43 @@ fn load_or_create_uniqueid() -> Result<String> {
|
||||
|
||||
/// Best-effort primary LAN IP: open a UDP socket "toward" a public address and read the
|
||||
/// local address the OS would route through. No packets are actually sent.
|
||||
fn primary_local_ip() -> Option<IpAddr> {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||
sock.connect("8.8.8.8:80").ok()?;
|
||||
sock.local_addr().ok().map(|a| a.ip())
|
||||
///
|
||||
/// Returns `None` — never loopback — when the machine has no LAN address yet, so callers have to
|
||||
/// decide what "unknown" means instead of silently inheriting `127.0.0.1`. During a cold boot the
|
||||
/// route probe fails outright (the host outruns DHCP: the Windows service is `AutoStart` with no
|
||||
/// network dependency), so it falls back to the first non-loopback interface address, which the
|
||||
/// NIC has as soon as it is configured even if the default route is not installed yet.
|
||||
pub(crate) fn primary_local_ip() -> Option<IpAddr> {
|
||||
let routed = UdpSocket::bind("0.0.0.0:0")
|
||||
.and_then(|sock| {
|
||||
sock.connect("8.8.8.8:80")?;
|
||||
sock.local_addr()
|
||||
})
|
||||
.ok()
|
||||
.map(|a| a.ip())
|
||||
.filter(|ip| usable_lan_ip(*ip));
|
||||
routed.or_else(first_lan_ipv4)
|
||||
}
|
||||
|
||||
/// First reachable IPv4 an interface holds, ignoring the routing table entirely.
|
||||
///
|
||||
/// Split out because this is the branch the boot race actually takes, and the one nothing would
|
||||
/// otherwise exercise: the route probe above needs a default route, which lands *after* the NIC
|
||||
/// has its address on a cold boot. Between those two moments the old code had no answer and fell
|
||||
/// back to loopback for good.
|
||||
fn first_lan_ipv4() -> Option<IpAddr> {
|
||||
if_addrs::get_if_addrs()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.map(|i| i.ip())
|
||||
.find(|ip| ip.is_ipv4() && usable_lan_ip(*ip))
|
||||
}
|
||||
|
||||
/// Is `ip` an address a client could actually reach this host on? Loopback and the unspecified
|
||||
/// address are both "we don't know yet" dressed up as an answer, and advertising either is the
|
||||
/// boot race that made a freshly-restarted host publish itself as `127.0.0.1`.
|
||||
fn usable_lan_ip(ip: IpAddr) -> bool {
|
||||
!ip.is_loopback() && !ip.is_unspecified()
|
||||
}
|
||||
|
||||
/// Where the paired-client allow-list persists (survives host restarts, like Sunshine).
|
||||
@@ -716,6 +760,106 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the operator's per-client display labels persist, keyed by certificate fingerprint.
|
||||
///
|
||||
/// A SIDECAR to [`paired_path`] rather than a field inside it, for two reasons. `paired.json` is a
|
||||
/// bare `Vec<Vec<u8>>` of certificate DERs — giving it a shape would be a migration on the one file
|
||||
/// that decides who may connect — and a label is not part of that trust decision, so a corrupt or
|
||||
/// missing label file must never be able to lock anybody out. Losing this file loses names, nothing
|
||||
/// else.
|
||||
///
|
||||
/// Why labels have to exist at all: every moonlight-common-c client self-signs with the SAME
|
||||
/// subject (`CN=NVIDIA GameStream Client`), so the certificate carries no device identity
|
||||
/// whatsoever. Without an operator-supplied name, a list of five paired devices is five identical
|
||||
/// rows and the only way to tell them apart — or to know which one to unpair — is the fingerprint.
|
||||
fn labels_path() -> Option<std::path::PathBuf> {
|
||||
Some(pf_paths::config_dir().join("client-labels.json"))
|
||||
}
|
||||
|
||||
/// Serializes the read-modify-write in [`set_client_label`]. Two concurrent renames would
|
||||
/// otherwise race on a whole-file rewrite and silently drop one of the two names.
|
||||
static LABELS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Load the fingerprint → label map (empty on first run, unreadable file, or parse failure — a
|
||||
/// label is cosmetic, so every failure degrades to "no names" and never to an error).
|
||||
pub(crate) fn load_client_labels() -> std::collections::BTreeMap<String, String> {
|
||||
let Some(path) = labels_path() else {
|
||||
return Default::default();
|
||||
};
|
||||
let Ok(raw) = std::fs::read(&path) else {
|
||||
return Default::default();
|
||||
};
|
||||
serde_json::from_slice(&raw).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "client-labels.json unreadable — listing clients without names");
|
||||
Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Set (`Some`) or clear (`None`) one client's label, persisted atomically. Returns the stored
|
||||
/// label. Fingerprints are normalized to lowercase hex so a rename and a later lookup agree
|
||||
/// regardless of how the caller cased the path parameter.
|
||||
pub(crate) fn set_client_label(fp_hex: &str, label: Option<&str>) -> Option<String> {
|
||||
let _guard = LABELS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let fp = fp_hex.to_ascii_lowercase();
|
||||
let mut labels = load_client_labels();
|
||||
let stored = match label {
|
||||
Some(l) => {
|
||||
let clean = crate::native_pairing::sanitize_device_name(l, &fp);
|
||||
labels.insert(fp, clean.clone());
|
||||
Some(clean)
|
||||
}
|
||||
None => {
|
||||
labels.remove(&fp);
|
||||
None
|
||||
}
|
||||
};
|
||||
save_client_labels(&labels);
|
||||
stored
|
||||
}
|
||||
|
||||
/// Drop the labels of fingerprints that are no longer paired. Called from the unpair paths so the
|
||||
/// file cannot grow without bound as devices come and go, and so a re-pairing of the same
|
||||
/// certificate starts unnamed rather than inheriting a stranger's name.
|
||||
pub(crate) fn retain_client_labels(still_paired: &[Vec<u8>]) {
|
||||
use sha2::{Digest, Sha256};
|
||||
let _guard = LABELS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let live: std::collections::BTreeSet<String> = still_paired
|
||||
.iter()
|
||||
.map(|der| hex::encode(Sha256::digest(der)))
|
||||
.collect();
|
||||
let mut labels = load_client_labels();
|
||||
let before = labels.len();
|
||||
labels.retain(|fp, _| live.contains(fp));
|
||||
if labels.len() != before {
|
||||
save_client_labels(&labels);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the label map — same atomic temp-file + rename as [`save_paired`], so a crash mid-write
|
||||
/// cannot truncate it.
|
||||
fn save_client_labels(labels: &std::collections::BTreeMap<String, String>) {
|
||||
let Some(path) = labels_path() else { return };
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = pf_paths::create_private_dir(dir);
|
||||
}
|
||||
let bytes = match serde_json::to_vec(labels) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "serializing client labels failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if let Err(e) = pf_paths::write_secret_file(&tmp, &bytes) {
|
||||
tracing::warn!(error = %e, "persisting client labels failed (temp write)");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, &path) {
|
||||
tracing::warn!(error = %e, "persisting client labels failed (rename)");
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod host_name_tests {
|
||||
use super::sanitize_display_name;
|
||||
@@ -740,6 +884,52 @@ mod host_name_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_ip_tests {
|
||||
use super::{first_lan_ipv4, primary_local_ip, usable_lan_ip};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[test]
|
||||
fn loopback_and_unspecified_are_never_advertisable() {
|
||||
// The bug: a host that started before its network did advertised these as its address and
|
||||
// kept doing so for the life of the process.
|
||||
for unusable in [
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
|
||||
] {
|
||||
assert!(
|
||||
!usable_lan_ip(unusable),
|
||||
"{unusable} must not be advertised"
|
||||
);
|
||||
}
|
||||
for usable in [
|
||||
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 173)),
|
||||
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)),
|
||||
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)),
|
||||
] {
|
||||
assert!(usable_lan_ip(usable), "{usable} is reachable and must pass");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_no_address_rather_than_loopback() {
|
||||
// Holds on a networked box and on an isolated CI runner alike: either we found a real LAN
|
||||
// address, or we admit we have none. `None` is what lets `Host::local_ip()` and the mDNS
|
||||
// advert keep retrying instead of freezing a wrong answer in place.
|
||||
assert!(primary_local_ip().is_none_or(usable_lan_ip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_fallback_never_offers_loopback() {
|
||||
// The branch a cold boot takes, before the default route exists. It may legitimately find
|
||||
// nothing (a machine with no NIC up, e.g. an isolated CI container) — what it must never
|
||||
// do is hand back the loopback that `get_if_addrs` also reports.
|
||||
assert!(first_lan_ipv4().is_none_or(usable_lan_ip));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_tests {
|
||||
use super::*;
|
||||
@@ -748,7 +938,6 @@ mod session_tests {
|
||||
let host = Host {
|
||||
hostname: "test-host".into(),
|
||||
uniqueid: "deadbeef".into(),
|
||||
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
os_chain: "linux".into(),
|
||||
|
||||
@@ -250,7 +250,7 @@ async fn h_launch(
|
||||
fps = session.fps,
|
||||
rikeyid = session.rikeyid,
|
||||
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
|
||||
st.host.local_ip
|
||||
st.host.local_ip()
|
||||
);
|
||||
xml(session_url_xml(&st, "gamesession")).into_response()
|
||||
}
|
||||
@@ -405,7 +405,7 @@ fn gamestream_admission(
|
||||
fn session_url_xml(st: &AppState, tag: &str) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
|
||||
st.host.local_ip
|
||||
st.host.local_ip()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -485,13 +485,11 @@ fn error_xml() -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
fn test_state() -> Arc<AppState> {
|
||||
let host = super::super::Host {
|
||||
hostname: "t".into(),
|
||||
uniqueid: "id".into(),
|
||||
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
os_chain: "linux".into(),
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String {
|
||||
uniqueid = host.uniqueid,
|
||||
https_port = host.https_port,
|
||||
http_port = host.http_port,
|
||||
local_ip = host.local_ip,
|
||||
local_ip = host.local_ip(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,7 +205,6 @@ mod tests {
|
||||
let host = Host {
|
||||
hostname: "test".into(),
|
||||
uniqueid: "uid".into(),
|
||||
local_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
|
||||
http_port: 47989,
|
||||
https_port: 47984,
|
||||
os_chain: "linux".into(),
|
||||
|
||||
@@ -1111,6 +1111,21 @@ fn spawn_sender(
|
||||
|
||||
use crate::send_pacing::percentile;
|
||||
|
||||
/// How long to ignore further keyframe requests after emitting one.
|
||||
///
|
||||
/// The window bounds IDR emission in TIME, so it needs an absolute floor rather than a frame
|
||||
/// count: it has to outlast the round trip in which the client receives and decodes the IDR it
|
||||
/// already asked for. The original `frame_interval * 2` closes long before that at high refresh —
|
||||
/// 16.7 ms at 120 fps, while a Moonlight client under loss re-asks every ~30 ms — so every request
|
||||
/// passed the gate and the stream became ~32 full IDRs/s, whose bulk causes the very loss that
|
||||
/// prompts the next request. That storm sustains itself and reads as stutter at a flat latency
|
||||
/// (field log, AMD RX 7800 XT / Bazzite 44 HEVC, 2026-08-22: 1118 requests, 1115 honoured, 3
|
||||
/// coalesced). 100 ms matches the encoder-reset backoff below and is about one IDR's service time
|
||||
/// on a saturated link.
|
||||
fn keyframe_coalesce_window(frame_interval: Duration) -> Duration {
|
||||
(frame_interval * 2).max(Duration::from_millis(100))
|
||||
}
|
||||
|
||||
/// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread
|
||||
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1194,6 +1209,11 @@ fn stream_body(
|
||||
// also fails safe when nobody tells it, but pass the REAL depth: `idd_depth` is configurable
|
||||
// and a deeper ring is free pipelining the fallback would forfeit.
|
||||
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
// What `enc` was opened against. The capture source can change size/format UNDER this loop with
|
||||
// nothing negotiating it (see the follow-the-source guard below); tracked so the loop can notice.
|
||||
// Both sites that swap `enc` re-bind `frame` with it, so this is always
|
||||
// `(frame.format, frame.width, frame.height)` right after one.
|
||||
let mut enc_src = (frame.format, frame.width, frame.height);
|
||||
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
|
||||
let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT")
|
||||
.ok()
|
||||
@@ -1273,9 +1293,9 @@ fn stream_body(
|
||||
// RFI (VAAPI/AMD — `supports_rfi=false`) each one becomes a full IDR, so an un-coalesced request
|
||||
// stream turns EVERY frame into a 4K IDR, saturates the send path, and collapses the session
|
||||
// instead of recovering. One fresh IDR already resolves all pending loss, so after emitting one
|
||||
// we ignore further keyframe requests for a short in-flight window (~2 frames). NVENC
|
||||
// ref-invalidation (cheap, no IDR spike) is never rate-limited — only full keyframes are.
|
||||
let keyframe_coalesce = frame_interval * 2;
|
||||
// we ignore further keyframe requests for the in-flight window below. NVENC ref-invalidation
|
||||
// (cheap, no IDR spike) is never rate-limited — only full keyframes are.
|
||||
let keyframe_coalesce = keyframe_coalesce_window(frame_interval);
|
||||
let mut last_keyframe: Option<Instant> = None;
|
||||
// A frame dropped at the pipeline head (below) breaks the reference chain for the following
|
||||
// P-frames: the client never receives it, but the encoder advanced its references past it, and —
|
||||
@@ -1362,6 +1382,7 @@ fn stream_body(
|
||||
.context("reopen encoder after rebuild")?;
|
||||
// A rebuilt encoder starts unconfigured — same reason as the first open above.
|
||||
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
supports_rfi = enc.caps().supports_rfi;
|
||||
enc.request_keyframe();
|
||||
last_keyframe = Some(Instant::now());
|
||||
@@ -1375,6 +1396,82 @@ fn stream_body(
|
||||
}
|
||||
}
|
||||
let t_cap = tick.elapsed();
|
||||
// Follow an AUTONOMOUS source mode change — one nothing negotiated. The IDD-push capturer
|
||||
// re-opens its ring on a confirmed display-descriptor change (a fullscreen game mode-setting
|
||||
// the virtual display, or an HDR flip changing the format), and the encoder is the one
|
||||
// component that cannot follow a resolution change in place. Every `submit` below then
|
||||
// refuses the frame ("captured WxH != encoder AxB"), and the submit ladder only rebuilds the
|
||||
// encoder IN PLACE — at the SAME configured size — which cannot fix a size the source has
|
||||
// already left, so all five resets burn on it and the stream ends (native/stream.rs carried
|
||||
// the identical gap; a 2026-08-22 field report hit it there at 4K→1080p).
|
||||
//
|
||||
// GameStream has no mid-stream mode-change message, so the client is NOT told: Moonlight
|
||||
// decodes a bitstream that disagrees with the resolution it configured its decoder from.
|
||||
// That is the same bargain the first open above already takes whenever the captured size
|
||||
// differs from the negotiated one (the monitor-mirror case) — tolerant decoders re-init off
|
||||
// the SPS and scale, a strict one (Media Foundation on Xbox) may stall and drop the session.
|
||||
// Taking it here too is strictly better than the alternative, which is ending every stream
|
||||
// the moment a game changes mode.
|
||||
if enc_src != (frame.format, frame.width, frame.height) {
|
||||
match encode::open_video(
|
||||
cfg.codec,
|
||||
frame.format,
|
||||
frame.width,
|
||||
frame.height,
|
||||
cfg.fps,
|
||||
cfg.bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
// Derived from the delivered format, so an HDR flip re-opens at the right depth.
|
||||
gs_bit_depth(frame.format),
|
||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0 — see the first open
|
||||
cursor_blend, // same capture cursor mode — see the first open
|
||||
cfg.slices, // client slicing ceiling — see the first open
|
||||
) {
|
||||
Ok(e) => {
|
||||
tracing::info!(
|
||||
from = %format!("{}x{} {:?}", enc_src.1, enc_src.2, enc_src.0),
|
||||
to = %format!("{}x{} {:?}", frame.width, frame.height, frame.format),
|
||||
negotiated = ?(cfg.width, cfg.height),
|
||||
"gamestream: the capture source changed mode mid-stream — reopened the \
|
||||
encoder at the delivered size (the client is not told; a strict decoder \
|
||||
may not follow — see the note at this guard)"
|
||||
);
|
||||
enc = e;
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
// A rebuilt encoder starts unconfigured — same reasons as the first open.
|
||||
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
supports_rfi = enc.caps().supports_rfi;
|
||||
enc.request_keyframe();
|
||||
last_keyframe = Some(Instant::now());
|
||||
// The old encoder died with its in-flight submissions — their AUs will never
|
||||
// arrive, so the numbering prediction restarts at `au_seq` (same reasoning as
|
||||
// the capture rebuild above). Restart the stall clock for the fresh encoder and
|
||||
// give it the full reset budget.
|
||||
enc_inflight = 0;
|
||||
encoder_resets = 0;
|
||||
last_au_at = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't spend the stream on the FIRST failed open: the mode-set that triggered
|
||||
// this is exactly the kind of event that leaves the driver settling, which is
|
||||
// what the submit ladder's backoff exists for. Spend the shared reset budget at
|
||||
// the same exponential pace, re-entering this guard each round — the old encoder
|
||||
// stays installed and mismatched meanwhile, so it simply keeps failing submit.
|
||||
encoder_resets += 1;
|
||||
if encoder_resets > MAX_ENCODER_RESETS {
|
||||
return Err(e).context("reopen encoder at the source's new mode");
|
||||
}
|
||||
let backoff = frame_interval
|
||||
.max(Duration::from_millis(100u64 << (encoder_resets - 1).min(4)));
|
||||
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
|
||||
max = MAX_ENCODER_RESETS,
|
||||
"gamestream: reopening the encoder at the source's new mode failed — retrying");
|
||||
next_frame = Instant::now() + backoff;
|
||||
std::thread::sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder
|
||||
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
|
||||
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
|
||||
@@ -1716,6 +1813,27 @@ mod tests {
|
||||
assert_eq!(t.game.title, "/opt/game/run");
|
||||
}
|
||||
|
||||
/// The coalesce window must bound forced IDRs in time, not in frames. A frame-scaled window
|
||||
/// vanishes exactly where it matters most — at high refresh, where a client's recovery spam
|
||||
/// arrives far slower than two frame intervals and so passes the gate every time.
|
||||
#[test]
|
||||
fn keyframe_coalesce_window_outlasts_a_clients_request_cadence() {
|
||||
// The observed storm: a 120 fps session against a client re-asking every ~30 ms. The
|
||||
// pre-floor window was 16.7 ms, so every request became a full IDR.
|
||||
let at_120 = keyframe_coalesce_window(Duration::from_secs_f64(1.0 / 120.0));
|
||||
assert!(
|
||||
at_120 >= Duration::from_millis(100),
|
||||
"120 fps window {at_120:?} does not outlast a ~30 ms request cadence"
|
||||
);
|
||||
// 60 fps was under the floor too (33.3 ms), which is why this is not a 120-only fix.
|
||||
assert!(keyframe_coalesce_window(Duration::from_secs_f64(1.0 / 60.0)) >= at_120);
|
||||
// A slow stream keeps the frame-scaled window — the floor only ever raises it.
|
||||
assert_eq!(
|
||||
keyframe_coalesce_window(Duration::from_millis(200)),
|
||||
Duration::from_millis(400)
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end check of the send thread: batches pushed on the channel arrive, complete and
|
||||
/// byte-identical, at a peer socket via the paced sendmmsg path.
|
||||
#[test]
|
||||
|
||||
@@ -55,7 +55,14 @@ pub struct DetectSpec {
|
||||
/// Steam appid, for titles Steam itself installed (never for non-Steam shortcuts, whose reaper
|
||||
/// appid semantics differ — those carry an [`exe`](Self::exe) instead). On Linux this is the
|
||||
/// sharpest signal available: Steam wraps every launch — native or Proton — in
|
||||
/// `reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's.
|
||||
/// `reaper SteamLaunch AppId=<appid>`.
|
||||
///
|
||||
/// ⚠ That reaper is the *appid's*, not the game's. Steam wraps its **pre-launch** work for a
|
||||
/// title in one too — shader pre-caching most visibly — so a launch is a chain of reaper trees
|
||||
/// and only the last of them is the game. Reading the first as the game is what dropped a
|
||||
/// stream 10 s into a Rocket League launch, mid-shader-compile (field report 2026-08-22); the
|
||||
/// shader job is excluded by name in [`crate::procscan`], and [`crate::gamelease`] waits out a
|
||||
/// window before believing any of them.
|
||||
pub steam_appid: Option<u32>,
|
||||
/// A launcher-stamped environment marker.
|
||||
pub env_marker: Option<EnvMarker>,
|
||||
|
||||
@@ -105,6 +105,9 @@ mod plugins;
|
||||
// session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a
|
||||
// platform with neither (macOS, which has no launch path either) the module is an empty shell.
|
||||
mod procscan;
|
||||
// The live half of the same binding: what a provider PLUGIN reports about its titles' liveness,
|
||||
// where `procscan` can only look at the process table.
|
||||
mod runstate;
|
||||
mod send_pacing;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/service.rs"]
|
||||
|
||||
@@ -328,7 +328,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
clients::list_paired_clients,
|
||||
clients::unpair_all_clients
|
||||
))
|
||||
.routes(routes!(clients::unpair_client));
|
||||
// DELETE and PATCH share `/clients/{fingerprint}` — one `routes!`, same rule as above.
|
||||
.routes(routes!(clients::unpair_client, clients::rename_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.
|
||||
#[cfg(feature = "gamestream")]
|
||||
@@ -372,6 +373,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
library::reconcile_provider_entries,
|
||||
library::delete_provider_entries
|
||||
))
|
||||
.routes(routes!(library::report_provider_running))
|
||||
.routes(routes!(library::get_library_art))
|
||||
.routes(routes!(stats::stats_capture_start))
|
||||
.routes(routes!(stats::stats_capture_stop))
|
||||
|
||||
@@ -250,6 +250,10 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
|
||||
(&Method::DELETE, "/api/v1/library/custom/{}"),
|
||||
(&Method::PUT, "/api/v1/library/provider/{}"),
|
||||
(&Method::DELETE, "/api/v1/library/provider/{}"),
|
||||
// Liveness reporting for a provider's OWN titles. No new authority: the host maps the
|
||||
// report through the catalog, so a plugin can only ever speak about entries it published,
|
||||
// and the worst a defective one can do to someone else's session is nothing at all.
|
||||
(&Method::PUT, "/api/v1/library/provider/{}/running"),
|
||||
// Stats / telemetry.
|
||||
(&Method::POST, "/api/v1/stats/capture/start"),
|
||||
(&Method::POST, "/api/v1/stats/capture/stop"),
|
||||
|
||||
@@ -11,7 +11,17 @@ pub(crate) struct PairedClient {
|
||||
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
|
||||
fingerprint: String,
|
||||
/// Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.
|
||||
///
|
||||
/// Do not display this as a device name. Every moonlight-common-c client self-signs with that
|
||||
/// same fixed subject, so it identifies the *protocol*, not the device — a list of paired
|
||||
/// phones, TVs and handhelds all read identically. [`Self::label`] is the field to show.
|
||||
subject: Option<String>,
|
||||
/// Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).
|
||||
///
|
||||
/// This is the ONLY thing that can tell two paired Moonlight devices apart in a list, because
|
||||
/// their certificates cannot: see [`Self::subject`]. Absent until somebody names the device.
|
||||
#[schema(example = "Living Room TV")]
|
||||
label: Option<String>,
|
||||
/// Certificate validity start (unix seconds).
|
||||
not_before_unix: Option<i64>,
|
||||
/// Certificate validity end (unix seconds).
|
||||
@@ -55,27 +65,112 @@ pub(crate) async fn list_paired_clients(
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
Json(ders.iter().map(|der| client_info(der)).collect())
|
||||
// One read of the label sidecar for the whole list, not one per row.
|
||||
let labels = crate::gamestream::load_client_labels();
|
||||
Json(ders.iter().map(|der| client_info(der, &labels)).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn client_info(der: &[u8]) -> PairedClient {
|
||||
pub(crate) fn client_info(
|
||||
der: &[u8],
|
||||
labels: &std::collections::BTreeMap<String, String>,
|
||||
) -> PairedClient {
|
||||
let fingerprint = hex::encode(Sha256::digest(der));
|
||||
let label = labels.get(&fingerprint).cloned();
|
||||
match x509_parser::parse_x509_certificate(der) {
|
||||
Ok((_, x509)) => PairedClient {
|
||||
fingerprint,
|
||||
subject: Some(x509.subject().to_string()),
|
||||
not_before_unix: Some(x509.validity().not_before.timestamp()),
|
||||
not_after_unix: Some(x509.validity().not_after.timestamp()),
|
||||
label,
|
||||
fingerprint,
|
||||
},
|
||||
Err(_) => PairedClient {
|
||||
fingerprint,
|
||||
subject: None,
|
||||
not_before_unix: None,
|
||||
not_after_unix: None,
|
||||
label,
|
||||
fingerprint,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Body of `PATCH /clients/{fingerprint}` — the device's display name.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct RenameClient {
|
||||
/// The name to show for this device. `null` (or an empty/whitespace-only string) clears it and
|
||||
/// the device goes back to being listed by fingerprint alone.
|
||||
///
|
||||
/// Scrubbed before storage by the same sanitizer the native plane runs on device names:
|
||||
/// control characters and Unicode bidi overrides are stripped (they could make one paired
|
||||
/// device impersonate another in this very list), whitespace collapsed, and the result capped
|
||||
/// at 64 characters.
|
||||
#[schema(example = "Living Room TV")]
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
/// Rename a paired client
|
||||
///
|
||||
/// Sets or clears the operator-visible display name for one paired Moonlight client. This is
|
||||
/// purely cosmetic — it touches no certificate and no trust decision — but it is the only way to
|
||||
/// tell paired devices apart: every moonlight-common-c client self-signs with the identical
|
||||
/// subject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable
|
||||
/// only by fingerprint. The name is stored beside the pairing store and survives host restarts;
|
||||
/// unpairing the device forgets it.
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/clients/{fingerprint}",
|
||||
tag = "clients",
|
||||
operation_id = "renameClient",
|
||||
params(
|
||||
("fingerprint" = String, Path,
|
||||
description = "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)")
|
||||
),
|
||||
request_body = RenameClient,
|
||||
responses(
|
||||
(status = OK, description = "The client as it now reads", body = PairedClient),
|
||||
(status = BAD_REQUEST, description = "Malformed fingerprint", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = NOT_FOUND, description = "No paired client with that fingerprint", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn rename_client(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
Path(fingerprint): Path<String>,
|
||||
Json(body): Json<RenameClient>,
|
||||
) -> Response {
|
||||
if fingerprint.len() != 64 || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"fingerprint must be the 64-char hex SHA-256 of the client certificate DER",
|
||||
);
|
||||
}
|
||||
// Only name a device that is actually paired: a label for an unknown fingerprint would be
|
||||
// invisible (nothing lists it) and would sit in the file forever, since the unpair cleanup
|
||||
// only ever removes labels whose device WAS paired.
|
||||
let paired = st.app.paired.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(der) = paired
|
||||
.iter()
|
||||
.find(|der| hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint))
|
||||
.cloned()
|
||||
else {
|
||||
return api_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"no paired client with that fingerprint",
|
||||
);
|
||||
};
|
||||
drop(paired);
|
||||
// An all-whitespace name is a cleared name, not a device called " ": the sanitizer would
|
||||
// otherwise turn it into the "device <fp8>" fallback and the row would look renamed.
|
||||
let wanted = body
|
||||
.label
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty());
|
||||
crate::gamestream::set_client_label(&fingerprint, wanted);
|
||||
let labels = crate::gamestream::load_client_labels();
|
||||
(StatusCode::OK, Json(client_info(&der, &labels))).into_response()
|
||||
}
|
||||
|
||||
/// Unpair a client
|
||||
///
|
||||
/// Removes the client's certificate from the pairing store (persisted — the removal survives a
|
||||
@@ -119,6 +214,9 @@ pub(crate) async fn unpair_client(
|
||||
// restart, which now also matters below: a resurrected pairing would silently
|
||||
// re-open the control port.
|
||||
crate::gamestream::save_paired(&paired);
|
||||
// Forget this device's display name with it, so the file can't grow without bound and a
|
||||
// later re-pairing of the same certificate starts unnamed.
|
||||
crate::gamestream::retain_client_labels(&paired);
|
||||
drop(paired);
|
||||
// Revocation reaches a LIVE session too: a mid-stream client whose pairing was just
|
||||
// removed must not keep streaming until it chooses to leave. Clearing the launch makes
|
||||
@@ -187,6 +285,8 @@ pub(crate) async fn unpair_all_clients(State(st): State<Arc<MgmtState>>) -> Resp
|
||||
// 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);
|
||||
// Nothing is paired any more, so no label can still belong to anyone.
|
||||
crate::gamestream::retain_client_labels(&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
|
||||
|
||||
@@ -23,13 +23,16 @@ pub(crate) struct Health {
|
||||
abi_version: u32,
|
||||
}
|
||||
|
||||
/// Host identity and advertised capabilities (static for the life of the process).
|
||||
/// Host identity and advertised capabilities (static for the life of the process, except
|
||||
/// `local_ip`).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct HostInfo {
|
||||
hostname: String,
|
||||
/// Stable per-host id (persisted across restarts), matched on pairing.
|
||||
uniqueid: String,
|
||||
/// Best-effort primary LAN IP.
|
||||
/// Best-effort primary LAN IP, read fresh on every request — a host that started before its
|
||||
/// network did (cold boot) reports `127.0.0.1` only until it actually has an address, and a
|
||||
/// host that moves networks reports the new one. Poll it rather than caching it.
|
||||
local_ip: String,
|
||||
/// `punktfunk-host` crate version.
|
||||
version: String,
|
||||
@@ -324,7 +327,7 @@ pub(crate) async fn get_host_info(State(st): State<Arc<MgmtState>>) -> Json<Host
|
||||
Json(HostInfo {
|
||||
hostname: h.hostname.clone(),
|
||||
uniqueid: h.uniqueid.clone(),
|
||||
local_ip: h.local_ip.to_string(),
|
||||
local_ip: h.local_ip().to_string(),
|
||||
version: env!("PUNKTFUNK_VERSION").into(),
|
||||
abi_version: punktfunk_core::ABI_VERSION,
|
||||
app_version: APP_VERSION.into(),
|
||||
|
||||
@@ -607,12 +607,130 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
|
||||
if removed > 0 {
|
||||
tracing::info!(provider, removed, "library provider entries removed");
|
||||
}
|
||||
// Its entries are gone, so its opinions about them are meaningless — and a lease must
|
||||
// never be held open by a provider that no longer exists.
|
||||
crate::runstate::forget(&provider);
|
||||
Json(ProviderRemoved { removed }).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One running title in a provider's liveness report.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct RunningTitle {
|
||||
/// The provider's own stable id for the title — the same key its reconcile payload uses.
|
||||
pub external_id: String,
|
||||
/// The process id the provider started for it, when it knows one. Optional, and never trusted
|
||||
/// as a bare number: the host re-resolves it and pins it to its start time before it is ever
|
||||
/// signalled, so a stale or recycled pid simply contributes nothing.
|
||||
#[serde(default)]
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// Request body for `reportProviderRunning`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct ProviderRunningInput {
|
||||
/// Every title of this provider's that is running **right now**. The full set, not a delta:
|
||||
/// anything absent from it is reported as stopped.
|
||||
#[serde(default)]
|
||||
pub running: Vec<RunningTitle>,
|
||||
}
|
||||
|
||||
/// The result of a liveness report.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct ProviderRunningAccepted {
|
||||
/// How many reported titles matched an entry this provider currently publishes.
|
||||
matched: usize,
|
||||
/// How many were ignored because no such entry exists (a report that raced a reconcile).
|
||||
unknown: usize,
|
||||
/// Seconds this report stays authoritative without being restated — re-report inside it while
|
||||
/// anything is running.
|
||||
ttl_s: u64,
|
||||
}
|
||||
|
||||
/// Report which of a provider's titles are running
|
||||
///
|
||||
/// The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to
|
||||
/// recognize* a title's process, this one says *it is running now* (design §9,
|
||||
/// [`crate::runstate`]). For a provider that starts games itself and knows when they stop —
|
||||
/// Playnite tracks every launch and fires an event on both edges — this is a fact the host would
|
||||
/// otherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated
|
||||
/// game, a manually added one) could not derive at all.
|
||||
///
|
||||
/// Declarative and idempotent, like the reconcile: the body is the provider's **complete** running
|
||||
/// set, so a missed event, a plugin restart or an install mid-game all self-correct on the next
|
||||
/// report rather than drifting.
|
||||
///
|
||||
/// The report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a
|
||||
/// live provider to keep a streaming session open for a game the host cannot see: a plugin that
|
||||
/// dies with a game running stops counting shortly after, and the host falls back to process
|
||||
/// scanning exactly as it does without one. Re-report on every change **and** on a timer well
|
||||
/// inside the window.
|
||||
///
|
||||
/// Titles the provider does not currently publish are ignored (counted in `unknown`), not an error:
|
||||
/// a report may legitimately race its own reconcile.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/library/provider/{provider}/running",
|
||||
tag = "library",
|
||||
operation_id = "reportProviderRunning",
|
||||
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
|
||||
request_body = ProviderRunningInput,
|
||||
responses(
|
||||
(status = OK, description = "The report was accepted", body = ProviderRunningAccepted),
|
||||
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn report_provider_running(
|
||||
Path(provider): Path<String>,
|
||||
ApiJson(input): ApiJson<ProviderRunningInput>,
|
||||
) -> Response {
|
||||
if let Err(e) = crate::library::validate_provider_name(&provider) {
|
||||
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||
}
|
||||
// Resolve the provider's own keys to the ids the rest of the host uses. A plugin knows its
|
||||
// titles by `external_id`; a lease knows them by the library id the catalog assigned
|
||||
// (`playnite:<guid>`), and only the catalog can map between the two — which is also what makes
|
||||
// this authorization-safe, since a provider can only ever speak about entries it published.
|
||||
let mine: Vec<(String, String)> = crate::library::load_custom()
|
||||
.into_iter()
|
||||
.filter(|e| e.provider.as_deref() == Some(provider.as_str()))
|
||||
.filter_map(|e| {
|
||||
let external = e.external_id.clone()?;
|
||||
Some((external, crate::library::library_id_for(&e)))
|
||||
})
|
||||
.collect();
|
||||
let owned: std::collections::HashSet<String> = mine.iter().map(|(_, id)| id.clone()).collect();
|
||||
|
||||
let mut running = std::collections::HashMap::new();
|
||||
let mut unknown = 0usize;
|
||||
for t in &input.running {
|
||||
match mine.iter().find(|(external, _)| *external == t.external_id) {
|
||||
Some((_, id)) => {
|
||||
running.insert(id.clone(), t.pid);
|
||||
}
|
||||
None => unknown += 1,
|
||||
}
|
||||
}
|
||||
let matched = running.len();
|
||||
tracing::debug!(
|
||||
provider,
|
||||
owned = owned.len(),
|
||||
matched,
|
||||
unknown,
|
||||
"provider liveness report"
|
||||
);
|
||||
crate::runstate::report(&provider, owned, running);
|
||||
Json(ProviderRunningAccepted {
|
||||
matched,
|
||||
unknown,
|
||||
ttl_s: crate::runstate::REPORT_TTL.as_secs(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Fetch one cover-art image for a library entry
|
||||
///
|
||||
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
|
||||
|
||||
@@ -47,7 +47,6 @@ use axum::body::Body;
|
||||
use axum::http::StatusCode;
|
||||
use http_body_util::BodyExt;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::atomic::Ordering;
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -73,7 +72,6 @@ fn test_state() -> Arc<AppState> {
|
||||
let host = Host {
|
||||
hostname: "test-host".into(),
|
||||
uniqueid: "deadbeef".into(),
|
||||
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
os_chain: "linux/arch/steamos".into(),
|
||||
@@ -821,6 +819,54 @@ async fn status_reflects_runtime_state() {
|
||||
assert!(!body.to_string().contains("gcm"));
|
||||
}
|
||||
|
||||
/// Point `PUNKTFUNK_CONFIG_DIR` at a throwaway tempdir for the body of a test, and put the previous
|
||||
/// value back on drop even if an assertion panics.
|
||||
///
|
||||
/// ONE of these for the whole file on purpose. Mutating the process environment is safe to call and
|
||||
/// unsound from a live multithreaded process, so `check-unsafe-hygiene.sh` (gate C) holds this file
|
||||
/// to a fixed count of such call sites — and counts plain prose mentions too, deliberately, since
|
||||
/// its grep is the contract. A second test that copy-pastes the dance trips it, which is exactly
|
||||
/// what it is for. This also bundles the serialization: the lock is a FIELD, so it cannot be
|
||||
/// forgotten, and `Drop::drop` runs before any field drops, meaning the environment is restored
|
||||
/// while this still holds the lock.
|
||||
struct ConfigDirOverride {
|
||||
tmp: tempfile::TempDir,
|
||||
prev: Option<std::ffi::OsString>,
|
||||
_serial: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl ConfigDirOverride {
|
||||
fn new() -> ConfigDirOverride {
|
||||
let _serial = crate::identity::CONFIG_DIR_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let prev = std::env::var_os("PUNKTFUNK_CONFIG_DIR");
|
||||
// SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK, which serializes every test in this binary
|
||||
// that reads or writes this variable.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
|
||||
ConfigDirOverride { tmp, prev, _serial }
|
||||
}
|
||||
|
||||
/// The throwaway config dir itself — used verbatim by `pf_paths`, with no `punktfunk`
|
||||
/// subdirectory appended.
|
||||
fn path(&self) -> &std::path::Path {
|
||||
self.tmp.path()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConfigDirOverride {
|
||||
fn drop(&mut self) {
|
||||
match self.prev.take() {
|
||||
// SAFETY: `self._serial` is still alive here (fields drop after `Drop::drop`), so this
|
||||
// runs under the same serialization as the `set_var` in `new`.
|
||||
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
|
||||
// SAFETY: as above.
|
||||
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Holding `CONFIG_DIR_TEST_LOCK` across the awaits is the POINT: the env override must cover
|
||||
// the whole test body, and `#[tokio::test]` is a single-threaded runtime — nothing else can
|
||||
// need the executor while we hold it.
|
||||
@@ -830,26 +876,7 @@ async fn paired_clients_list_and_unpair() {
|
||||
// Unpair PERSISTS (save_paired → paired.json in the config dir), so point the config dir
|
||||
// at a throwaway tempdir — this test must never rewrite the dev box's real pairing store.
|
||||
// The guard restores the previous value even if an assertion below panics.
|
||||
struct EnvGuard(Option<std::ffi::OsString>);
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
// SAFETY: dropped while this test still holds CONFIG_DIR_TEST_LOCK, which
|
||||
// serializes every test that writes or reads this variable in the binary.
|
||||
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
|
||||
// SAFETY: as above.
|
||||
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
|
||||
}
|
||||
}
|
||||
}
|
||||
let _serial = crate::identity::CONFIG_DIR_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _env = EnvGuard(std::env::var_os("PUNKTFUNK_CONFIG_DIR"));
|
||||
// SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK (taken above), serializing every test that
|
||||
// writes or reads this variable in the binary.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
|
||||
let tmp = ConfigDirOverride::new();
|
||||
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
@@ -1003,6 +1030,137 @@ async fn paired_clients_list_and_unpair() {
|
||||
assert_eq!(body["unpaired"], 0);
|
||||
}
|
||||
|
||||
/// Renaming a paired Moonlight client: the round trip, the scrub, the clear, and the cleanup.
|
||||
///
|
||||
/// Worth a test because the label is the ONLY thing that distinguishes two paired Moonlight
|
||||
/// devices — their certificates all carry the same subject — so "the name silently didn't stick"
|
||||
/// is indistinguishable from "the device is the other one" in the console.
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn client_label_round_trips_scrubs_and_is_forgotten_on_unpair() {
|
||||
let tmp = ConfigDirOverride::new();
|
||||
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
let stand_in = crate::identity::ephemeral().unwrap();
|
||||
let (_, pem) = x509_parser::pem::parse_x509_pem(stand_in.cert_pem.as_bytes()).unwrap();
|
||||
let der = pem.contents.clone();
|
||||
let fingerprint = hex::encode(Sha256::digest(&der));
|
||||
{
|
||||
let mut p = state.paired.lock().unwrap();
|
||||
p.clear();
|
||||
p.push(der.clone());
|
||||
}
|
||||
|
||||
let patch = |fp: String, body: serde_json::Value| {
|
||||
axum::http::Request::patch(format!("/api/v1/clients/{fp}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Unnamed until somebody names it — the field is absent, not an empty string.
|
||||
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
|
||||
assert!(body[0]["label"].is_null());
|
||||
|
||||
// Name it (uppercase fingerprint must match too — the path is documented case-insensitive).
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
patch(
|
||||
fingerprint.to_uppercase(),
|
||||
serde_json::json!({ "label": "Living Room TV" }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["label"], "Living Room TV");
|
||||
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
|
||||
assert_eq!(body[0]["label"], "Living Room TV");
|
||||
|
||||
// The scrub runs: a bidi override could make one paired device read like another in the very
|
||||
// list an operator uses to decide what to unpair, and the whitespace collapse keeps the name
|
||||
// one line. (`\u{202E}` = RIGHT-TO-LEFT OVERRIDE.)
|
||||
let (_, body) = send(
|
||||
&app,
|
||||
patch(
|
||||
fingerprint.clone(),
|
||||
serde_json::json!({ "label": " Deck\u{202E}evil\n\nx " }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(body["label"], "Deckevil x");
|
||||
|
||||
// Whitespace-only clears rather than storing a device called " " (or the sanitizer's
|
||||
// "device <fp8>" fallback, which would look like a successful rename).
|
||||
let (_, body) = send(
|
||||
&app,
|
||||
patch(fingerprint.clone(), serde_json::json!({ "label": " " })),
|
||||
)
|
||||
.await;
|
||||
assert!(body["label"].is_null());
|
||||
|
||||
// …and an explicit null clears too.
|
||||
send(
|
||||
&app,
|
||||
patch(
|
||||
fingerprint.clone(),
|
||||
serde_json::json!({ "label": "Bedroom" }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let (_, body) = send(
|
||||
&app,
|
||||
patch(fingerprint.clone(), serde_json::json!({ "label": null })),
|
||||
)
|
||||
.await;
|
||||
assert!(body["label"].is_null());
|
||||
|
||||
// Malformed fingerprint → 400; unknown-but-well-formed → 404 (naming a device that is not
|
||||
// paired would write a label nothing can ever list or clean up).
|
||||
assert_eq!(
|
||||
send(
|
||||
&app,
|
||||
patch("zz".into(), serde_json::json!({ "label": "x" }))
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
send(
|
||||
&app,
|
||||
patch("aa".repeat(32), serde_json::json!({ "label": "x" }))
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
|
||||
// Unpairing forgets the name: it must not survive to be inherited by a later re-pairing of
|
||||
// the same certificate.
|
||||
send(
|
||||
&app,
|
||||
patch(
|
||||
fingerprint.clone(),
|
||||
serde_json::json!({ "label": "Living Room TV" }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let del = axum::http::Request::delete(format!("/api/v1/clients/{fingerprint}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
|
||||
let on_disk: std::collections::BTreeMap<String, String> =
|
||||
std::fs::read(tmp.path().join("client-labels.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!on_disk.contains_key(&fingerprint),
|
||||
"unpair must forget the device's label, got {on_disk:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "gamestream")]
|
||||
#[tokio::test]
|
||||
async fn submit_pin_validates_and_requires_pending_pairing() {
|
||||
@@ -1380,6 +1538,12 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
|
||||
// roster's read permission must never carry over to emptying it.
|
||||
("DELETE", "/api/v1/clients", false, false),
|
||||
("DELETE", "/api/v1/clients/{fingerprint}", false, false),
|
||||
// Renaming is cosmetic but NOT harmless, so it takes the same lanes as removal rather than
|
||||
// the roster's read permission: the label is the only thing distinguishing one paired
|
||||
// Moonlight device from another in the console, so anything that could set it could dress
|
||||
// its own device up as the operator's TV — and be trusted, or spared an unpair, on that
|
||||
// basis. Sharing a path with the plugin-forbidden DELETE, it needs its own row anyway.
|
||||
("PATCH", "/api/v1/clients/{fingerprint}", false, false),
|
||||
("GET", "/api/v1/native/clients", true, false),
|
||||
("DELETE", "/api/v1/native/clients", false, false),
|
||||
(
|
||||
@@ -1440,6 +1604,16 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
|
||||
("DELETE", "/api/v1/library/custom/{id}", true, false),
|
||||
("PUT", "/api/v1/library/provider/{provider}", true, false),
|
||||
("DELETE", "/api/v1/library/provider/{provider}", true, false),
|
||||
// Liveness for a provider's own titles: the plugin lane's, like the reconcile beside it,
|
||||
// and for the same reason — the host maps the report through the catalog, so a provider can
|
||||
// only ever speak about entries it published. Never the cert lane: a streaming client has
|
||||
// no titles of its own to report on.
|
||||
(
|
||||
"PUT",
|
||||
"/api/v1/library/provider/{provider}/running",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
// ---- stats.
|
||||
("POST", "/api/v1/stats/capture/start", true, false),
|
||||
("POST", "/api/v1/stats/capture/stop", true, false),
|
||||
@@ -2935,3 +3109,54 @@ async fn provider_reconcile_validation() {
|
||||
let (s, _) = send(&app, del).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// Liveness reporting: the provider id is validated like every other provider write, and a title
|
||||
/// the provider does not publish is *counted*, not refused.
|
||||
///
|
||||
/// That tolerance is the point. A report races its own reconcile by construction — a game can start
|
||||
/// before the entry that describes it has landed — and 400-ing the whole report over one unknown id
|
||||
/// would throw away the liveness of every other running title, which is precisely the failure the
|
||||
/// launcher-tile 400 taught us to avoid (`sanitize_launcher_entries`). The developer's real catalog
|
||||
/// is not touched here, so every id in this test is `unknown` by construction — which is exactly
|
||||
/// the case being pinned.
|
||||
#[tokio::test]
|
||||
async fn provider_running_report_validation() {
|
||||
let app = test_app(test_state(), None);
|
||||
let put = |provider: &str, body: serde_json::Value| {
|
||||
axum::http::Request::put(format!("/api/v1/library/provider/{provider}/running"))
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let (s, json) = send(&app, put("manual", serde_json::json!({"running": []}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
assert!(json["error"].as_str().unwrap().contains("reserved"));
|
||||
let (s, _) = send(&app, put("Bad%2FName", serde_json::json!({"running": []}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
|
||||
// An unreported provider is a legitimate report of "nothing is running".
|
||||
let (s, json) = send(&app, put("playnite", serde_json::json!({"running": []}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(json["matched"], 0);
|
||||
assert_eq!(json["unknown"], 0);
|
||||
assert!(json["ttl_s"].as_u64().unwrap() > 0);
|
||||
|
||||
// An id this provider does not publish is ignored, not an error.
|
||||
let (s, json) = send(
|
||||
&app,
|
||||
put(
|
||||
"playnite",
|
||||
serde_json::json!({"running": [{"external_id": "no-such-title", "pid": 4242}]}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(json["matched"], 0);
|
||||
assert_eq!(json["unknown"], 1);
|
||||
|
||||
// A report leaves no opinion behind about a title nobody published, so nothing this test did
|
||||
// can hold a real lease open.
|
||||
assert!(!crate::runstate::speaks_for(Some("playnite:no-such-title")));
|
||||
crate::runstate::forget("playnite");
|
||||
}
|
||||
|
||||
@@ -48,8 +48,10 @@ mod compositor;
|
||||
use compositor::resolve_compositor;
|
||||
|
||||
/// Virtual-gamepad backend resolution (plan §W1); `serve_session` + the `Pads` state machine reach
|
||||
/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here.
|
||||
mod gamepad;
|
||||
/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here. Crate-visible because the choice of
|
||||
/// Windows Xbox backend (`windows_xbox_hid`) is not the native plane's alone — the GameStream plane
|
||||
/// presents the same virtual pad and has to make the same choice, from one definition.
|
||||
pub(crate) mod gamepad;
|
||||
use gamepad::{resolve_gamepad, resolve_pad_kind, route_decision};
|
||||
|
||||
/// The SPAKE2 pairing ceremony (plan §W1); `serve_session` dispatches a PairRequest connection here.
|
||||
@@ -154,9 +156,33 @@ pub struct Punktfunk1Options {
|
||||
/// the client's reported address, no hole-punch"; `false` (random port, or a busy fixed port) means
|
||||
/// "hole-punch". The socket is held from the handshake through streaming — no drop-then-rebind
|
||||
/// window in which a concurrent session could steal a fixed port.
|
||||
fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSocket, bool)> {
|
||||
///
|
||||
/// `local_ip` is the address the client's QUIC connection was RECEIVED on (`Connection::local_ip`),
|
||||
/// and binding to it is load-bearing on a multi-homed host. The client's data socket is
|
||||
/// `connect`ed to the host IP it dialed, so its kernel accepts video only from THAT source
|
||||
/// address; a wildcard bind here lets the routing table pick the egress interface independently of
|
||||
/// the one the control plane arrived on, and the two differ whenever a host has two paths to the
|
||||
/// client — Ethernet and Wi-Fi both up on the same LAN is the everyday case. Every video datagram
|
||||
/// is then dropped by the client's kernel before userspace: nothing counts it, `loss_ppm` stays 0
|
||||
/// (no packets, no gaps), the hole-punch still arrives so the host logs `punched=true`, and the
|
||||
/// control plane — which quinn pins to the right local address — stays perfectly healthy. That is
|
||||
/// the "connects fine, black screen forever" shape with every gauge green, and it is invisible on
|
||||
/// both ends. `None` (platform can't report it) or a bind failure falls back to the wildcard.
|
||||
fn bind_data_socket(
|
||||
data_port: Option<u16>,
|
||||
local_ip: Option<std::net::IpAddr>,
|
||||
) -> std::io::Result<(std::net::UdpSocket, bool)> {
|
||||
// An IPv4-mapped v6 local address (dual-stack endpoint) must be unmapped before it can bind a
|
||||
// socket that will `connect` to a v4 peer — the families have to match.
|
||||
let local_ip = local_ip.map(|ip| match ip {
|
||||
std::net::IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, std::net::IpAddr::V4),
|
||||
v4 => v4,
|
||||
});
|
||||
let wildcard = |ip: Option<std::net::IpAddr>| {
|
||||
ip.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED))
|
||||
};
|
||||
if let Some(p) = data_port.filter(|p| *p != 0) {
|
||||
match std::net::UdpSocket::bind(("0.0.0.0", p)) {
|
||||
match std::net::UdpSocket::bind((wildcard(local_ip), p)) {
|
||||
Ok(sock) => return Ok((sock, true)),
|
||||
Err(e) => tracing::warn!(
|
||||
data_port = p,
|
||||
@@ -166,7 +192,23 @@ fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSoc
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
|
||||
match std::net::UdpSocket::bind((wildcard(local_ip), 0)) {
|
||||
Ok(sock) => Ok((sock, false)),
|
||||
// The control plane arrived on this address moments ago, so a failure here means it just
|
||||
// went away (an adapter dropped mid-handshake). The wildcard still reaches a client the
|
||||
// routing table can route to — degraded, not dead — so take it and say why.
|
||||
Err(e) if local_ip.is_some() => {
|
||||
tracing::warn!(
|
||||
local_ip = ?local_ip,
|
||||
error = %e,
|
||||
"could not bind the data plane to the address the control connection arrived on \
|
||||
— falling back to the wildcard. On a multi-homed host video may now egress from \
|
||||
a different interface than the client dialed, which it silently drops."
|
||||
);
|
||||
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
|
||||
@@ -363,7 +405,6 @@ pub(crate) async fn serve(
|
||||
match crate::gamestream::Host::detect() {
|
||||
Ok(h) => crate::discovery::advertise_native(
|
||||
&h.hostname,
|
||||
h.local_ip,
|
||||
opts.port,
|
||||
&fingerprint_hex(&fingerprint),
|
||||
opts.require_pairing,
|
||||
@@ -1402,6 +1443,12 @@ async fn serve_session(
|
||||
// evidence (a refusal without the score left a 23-minute floor-pinned field session with no
|
||||
// trace of why).
|
||||
let cadence_behind_score = Arc::new(AtomicU32::new(0));
|
||||
// Delivery truth, control task → data plane: the packet count the client reports having
|
||||
// received all session (`u32::MAX` until a client new enough to answer sends one). The data
|
||||
// plane needs it to tell a clean link from a dead one — `loss_ppm = 0` means both — before it
|
||||
// blames the client for a stream that never reached it.
|
||||
let client_packets_received = Arc::new(AtomicU32::new(u32::MAX));
|
||||
let client_packets_received_ctl = client_packets_received.clone();
|
||||
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
||||
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
|
||||
@@ -1533,6 +1580,7 @@ async fn serve_session(
|
||||
encoder_ceiling_kbps.clone(),
|
||||
cadence_degraded.clone(),
|
||||
cadence_behind_score.clone(),
|
||||
client_packets_received_ctl,
|
||||
fec_target_ctl,
|
||||
phase_ctl_control,
|
||||
reconfig_tx,
|
||||
@@ -2048,6 +2096,10 @@ async fn serve_session(
|
||||
// stages ride the same per-session trace; resizes write their totals into the shared slot.
|
||||
let bringup_dp = bringup.clone();
|
||||
let resize_ms_dp = resize_ms.clone();
|
||||
// The address the control connection arrived on, for the data plane's source-address check
|
||||
// below — the one comparison that distinguishes "the client is filtering our video" from
|
||||
// "the video never left". Captured here because the send loop runs on a blocking thread.
|
||||
let control_local_ip = conn.local_ip();
|
||||
let result: Result<()> = async {
|
||||
let stream_thread = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
|
||||
@@ -2082,15 +2134,64 @@ async fn serve_session(
|
||||
}
|
||||
};
|
||||
bringup_dp.mark("punch_done");
|
||||
// Post-`connect`, `local_addr` reports the source address the kernel will actually
|
||||
// stamp on every video datagram — the number that has to match the host IP the client
|
||||
// dialed, because its data socket is connected and its kernel drops anything else
|
||||
// before userspace. Logged unconditionally: a black-screen report is unanswerable
|
||||
// without it (this session's showed only the port).
|
||||
let local = transport.local_addr().ok();
|
||||
tracing::info!(
|
||||
%client_udp,
|
||||
udp_port,
|
||||
direct,
|
||||
punched,
|
||||
local = ?local,
|
||||
"data plane bound (direct=true → fixed --data-port, streaming to the reported \
|
||||
address with no hole-punch; else punched=true → the client's observed source, \
|
||||
false → no punch seen, the reported address)"
|
||||
);
|
||||
// A video source address that isn't the one the control plane arrived on means the
|
||||
// client will discard every datagram we send, however healthy this end looks.
|
||||
if let (Some(l), Some(c)) = (local.map(|a| a.ip()), control_local_ip) {
|
||||
let c = match c {
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
v6.to_ipv4_mapped().map_or(c, std::net::IpAddr::V4)
|
||||
}
|
||||
v4 => v4,
|
||||
};
|
||||
if !l.is_unspecified() && l != c {
|
||||
tracing::warn!(
|
||||
video_source_ip = %l,
|
||||
control_local_ip = %c,
|
||||
"the video data plane egresses from a DIFFERENT host address than the one \
|
||||
this client connected to — its data socket is connected to the address it \
|
||||
dialed, so its kernel drops every video datagram before userspace: black \
|
||||
screen, zero reported loss, healthy control plane. Usual cause is two \
|
||||
live paths to the client (Ethernet and Wi-Fi both up on the same LAN, or \
|
||||
a VPN/overlay adapter claiming the route)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// A punch that never arrives is not a routine fallback — it is the fingerprint of a
|
||||
// data port the client cannot reach INBOUND, and every client punches (5/s for the
|
||||
// first three seconds, then every two). Video then goes to an address the client only
|
||||
// CLAIMED, unverified, and if anything on the path needed the flow opened client-first
|
||||
// it silently goes nowhere: black picture, healthy control plane, no error anywhere.
|
||||
// On Windows the usual cause is a firewall rule that opens fixed ports only, while
|
||||
// this port is ephemeral and different every session (fixed by the program-scoped rule
|
||||
// `service install` now adds — an install predating it still has the old rules).
|
||||
// `direct` skips the punch by operator choice, so it is not a failure there.
|
||||
if !direct && !punched {
|
||||
tracing::warn!(
|
||||
%client_udp,
|
||||
udp_port,
|
||||
"no hole-punch reached this host's data port — inbound UDP to it looks \
|
||||
BLOCKED, so video is being sent to the address the client reported without \
|
||||
any confirmed return path. If the picture stays black while the session is \
|
||||
otherwise healthy, this line is the reason: allow inbound UDP for the host \
|
||||
executable (any port), or pin --data-port and open that one"
|
||||
);
|
||||
}
|
||||
let mut session = Session::new(cfg, Box::new(transport))
|
||||
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
||||
match source {
|
||||
@@ -2125,6 +2226,7 @@ async fn serve_session(
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
cadence_behind_score,
|
||||
client_packets_received,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
chroma,
|
||||
@@ -2485,7 +2587,7 @@ mod tests {
|
||||
// No fixed port (and the explicit-0 alias) → a random ephemeral port, and NOT direct: the
|
||||
// caller hole-punches.
|
||||
for req in [None, Some(0)] {
|
||||
let (sock, direct) = bind_data_socket(req).expect("bind random data socket");
|
||||
let (sock, direct) = bind_data_socket(req, None).expect("bind random data socket");
|
||||
assert!(!direct, "req={req:?} must hole-punch, not stream direct");
|
||||
assert_ne!(sock.local_addr().unwrap().port(), 0);
|
||||
}
|
||||
@@ -2502,13 +2604,14 @@ mod tests {
|
||||
.port();
|
||||
|
||||
// A free fixed port binds exactly it, in DIRECT mode (no hole-punch).
|
||||
let (held, direct) = bind_data_socket(Some(free)).expect("bind fixed data socket");
|
||||
let (held, direct) = bind_data_socket(Some(free), None).expect("bind fixed data socket");
|
||||
assert!(direct, "a fixed --data-port must stream direct");
|
||||
assert_eq!(held.local_addr().unwrap().port(), free);
|
||||
|
||||
// While it's held, a second session on the same fixed port can't bind it → it must fall
|
||||
// back to a random port + hole-punch rather than fail (so concurrency never regresses).
|
||||
let (fallback, direct2) = bind_data_socket(Some(free)).expect("busy fixed port falls back");
|
||||
let (fallback, direct2) =
|
||||
bind_data_socket(Some(free), None).expect("busy fixed port falls back");
|
||||
assert!(!direct2, "a busy fixed port must fall back to hole-punch");
|
||||
assert_ne!(
|
||||
fallback.local_addr().unwrap().port(),
|
||||
@@ -2517,6 +2620,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The multi-homed black screen: video must egress from the address the client's control
|
||||
/// connection arrived on, because the client's data socket is connected to the host address it
|
||||
/// dialed and its kernel drops every datagram from any other source — silently, before
|
||||
/// userspace, so nothing on either end counts it. A wildcard bind here lets the routing table
|
||||
/// choose a different interface whenever the host has two paths to the client.
|
||||
#[test]
|
||||
fn data_socket_binds_the_address_the_control_plane_arrived_on() {
|
||||
let loopback = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
|
||||
let (sock, direct) =
|
||||
bind_data_socket(None, Some(loopback)).expect("bind pinned data socket");
|
||||
assert!(!direct);
|
||||
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
|
||||
|
||||
// An IPv4-mapped v6 local address (a dual-stack QUIC endpoint reports one) has to be
|
||||
// unmapped, or the socket binds v6 and can never `connect` to the v4 client.
|
||||
let mapped = std::net::IpAddr::V6(std::net::Ipv4Addr::LOCALHOST.to_ipv6_mapped());
|
||||
let (sock, _) = bind_data_socket(None, Some(mapped)).expect("bind mapped data socket");
|
||||
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
|
||||
|
||||
// No reported local address (platform can't say) keeps the old wildcard behaviour.
|
||||
let (sock, _) = bind_data_socket(None, None).expect("bind wildcard data socket");
|
||||
assert!(sock.local_addr().unwrap().ip().is_unspecified());
|
||||
}
|
||||
|
||||
/// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value in
|
||||
/// `punktfunk_core::input::gamepad` — the single source both the punktfunk/1 native wire and the
|
||||
/// GameStream/Limelight wire read from (they are one and the same). Renumbering a bit in core
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user