Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aef7f7877f | ||
|
|
5e30805490 | ||
|
|
7312f0ddba | ||
|
|
e7ebaf591c | ||
|
|
010949fead | ||
|
|
f5931650e0 | ||
|
|
519d004cab | ||
|
|
46201fd9c3 | ||
|
|
f320f4b465 | ||
|
|
89eb031cd6 | ||
|
|
2991001fe4 | ||
|
|
19df33e0f7 | ||
|
|
c920204184 | ||
|
|
6f4613e146 | ||
|
|
96f75f4e52 | ||
|
|
3b08da11ff | ||
|
|
3ee88bb8cf | ||
|
|
4c5b97cfe4 | ||
|
|
773eea24d9 | ||
|
|
3b5c95959b | ||
|
|
a2bc9a2bdc | ||
|
|
4beee17953 | ||
|
|
4ad0055416 | ||
|
|
db9cd40079 | ||
|
|
cb07a8f983 | ||
|
|
c63e8cee39 | ||
|
|
b670b5d844 | ||
|
|
11abff5343 | ||
|
|
064ea3de7d | ||
|
|
ec278c0478 | ||
|
|
5d91176500 | ||
|
|
cf7baf3ba8 | ||
|
|
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 |
@@ -171,6 +171,13 @@ jobs:
|
||||
- name: Rust Android targets (no-op unless the toolchain pin outran the image)
|
||||
run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||
|
||||
# Must precede every cargo step below: skia-bindings' ~19 MB prebuilt download runs inside
|
||||
# a build script with no retry, and a truncated transfer here does not surface as a network
|
||||
# error — it silently becomes a from-source Skia build that dies in the container. See the
|
||||
# script for the measured failure.
|
||||
- name: curl with retries (skia-bindings' prebuilt fetch has none)
|
||||
run: sh scripts/ci/install-retrying-curl.sh
|
||||
|
||||
# Same key namespace as ci.yml/deb.yml ON PURPOSE: identical Cargo.lock, identical
|
||||
# CARGO_HOME layout (/usr/local/cargo), so the registry/git downloads dedupe with
|
||||
# the rest of the fleet in the central cache. target/ is deliberately NOT cached
|
||||
@@ -209,9 +216,25 @@ jobs:
|
||||
# The task lints arm64-v8a AND armeabi-v7a, and reuses the build task's exact cargo-ndk
|
||||
# environment — see the long note on `registerCargoNdkClippy` in kit/build.gradle.kts for why
|
||||
# both pointer widths are load-bearing and why the environment must not be duplicated here.
|
||||
# The `STARTING A FULL BUILD` check turns the manual rule in this workflow's `env:` block
|
||||
# ("Every ABI's log must show DOWNLOAD AND INSTALL SUCCEEDED") into something that fails the
|
||||
# job by itself. Without it a missed prebuilt reads as a Gradle stack trace with the real
|
||||
# cause ~1,800 lines up — which is exactly how 2026-08-22 spent a week looking like a lint
|
||||
# failure. This is the first cargo step in the job, so it catches the drop earliest.
|
||||
#
|
||||
# No pipefail: the runner is dash. Capture, then decide.
|
||||
- name: Clippy (Android target, deny warnings)
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:cargoNdkClippy --stacktrace
|
||||
run: |
|
||||
set -e
|
||||
rc=0
|
||||
./gradlew :kit:cargoNdkClippy --stacktrace > /tmp/android-clippy.log 2>&1 || rc=$?
|
||||
cat /tmp/android-clippy.log
|
||||
if grep -q "STARTING A FULL BUILD" /tmp/android-clippy.log; then
|
||||
echo "::error::skia-bindings did not get its prebuilt archive and started building Skia from source — the download was dropped (see DOWNLOAD AND INSTALL FAILED above). This is a fetch failure, not a lint failure."
|
||||
exit 1
|
||||
fi
|
||||
exit $rc
|
||||
|
||||
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
|
||||
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
|
||||
|
||||
@@ -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)
|
||||
|
||||
+27
-3
@@ -107,6 +107,12 @@ jobs:
|
||||
# registry/git are download caches, target/ the incremental build. The target key
|
||||
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
|
||||
# from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
|
||||
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
|
||||
# Skia from the same no-retry build-script fetch that took the android job out on
|
||||
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
|
||||
- name: curl with retries (skia-bindings' prebuilt fetch has none)
|
||||
run: sh scripts/ci/install-retrying-curl.sh
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
@@ -270,6 +276,12 @@ jobs:
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
|
||||
# Skia from the same no-retry build-script fetch that took the android job out on
|
||||
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
|
||||
- name: curl with retries (skia-bindings' prebuilt fetch has none)
|
||||
run: sh scripts/ci/install-retrying-curl.sh
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
@@ -339,8 +351,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 +391,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 +441,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 }
|
||||
}
|
||||
|
||||
+1384
-50
File diff suppressed because it is too large
Load Diff
Generated
+38
-38
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -1959,9 +1959,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -2343,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3032,7 +3032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3050,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3073,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3083,7 +3083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3109,7 +3109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3122,7 +3122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3136,11 +3136,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3169,14 +3169,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3191,7 +3191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3199,7 +3199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3211,7 +3211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3220,7 +3220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3253,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3264,7 +3264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3275,7 +3275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3487,7 +3487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3497,7 +3497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"anyhow",
|
||||
@@ -3521,7 +3521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pf-client-core",
|
||||
@@ -3554,7 +3554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3572,7 +3572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3605,7 +3605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3614,7 +3614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3684,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3698,7 +3698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3722,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.31.0"
|
||||
version = "0.31.3"
|
||||
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.3"
|
||||
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.3"
|
||||
},
|
||||
"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))
|
||||
|
||||
@@ -872,6 +932,10 @@ private val TEST_BUTTONS = listOf(
|
||||
"Select" to KeyEvent.KEYCODE_BUTTON_SELECT,
|
||||
"Start" to KeyEvent.KEYCODE_BUTTON_START,
|
||||
"Guide" to KeyEvent.KEYCODE_BUTTON_MODE,
|
||||
// The two buttons Android has no keycode for, on the keycodes [Gamepad.buttonBit] borrows for
|
||||
// them. Only a driverless Sony pad reaches these; every other controller leaves them dark.
|
||||
"Touch" to KeyEvent.KEYCODE_BUTTON_15,
|
||||
"Mute" to KeyEvent.KEYCODE_BUTTON_16,
|
||||
"↑" to KeyEvent.KEYCODE_DPAD_UP,
|
||||
"↓" to KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
"←" to KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
|
||||
@@ -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(
|
||||
@@ -615,8 +628,8 @@ class MainActivity : ComponentActivity() {
|
||||
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
|
||||
// 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)
|
||||
if (fromPad(event)) {
|
||||
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
|
||||
@@ -697,7 +710,7 @@ class MainActivity : ComponentActivity() {
|
||||
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
|
||||
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
|
||||
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
||||
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
lastPadIsGamepad = fromPad(event)
|
||||
if (lastPadIsGamepad) {
|
||||
lastPadStyle = Gamepad.styleFor(event.device)
|
||||
lastPadDeviceId = event.deviceId
|
||||
@@ -705,11 +718,13 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
// The Controllers debug screen sees pad events before the navigation remap below.
|
||||
padKeyProbe?.let { if (it(event)) return true }
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
if (fromPad(event)) {
|
||||
// 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.
|
||||
@@ -726,6 +741,32 @@ class MainActivity : ComponentActivity() {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Did this key event come from a controller — the question every pad branch here actually
|
||||
* means when it asks `isFromSource(SOURCE_GAMEPAD)`.
|
||||
*
|
||||
* The event's source class is the platform's per-EVENT guess, and some boxes get it wrong:
|
||||
* Fire OS is reported to deliver a Bluetooth DualSense's Triangle, touchpad and Mode/PS with
|
||||
* standard `KEYCODE_BUTTON_*` keycodes but a SOURCE_KEYBOARD tag, and the plain gate then
|
||||
* drops them before anything can map them. The DEVICE's source classes are the fact, so widen
|
||||
* to the device — but only for keycodes that cannot be anything BUT a gamepad button.
|
||||
*
|
||||
* That restriction is the whole safety of this. [KeyEvent.isGamepadButton] is exactly the
|
||||
* `KEYCODE_BUTTON_*` block — no `KEYCODE_DPAD_*`, no `KEYCODE_BACK` — and both exclusions are
|
||||
* load-bearing: a keyboard's arrow keys share the D-pad keycodes and belong to the VK path
|
||||
* ([Gamepad.buttonBit]), and a remote's or keyboard's BACK shares `KEYCODE_BACK` and has to
|
||||
* keep leaving the stream, which for a device with no pad on it is the documented way out
|
||||
* ([Gamepad.padButtonBit]). Widening on the device alone — or on its vendor id, which for
|
||||
* `0x045E`/`0x054C` covers those vendors' keyboards and mice too — routes both into the pad
|
||||
* branch and breaks them.
|
||||
*
|
||||
* The RAW keycode is what is asked: routing happens before [Gamepad.padKeyCode]'s correction,
|
||||
* and both the raw and the corrected keycode are in this block for every button concerned.
|
||||
*/
|
||||
private fun fromPad(event: KeyEvent): Boolean =
|
||||
event.isFromSource(InputDevice.SOURCE_GAMEPAD) ||
|
||||
(KeyEvent.isGamepadButton(event.keyCode) && Gamepad.isPad(event.device))
|
||||
|
||||
/**
|
||||
* `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is
|
||||
* anything else — including a remote's or keyboard's BACK, which must keep exiting the stream.
|
||||
|
||||
@@ -114,6 +114,22 @@ data class Settings(
|
||||
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||
*/
|
||||
val gamepadUiEnabled: Boolean = true,
|
||||
/**
|
||||
* Draw the console UI at 1080p and let the display scale it up, instead of at the panel's own
|
||||
* resolution. Off by default — this is a deliberate sharpness-for-smoothness trade, not
|
||||
* something to impose on a device that does not need it.
|
||||
*
|
||||
* It exists for 4K TVs and projectors. Their graphics chips are chosen to decode and composite
|
||||
* video, not to shade a UI, and are far slower than a phone's; at 4K every pass the console
|
||||
* draws — the mesh backdrop above all — costs four times what it does at 1080p on hardware
|
||||
* that is nowhere near four times faster. A "premium" 4K box is MORE likely to want this than
|
||||
* a cheap 1080p stick, which never had the extra pixels to begin with.
|
||||
*
|
||||
* Read by [io.unom.punktfunk.console.SkiaConsoleShell], which applies it with
|
||||
* `SurfaceHolder.setFixedSize` — the compositor then scales the smaller buffer up for free.
|
||||
* The stream is untouched; that has its own `renderScale`.
|
||||
*/
|
||||
val reduceUiResolution: Boolean = false,
|
||||
/**
|
||||
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
|
||||
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
|
||||
@@ -329,6 +345,7 @@ class SettingsStore(context: Context) {
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
reduceUiResolution = prefs.getBoolean(K_REDUCE_UI_RES, false),
|
||||
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
|
||||
?: GAMEPAD_UI_WHEN_CONNECTED,
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
@@ -373,6 +390,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_REDUCE_UI_RES, s.reduceUiResolution)
|
||||
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putString(K_UI_PALETTE, s.uiPalette)
|
||||
@@ -415,6 +433,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_REDUCE_UI_RES = "reduce_ui_resolution"
|
||||
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
const val K_UI_PALETTE = "ui_palette"
|
||||
@@ -478,7 +497,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,21 @@ 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)
|
||||
j.put("android.reduce_ui_resolution", s.reduceUiResolution)
|
||||
// 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 +341,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 +379,15 @@ 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),
|
||||
reduceUiResolution = j.optBoolean("android.reduce_ui_resolution", s.reduceUiResolution),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())))
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
@@ -137,13 +138,53 @@ fun SkiaConsoleShell(
|
||||
// Phone) still read a step too small in the hand: the floor is what sets the phone scale
|
||||
// (the couch term only wins on tablets and TVs), so this is a phones-only bump.
|
||||
val tv = remember { io.unom.punktfunk.isTvDevice(context) }
|
||||
val scale = if (tv) 0f else {
|
||||
val dm = context.resources.displayMetrics
|
||||
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
|
||||
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f)
|
||||
// The SurfaceView's own laid-out size, fed back by `onSizeChanged` below — deliberately not
|
||||
// `displayMetrics`. The reduced buffer's aspect ratio has to match the RECT it is scaled into
|
||||
// or the compositor stretches the whole interface, and while those two normally agree,
|
||||
// `displayMetrics` has a long history of disagreeing with a view's real size by a system bar
|
||||
// depending on the version and on who is currently hiding what. "Normally agree" is not
|
||||
// something to hang picture geometry on. Zero until the first layout, which is exactly what
|
||||
// `render` wants: the surface comes up at its natural size and is re-fixed a frame later.
|
||||
var viewW by remember { mutableStateOf(0) }
|
||||
var viewH by remember { mutableStateOf(0) }
|
||||
// "Reduce interface resolution" (`Settings.reduceUiResolution`): cap the console's BUFFER at
|
||||
// 1920 on its long edge and let the compositor scale it up to the panel. 1 means "draw at the
|
||||
// panel's own resolution" — the setting is off, or the display is already at or under 1080p
|
||||
// and there is nothing to give back.
|
||||
//
|
||||
// ONE factor on both axes, so the aspect ratio survives exactly and no layout can stretch.
|
||||
// Everything else in this function that speaks in SURFACE pixels multiplies by it — the insets
|
||||
// and design-unit scale just below, the pointer coordinates further down — because
|
||||
// `setFixedSize` shrinks the buffer WITHOUT shrinking the view: a mouse still reports its
|
||||
// position in view pixels, and handing those straight to a half-size surface would land the
|
||||
// cursor at twice its true offset.
|
||||
val render = if (!settings.reduceUiResolution) 1f else {
|
||||
val long = maxOf(viewW, viewH)
|
||||
if (long > 1920) 1920f / long else 1f
|
||||
}
|
||||
LaunchedEffect(handle, left, top, right, bottom, scale) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)
|
||||
// The pointer listeners below are installed in `factory`, which runs ONCE — capturing `render`
|
||||
// directly would freeze them at its first-composition value (1, before the first layout has
|
||||
// reported a size), and a mouse would keep reporting view pixels into a half-size surface for
|
||||
// the rest of the session. Same reason `platformUp` is held this way.
|
||||
val currentRender by rememberUpdatedState(render)
|
||||
val dm = context.resources.displayMetrics
|
||||
val scale = if (tv) 0f else {
|
||||
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
|
||||
// `render` too: the design-unit scale is in SURFACE pixels, so shrinking the buffer without
|
||||
// shrinking this would draw the type larger on screen than the same phone draws it today.
|
||||
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f) * render
|
||||
}
|
||||
LaunchedEffect(handle, left, top, right, bottom, scale, render) {
|
||||
if (handle != 0L) {
|
||||
NativeBridge.nativeConsoleSetViewport(
|
||||
handle,
|
||||
left * render,
|
||||
top * render,
|
||||
right * render,
|
||||
bottom * render,
|
||||
scale,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The pad, raw, before MainActivity's B→Back and stick→D-pad synthesis: face buttons and the
|
||||
@@ -159,7 +200,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 +225,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 +237,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
|
||||
}
|
||||
@@ -267,7 +313,9 @@ fun SkiaConsoleShell(
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { viewW = it.width; viewH = it.height },
|
||||
factory = { ctx ->
|
||||
SurfaceView(ctx).apply {
|
||||
// The console draws opaque, edge to edge; Compose overlays sit above it.
|
||||
@@ -300,7 +348,8 @@ fun SkiaConsoleShell(
|
||||
MotionEvent.ACTION_CANCEL -> 5
|
||||
else -> return@setOnTouchListener false
|
||||
}
|
||||
NativeBridge.nativeConsolePointer(handle, kind, ev.x, ev.y, 0f)
|
||||
// View pixels → SURFACE pixels (see `render` above).
|
||||
NativeBridge.nativeConsolePointer(handle, kind, ev.x * currentRender, ev.y * currentRender, 0f)
|
||||
if (ev.actionMasked == MotionEvent.ACTION_UP) v.performClick()
|
||||
true
|
||||
}
|
||||
@@ -308,13 +357,27 @@ fun SkiaConsoleShell(
|
||||
if (handle != 0L && ev.actionMasked == MotionEvent.ACTION_SCROLL &&
|
||||
ev.isFromSource(InputDevice.SOURCE_CLASS_POINTER)
|
||||
) {
|
||||
NativeBridge.nativeConsolePointer(handle, 4, ev.x, ev.y, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||
NativeBridge.nativeConsolePointer(handle, 4, ev.x * currentRender, ev.y * currentRender, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||
true
|
||||
} else false
|
||||
}
|
||||
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||
}
|
||||
},
|
||||
// Applied here rather than in `factory` so flipping the setting takes effect without
|
||||
// leaving the console: `setFixedSize` re-creates the buffer and the render thread
|
||||
// re-wraps it through the ordinary surfaceChanged path. `setSizeFromLayout` is the
|
||||
// documented way back to "the view's own size" when the setting goes off again.
|
||||
update = { view ->
|
||||
if (render < 1f) {
|
||||
view.holder.setFixedSize(
|
||||
(viewW * render).roundToInt().coerceAtLeast(1),
|
||||
(viewH * render).roundToInt().coerceAtLeast(1),
|
||||
)
|
||||
} else {
|
||||
view.holder.setSizeFromLayout()
|
||||
}
|
||||
},
|
||||
)
|
||||
when (platformScreen) {
|
||||
"licenses" -> ConsoleLicensesScreen(onBack = { platformScreen = null }, navActive = 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()
|
||||
@@ -233,6 +282,17 @@ object Gamepad {
|
||||
* `KEYCODE_DPAD_*` are included but must only be routed here when the event is from a gamepad
|
||||
* (a keyboard's arrow keys share these keycodes and belong to the VK path) — see MainActivity.
|
||||
* L2/R2 are forwarded as the analog trigger axes, never as buttons.
|
||||
*
|
||||
* [BTN_TOUCHPAD] and [BTN_MISC1] have no Android keycode at all, so
|
||||
* [PadButtons.GENERIC_SONY] BORROWS the last two rows of `Generic.kl`'s joystick block for
|
||||
* them ([KEYCODE_BUTTON_15][KeyEvent.KEYCODE_BUTTON_15] / `_16`, evdev `BTN_BASE5`/`BTN_BASE6`)
|
||||
* — see there. This table is global, so a device that genuinely presses one of those two
|
||||
* emits the bit as well. That is the cost of the borrow, and it is why the borrow is at the
|
||||
* TOP of the block rather than at `BUTTON_1`/`BUTTON_2`: those are a flight stick's trigger
|
||||
* and thumb button, which any joystick-usage HID device reports, whereas reaching `BUTTON_15`
|
||||
* takes a pad that declares fifteen. The residual case — a fifteen-button HOTAS whose button
|
||||
* 16 also toggles the client's mic — is the one this leaves on the table; narrowing it
|
||||
* further needs per-device knowledge the router does not have (see `GamepadRouter`).
|
||||
*/
|
||||
fun buttonBit(keyCode: Int): Int = when (keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> BTN_A
|
||||
@@ -246,6 +306,8 @@ object Gamepad {
|
||||
KeyEvent.KEYCODE_BUTTON_START -> BTN_START
|
||||
KeyEvent.KEYCODE_BUTTON_SELECT -> BTN_BACK
|
||||
KeyEvent.KEYCODE_BUTTON_MODE -> BTN_GUIDE
|
||||
KeyEvent.KEYCODE_BUTTON_15 -> BTN_TOUCHPAD // borrowed — see the KDoc
|
||||
KeyEvent.KEYCODE_BUTTON_16 -> BTN_MISC1 // borrowed — see the KDoc
|
||||
KeyEvent.KEYCODE_DPAD_UP -> BTN_DPAD_UP
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> BTN_DPAD_DOWN
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> BTN_DPAD_LEFT
|
||||
@@ -293,6 +355,353 @@ 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 touchpad mute, i.e. `0x130`..`0x13e` 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.
|
||||
*
|
||||
* This order — and ONLY this order — is where `0x13d`/`0x13e` mean the touchpad click and
|
||||
* the mute button. Everywhere else they are L3/R3.
|
||||
*/
|
||||
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
|
||||
// Touchpad click and mute. The wire has bits for both ([BTN_TOUCHPAD] /
|
||||
// [BTN_MISC1]) and Android has no keycode for either, so these two borrow
|
||||
// BUTTON_15/BUTTON_16 to reach [buttonBit] — see its KDoc for the cost.
|
||||
//
|
||||
// ONLY here. `0x13d`/`0x13e` are BTN_THUMBL/BTN_THUMBR (L3/R3) in the standard
|
||||
// Linux mapping — [genericKeyCode] says so itself — and they mean touchpad and
|
||||
// mute purely because a driverless DualSense enumerates its buttons straight
|
||||
// through in its own report order, which is what GENERIC_SONY IS. Hoisting
|
||||
// this above `padMap(dev)` would put L3 on the touchpad and R3 on the mic for
|
||||
// every Xbox pad, Switch Pro, 8BitDo, Steam Deck and `hid-playstation`
|
||||
// DualSense on the couch. There is no scancode that means the same button on
|
||||
// all pads; that is the entire reason this enum exists.
|
||||
0x13d -> KeyEvent.KEYCODE_BUTTON_15 // touchpad click → BTN_TOUCHPAD
|
||||
0x13e -> KeyEvent.KEYCODE_BUTTON_16 // mute → BTN_MISC1
|
||||
// Unreachable with the guard above in force (it only lets `0x130`..`0x13e`
|
||||
// through, and every one of those is now named), and KEYCODE_UNKNOWN is the
|
||||
// safe answer if that ever changes.
|
||||
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 +715,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 +731,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 +785,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
|
||||
|
||||
@@ -101,6 +101,18 @@ class GamepadRouter(
|
||||
* the whole session. The capture-link pads carry the same flag on [ExternalPad].
|
||||
*/
|
||||
val motionReaches: Boolean = true,
|
||||
/**
|
||||
* Whether [Gamepad.BTN_MISC1] means a MUTE button on this particular pad — the one bit
|
||||
* whose physical meaning differs per controller, and the gate on the mic toggle in
|
||||
* [slotButton].
|
||||
*
|
||||
* A DualSense has one; a Steam Controller 2 puts its QAM button on the same wire bit
|
||||
* (`Sc2Device`), and QAM must not mute anyone's microphone. Asked once at open, off the
|
||||
* fact each path actually knows: the report order for an [InputDevice] (only
|
||||
* [Gamepad.PadButtons.GENERIC_SONY] mints this bit there), the declared pad kind for a
|
||||
* capture link.
|
||||
*/
|
||||
val hasMuteButton: Boolean = false,
|
||||
) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
@@ -160,7 +172,8 @@ class GamepadRouter(
|
||||
|
||||
/**
|
||||
* Invoked (main thread) each time the mic-mute chord ([MIC_CHORD], Select + Y) is COMPLETED on
|
||||
* a pad — the couch equivalent of the stream's on-screen mute button, which a gamepad user
|
||||
* a pad, or a pad's own mute button ([Gamepad.BTN_MISC1] — a DualSense's) is pressed — the
|
||||
* couch equivalent of the stream's on-screen mute button, which a gamepad user
|
||||
* cannot reach. `StreamScreen` wires it to the mute toggle. Unlike the exit chord this fires
|
||||
* immediately: muting is the kind of thing you want to have already happened, and the on-screen
|
||||
* indicator makes an accidental toggle self-evident. The buttons still go to the host — the
|
||||
@@ -234,15 +247,40 @@ class GamepadRouter(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this bit's WIRE SEND kept with this device, though the bit is otherwise tracked normally?
|
||||
*
|
||||
* Exactly one is: a real mute button ([Slot.hasMuteButton]) under the "local" [systemForward]
|
||||
* policy. It is tracked — the mic toggle in [slotButton] is edge-triggered off held state —
|
||||
* but not forwarded, so every send site has to ask, including [releaseHeld]'s close-time
|
||||
* flush, or a mute held across a disconnect would put a release on the wire for a press that
|
||||
* never went out. Every other system button under that policy leaves [slotButton] at the top
|
||||
* and never reaches a send at all.
|
||||
*/
|
||||
private fun localOnly(slot: Slot, bit: Int): Boolean =
|
||||
!systemForward && bit == Gamepad.BTN_MISC1 && slot.hasMuteButton
|
||||
|
||||
/**
|
||||
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
|
||||
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
|
||||
* the instant chords ([MIC_CHORD], [STATS_CHORD]).
|
||||
* the instant chords ([MIC_CHORD], [STATS_CHORD], and the mute button's own mic toggle).
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
// Raw system buttons stay local under the "local" policy — no wire send and no held
|
||||
// tracking, symmetric on both edges so nothing leaks into the chords either.
|
||||
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
|
||||
// tracking, symmetric on both edges so nothing leaks into the chords either. A Steam
|
||||
// Controller 2's QAM button is BTN_MISC1 and keeps exactly that behaviour.
|
||||
//
|
||||
// A real MUTE button ([Slot.hasMuteButton]) is deliberately exempt: that policy's own
|
||||
// words are "keeps them entirely with this device", and toggling this device's microphone
|
||||
// is precisely what a mute button does with itself. Returning here would have left the
|
||||
// button present and silently dead under `local`, for a reason nobody would ever find. It
|
||||
// loses its wire send instead (see [localOnly]) and keeps the held tracking the toggle's
|
||||
// edge-trigger reads. It cannot leak into a chord — MISC1 is in none of them.
|
||||
if (!systemForward &&
|
||||
(bit == Gamepad.BTN_GUIDE || (bit == Gamepad.BTN_MISC1 && !slot.hasMuteButton))
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (down) {
|
||||
if (guideGesture && send) {
|
||||
// A Select pressed ALONE is held back until it resolves: a tap (delivered
|
||||
@@ -258,7 +296,7 @@ class GamepadRouter(
|
||||
}
|
||||
flushPendingSelect(slot)
|
||||
}
|
||||
if (send && forwarding) {
|
||||
if (send && forwarding && !localOnly(slot, bit)) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
}
|
||||
val wasHeld = slot.held
|
||||
@@ -268,11 +306,26 @@ class GamepadRouter(
|
||||
// Mic mute and the stats-tier cycle, each edge-triggered on the button that COMPLETES
|
||||
// its chord (see [completesChord]) — the two meanings this client gives Select plus a
|
||||
// face button. Both leave the press on the wire: the game still gets its buttons.
|
||||
if (completesChord(wasHeld, bit, MIC_CHORD)) onMicChord?.invoke()
|
||||
//
|
||||
// A pad's own mute button is a second trigger for the SAME toggle, not a new
|
||||
// mechanism — so it gets the same edge-trigger, expressed as the one-button chord it
|
||||
// is. That is load-bearing rather than tidy: [onButton] deliberately still calls this
|
||||
// with `down = true` on auto-repeat and suppresses only `send` (its repeatCount
|
||||
// guard), so an unguarded `bit == BTN_MISC1` would flap the mic for as long as the
|
||||
// button is held down.
|
||||
//
|
||||
// [Slot.hasMuteButton] is the other half, and it is not belt-and-braces: BTN_MISC1 is
|
||||
// the wire's misc/QAM bit, and `Sc2Device` puts a Steam Controller 2's QAM button on
|
||||
// it. Reading "any MISC1" as mute would mute the microphone on every QAM press.
|
||||
if (completesChord(wasHeld, bit, MIC_CHORD) ||
|
||||
(slot.hasMuteButton && completesChord(wasHeld, bit, Gamepad.BTN_MISC1))
|
||||
) {
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
if (completesChord(wasHeld, bit, STATS_CHORD)) onStatsChord?.invoke()
|
||||
} else {
|
||||
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
|
||||
if (!owned && send && forwarding) {
|
||||
if (!owned && send && forwarding && !localOnly(slot, bit)) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
slot.held = slot.held and bit.inv()
|
||||
@@ -543,7 +596,15 @@ class GamepadRouter(
|
||||
// time. Cheap enough to ask unconditionally; the answer holds for the pad's lifetime.
|
||||
val motionReaches = NativeBridge.nativePadMotionReaches(handle, pref)
|
||||
if (forwarding && hasGyro && !motionReaches) onMotionUnreachable?.invoke()
|
||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
// `DsDevice` raises BTN_MISC1 from the DualSense report's mute bit; `Sc2Device` raises the
|
||||
// same bit from the Steam Controller 2's QAM button, which must not touch the microphone.
|
||||
// The declared kind separates them (a DualShock 4 has no mute button either).
|
||||
val hasMute = pref == Gamepad.PREF_DUALSENSE || pref == Gamepad.PREF_DUALSENSEEDGE
|
||||
slots[syntheticId] = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index),
|
||||
hasMuteButton = hasMute,
|
||||
)
|
||||
return ExternalPad(syntheticId, index, motionReaches)
|
||||
}
|
||||
|
||||
@@ -603,10 +664,15 @@ class GamepadRouter(
|
||||
// Asked here, off the kind this pad just DECLARED — not off the session's resolved backend,
|
||||
// which under Automatic answers for whichever pad happened to be active at dial time. Held
|
||||
// for the slot's life; the sensor path reads it on every sample.
|
||||
val map = Gamepad.padMap(dev)
|
||||
val slot = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index),
|
||||
Gamepad.AxisMapper(handle, index, map),
|
||||
NativeBridge.nativePadMotionReaches(handle, pref),
|
||||
// The only route to BTN_MISC1 on this path is GENERIC_SONY's `0x13e` row, so the
|
||||
// report order IS the answer — and unlike `pref` it survives the user pinning every
|
||||
// pad to one type, which would otherwise cost a DualSense its mute button.
|
||||
hasMuteButton = map.buttons == Gamepad.PadButtons.GENERIC_SONY,
|
||||
)
|
||||
slots[dev.id] = slot
|
||||
// After the table holds the slot, so a listener that sends on this device the moment it is
|
||||
@@ -652,7 +718,9 @@ class GamepadRouter(
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
if (forwarding && !localOnly(slot, bit)) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
bits = bits and bit.inv()
|
||||
}
|
||||
slot.held = 0
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -155,6 +156,44 @@ class GamepadChordTest {
|
||||
assertEquals(instantChords, pad.press(Gamepad.BTN_BACK))
|
||||
}
|
||||
|
||||
/**
|
||||
* A pad's own mute button (a DualSense's) is a second trigger for the mic toggle, and
|
||||
* `slotButton` reads it through the SAME edge rule expressed as a one-button chord.
|
||||
*
|
||||
* That is not decoration. `onButton` deliberately still calls `slotButton(down = true)` on
|
||||
* auto-repeat and suppresses only the wire send (its repeatCount guard), so a plain
|
||||
* `bit == BTN_MISC1` would toggle the mic on every repeat — hold the button and the mic
|
||||
* flaps. `completesChord` against a single-bit mask is exactly "a fresh press of it".
|
||||
*
|
||||
* The other half is which buttons must NOT reach it. `0x13e` is R3 on every pad but a
|
||||
* driverless Sony one, so a mapping that leaked touchpad/mute meanings outside
|
||||
* [Gamepad.PadButtons.GENERIC_SONY] would put the mic toggle on every R3 press in the house.
|
||||
*
|
||||
* `slotButton` ANDs this rule with `Slot.hasMuteButton`, because BTN_MISC1 is the wire's
|
||||
* misc/QAM bit and a Steam Controller 2's QAM button rides it too. That term needs a live
|
||||
* `Slot`, which needs an InputManager and a main Looper, so it is out of reach from here —
|
||||
* the edge rule below is the half a unit test can hold.
|
||||
*/
|
||||
@Test
|
||||
fun `the mute button toggles the mic once per press`() {
|
||||
fun fires(wasHeld: Int, bit: Int) =
|
||||
GamepadRouter.completesChord(wasHeld, bit, Gamepad.BTN_MISC1)
|
||||
|
||||
assertTrue("a fresh press must toggle", fires(0, Gamepad.BTN_MISC1))
|
||||
assertFalse("auto-repeat re-fired the toggle", fires(Gamepad.BTN_MISC1, Gamepad.BTN_MISC1))
|
||||
assertTrue(
|
||||
"a press while other buttons are held is still a fresh press",
|
||||
fires(Gamepad.BTN_A or Gamepad.BTN_BACK, Gamepad.BTN_MISC1),
|
||||
)
|
||||
for (other in listOf(
|
||||
Gamepad.BTN_A, Gamepad.BTN_X, Gamepad.BTN_Y, Gamepad.BTN_BACK,
|
||||
Gamepad.BTN_LS_CLICK, Gamepad.BTN_RS_CLICK, Gamepad.BTN_GUIDE, Gamepad.BTN_TOUCHPAD,
|
||||
)) {
|
||||
assertFalse("$other toggled the mic", fires(0, other))
|
||||
assertFalse("$other toggled the mic under a held mute", fires(Gamepad.BTN_MISC1, other))
|
||||
}
|
||||
}
|
||||
|
||||
/** The chord bits are the wire's, so they must stay inside the 32-bit button mask. */
|
||||
@Test
|
||||
fun `chord masks are wire button bits`() {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
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 the mute button reach the wire, on the two bits that exist for them.
|
||||
* Android has no keycode for either, so [Gamepad.PadButtons.GENERIC_SONY] borrows BUTTON_15
|
||||
* and BUTTON_16 to carry them into [Gamepad.buttonBit] — the keycode is an implementation
|
||||
* detail of that hop, the BIT is the contract, so both halves are pinned here.
|
||||
*/
|
||||
@Test
|
||||
fun `a DualSense's touchpad and mute reach their wire buttons`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_15, sony(0x13d))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_16, sony(0x13e))
|
||||
assertEquals(Gamepad.BTN_TOUCHPAD, Gamepad.buttonBit(sony(0x13d)))
|
||||
assertEquals(Gamepad.BTN_MISC1, Gamepad.buttonBit(sony(0x13e)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression the touchpad/mute mapping is one hoist away from causing, and the reason it
|
||||
* lives inside GENERIC_SONY rather than anywhere above `padMap(dev)`.
|
||||
*
|
||||
* `0x13d`/`0x13e` are `BTN_THUMBL`/`BTN_THUMBR` — L3 and R3 — in the standard Linux/AOSP
|
||||
* mapping, which is what [Gamepad.genericKeyCode] says they are. They mean touchpad click and
|
||||
* mute ONLY inside the straight-through enumeration a driverless Sony pad uses. Read as
|
||||
* touchpad and mute anywhere else, every Xbox pad, Switch Pro, 8BitDo, Steam Deck and
|
||||
* `hid-playstation` DualSense loses both stick clicks — and R3 starts toggling the microphone.
|
||||
*/
|
||||
@Test
|
||||
fun `every other pad keeps L3 and R3 on those scancodes`() {
|
||||
for (p in listOf(
|
||||
Gamepad.PadButtons.NATIVE,
|
||||
Gamepad.PadButtons.GENERIC_XBOX,
|
||||
Gamepad.PadButtons.SONY_MODERN,
|
||||
)) {
|
||||
val l3 = p.correct(0x13d, Gamepad.genericKeyCode(0x13d))
|
||||
val r3 = p.correct(0x13e, Gamepad.genericKeyCode(0x13e))
|
||||
assertEquals("$p L3", KeyEvent.KEYCODE_BUTTON_THUMBL, l3)
|
||||
assertEquals("$p R3", KeyEvent.KEYCODE_BUTTON_THUMBR, r3)
|
||||
assertEquals("$p L3 bit", Gamepad.BTN_LS_CLICK, Gamepad.buttonBit(l3))
|
||||
assertEquals("$p R3 bit", Gamepad.BTN_RS_CLICK, Gamepad.buttonBit(r3))
|
||||
}
|
||||
}
|
||||
|
||||
/** 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),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard's NEGATIVE path — the half that decides anything.
|
||||
*
|
||||
* The cases above all deliver the keycode `Generic.kl` would have produced, so the guard is
|
||||
* transparent in every one of them and the assertions would hold with it deleted. These are
|
||||
* the ones that fail without it: a device-specific key layout answering something the table
|
||||
* disagrees with, on a scancode the table has an opinion about. The layout wins — it knows
|
||||
* this controller, and the table is only ever a guess about a pad nothing knew.
|
||||
*/
|
||||
@Test
|
||||
fun `a device layout outranks the table on a scancode the table would have rewritten`() {
|
||||
// `Generic.kl` calls 0x134 BUTTON_Y, and GENERIC_SONY/GENERIC_XBOX both rewrite that
|
||||
// scancode to BUTTON_L1. A layout that says BUTTON_X must survive both.
|
||||
for (p in listOf(Gamepad.PadButtons.GENERIC_SONY, Gamepad.PadButtons.GENERIC_XBOX)) {
|
||||
assertEquals("$p", KeyEvent.KEYCODE_BUTTON_X, p.correct(0x134, KeyEvent.KEYCODE_BUTTON_X))
|
||||
}
|
||||
// And the two rows added for the touchpad and mute are no different: a pad whose layout
|
||||
// resolved 0x13d itself keeps that answer rather than the borrowed BUTTON_15.
|
||||
assertEquals(
|
||||
KeyEvent.KEYCODE_BUTTON_1,
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(0x13d, KeyEvent.KEYCODE_BUTTON_1),
|
||||
)
|
||||
}
|
||||
|
||||
/** 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,7 @@ impl ConsoleHost {
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("pf-console".into())
|
||||
.spawn(move || {
|
||||
boost_thread_priority();
|
||||
let run = || -> Result<()> {
|
||||
let console = Console::new(opts, entry, &thread_handles)?;
|
||||
render_loop(console, thread_shared.clone(), thread_store)
|
||||
@@ -249,6 +250,34 @@ impl ConsoleHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: lift the console's render thread off the default nice band, the same way
|
||||
/// `decode::setup::boost_thread_priority` lifts the decode thread. This thread IS the console's
|
||||
/// frame loop — every menu press waits on it — and at default priority a TV box's scheduler is
|
||||
/// free to park it on a little core behind whatever else the system is doing, which reads as a
|
||||
/// UI that lags the remote. `-8` rather than the decode path's `-10`: a stream's frames are the
|
||||
/// harder deadline, and the two should not compete when the console is up during a session.
|
||||
///
|
||||
/// Non-fatal if the platform refuses (the exact floor a foreground app may set is policy).
|
||||
fn boost_thread_priority() {
|
||||
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls; PRIO_PROCESS
|
||||
// with a TID targets that one task on Linux — the idiom `Process.setThreadPriority` uses.
|
||||
unsafe {
|
||||
let tid = libc::gettid();
|
||||
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8) != 0 {
|
||||
log::debug!(
|
||||
"console: setpriority(-8) failed (non-fatal): {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the render loop reports what a frame is costing it. Nothing in a bug report from a
|
||||
/// TV said whether the console was drawing at 4K or at 60 Hz, so "it feels sluggish" could not be
|
||||
/// triaged from a log bundle at all — this is that missing line. One line a minute is cheap
|
||||
/// enough to leave on for everyone, and the answer is only useful from the box that is slow.
|
||||
const FRAME_REPORT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// No input for this long = the console is being looked at, not used — halve the redraw
|
||||
/// rate (`IDLE_FRAME_STEP` slept between swaps). 60 s keeps every interaction and its
|
||||
/// afterglow at full smoothness and only calms a genuinely parked screen.
|
||||
@@ -283,6 +312,9 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
// SurfaceView forever. Dying raises `Dead`, and Kotlin answers with the touch UI.
|
||||
let mut gl_failures = 0u32;
|
||||
const GL_FAILURE_LIMIT: u32 = 3;
|
||||
// What a frame is costing, reported once a `FRAME_REPORT` window (see there).
|
||||
let (mut frames, mut frame_time, mut frame_peak) = (0u32, Duration::ZERO, Duration::ZERO);
|
||||
let mut report_at = Instant::now();
|
||||
|
||||
loop {
|
||||
// Take everything queued. With no surface up, block until something arrives.
|
||||
@@ -446,8 +478,17 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
skia = None;
|
||||
match g.wrap_window(&egl, w, h) {
|
||||
Ok(surf) => {
|
||||
// The console's real render resolution — the one number a bug report
|
||||
// from a TV never carried. A 4K panel is 4× the fragment work of 1080p
|
||||
// for every pass the shell draws.
|
||||
log::info!("console: drawing at {w}×{h}");
|
||||
skia = Some((surf, w, h));
|
||||
gl_failures = 0;
|
||||
// Start the frame window here, not at loop entry: the console parks
|
||||
// with no surface while a stream is up, and a window that had been
|
||||
// open across that would report its first frame as "1 frame in 20 min".
|
||||
(frames, frame_time, frame_peak, report_at) =
|
||||
(0, Duration::ZERO, Duration::ZERO, Instant::now());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("console: {e:#}");
|
||||
@@ -462,6 +503,11 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
insets,
|
||||
scale,
|
||||
};
|
||||
// Around the DRAW only, not the swap: `eglSwapBuffers` blocks on vsync, so
|
||||
// wall-clock per iteration is always ~the panel period and says nothing. What
|
||||
// matters is how much of that period the shell spends building the frame —
|
||||
// once that passes the period, the console is missing vsyncs.
|
||||
let drew = Instant::now();
|
||||
console.frame(
|
||||
surf.canvas(),
|
||||
&viewport,
|
||||
@@ -470,6 +516,20 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
&pads,
|
||||
);
|
||||
g.context.flush_and_submit();
|
||||
let cost = drew.elapsed();
|
||||
frame_time += cost;
|
||||
frame_peak = frame_peak.max(cost);
|
||||
frames += 1;
|
||||
if report_at.elapsed() >= FRAME_REPORT {
|
||||
log::info!(
|
||||
"console: {w}×{h}, {frames} frames in {:?} — {:.1} ms/frame mean, {:.1} ms peak",
|
||||
report_at.elapsed(),
|
||||
frame_time.as_secs_f64() * 1000.0 / f64::from(frames),
|
||||
frame_peak.as_secs_f64() * 1000.0,
|
||||
);
|
||||
(frames, frame_time, frame_peak, report_at) =
|
||||
(0, Duration::ZERO, Duration::ZERO, Instant::now());
|
||||
}
|
||||
if let Err(e) = s.swap() {
|
||||
// The window went away under us; wait for the next surface.
|
||||
log::warn!("console: {e:#} — dropping the surface");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -21,6 +21,25 @@
|
||||
//! handle early at worst reuses a buffer a touch soon (a visible tear), never a use-after-free. The
|
||||
//! fences are the correctness of *timing*, not of memory — which is what lets this ship behind an
|
||||
//! auto-fallback with the residual risk being visual, not a crash.
|
||||
//!
|
||||
//! **The acquire fence must come from `acquireNextImageAsync`, never `acquireLatestImageAsync`.**
|
||||
//! `AImageReader::acquireLatestImage` (`NdkImageReader.cpp`, unfixed as of AOSP main) drains with
|
||||
//! one `int*` out-param it overwrites per image, then releases each dropped image with whatever the
|
||||
//! out-param currently holds — the *successor's* fence:
|
||||
//!
|
||||
//! ```text
|
||||
//! acquireImageLocked(&prev, fd) → *fd = F1 (prev = img1)
|
||||
//! acquireImageLocked(&next, fd) → *fd = F2 (next = img2; F1 overwritten and leaked)
|
||||
//! prev->close(*fd) → reader adopts F2 as img1's release fence, then closes it
|
||||
//! acquireImageLocked(&next, fd) → no buffer; leaves *fd alone
|
||||
//! returns img2 with *fd = F2 ← already given away and closed
|
||||
//! ```
|
||||
//!
|
||||
//! So the moment a burst gives it two images to collapse, the caller is handed a stale fd plus one
|
||||
//! leaked fd per extra drop. Passing that stale fd to `setBuffer` transfers it to SurfaceFlinger,
|
||||
//! which closes it again — an `fdsan` `SIGABRT` on the decode thread, either at `Fence::Fence(int)`
|
||||
//! inside `setBuffer` (the number was already re-owned) or at the end of `Transaction::apply` when
|
||||
//! the layer state is torn down. `AscBackend::drain_reader` therefore does newest-wins itself.
|
||||
|
||||
use ndk::hardware_buffer::HardwareBuffer;
|
||||
use ndk::media::image_reader::{AcquireResult, Image, ImageFormat, ImageReader};
|
||||
@@ -142,21 +161,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(
|
||||
@@ -376,19 +395,24 @@ impl AscBackend {
|
||||
true
|
||||
}
|
||||
|
||||
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older are
|
||||
/// dropped back to the pool by `acquireLatest`); smooth keeps order up to capacity.
|
||||
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older ones
|
||||
/// drop back to the pool as they are superseded); smooth keeps order up to capacity.
|
||||
///
|
||||
/// Both modes drain with `acquireNextImageAsync`, one image at a time. `acquireLatestImageAsync`
|
||||
/// is the obvious newest-wins call and is NOT usable — see the acquire-fence note at the top of
|
||||
/// this module.
|
||||
fn drain_reader(&mut self) {
|
||||
if self.fifo_capacity == 0 {
|
||||
// Newest-wins: one acquire-latest collapses the whole burst to the freshest buffer.
|
||||
if let Some(acq) = self.acquire(true) {
|
||||
// Newest-wins: collapse the burst to the freshest buffer ourselves. Each superseded
|
||||
// candidate drops here — its image returns to the pool, its own acquire fence closes.
|
||||
while let Some(acq) = self.acquire() {
|
||||
if self.candidate.replace(acq).is_some() {
|
||||
self.skipped += 1; // an un-presented candidate was superseded
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Smooth: pull every ready image in order into the FIFO, evicting the oldest past cap.
|
||||
while let Some(acq) = self.acquire(false) {
|
||||
while let Some(acq) = self.acquire() {
|
||||
self.fifo.push_back(acq);
|
||||
while self.fifo.len() > self.fifo_capacity {
|
||||
self.fifo.pop_front();
|
||||
@@ -398,19 +422,13 @@ impl AscBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire one image (`latest` drops older, else FIFO) and pair its decode stamps + cadence due.
|
||||
/// `None` when the reader is empty or a transient acquire error occurs.
|
||||
fn acquire(&mut self, latest: bool) -> Option<Acquired> {
|
||||
/// Acquire the next image and pair its decode stamps + cadence due. `None` when the reader is
|
||||
/// empty or a transient acquire error occurs.
|
||||
fn acquire(&mut self) -> Option<Acquired> {
|
||||
// SAFETY: we never touch the image's pixels — the acquire fence is handed straight to
|
||||
// SurfaceFlinger via `setBuffer`, which is exactly the "await before access" the async
|
||||
// acquire requires.
|
||||
let res = unsafe {
|
||||
if latest {
|
||||
self.reader.acquire_latest_image_async()
|
||||
} else {
|
||||
self.reader.acquire_next_image_async()
|
||||
}
|
||||
};
|
||||
let res = unsafe { self.reader.acquire_next_image_async() };
|
||||
let (image, fence) = match res {
|
||||
Ok(AcquireResult::Image(pair)) => pair,
|
||||
Ok(_) => return None, // no buffer available / max acquired
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -317,6 +317,12 @@ impl ImageReader {
|
||||
/// If the returned file descriptor is not [`None`], it must be awaited before attempting to
|
||||
/// access the [`Image`] returned.
|
||||
///
|
||||
/// **The returned fence is unsound whenever the platform actually drops an older image.**
|
||||
/// `AImageReader::acquireLatestImage` reuses one out-param across the drain and releases each
|
||||
/// dropped image with the *successor's* fence fd, so the fd handed back has already been given
|
||||
/// to the reader (and closed by it) — adopting it here yields a double close and an `fdsan`
|
||||
/// abort. Drain with [`ImageReader::acquire_next_image_async()`] and pick the newest yourself.
|
||||
///
|
||||
/// <https://developer.android.com/ndk/reference/group/media#aimagereader_acquirelatestimageasync>
|
||||
#[cfg(feature = "api-level-26")]
|
||||
#[doc(alias = "AImageReader_acquireLatestImageAsync")]
|
||||
|
||||
+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()),
|
||||
|
||||
@@ -1250,7 +1250,9 @@ pub fn show_scoped(
|
||||
"Above 1× supersamples for sharpness; below is lighter on the host",
|
||||
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||
// 1 Mbit/s per step: the rungs that matter on a thin link are 3, 4, 6 — a 5-wide step
|
||||
// could not name any of them, and typing was the only way to reach one.
|
||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 1.0);
|
||||
bitrate_row.set_title("Bitrate");
|
||||
bitrate_row
|
||||
.set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test");
|
||||
|
||||
@@ -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,
|
||||
@@ -242,16 +246,22 @@ impl Screen {
|
||||
match self {
|
||||
Screen::AddHost(s) => s.text_input(text),
|
||||
Screen::Pair(s) => s.text_input(text),
|
||||
Screen::Settings(s) => s.text_input(text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw key edits while a field is editing (Backspace repeats, Return = done).
|
||||
/// Returns true when consumed.
|
||||
pub(crate) fn edit_key(&mut self, key: crate::input::Key) -> bool {
|
||||
///
|
||||
/// Takes the context because a field can commit into the settings store on close —
|
||||
/// the settings screen's typed bitrate does, where add-host and pair only hold text a
|
||||
/// later action row reads.
|
||||
pub(crate) fn edit_key(&mut self, key: crate::input::Key, ctx: &mut Ctx) -> bool {
|
||||
match self {
|
||||
Screen::AddHost(s) => s.edit_key(key),
|
||||
Screen::Pair(s) => s.edit_key(key),
|
||||
Screen::Settings(s) => s.edit_key(key, ctx),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -261,6 +271,7 @@ impl Screen {
|
||||
match self {
|
||||
Screen::AddHost(s) => s.editing(),
|
||||
Screen::Pair(s) => s.editing(),
|
||||
Screen::Settings(s) => s.editing(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,9 @@ use crate::glyphs::{Hint, HintKey};
|
||||
use crate::pointer::Pointer;
|
||||
use crate::screens::{Ctx, Outbox, Screen};
|
||||
use crate::theme::{fg, Fonts, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H};
|
||||
use crate::widgets::{
|
||||
permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H,
|
||||
};
|
||||
use pf_client_core::audio_format::{AUDIO_FORMATS, AUDIO_FORMAT_OPUS};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuPulse};
|
||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
||||
@@ -80,6 +82,12 @@ enum RowId {
|
||||
/// beside the palette row for the same reason it does: both are presentation, and the
|
||||
/// effect of stepping this one is visible on the backdrop behind it.
|
||||
ReduceMotion,
|
||||
/// Draw the console at 1080p and let the display scale it up, instead of at the panel's
|
||||
/// own resolution. Android-only, and beside [`RowId::ReduceMotion`] on purpose: both are
|
||||
/// "give up some fidelity for a smoother console", and this is the one that matters on a
|
||||
/// 4K TV or projector, where every pass the shell draws costs four times what it does at
|
||||
/// 1080p on a GPU that is not four times faster.
|
||||
ReduceUiResolution,
|
||||
/// How the game library arranges its titles — see `library::LibraryView`. The library
|
||||
/// changes it in place now, from the bar over its own field, which is where an
|
||||
/// arrangement you want to SEE the effect of belongs; this row stays because both
|
||||
@@ -104,6 +112,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 +135,8 @@ 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";
|
||||
pub const REDUCE_UI_RES: &str = "android.reduce_ui_resolution";
|
||||
}
|
||||
|
||||
/// The Android console-UI mode's stored values (`GamepadUi.kt`).
|
||||
@@ -240,11 +256,13 @@ const TABS: [(&str, &[RowId]); 7] = [
|
||||
&[
|
||||
RowId::Palette,
|
||||
RowId::ReduceMotion,
|
||||
RowId::ReduceUiResolution,
|
||||
RowId::LibraryView,
|
||||
RowId::LibraryCollections,
|
||||
RowId::Stats,
|
||||
RowId::Fullscreen,
|
||||
RowId::AutoWake,
|
||||
RowId::GamepadUi,
|
||||
RowId::GamepadUiMode,
|
||||
RowId::Licenses,
|
||||
],
|
||||
@@ -272,7 +290,18 @@ const RESOLUTIONS: [(u32, u32); 6] = [
|
||||
const REFRESH: [u32; 5] = [0, 30, 60, 90, 120];
|
||||
/// Mirrors [`punktfunk_core::render_scale::PRESETS`] (and the desktop pickers).
|
||||
const RENDER_SCALES: [f64; 9] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
const BITRATES: [u32; 7] = [0, 5_000, 10_000, 20_000, 30_000, 50_000, 80_000];
|
||||
/// The rungs left/right steps through, in kbps. Tight at the bottom, where one rung is the
|
||||
/// difference between watchable and a slideshow on a thin link, and coarse at the top, where
|
||||
/// a rung is noise; the ceiling is 2 Gbps. The list is deliberately long — a ladder no thumb
|
||||
/// can walk to the value it wants is what the Y field is for.
|
||||
const BITRATES: [u32; 30] = [
|
||||
0, 1_000, 2_000, 3_000, 4_000, 5_000, 6_000, 8_000, 10_000, 12_000, 15_000, 20_000, 25_000,
|
||||
30_000, 40_000, 50_000, 60_000, 80_000, 100_000, 125_000, 150_000, 200_000, 250_000, 300_000,
|
||||
400_000, 500_000, 750_000, 1_000_000, 1_500_000, 2_000_000,
|
||||
];
|
||||
/// What the typed field accepts, in Mbps: the ladder's own ceiling. The host clamps to its
|
||||
/// range anyway (500 kbps – 8 Gbps), so this is about what a client should let you ask for.
|
||||
const CUSTOM_MAX_MBPS: u32 = 2_000;
|
||||
const COMPOSITORS: [(&str, &str); 5] = [
|
||||
("auto", "Automatic"),
|
||||
("kwin", "KWin"),
|
||||
@@ -355,6 +384,14 @@ pub(crate) struct SettingsScreen {
|
||||
/// can't create profiles (design §5.4: the desktop app does), so the list is stable
|
||||
/// for the screen's lifetime.
|
||||
profiles: Vec<(String, String)>,
|
||||
/// The Bitrate row's typed rate in Mbps while Y has the field open — `None` the rest of
|
||||
/// the time. Every other row on this screen is a list of options, and a ladder is the
|
||||
/// right shape for a list; a bitrate is a NUMBER, and the one a link actually carries is
|
||||
/// rarely a round rung. Y rather than A so the A-cycles-forward grammar holds everywhere.
|
||||
custom_bitrate: Option<String>,
|
||||
/// The tray keyboard the field types through, where the platform has no keyboard of its
|
||||
/// own (on a Deck, Steam's keyboard types and ours never draws — same rule as add-host).
|
||||
keyboard: Keyboard,
|
||||
}
|
||||
|
||||
impl SettingsScreen {
|
||||
@@ -372,6 +409,112 @@ impl SettingsScreen {
|
||||
tab: 0,
|
||||
tab_cursors: [0; TABS.len()],
|
||||
profiles,
|
||||
custom_bitrate: None,
|
||||
keyboard: Keyboard::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A text field is open — the run loop keeps SDL text input started, so a hardware
|
||||
/// keyboard (and Steam's, on a Deck) types straight into it.
|
||||
pub(crate) fn editing(&self) -> bool {
|
||||
self.custom_bitrate.is_some()
|
||||
}
|
||||
|
||||
/// Committed text from SDL. Digits only, four of them: 2000 Mbps is the ceiling.
|
||||
pub(crate) fn text_input(&mut self, text: &str) {
|
||||
for ch in text.chars() {
|
||||
self.type_char(ch);
|
||||
}
|
||||
}
|
||||
|
||||
fn type_char(&mut self, ch: char) -> bool {
|
||||
let Some(buf) = self.custom_bitrate.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if !permits(Charset::Digits, ch) || buf.chars().count() >= 4 {
|
||||
return false;
|
||||
}
|
||||
buf.push(ch);
|
||||
true
|
||||
}
|
||||
|
||||
fn backspace(&mut self) -> bool {
|
||||
self.custom_bitrate.as_mut().and_then(String::pop).is_some()
|
||||
}
|
||||
|
||||
/// Raw key edits while the field is open (Backspace repeats, Return/Escape are done).
|
||||
pub(crate) fn edit_key(&mut self, key: crate::input::Key, ctx: &mut Ctx) -> bool {
|
||||
use crate::input::Key as K;
|
||||
if self.custom_bitrate.is_none() {
|
||||
return false;
|
||||
}
|
||||
match key {
|
||||
K::Backspace => {
|
||||
self.backspace();
|
||||
true
|
||||
}
|
||||
K::Return | K::Escape => {
|
||||
self.commit_custom(ctx);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the field, storing what was typed. An empty field (or a typed `0`) leaves the
|
||||
/// rate alone: a cleared field is an abandoned edit, and "let the host decide" is the
|
||||
/// ladder's own first rung, not something to reach by deleting four digits.
|
||||
fn commit_custom(&mut self, ctx: &mut Ctx) {
|
||||
let Some(text) = self.custom_bitrate.take() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mbps) = text.parse::<u32>() else {
|
||||
return;
|
||||
};
|
||||
if mbps == 0 {
|
||||
return;
|
||||
}
|
||||
// The same rebase-then-save every other write here does: another writer may have
|
||||
// stored the file while the keyboard was up.
|
||||
*ctx.settings = ctx.store.load();
|
||||
ctx.settings.bitrate_kbps = mbps.min(CUSTOM_MAX_MBPS) * 1000;
|
||||
ctx.store.save(ctx.settings);
|
||||
}
|
||||
|
||||
/// The field is modal while it is up: the tray takes the events, and closing commits.
|
||||
fn custom_menu(&mut self, ev: MenuEvent, ctx: &mut Ctx) -> Option<MenuPulse> {
|
||||
if ctx.deck {
|
||||
// Steam's keyboard is doing the typing (text arrives through `text_input`); the
|
||||
// pad is only here to say when it's done.
|
||||
return match ev {
|
||||
MenuEvent::Back | MenuEvent::Confirm => {
|
||||
self.commit_custom(ctx);
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let (msg, pulse) = self.keyboard.menu(ev);
|
||||
match msg {
|
||||
KeyMsg::Type(c) => {
|
||||
if self.type_char(c) {
|
||||
Some(MenuPulse::Move)
|
||||
} else {
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
KeyMsg::Backspace => {
|
||||
if self.backspace() {
|
||||
Some(MenuPulse::Move)
|
||||
} else {
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
KeyMsg::Done => {
|
||||
self.commit_custom(ctx);
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
KeyMsg::None => pulse,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +527,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() {
|
||||
@@ -441,6 +584,27 @@ impl SettingsScreen {
|
||||
/// Mouse/touch. The strip is checked first — its pills sit above the list and a press
|
||||
/// there is never meant for a row.
|
||||
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
||||
if self.custom_bitrate.is_some() && !ctx.deck {
|
||||
if !self.keyboard.covers(p) {
|
||||
if p.press() {
|
||||
self.commit_custom(ctx);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let (msg, _) = self.keyboard.pointer(p);
|
||||
match msg {
|
||||
KeyMsg::Type(c) => {
|
||||
self.type_char(c);
|
||||
}
|
||||
KeyMsg::Backspace => {
|
||||
self.backspace();
|
||||
}
|
||||
KeyMsg::Done => self.commit_custom(ctx),
|
||||
KeyMsg::None => {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if let Some(tab) = self.strip.pointer(p) {
|
||||
self.show_tab(tab, ctx);
|
||||
return true;
|
||||
@@ -461,6 +625,9 @@ impl SettingsScreen {
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if self.custom_bitrate.is_some() {
|
||||
return self.custom_menu(ev, ctx);
|
||||
}
|
||||
match ev {
|
||||
MenuEvent::Back => {
|
||||
fx.pop();
|
||||
@@ -472,6 +639,16 @@ impl SettingsScreen {
|
||||
}
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
// Y on the Bitrate row opens the typed rate; on every other row it means nothing,
|
||||
// and the hint bar only offers it where it does.
|
||||
if ev == MenuEvent::Secondary {
|
||||
return if ids.get(self.list.cursor) == Some(&RowId::Bitrate) {
|
||||
self.custom_bitrate = Some(String::new());
|
||||
Some(MenuPulse::Confirm)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
let (msg, pulse) = self.list.menu(ev, ids.len());
|
||||
self.apply_row(msg, pulse, &ids, ctx, fx)
|
||||
}
|
||||
@@ -574,6 +751,20 @@ impl SettingsScreen {
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
if self.custom_bitrate.is_some() {
|
||||
if ctx.deck {
|
||||
return vec![
|
||||
Hint::new(HintKey::Key("STEAM + X"), "Keyboard"),
|
||||
Hint::new(HintKey::Confirm, "Done"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
return vec![
|
||||
Hint::new(HintKey::Confirm, "Type"),
|
||||
Hint::new(HintKey::Tertiary, "Delete"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
let ids = self.row_ids(ctx);
|
||||
// The shoulders always change section, so that hint leads on every row.
|
||||
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
|
||||
@@ -587,6 +778,12 @@ impl SettingsScreen {
|
||||
Hint::new(HintKey::Confirm, "Open"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
// The one row with a value the ladder cannot name every version of.
|
||||
Some(RowId::Bitrate) => vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Secondary, "Type a rate"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
Some(_) => vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Confirm, "Change"),
|
||||
@@ -619,20 +816,49 @@ impl SettingsScreen {
|
||||
k,
|
||||
dt,
|
||||
);
|
||||
let seat = self
|
||||
.keyboard
|
||||
.seat(self.custom_bitrate.is_some() && !ctx.deck, dt);
|
||||
let tray_h = if seat > 0.0 {
|
||||
(Keyboard::tray_height() + 12.0) * k * seat
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top + strip_h as f32,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
rect.bottom - detail_h as f32 - tray_h as f32,
|
||||
);
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let rows: Vec<RowSpec> = ids
|
||||
let mut rows: Vec<RowSpec> = ids
|
||||
.iter()
|
||||
.map(|id| row_spec(*id, ctx, &self.profiles))
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
// While the field is open the Bitrate row IS the field: it shows the digits typed so
|
||||
// far and carries the caret, so the value being edited is where the value lives.
|
||||
if let (Some(text), Some(i)) = (
|
||||
self.custom_bitrate.as_ref(),
|
||||
ids.iter().position(|id| *id == RowId::Bitrate),
|
||||
) {
|
||||
rows[i].value = Some(if text.is_empty() {
|
||||
"Mbps".into()
|
||||
} else {
|
||||
format!("{text} Mbps")
|
||||
});
|
||||
rows[i].value_dim = text.is_empty();
|
||||
rows[i].caret = true;
|
||||
}
|
||||
self.list.render(
|
||||
canvas,
|
||||
list_rect,
|
||||
&rows,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
self.custom_bitrate.is_none(),
|
||||
);
|
||||
let detail = ids
|
||||
.get(self.list.cursor)
|
||||
.copied()
|
||||
@@ -644,9 +870,19 @@ impl SettingsScreen {
|
||||
13.0 * k,
|
||||
fg(0.55),
|
||||
f64::from(rect.left) + f64::from(rect.width()) / 2.0,
|
||||
f64::from(rect.bottom) - detail_h + 6.0 * k,
|
||||
f64::from(rect.bottom) - detail_h - tray_h + 6.0 * k,
|
||||
f64::from(rect.width()) * 0.8,
|
||||
);
|
||||
if seat > 0.0 {
|
||||
self.keyboard.render(
|
||||
canvas,
|
||||
fonts,
|
||||
f64::from(rect.width()),
|
||||
f64::from(rect.bottom),
|
||||
seat,
|
||||
k,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,7 +911,9 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
|
||||
| RowId::PhoneGyro
|
||||
| RowId::Sc2Passthrough
|
||||
| RowId::DsCapture
|
||||
| RowId::GamepadUi
|
||||
| RowId::GamepadUiMode
|
||||
| RowId::ReduceUiResolution
|
||||
| RowId::Controllers
|
||||
| RowId::Licenses
|
||||
);
|
||||
@@ -694,9 +932,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,
|
||||
}
|
||||
}
|
||||
@@ -806,7 +1057,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
if s.bitrate_kbps == 0 {
|
||||
"Automatic".into()
|
||||
} else {
|
||||
format!("{} Mbps", s.bitrate_kbps / 1000)
|
||||
bitrate_label(s.bitrate_kbps)
|
||||
},
|
||||
),
|
||||
RowId::Compositor => (
|
||||
@@ -914,6 +1165,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// Phrased as the thing that is ON, not as the suppression, so "On" means the
|
||||
// reduction is in effect — the same way every other toggle on this screen reads.
|
||||
RowId::ReduceMotion => (None, "Reduce motion", on_off(s.reduce_motion).into()),
|
||||
RowId::ReduceUiResolution => (
|
||||
None,
|
||||
"Reduce interface resolution",
|
||||
on_off(extra_bool(s, android_keys::REDUCE_UI_RES, false)).into(),
|
||||
),
|
||||
RowId::LibraryView => (
|
||||
None,
|
||||
"Library view",
|
||||
@@ -962,9 +1218,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"),
|
||||
@@ -1001,7 +1265,9 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
"The host renders larger or smaller than the stream mode and this window \
|
||||
resamples — above 1× supersamples, below saves bandwidth."
|
||||
}
|
||||
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
|
||||
RowId::Bitrate => {
|
||||
"Automatic uses the host's default (20 Mbps). Y types an exact rate, up to 2 Gbps."
|
||||
}
|
||||
RowId::Compositor => {
|
||||
"Which compositor drives the virtual output — honored only if available on the host."
|
||||
}
|
||||
@@ -1101,6 +1367,12 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
fades. Also the gentler choice on an OLED, where a still field can sit for \
|
||||
hours."
|
||||
}
|
||||
RowId::ReduceUiResolution => {
|
||||
"Draws the menus at 1080p and lets the display scale them up. Text goes a \
|
||||
little softer; the console gets much smoother on a 4K TV or projector, whose \
|
||||
graphics chip is far slower than the panel in front of it. Nothing about a \
|
||||
stream changes — this is the interface only."
|
||||
}
|
||||
RowId::LibraryView => {
|
||||
"Shelf shows one cover at a time, big. Grid shows about eighteen at once — \
|
||||
for when you already know what you are looking for. The library's own bar \
|
||||
@@ -1145,9 +1417,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.",
|
||||
@@ -1164,6 +1441,26 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A rate as the row says it: Mbps to a gigabit, Gbps above it, and a decimal only where
|
||||
/// dropping one would print two different rates the same way (12.5 Mbps, 1.5 Gbps). Rates
|
||||
/// off the ladder are real — the field below types them, and the desktop shells' free-form
|
||||
/// spinner has always been able to store one.
|
||||
fn bitrate_label(kbps: u32) -> String {
|
||||
let unit = |v: f64, suffix: &str| {
|
||||
if (v - v.round()).abs() < 0.05 {
|
||||
format!("{} {suffix}", v.round())
|
||||
} else {
|
||||
format!("{v:.1} {suffix}")
|
||||
}
|
||||
};
|
||||
let mbps = f64::from(kbps) / 1000.0;
|
||||
if kbps >= 1_000_000 {
|
||||
unit(mbps / 1000.0, "Gbps")
|
||||
} else {
|
||||
unit(mbps, "Mbps")
|
||||
}
|
||||
}
|
||||
|
||||
fn on_off(v: bool) -> &'static str {
|
||||
if v {
|
||||
"On"
|
||||
@@ -1228,8 +1525,21 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
.map(|i| s.render_scale = RENDER_SCALES[i])
|
||||
}
|
||||
RowId::Bitrate => {
|
||||
let cur = BITRATES.iter().position(|b| *b == s.bitrate_kbps);
|
||||
step_option(cur, BITRATES.len(), delta, wrap).map(|i| s.bitrate_kbps = BITRATES[i])
|
||||
// A typed rate (or one a desktop shell's spinner stored) sits BETWEEN rungs, and
|
||||
// the generic step snaps a value it cannot find to the first option — which here
|
||||
// is Automatic, i.e. one nudge throws the custom rate away. Step to the rung the
|
||||
// thumb is heading for instead.
|
||||
let stepped = match BITRATES.iter().position(|b| *b == s.bitrate_kbps) {
|
||||
Some(i) => step_option(Some(i), BITRATES.len(), delta, wrap),
|
||||
None if delta < 0 => BITRATES.iter().rposition(|b| *b < s.bitrate_kbps),
|
||||
// Above the top rung there is nothing higher to step to; A (which wraps) still
|
||||
// comes back round to Automatic.
|
||||
None => BITRATES
|
||||
.iter()
|
||||
.position(|b| *b > s.bitrate_kbps)
|
||||
.or(if wrap { Some(0) } else { None }),
|
||||
};
|
||||
stepped.map(|i| s.bitrate_kbps = BITRATES[i])
|
||||
}
|
||||
RowId::Compositor => step_str(&COMPOSITORS, &mut s.compositor, delta, wrap),
|
||||
RowId::Codec => step_str(&CODECS, &mut s.codec, delta, wrap),
|
||||
@@ -1361,6 +1671,9 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, all.len(), delta, wrap).map(|i| s.ui_palette = all[i].id.to_string())
|
||||
}
|
||||
RowId::ReduceMotion => toggle(&mut s.reduce_motion, delta, wrap),
|
||||
RowId::ReduceUiResolution => {
|
||||
toggle_extra(s, android_keys::REDUCE_UI_RES, false, delta, wrap)
|
||||
}
|
||||
RowId::LibraryView => {
|
||||
let all = &crate::library::LibraryView::ALL;
|
||||
let cur = crate::library::LibraryView::parse(&s.library_view);
|
||||
@@ -1375,6 +1688,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 +1818,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 +1853,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 +1919,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 +1969,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 +2004,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 +2046,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 +2077,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 +2114,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 +2189,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 +2223,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 +2255,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1947,10 +2272,14 @@ pub(super) mod tests {
|
||||
assert_eq!(ctx.settings.mouse_mode, "capture");
|
||||
}
|
||||
|
||||
/// A rate that is not a rung — typed on the row, or stored by a desktop shell's
|
||||
/// free-form spinner — steps to its NEIGHBOUR. Every other picker here snaps an
|
||||
/// unrecognised value to its first option, which on this row is Automatic: one nudge
|
||||
/// would throw away the exact rate the user went to the trouble of typing.
|
||||
#[test]
|
||||
fn unknown_value_snaps_to_first() {
|
||||
fn an_off_ladder_rate_steps_to_its_neighbour() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
settings.bitrate_kbps = 12_345; // set via a desktop shell's free-form field
|
||||
settings.bitrate_kbps = 12_345;
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
@@ -1960,11 +2289,86 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic");
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 15_000, "the rung above");
|
||||
ctx.settings.bitrate_kbps = 12_345;
|
||||
assert!(adjust(RowId::Bitrate, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 12_000, "the rung below");
|
||||
// The ends still thud rather than wrap under left/right.
|
||||
ctx.settings.bitrate_kbps = 2_000_000;
|
||||
assert!(!adjust(RowId::Bitrate, 1, false, &mut ctx), "the ceiling");
|
||||
// …and a rung it does know steps as it always did.
|
||||
ctx.settings.bitrate_kbps = 5_000;
|
||||
assert!(adjust(RowId::Bitrate, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 4_000);
|
||||
}
|
||||
|
||||
/// The typed rate: Y opens the field on the Bitrate row (and nowhere else), digits land
|
||||
/// in it, and closing stores what was typed — clamped to the ceiling, because four
|
||||
/// digits can ask for 9999 Mbps and no client should send that.
|
||||
#[test]
|
||||
fn a_typed_bitrate_is_stored_and_clamped() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
// A snapshot store, not the file one: this test SAVES, and a unit test must not
|
||||
// rewrite the machine's real settings file to prove it.
|
||||
let store = crate::store::SnapshotStore::new(settings.clone(), Vec::new());
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: &store,
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
let mut fx = Outbox::default();
|
||||
let ids = s.row_ids(&ctx);
|
||||
s.list.cursor = ids
|
||||
.iter()
|
||||
.position(|id| *id == RowId::Bitrate)
|
||||
.expect("the bitrate row");
|
||||
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
|
||||
assert!(s.editing(), "Y opens the field");
|
||||
s.text_input("13x7"); // digits only: the 'x' is refused, not typed
|
||||
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
|
||||
assert!(!s.editing(), "Return closes it");
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 137_000);
|
||||
|
||||
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
|
||||
s.text_input("99999"); // four digits fit; the fifth is refused
|
||||
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
|
||||
assert_eq!(
|
||||
ctx.settings.bitrate_kbps, 2_000_000,
|
||||
"clamped to the ceiling"
|
||||
);
|
||||
|
||||
// An emptied field is an abandoned edit, not a request for Automatic.
|
||||
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
|
||||
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 2_000_000, "left alone");
|
||||
|
||||
// Y is the bitrate row's alone — on a neighbour it does nothing at all.
|
||||
s.list.cursor = 0;
|
||||
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
|
||||
assert!(!s.editing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rates_read_in_the_biggest_round_unit() {
|
||||
assert_eq!(bitrate_label(20_000), "20 Mbps");
|
||||
assert_eq!(bitrate_label(12_500), "12.5 Mbps");
|
||||
assert_eq!(bitrate_label(1_000_000), "1 Gbps");
|
||||
assert_eq!(bitrate_label(1_500_000), "1.5 Gbps");
|
||||
assert_eq!(bitrate_label(2_000_000), "2 Gbps");
|
||||
}
|
||||
|
||||
/// The Profiles section trails the settings rows: one row per catalog profile whose
|
||||
@@ -2009,6 +2413,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 +2465,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 +2511,10 @@ pub(super) mod tests {
|
||||
RowId::Sc2Passthrough,
|
||||
RowId::DsCapture,
|
||||
RowId::Controllers,
|
||||
// Between the Input tab's rows and the rest of Interface: this one sits under
|
||||
// Reduce motion, which is earlier in that tab than the console-UI switch.
|
||||
RowId::ReduceUiResolution,
|
||||
RowId::GamepadUi,
|
||||
RowId::GamepadUiMode,
|
||||
RowId::Licenses,
|
||||
]
|
||||
@@ -2147,6 +2557,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 +2567,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,11 +2609,12 @@ 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 ten Android-only ones (design android-skia-console-port.md
|
||||
// D3): eight `extra`-backed settings and two platform-screen action rows.
|
||||
assert_eq!(seen.len(), 45, "{seen:?}");
|
||||
assert!(seen.contains(&RowId::Palette));
|
||||
assert!(seen.contains(&RowId::ReduceMotion));
|
||||
assert!(seen.contains(&RowId::ReduceUiResolution));
|
||||
assert!(seen.contains(&RowId::AudioFormat));
|
||||
// The catalog rows belong to the trailing tab, which builds them at render time.
|
||||
assert!(TABS[PROFILES_TAB].1.is_empty());
|
||||
@@ -2209,6 +2651,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 +2693,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 +2739,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 +2818,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(),
|
||||
};
|
||||
@@ -990,8 +1005,20 @@ impl Shell {
|
||||
pub(crate) fn key(&mut self, key: crate::input::Key, shift: bool, repeat: bool) -> bool {
|
||||
use crate::input::Key as S;
|
||||
if self.editing() {
|
||||
let mut ctx = Ctx {
|
||||
hosts: &self.hosts,
|
||||
library: &self.library,
|
||||
settings: &mut self.settings,
|
||||
store: &*self.store,
|
||||
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(),
|
||||
};
|
||||
if let Some(top) = self.stack.last_mut() {
|
||||
if top.edit_key(key) {
|
||||
if top.edit_key(key, &mut ctx) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,8 +163,19 @@ impl Shell {
|
||||
let bw = lead + tw + pad_x;
|
||||
let bx = (w - bw) / 2.0;
|
||||
let by = h - BOTTOM_BAND * k - bh - 8.0 * k + (1.0 - slide) * 12.0 * k;
|
||||
canvas.save_layer_alpha_f(None, alpha);
|
||||
let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32);
|
||||
// BOUNDED to the pill. Unbounded, `save_layer` allocates an offscreen the size of
|
||||
// the whole SURFACE and composites it back — on a 4K TV that is a 33 MB render
|
||||
// target raised and torn down every frame, for four seconds, to fade a 34 dp pill
|
||||
// (and on a box whose whole Skia budget is 64 MB, it evicts real work to do it).
|
||||
//
|
||||
// Everything drawn inside is inside `rect`: the pill fill, `theme::panel`'s
|
||||
// hairline ON that rect, the kind mark centred in it, and text that ends a `pad_x`
|
||||
// short of its right edge. There is no blur to reach further, so the outset is
|
||||
// slack for the stroke rather than a computed reach — `screens::home` needs 36 k
|
||||
// for the same layer only because it wraps a σ = 10 k halo.
|
||||
let bounds = rect.with_outset((12.0 * k as f32, 12.0 * k as f32));
|
||||
canvas.save_layer_alpha_f(Some(bounds), alpha);
|
||||
canvas.draw_rrect(
|
||||
skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32),
|
||||
&fill(crate::theme::shade(0.6)),
|
||||
|
||||
@@ -67,6 +67,8 @@ impl Shell {
|
||||
}
|
||||
None => dt,
|
||||
};
|
||||
// The shaped-paragraph cache's clock, before anything asks it to draw.
|
||||
fonts.begin_frame();
|
||||
self.sync();
|
||||
// Publish the palette's ink before ANYTHING draws — every widget, glyph and panel in
|
||||
// the crate reads it (see `theme::set_ink`), so a frame that skipped this would paint
|
||||
@@ -80,10 +82,14 @@ impl Shell {
|
||||
crate::theme::set_reduce_motion(reduce);
|
||||
self.pads = pads.to_vec();
|
||||
self.glyphs = GlyphStyle::from_pref(pad_pref);
|
||||
self.chip = Some(pad.map_or_else(
|
||||
|| "No controller — keyboard works too".to_string(),
|
||||
str::to_owned,
|
||||
));
|
||||
// Compared before it is rebuilt: this string changes when someone plugs a controller
|
||||
// in, and was being re-allocated 60 times a second to say so. (`pads` above is left
|
||||
// alone — it is at most a handful of small structs, and `PadInfo` would have to grow a
|
||||
// `PartialEq` in another crate to be worth the same treatment.)
|
||||
let chip = pad.unwrap_or("No controller — keyboard works too");
|
||||
if self.chip.as_deref() != Some(chip) {
|
||||
self.chip = Some(chip.to_owned());
|
||||
}
|
||||
|
||||
let (full_w, full_h) = (f64::from(viewport.width), f64::from(viewport.height));
|
||||
let ins = viewport.insets;
|
||||
@@ -172,6 +178,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 +338,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,
|
||||
@@ -350,7 +359,26 @@ impl LayerEnv<'_> {
|
||||
scale: f64,
|
||||
) -> Vec<(crate::glyphs::HintKey, Rect)> {
|
||||
let canvas = self.canvas;
|
||||
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
|
||||
// Only RAISE the layer when it carries something. A settled screen is painted at full
|
||||
// alpha, unscaled and unslid, and an unbounded `save_layer` allocates an offscreen the
|
||||
// size of the whole SURFACE and composites it back — so the console was paying for one
|
||||
// full-screen offscreen on every frame it sat still, to apply an alpha of 1. Skia does
|
||||
// not elide it either: `SkCanvas::saveLayerAlphaf` forwards alpha ≥ 1 straight to
|
||||
// `saveLayer(bounds, nullptr)`, whose only early-out is an empty clip.
|
||||
//
|
||||
// Dropping the layer is pixel-identical rather than merely close: nothing in this crate
|
||||
// draws with a blend mode other than `SrcOver`, and `SrcOver` is associative, so
|
||||
// compositing the draws into a transparent layer and then over the backdrop lands on
|
||||
// exactly the value drawing them straight onto the backdrop does. (It is also why the
|
||||
// text stays grayscale-AA — no LCD subpixel text to gain or lose an isolation.) Same
|
||||
// reasoning `screens::home` already bounds its per-tile layer by.
|
||||
let layered = alpha < 0.999 || (scale - 1.0).abs() > 0.001 || dy.abs() > 0.001;
|
||||
if layered {
|
||||
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
|
||||
} else {
|
||||
// Still a save: the transform below is undone by the same `restore`.
|
||||
canvas.save();
|
||||
}
|
||||
canvas.translate((0.0, dy as f32));
|
||||
let (cx, cy) = ((self.w / 2.0) as f32, (self.h / 2.0) as f32);
|
||||
canvas.translate((cx, cy));
|
||||
@@ -365,6 +393,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);
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use skia_safe::textlayout::{
|
||||
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle, TypefaceFontProvider,
|
||||
FontCollection, Paragraph, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle,
|
||||
TypefaceFontProvider,
|
||||
};
|
||||
use skia_safe::{
|
||||
gradient, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect, Point,
|
||||
RRect, Rect, TileMode, Typeface,
|
||||
};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// --- Paint ----------------------------------------------------------------------------------
|
||||
|
||||
@@ -396,9 +399,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 +449,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) {
|
||||
@@ -509,7 +524,7 @@ pub(crate) const EDGE_INSET: f64 = 24.0;
|
||||
// --- Typography ---------------------------------------------------------------------------
|
||||
|
||||
/// Geist weights the console uses (matching the Apple client's `.geist(size, weight)`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum W {
|
||||
Regular,
|
||||
Medium,
|
||||
@@ -526,6 +541,111 @@ pub(crate) struct Fonts {
|
||||
semibold: Typeface,
|
||||
bold: Typeface,
|
||||
collection: FontCollection,
|
||||
/// Shaped paragraphs, keyed by everything that shapes one ([`ParaKey`]).
|
||||
///
|
||||
/// `Paragraph::layout` runs the whole shaper — HarfBuzz, line breaking, font fallback —
|
||||
/// and the shell re-built every paragraph on screen from scratch EVERY frame, which on a
|
||||
/// TV box is the largest CPU cost in the frame. Position is deliberately not part of the
|
||||
/// key (`paint` takes it), so one shaped paragraph serves a string wherever it moves to:
|
||||
/// a scrolling shelf and a screen transition both re-use it rather than re-shaping.
|
||||
///
|
||||
/// `RefCell` because every draw path here takes `&self` and the console's shell is
|
||||
/// single-threaded by construction (one render thread owns it on all three ABIs).
|
||||
paragraphs: RefCell<HashMap<ParaKey, Cached>>,
|
||||
/// The frame counter [`Fonts::begin_frame`] bumps — the cache's liveness clock.
|
||||
frame: Cell<u64>,
|
||||
}
|
||||
|
||||
/// The three paragraph shapes the console draws. A single tag rather than a loose
|
||||
/// `(TextAlign, Option<usize>)` pair because it is half of a hash key, and because those two
|
||||
/// were never independent — every call site picks one of these three.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum Para {
|
||||
/// Centred, wrapping freely.
|
||||
Centered,
|
||||
/// Left-aligned, wrapping freely.
|
||||
Leading,
|
||||
/// Left-aligned, clamped to one ellipsized line.
|
||||
Heading,
|
||||
}
|
||||
|
||||
impl Para {
|
||||
/// The paragraph style this shape asks for: alignment, and the line clamp if it has one.
|
||||
fn style(self) -> (TextAlign, Option<usize>) {
|
||||
match self {
|
||||
Para::Centered => (TextAlign::Center, None),
|
||||
Para::Leading => (TextAlign::Left, None),
|
||||
Para::Heading => (TextAlign::Left, Some(1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything [`shape`] bakes into a laid-out `Paragraph` — change any of it and the shaped
|
||||
/// result differs, so all of it is in the key.
|
||||
///
|
||||
/// The floats ride as bits: the sizes and widths are all `k`-scaled, so they are never whole
|
||||
/// numbers, and `f64`/`f32` are not `Hash`. Bit equality is the right test anyway — the same
|
||||
/// `k` produces the same bits, and a different `k` must re-shape.
|
||||
#[derive(PartialEq, Eq, Hash)]
|
||||
struct ParaKey {
|
||||
text: String,
|
||||
kind: Para,
|
||||
weight: W,
|
||||
size: u64,
|
||||
max_w: u32,
|
||||
/// ARGB, as `[a, r, g, b]`.
|
||||
color: [u8; 4],
|
||||
}
|
||||
|
||||
/// One shaped paragraph and the frame it was last drawn on.
|
||||
struct Cached {
|
||||
para: Paragraph,
|
||||
used: u64,
|
||||
}
|
||||
|
||||
/// How many shaped paragraphs stay resident before the cold ones are dropped. A screen draws
|
||||
/// well under this; the ceiling exists for the library, where paging a large catalogue walks
|
||||
/// through thousands of titles and every one of them would otherwise be kept forever.
|
||||
const PARA_CACHE_MAX: usize = 512;
|
||||
|
||||
/// Build and lay out one paragraph — the shaping [`Fonts::draw_paragraph`]'s cache exists to
|
||||
/// do exactly once per distinct key.
|
||||
///
|
||||
/// A free function rather than a method because the cache hands it a `&ParaKey` borrowed out
|
||||
/// of the map it is inserting into, which rules out holding `&self` across the call.
|
||||
fn shape(collection: &FontCollection, key: &ParaKey) -> Paragraph {
|
||||
let (align, clamp) = key.kind.style();
|
||||
let mut style = ParagraphStyle::new();
|
||||
style.set_text_align(align);
|
||||
if let Some(lines) = clamp {
|
||||
style.set_max_lines(lines);
|
||||
style.set_ellipsis("\u{2026}");
|
||||
}
|
||||
let mut ts = TextStyle::new();
|
||||
ts.set_font_families(&["Geist"]);
|
||||
ts.set_font_size(f64::from_bits(key.size) as f32);
|
||||
let [a, r, g, b] = key.color;
|
||||
ts.set_color(skia_safe::Color::from_argb(a, r, g, b));
|
||||
ts.set_font_style(match key.weight {
|
||||
W::Regular => FontStyle::normal(),
|
||||
W::Medium => FontStyle::new(
|
||||
skia_safe::font_style::Weight::MEDIUM,
|
||||
skia_safe::font_style::Width::NORMAL,
|
||||
skia_safe::font_style::Slant::Upright,
|
||||
),
|
||||
W::SemiBold => FontStyle::new(
|
||||
skia_safe::font_style::Weight::SEMI_BOLD,
|
||||
skia_safe::font_style::Width::NORMAL,
|
||||
skia_safe::font_style::Slant::Upright,
|
||||
),
|
||||
W::Bold => FontStyle::bold(),
|
||||
});
|
||||
style.set_text_style(&ts);
|
||||
let mut builder = ParagraphBuilder::new(&style, collection.clone());
|
||||
builder.add_text(&key.text);
|
||||
let mut p = builder.build();
|
||||
p.layout(f32::from_bits(key.max_w));
|
||||
p
|
||||
}
|
||||
|
||||
/// The Geist faces ride in the binary — the console must look right on a bare gamescope
|
||||
@@ -562,6 +682,8 @@ pub(crate) fn build_fonts() -> Result<Fonts> {
|
||||
semibold,
|
||||
bold,
|
||||
collection,
|
||||
paragraphs: RefCell::new(HashMap::new()),
|
||||
frame: Cell::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -629,50 +751,59 @@ impl Fonts {
|
||||
}
|
||||
}
|
||||
|
||||
/// `clamp` caps the paragraph at that many lines and ellipsizes what doesn't fit; `None`
|
||||
/// wraps freely. A heading has to clamp — an over-long one used to grow DOWNWARD into the
|
||||
/// screen's content, which is why both other clients pin theirs to one line.
|
||||
/// Start a frame — the paragraph cache's clock. Anything not drawn on this frame or the
|
||||
/// one before it becomes a candidate for eviction, so the live set is exactly "what the
|
||||
/// last two frames drew". The shell calls this once per `render_in`.
|
||||
pub(crate) fn begin_frame(&self) {
|
||||
self.frame.set(self.frame.get().wrapping_add(1));
|
||||
}
|
||||
|
||||
/// Draw a shaped paragraph, building and laying it out only the first time this exact
|
||||
/// (text, shape, weight, size, width, colour) is asked for — see [`Fonts::paragraphs`].
|
||||
/// `at` is the paragraph's TOP-LEFT, and is deliberately not part of the key.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paragraph(
|
||||
fn draw_paragraph(
|
||||
&self,
|
||||
canvas: &Canvas,
|
||||
text: &str,
|
||||
kind: Para,
|
||||
w: W,
|
||||
size: f64,
|
||||
color: Color4f,
|
||||
align: TextAlign,
|
||||
max_w: f64,
|
||||
clamp: Option<usize>,
|
||||
) -> skia_safe::textlayout::Paragraph {
|
||||
let mut style = ParagraphStyle::new();
|
||||
style.set_text_align(align);
|
||||
if let Some(lines) = clamp {
|
||||
style.set_max_lines(lines);
|
||||
style.set_ellipsis("\u{2026}");
|
||||
}
|
||||
let mut ts = TextStyle::new();
|
||||
ts.set_font_families(&["Geist"]);
|
||||
ts.set_font_size(size as f32);
|
||||
ts.set_color(color.to_color());
|
||||
ts.set_font_style(match w {
|
||||
W::Regular => FontStyle::normal(),
|
||||
W::Medium => FontStyle::new(
|
||||
skia_safe::font_style::Weight::MEDIUM,
|
||||
skia_safe::font_style::Width::NORMAL,
|
||||
skia_safe::font_style::Slant::Upright,
|
||||
),
|
||||
W::SemiBold => FontStyle::new(
|
||||
skia_safe::font_style::Weight::SEMI_BOLD,
|
||||
skia_safe::font_style::Width::NORMAL,
|
||||
skia_safe::font_style::Slant::Upright,
|
||||
),
|
||||
W::Bold => FontStyle::bold(),
|
||||
at: Point,
|
||||
) {
|
||||
let frame = self.frame.get();
|
||||
// ponytail: the key owns its text, so a HIT still costs one small `String` allocation
|
||||
// where a borrowed-key lookup would cost none. Deliberate — it is a rounding error
|
||||
// against the shape it replaces, and the alternatives (hash-only keys, `hashbrown`'s
|
||||
// raw entry) trade a real collision risk or a dependency for it. Revisit only if a
|
||||
// profile ever puts this line on the board.
|
||||
let key = ParaKey {
|
||||
text: text.to_owned(),
|
||||
kind,
|
||||
weight: w,
|
||||
size: size.to_bits(),
|
||||
max_w: (max_w as f32).to_bits(),
|
||||
color: {
|
||||
// The 8-bit ARGB the paragraph actually bakes, not the `Color4f` it came
|
||||
// from — two float colours that round to the same pixel share an entry.
|
||||
let c = color.to_color();
|
||||
[c.a(), c.r(), c.g(), c.b()]
|
||||
},
|
||||
};
|
||||
let mut cache = self.paragraphs.borrow_mut();
|
||||
let entry = cache.entry(key).or_insert_with_key(|k| Cached {
|
||||
para: shape(&self.collection, k),
|
||||
used: frame,
|
||||
});
|
||||
style.set_text_style(&ts);
|
||||
let mut b = ParagraphBuilder::new(&style, self.collection.clone());
|
||||
b.add_text(text);
|
||||
let mut p = b.build();
|
||||
p.layout(max_w as f32);
|
||||
p
|
||||
entry.used = frame;
|
||||
entry.para.paint(canvas, at);
|
||||
// Drop what the last two frames did not draw. Every entry still on screen is
|
||||
// re-stamped above on the frame it appears in, so this only reaps strings that left.
|
||||
if cache.len() > PARA_CACHE_MAX {
|
||||
cache.retain(|_, c| c.used + 1 >= frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// Centered, wrapping paragraph with `y` as its TOP edge (shaping + CJK fallback).
|
||||
@@ -688,8 +819,8 @@ impl Fonts {
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, w, size, color, TextAlign::Center, max_w, None);
|
||||
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
|
||||
let at = Point::new((cx - max_w / 2.0) as f32, y as f32);
|
||||
self.draw_paragraph(canvas, text, Para::Centered, w, size, color, max_w, at);
|
||||
}
|
||||
|
||||
/// [`centered`](Self::centered)'s LEFT-ALIGNED twin: `x` is the text's left edge, `y` its
|
||||
@@ -707,8 +838,8 @@ impl Fonts {
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, None);
|
||||
p.paint(canvas, Point::new(x as f32, y as f32));
|
||||
let at = Point::new(x as f32, y as f32);
|
||||
self.draw_paragraph(canvas, text, Para::Leading, w, size, color, max_w, at);
|
||||
}
|
||||
|
||||
/// A screen's heading: left-aligned at `x`, top edge at `y`, clamped to ONE ellipsized
|
||||
@@ -731,8 +862,8 @@ impl Fonts {
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, Some(1));
|
||||
p.paint(canvas, Point::new(x as f32, y as f32));
|
||||
let at = Point::new(x as f32, y as f32);
|
||||
self.draw_paragraph(canvas, text, Para::Heading, w, size, color, max_w, at);
|
||||
}
|
||||
|
||||
/// A single shaped line, middle-ellipsized to `max_w`, drawn at a baseline. For
|
||||
@@ -758,8 +889,12 @@ impl Fonts {
|
||||
let ell_w = font.measure_str(ell, None).0;
|
||||
let mut fitted = String::new();
|
||||
let mut used = 0.0f32;
|
||||
// The char goes onto the stack to be measured, not into a fresh `String` per character:
|
||||
// this runs for every over-long title on screen, every frame, and the allocation was
|
||||
// the bulk of it. `encode_utf8` writes the same bytes `to_string` would have.
|
||||
let mut buf = [0u8; 4];
|
||||
for ch in text.chars() {
|
||||
let cw = font.measure_str(ch.to_string().as_str(), None).0;
|
||||
let cw = font.measure_str(&*ch.encode_utf8(&mut buf), None).0;
|
||||
if used + cw + ell_w > max_w as f32 {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -483,7 +483,12 @@ fn gamescope_patch_level() -> u32 {
|
||||
cursor composited into the capture stream"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
// INFO, not DEBUG: this is the whole reason a box streams SDR, and the branch above
|
||||
// announces the good news at INFO. A field report ("HDR stopped working after the
|
||||
// update") cost a deep dive because the handshake's `capture_supports_hdr=false` was
|
||||
// visible at INFO while the ONE line saying why sat a level below it. Fires once per
|
||||
// process — the answer is cached in `LEVEL`.
|
||||
tracing::info!(
|
||||
bin = %gamescope_bin(),
|
||||
"gamescope has no {PFHDR_MARKER} marker — sessions on this backend stay 8-bit SDR \
|
||||
with a host-composited cursor (install punktfunk-gamescope for HDR)"
|
||||
|
||||
@@ -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,
|
||||
@@ -122,18 +128,31 @@ impl DataPump {
|
||||
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
|
||||
// reply) or never answer (timeout clears the state so LossReports resume) — either way
|
||||
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
|
||||
// `PUNKTFUNK_ABR_PROBE_KBPS` lowers the burst target (unset/0/garbage → the 2 Gbps
|
||||
// default): the target is deliberately far above any plausible link so the burst measures
|
||||
// the link and not itself, but on links the burst DISTURBS that backfires — a constrained
|
||||
// Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting the 6 s
|
||||
// timeout delayed first video to 14 s, and a "successful" one still reported
|
||||
// send_dropped=20211), and a 2-3 core TV client starves decoding the firehose. An
|
||||
// embedder that caps its own speed test wants this capped to match.
|
||||
// The burst target is DERIVED from `stream_cap_kbps`, not set "far above any plausible
|
||||
// link". It used to be a flat 2 Gbps on that reasoning — the burst must measure the link
|
||||
// and not itself — but the ABR already discards every bit measured above what the session
|
||||
// could use: `set_ceiling` clamps to the stream cap set a few lines up, so everything past
|
||||
// `stream_cap_kbps / 0.7` is thrown away the moment it lands. All that height bought was
|
||||
// bufferbloat for a number nothing reads, and on links the burst DISTURBS it backfires — a
|
||||
// constrained Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting
|
||||
// the 6 s timeout delayed first video to 14 s, and a "successful" one still reported
|
||||
// send_dropped=20211; the same shape is reported on a Fire TV Stick 4K Max), and a 2-3
|
||||
// core TV client starves decoding the firehose.
|
||||
//
|
||||
// ×2 is the smallest multiplier that still PROVES the cap: the measured ceiling is
|
||||
// `delivered × 0.7`, so reaching `stream_cap_kbps` needs `delivered ≥ cap × 1.43` and the
|
||||
// rest is margin. Deriving it this way cannot cap anyone — a session whose mode and codec
|
||||
// justify a high ceiling asks for a correspondingly high target by itself, and a mode we
|
||||
// cannot size (`stream_ceiling_kbps` → `u32::MAX`) still gets the old 2 Gbps. It also
|
||||
// fixes webOS and every other constrained client, not just the box that reported it.
|
||||
//
|
||||
// `PUNKTFUNK_ABR_PROBE_KBPS` overrides the target outright (unset/0/garbage → the derived
|
||||
// one). An embedder that caps its own speed test wants this capped to match.
|
||||
let capacity_probe_kbps: u32 = std::env::var("PUNKTFUNK_ABR_PROBE_KBPS")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u32>().ok())
|
||||
.filter(|&v| v > 0)
|
||||
.unwrap_or(2_000_000);
|
||||
.unwrap_or_else(|| probe_target_kbps(stream_cap_kbps));
|
||||
const CAPACITY_PROBE_MS: u32 = 800;
|
||||
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
|
||||
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
@@ -148,6 +167,9 @@ impl DataPump {
|
||||
// in; the embedder path had neither, so an unanswered request wedged the report tick and a
|
||||
// finished one left the ABR window anchored before the burst.
|
||||
let mut was_probing = false;
|
||||
// `frames_completed` as the burst began, so the probe-end block below can ask "did ANY
|
||||
// frame survive this burst" rather than only "has one ever arrived" — see there.
|
||||
let mut frames_at_probe_start: u64 = 0;
|
||||
// The window this closes is discarded outright: no LossReport, no standing-latency close,
|
||||
// no ABR feed. Two causes, both of them "this window's signals describe something other
|
||||
// than the link, and one bogus congestion verdict here ends slow start for good":
|
||||
@@ -283,6 +305,24 @@ impl DataPump {
|
||||
last_report = Instant::now();
|
||||
discard_abr_window = true;
|
||||
flush_in_window = false;
|
||||
// …and if the burst swallowed the video with it, re-anchor the decoder. This runs
|
||||
// on EVERY probe end — a successful one, a timed-out one, an embedder "Test
|
||||
// connection" — and the frame-count guard is what makes it a no-op the rest of the
|
||||
// time: a burst the link couldn't hold can take the keyframe down with it, and
|
||||
// then nothing re-requests one, so the client sits on black until some unrelated
|
||||
// recovery path happens to fire. That is the reported Fire TV / webOS black
|
||||
// screen. Compared against the count SNAPSHOTTED at the burst's leading edge
|
||||
// rather than against 0: at startup the two are the same test, but this one also
|
||||
// catches a burst that kills an already-running stream (an embedder speed test
|
||||
// mid-session), which the cumulative counter never could. At most one request per
|
||||
// probe, and it funnels through the control task's coalescer like the other two
|
||||
// emitters in this file, so it cannot IDR-storm.
|
||||
if st.frames_completed == frames_at_probe_start {
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
|
||||
tracing::warn!(
|
||||
"no frame survived the capacity probe — requested a keyframe to re-anchor"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
|
||||
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
|
||||
@@ -290,6 +330,7 @@ impl DataPump {
|
||||
if !was_probing && probe_active {
|
||||
let burst = Duration::from_millis(pump_probe.lock().unwrap().duration_ms as u64);
|
||||
probe_watchdog = Some(Instant::now() + burst + CAPACITY_PROBE_TIMEOUT);
|
||||
frames_at_probe_start = st.frames_completed;
|
||||
}
|
||||
if !probe_active {
|
||||
probe_watchdog = None;
|
||||
@@ -415,6 +456,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 +819,104 @@ 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
|
||||
}
|
||||
|
||||
/// The capacity probe's burst target for a session bounded at `stream_cap_kbps`, in kbps — the
|
||||
/// default `PUNKTFUNK_ABR_PROBE_KBPS` overrides. See the probe's comment in the pump for why it is
|
||||
/// derived rather than fixed: `BitrateController::set_ceiling` clamps the measurement to the
|
||||
/// stream cap, so every bit measured above `cap / 0.7` is discarded, and bursting for it only
|
||||
/// buys bufferbloat. ×2 clears that `1.43×` bar with margin.
|
||||
///
|
||||
/// `u32::MAX` in (a mode [`crate::abr::stream_ceiling_kbps`] declines to size) keeps the historic
|
||||
/// 2 Gbps, which is also the ceiling on the whole derivation: this can only ever lower the target.
|
||||
fn probe_target_kbps(stream_cap_kbps: u32) -> u32 {
|
||||
stream_cap_kbps.saturating_mul(2).min(2_000_000)
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// The burst has to be big enough to PROVE the stream cap and no bigger. Anything the burst
|
||||
/// measures above `cap / 0.7` is discarded by `BitrateController::set_ceiling` (pinned by
|
||||
/// `abr::tests::the_stream_bound_clamps_a_learned_ceiling_only`) and paid for in bufferbloat.
|
||||
#[test]
|
||||
fn the_probe_target_proves_the_stream_cap_without_overshooting_it() {
|
||||
// Real modes, from the smallest a session runs to the largest — including 1440p120, the
|
||||
// field session that walked to 657 Mbps and taught the ABR the cap in the first place.
|
||||
for (w, h, hz, codec, depth) in [
|
||||
(1280, 720, 60, crate::quic::CODEC_HEVC, 8),
|
||||
(1920, 1080, 60, crate::quic::CODEC_H264, 8),
|
||||
(2560, 1440, 120, crate::quic::CODEC_HEVC, 8),
|
||||
(3840, 2160, 120, crate::quic::CODEC_HEVC, 10),
|
||||
] {
|
||||
let cap = crate::abr::stream_ceiling_kbps(w, h, hz, codec, depth, 0);
|
||||
let target = probe_target_kbps(cap);
|
||||
// Enough: a link that delivers the whole burst measures `delivered × 0.7`, and that
|
||||
// has to reach the cap or the session can never climb to what its mode allows.
|
||||
assert!(
|
||||
target.saturating_mul(7) / 10 >= cap,
|
||||
"{w}x{h}@{hz}: a {target} kbps burst cannot prove a {cap} kbps cap"
|
||||
);
|
||||
// …and no more: a target that overshoots what the clamp keeps is pure bufferbloat.
|
||||
// (The old flat 2 Gbps overshot 1440p120 by 6×.)
|
||||
assert!(
|
||||
target <= cap.saturating_mul(2),
|
||||
"{w}x{h}@{hz}: {target} kbps chases capacity the clamp discards"
|
||||
);
|
||||
}
|
||||
// A mode `stream_ceiling_kbps` declines to size (`u32::MAX`) keeps the historic 2 Gbps,
|
||||
// which is also the hard ceiling on the derivation — it can only ever lower the target.
|
||||
assert_eq!(probe_target_kbps(u32::MAX), 2_000_000);
|
||||
assert_eq!(probe_target_kbps(1_500_000), 2_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pipeline_gap_is_taken_exactly_once() {
|
||||
let slot = AtomicU32::new(0);
|
||||
@@ -935,8 +1091,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"]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user