Compare commits
122
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcf4076eb7 | ||
|
|
53eb592c43 | ||
|
|
956d8dd8ef | ||
|
|
b2e716ad5f | ||
|
|
ec288d64d3 | ||
|
|
68353a5d57 | ||
|
|
ffd5a33598 | ||
|
|
4af8b02be1 | ||
|
|
b31495bea5 | ||
|
|
ee0b179618 | ||
|
|
d7e22c3db2 | ||
|
|
c1231fa2e6 | ||
|
|
1db7058a5d | ||
|
|
83a12c7413 | ||
|
|
7b1554af4b | ||
|
|
8f35155c14 | ||
|
|
0d407a866d | ||
|
|
0890cf3244 | ||
|
|
bf2d8505cf | ||
|
|
414380fc9e | ||
|
|
6267dcdcd3 | ||
|
|
8042a2fd52 | ||
|
|
7e40098bc6 | ||
|
|
ac5299d4ce | ||
|
|
017c37b78a | ||
|
|
2fd303e22f | ||
|
|
a9a514dea0 | ||
|
|
f84c5b8114 | ||
|
|
aec02b9d26 | ||
|
|
48bb1769b4 | ||
|
|
454fa2e0cb | ||
|
|
6e001e54b4 | ||
|
|
9f1f23eb40 | ||
|
|
d1c4cb18dd | ||
|
|
91aa684f0d | ||
|
|
e629606e39 | ||
|
|
ff5602361f | ||
|
|
5e319f3b77 | ||
|
|
34ad3cc611 | ||
|
|
857d7d7b6b | ||
|
|
f3c0ee47d7 | ||
|
|
80b4eccff9 | ||
|
|
63a4f583b9 | ||
|
|
290d760ea4 | ||
|
|
31b5f90b12 | ||
|
|
8abdd74a62 | ||
|
|
69f1db5ea9 | ||
|
|
7331be0a40 | ||
|
|
4bc7eecf05 | ||
|
|
dbc12dedcc | ||
|
|
2dfb7791a2 | ||
|
|
66a28d5abb | ||
|
|
e2faecfd42 | ||
|
|
5e19a4611f | ||
|
|
6f54fcdd2d | ||
|
|
c6597cbeb5 | ||
|
|
2cfc82e96c | ||
|
|
e9a209ef61 | ||
|
|
a12f1f092c | ||
|
|
3055e29ebb | ||
|
|
76832a5b86 | ||
|
|
7077b0a0df | ||
|
|
e5453aebb7 | ||
|
|
ec4bf75a6e | ||
|
|
2c03290a5e | ||
|
|
b6a370a0fd | ||
|
|
7db83445b2 | ||
|
|
5582a6ea51 | ||
|
|
f7b85ec1fd | ||
|
|
327301e012 | ||
|
|
ab4cd06e86 | ||
|
|
3eab1e41df | ||
|
|
62573d2781 | ||
|
|
d383fa6103 | ||
|
|
93608980ae | ||
|
|
1feeff3ca6 | ||
|
|
1ae8b4d4ca | ||
|
|
33ecd8e1a5 | ||
|
|
48565c4e9e | ||
|
|
e9a7373c76 | ||
|
|
f7a8c2013d | ||
|
|
926e2ccbdd | ||
|
|
b8b38d082e | ||
|
|
9979489b56 | ||
|
|
14502769e0 | ||
|
|
db1faef9fb | ||
|
|
442ea12b96 | ||
|
|
1e56705b86 | ||
|
|
365caa23be | ||
|
|
f71bee917b | ||
|
|
6de78213ee | ||
|
|
aa3bcfd0d0 | ||
|
|
6b3c582eb1 | ||
|
|
e08474d96d | ||
|
|
f422ae3e38 | ||
|
|
e38e3c44c9 | ||
|
|
b1ac4d02de | ||
|
|
5f55fa874a | ||
|
|
8af6e2dd02 | ||
|
|
d839f4c2b6 | ||
|
|
0de161e29b | ||
|
|
b297542c4d | ||
|
|
98e040fd01 | ||
|
|
5174a59832 | ||
|
|
c2a6d30d7b | ||
|
|
20de58a78a | ||
|
|
97b2c01ac1 | ||
|
|
29473d6280 | ||
|
|
b6acbd096e | ||
|
|
d63e913f52 | ||
|
|
362595b20f | ||
|
|
caa47e28e6 | ||
|
|
652abeb397 | ||
|
|
48511d1267 | ||
|
|
3e649d372e | ||
|
|
0d004c4680 | ||
|
|
8d7e273a96 | ||
|
|
e726542f96 | ||
|
|
e0427a3bb6 | ||
|
|
02a5bdb965 | ||
|
|
09b9ee8f53 | ||
|
|
43e3c7b69f |
@@ -0,0 +1,95 @@
|
||||
# Move a versionCode that is ALREADY on Google Play between tracks — no rebuild.
|
||||
#
|
||||
# Why this is separate from android.yml: promotion must not rebuild. A rebuild produces a fresh
|
||||
# versionCode (github.run_number) from possibly-newer sources, so it ships something nobody tested;
|
||||
# promoting assigns the byte-identical artifact the testers already ran. Bolting this onto
|
||||
# android.yml would mean an `if:` on all ten of its build steps.
|
||||
#
|
||||
# What it is for:
|
||||
# * promote a tested build up a track (alpha -> production)
|
||||
# * roll production back by re-pointing it at an older versionCode (to_track=production,
|
||||
# version_code=<the good one>, from_track blank)
|
||||
# * halt a rollout (status=halted)
|
||||
#
|
||||
# Defaults are deliberately the safe ones: dry_run starts TRUE, so a mis-typed versionCode
|
||||
# validates and deletes the edit instead of publishing. Flip it to false only when the dry run
|
||||
# printed what you meant.
|
||||
name: android-promote
|
||||
|
||||
# Two concurrent promotions would race on the same Play edit; the loser fails with a stale-edit
|
||||
# error. One at a time, and never cancel one mid-flight — a half-applied track change is worse
|
||||
# than a queued one.
|
||||
concurrency:
|
||||
group: android-promote
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_code:
|
||||
description: 'versionCode already on Play (e.g. 10816)'
|
||||
required: true
|
||||
to_track:
|
||||
description: 'destination track'
|
||||
required: true
|
||||
default: 'production'
|
||||
from_track:
|
||||
description: 'track to verify it is on, then clear (blank = touch nothing else)'
|
||||
required: false
|
||||
default: 'alpha'
|
||||
notes_tag:
|
||||
description: "tag whose docs/releases/whatsnew/<tag>.txt to attach, e.g. v0.23.0 (blank = none)"
|
||||
required: false
|
||||
default: ''
|
||||
status:
|
||||
description: 'completed (100%) | inProgress (needs user_fraction) | halted | draft'
|
||||
required: true
|
||||
default: 'completed'
|
||||
user_fraction:
|
||||
description: 'staged rollout fraction for inProgress, e.g. 0.2 (blank otherwise)'
|
||||
required: false
|
||||
default: ''
|
||||
dry_run:
|
||||
description: 'validate only, publish nothing'
|
||||
required: true
|
||||
default: 'true'
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-24.04
|
||||
# Same image as android.yml purely for python3 + openssl (play-upload.py's only deps); it is
|
||||
# already warm on the runner. Nothing here builds.
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-android-ci:latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Promote
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
TO_TRACK: ${{ inputs.to_track }}
|
||||
FROM_TRACK: ${{ inputs.from_track }}
|
||||
NOTES_TAG: ${{ inputs.notes_tag }}
|
||||
STATUS: ${{ inputs.status }}
|
||||
USER_FRACTION: ${{ inputs.user_fraction }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -- --package io.unom.punktfunk \
|
||||
--promote "$VERSION_CODE" \
|
||||
--track "$TO_TRACK" --status "$STATUS"
|
||||
# Explicit `if`, not `[ ] && …`: under `sh -e` a false AND-OR list that ends up LAST in
|
||||
# the script aborts the step, and these get reordered.
|
||||
if [ -n "$FROM_TRACK" ]; then set -- "$@" --promote-from "$FROM_TRACK"; fi
|
||||
if [ -n "$USER_FRACTION" ]; then set -- "$@" --user-fraction "$USER_FRACTION"; fi
|
||||
if [ -n "$NOTES_TAG" ]; then
|
||||
NOTES="docs/releases/whatsnew/${NOTES_TAG}.txt"
|
||||
# Fail loudly rather than silently publishing with the PREVIOUS release's text still
|
||||
# showing on the store listing.
|
||||
[ -f "$NOTES" ] || { echo "ERROR: no such notes file: $NOTES"; exit 1; }
|
||||
set -- "$@" --release-notes-file "$NOTES"
|
||||
fi
|
||||
if [ "$DRY_RUN" = "true" ]; then set -- "$@" --no-commit; fi
|
||||
echo "promoting versionCode=$VERSION_CODE -> $TO_TRACK (dry_run=$DRY_RUN)"
|
||||
python3 clients/android/ci/play-upload.py "$@"
|
||||
@@ -34,9 +34,10 @@ on:
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/android.yml'
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed
|
||||
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
|
||||
# push is canary (Play `internal`).
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at
|
||||
# 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary
|
||||
# (Play `internal`). Production access was granted 2026-08-01; before that a tag could only
|
||||
# reach `alpha` and someone had to promote it by hand in the Console.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -75,6 +76,56 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# FIRST, because it costs a second and everything after it costs ten minutes.
|
||||
#
|
||||
# A release tag MUST carry its own Play "What's new". If the file is absent Play does not
|
||||
# show nothing — it carries the PREVIOUS release's text onto this version, so production
|
||||
# users read notes for a build they are not getting. That is the same defect the v0.22.3
|
||||
# notes shipped (see docs/releases/README.md), and it is invisible until someone reads the
|
||||
# store listing. Failing here also means a missing file cannot leave a half-published
|
||||
# release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play.
|
||||
#
|
||||
# Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal
|
||||
# testers costs nothing.
|
||||
- name: Play release notes gate (tags only)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
|
||||
if [ ! -f "$NOTES" ]; then
|
||||
echo "ERROR: $NOTES does not exist."
|
||||
echo "A production release needs its own Play 'What's new' (<=500 chars, written for"
|
||||
echo "phone/TV users). Without it Play reuses the previous release's text."
|
||||
echo "See docs/releases/README.md; copy docs/releases/whatsnew/TEMPLATE.txt."
|
||||
exit 1
|
||||
fi
|
||||
# A verbatim copy of another release's file is the same bug wearing a hat: the store
|
||||
# listing still describes the wrong build. Cheap to check, and only ever trips on an
|
||||
# actual copy-paste that was never edited.
|
||||
for other in docs/releases/whatsnew/*.txt; do
|
||||
if [ "$other" != "$NOTES" ] && [ "$other" != "docs/releases/whatsnew/TEMPLATE.txt" ]; then
|
||||
if cmp -s "$NOTES" "$other"; then
|
||||
echo "ERROR: $NOTES is byte-identical to $other."
|
||||
echo "Write notes describing THIS release, not the one before it."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# Length is checked here as well as in play-upload.py. Not redundant: the uploader is
|
||||
# the last line of defence (and the only one android-promote.yml gets), but it runs at
|
||||
# step 9 — this catches an unedited TEMPLATE copy at step 1 instead of after the build.
|
||||
# Must count CHARACTERS, not bytes: Play's cap is 500 chars and `•` is 3 bytes in UTF-8,
|
||||
# so `wc -c` would reject a file that is comfortably legal.
|
||||
python3 - "$NOTES" <<'PY'
|
||||
import sys
|
||||
path = sys.argv[1]
|
||||
text = open(path, encoding="utf-8").read().strip()
|
||||
if not text:
|
||||
sys.exit(f"ERROR: {path} is empty.")
|
||||
if len(text) > 500:
|
||||
sys.exit(f"ERROR: {path} is {len(text)} chars; Play allows 500. Trim it.")
|
||||
print(f"Play release notes OK: {path} ({len(text)}/500 chars)")
|
||||
PY
|
||||
|
||||
# Everything below the checkout used to be four download steps (JDK, SDK,
|
||||
# NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked
|
||||
# into the image now. This guard only re-asserts the Android targets so a
|
||||
@@ -109,6 +160,14 @@ jobs:
|
||||
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
# 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
|
||||
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
|
||||
# built module) and it is the only automated cover those behaviours have.
|
||||
- name: kit unit tests
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:testDebugUnitTest --stacktrace
|
||||
|
||||
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
||||
working-directory: clients/android
|
||||
env:
|
||||
@@ -122,11 +181,20 @@ jobs:
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;;
|
||||
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
esac
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
# Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB).
|
||||
# On a tag the gate step above already proved this exists, so the else branch is only
|
||||
# ever the canary path. See docs/releases/README.md.
|
||||
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
|
||||
if [ -f "$NOTES" ]; then
|
||||
echo "PLAY_NOTES=$NOTES" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "no Play release notes at $NOTES (canary — Play keeps the previous text)"
|
||||
fi
|
||||
echo "android version $VN -> Play track '$TRACK'"
|
||||
|
||||
- name: Build Release (signed AAB + universal APK)
|
||||
@@ -199,15 +267,21 @@ jobs:
|
||||
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
|
||||
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
|
||||
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual
|
||||
# promotion to production in the Play console.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `production` at 100% (`completed`).
|
||||
#
|
||||
# A tag therefore ships to real users with no further click. Two things keep that honest:
|
||||
# the tag is only pushed once every platform is green, and Play reviews each production
|
||||
# release before it reaches anyone. To ramp instead of going straight to 100%, this is
|
||||
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
|
||||
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
|
||||
- name: Upload to Google Play
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
echo "uploading to Play track '$PLAY_TRACK'"
|
||||
python3 clients/android/ci/play-upload.py \
|
||||
--package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
set -- --package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi
|
||||
python3 clients/android/ci/play-upload.py "$@"
|
||||
|
||||
Generated
+32
-32
@@ -947,7 +947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1036,7 +1036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -2221,7 +2221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2326,7 +2326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2871,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2897,7 +2897,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2915,7 +2915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2936,7 +2936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2960,7 +2960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2969,7 +2969,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2981,7 +2981,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2995,11 +2995,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3028,14 +3028,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3050,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3058,7 +3058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3070,7 +3070,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3103,7 +3103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3115,7 +3115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3323,7 +3323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3334,7 +3334,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3350,7 +3350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3367,7 +3367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3382,7 +3382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3402,7 +3402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3434,7 +3434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3519,7 +3519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3556,7 +3556,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+90
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.22.3"
|
||||
"version": "0.23.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -2170,6 +2170,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/logs": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "Ingest runner log lines",
|
||||
"description": "The plugin/script runner ships its output here so the console's **Logs** page can show it.\n\nPlugins are not host child processes — the runner is a separate `bun` process that `import()`s\neach plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and\nbefore this endpoint the console's log page could not show a single plugin line. On Linux the\nfallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no\nlog file at all, so a failing plugin was diagnosable only by stopping the scheduled task and\nre-running the runner by hand. Both are shell access on the host box, which is exactly what the\nconsole exists to avoid.\n\nLines land in the same ring as the host's own, sharing one `seq` cursor, targeted\n`plugin:<source>` — so `GET /logs` needs no second cursor and the console needs no second poll.",
|
||||
"operationId": "ingestPluginLogs",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PluginLogBatch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Lines ingested"
|
||||
},
|
||||
"400": {
|
||||
"description": "Batch too large",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -6238,6 +6283,50 @@
|
||||
"gamestream"
|
||||
]
|
||||
},
|
||||
"PluginLogBatch": {
|
||||
"type": "object",
|
||||
"description": "A batch of runner log lines.",
|
||||
"required": [
|
||||
"entries"
|
||||
],
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginLogLine"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginLogLine": {
|
||||
"type": "object",
|
||||
"description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).",
|
||||
"required": [
|
||||
"ts_ms",
|
||||
"level",
|
||||
"source",
|
||||
"msg"
|
||||
],
|
||||
"properties": {
|
||||
"level": {
|
||||
"type": "string",
|
||||
"description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`."
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:<source>`."
|
||||
},
|
||||
"ts_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginRegistration": {
|
||||
"type": "object",
|
||||
"description": "Register/renew body for `PUT /plugins/{id}`.",
|
||||
|
||||
@@ -50,10 +50,12 @@ import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -250,6 +252,139 @@ fun GamepadHostOptionsDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pin-to-hosts picker the settings screen's Profiles section opens — the Android mirror of the
|
||||
* desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down
|
||||
* moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes.
|
||||
* A toggle is presentation only: it edits the host's pinned cards through the same store write the
|
||||
* carousel's unpin uses, never the profile itself and never the host's default binding.
|
||||
*
|
||||
* Pin state is read live from [pinned] (backed by the host records), so what a switch shows is
|
||||
* always what the store holds — the row can't disagree with the carousel it feeds.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun GamepadPinHostsDialog(
|
||||
profileName: String,
|
||||
hosts: List<KnownHost>,
|
||||
pinned: (KnownHost) -> Boolean,
|
||||
onToggle: (KnownHost) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
|
||||
// Done, so it starts focused).
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
BackHandler(onBack = onDismiss)
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < hosts.size) focus++
|
||||
// Directional = state-targeted (left → unpinned, right → pinned), so holding a
|
||||
// direction can't oscillate; asking for the state it's already in is a no-op.
|
||||
NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) }
|
||||
NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) }
|
||||
}
|
||||
},
|
||||
onActivate = {
|
||||
val kh = hosts.getOrNull(focus)
|
||||
if (kh != null) onToggle(kh) else onDismiss()
|
||||
},
|
||||
)
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 520.dp)
|
||||
.heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
"Pin “$profileName”",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(
|
||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (hosts.isEmpty()) {
|
||||
DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.")
|
||||
} else {
|
||||
DialogText("A pinned profile appears as its own card on the host — one press connects with it.")
|
||||
hosts.forEachIndexed { i, kh ->
|
||||
PinHostRow(
|
||||
label = kh.name,
|
||||
on = pinned(kh),
|
||||
focused = i == focus,
|
||||
onClick = { onToggle(kh) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
DialogButton(
|
||||
"Done",
|
||||
focused = focus == hosts.size,
|
||||
primary = true,
|
||||
enabled = true,
|
||||
onClick = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
|
||||
// landscape window pulls itself into view.
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
ConsoleSwitch(on = on, focused = focused)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a
|
||||
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
|
||||
|
||||
@@ -57,6 +57,8 @@ import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
@@ -72,6 +74,8 @@ private class GpRow(
|
||||
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
|
||||
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
|
||||
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -89,7 +93,39 @@ fun GamepadSettingsScreen(
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
|
||||
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update)
|
||||
|
||||
// The Profiles section's stores, constructed here the way ConnectScreen constructs its own.
|
||||
// The catalog is read once per screen entry: this screen can't create or edit profiles
|
||||
// (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved
|
||||
// hosts DO change under it — every pin toggle writes one — so they live in state and refresh
|
||||
// on each toggle, keeping the "Pinned to N hosts" counts honest.
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
val profileStore = remember { ProfileStore(context) }
|
||||
val profiles = remember { profileStore.all() }
|
||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||
// The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad
|
||||
// (this screen's nav gates on it, the ConnectScreen-dialog pattern).
|
||||
var pinProfile by remember { mutableStateOf<StreamProfile?>(null) }
|
||||
|
||||
// Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation
|
||||
// only: pin appends at the end (card order), unpin removes, and the host's default binding
|
||||
// (profileId) is never touched.
|
||||
fun togglePin(kh: KnownHost, profile: StreamProfile) {
|
||||
val pins = if (profile.id in kh.pinnedProfileIds) {
|
||||
kh.pinnedProfileIds - profile.id
|
||||
} else {
|
||||
kh.pinnedProfileIds + profile.id
|
||||
}
|
||||
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
// On a TV "the touch interface" is confusing advice (no touch to reach it with) — the honest
|
||||
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
|
||||
// interface remote-navigably. The strings branch on it.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
@@ -101,7 +137,9 @@ fun GamepadSettingsScreen(
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
GamepadNavEffect2D(
|
||||
active = navActive,
|
||||
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
|
||||
// drops its probes — the pattern ConnectScreen's dialogs use.
|
||||
active = navActive && pinProfile == null,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
@@ -162,16 +200,41 @@ fun GamepadSettingsScreen(
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
// The legend follows the focused row (the desktop console's hints() does the same):
|
||||
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
|
||||
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
|
||||
val focused = rows.getOrNull(focus)
|
||||
GamepadHintBar(
|
||||
listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
),
|
||||
when {
|
||||
focused != null && !focused.enabled -> listOf(
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
focused != null && !focused.adjustable -> listOf(
|
||||
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
else -> listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
|
||||
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
|
||||
// touch UI's per-profile pin toggles in the host edit sheet.
|
||||
pinProfile?.let { p ->
|
||||
GamepadPinHostsDialog(
|
||||
profileName = p.name,
|
||||
hosts = savedHosts,
|
||||
pinned = { kh -> p.id in kh.pinnedProfileIds },
|
||||
onToggle = { kh -> togglePin(kh, p) },
|
||||
onDismiss = { pinProfile = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,8 +243,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
|
||||
// focus arrives; the value colour cross-fades with them.
|
||||
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
|
||||
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
|
||||
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
|
||||
val chevronAlpha by animateFloatAsState(
|
||||
if (focused && row.adjustable) 0.6f else 0f,
|
||||
tween(160),
|
||||
label = "chevrons",
|
||||
)
|
||||
val valueColor by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 1f else 0.6f),
|
||||
tween(160),
|
||||
@@ -216,7 +284,9 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
|
||||
// so its detail line can still explain what would go here.
|
||||
color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f),
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
@@ -401,11 +471,30 @@ private fun buildSettingsRows(
|
||||
s.echoCancel,
|
||||
) { update(s.copy(echoCancel = it)) },
|
||||
|
||||
toggle(
|
||||
"padForward", "Controllers", "Forward controllers",
|
||||
"Send this device's controllers to the host. Turn it off when your controller " +
|
||||
"already reaches the host another way — USB passthrough such as VirtualHere — " +
|
||||
"so games don't see two of them.",
|
||||
s.gamepadForwarding,
|
||||
) { update(s.copy(gamepadForwarding = it)) },
|
||||
choice(
|
||||
"padType", "Controllers", "Controller type",
|
||||
"padType", null, "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
choice(
|
||||
"systemButtons", null, "Guide button",
|
||||
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
|
||||
"sends them to the host whenever this device delivers them.",
|
||||
SYSTEM_BUTTON_OPTIONS, s.systemButtons,
|
||||
) { update(s.copy(systemButtons = it)) },
|
||||
choice(
|
||||
"guideGesture", null, "Hold Select for guide",
|
||||
"Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
|
||||
GUIDE_GESTURE_OPTIONS, s.guideGesture,
|
||||
) { update(s.copy(guideGesture = it)) },
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
toggle(
|
||||
@@ -428,3 +517,62 @@ private fun buildSettingsRows(
|
||||
) { update(s.copy(sc2Capture = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4):
|
||||
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
|
||||
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
|
||||
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
|
||||
* instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points
|
||||
* nowhere useful on a touchless device, so the strings name the actual route — the
|
||||
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
|
||||
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
|
||||
*/
|
||||
private fun buildProfileRows(
|
||||
profiles: List<StreamProfile>,
|
||||
savedHosts: List<KnownHost>,
|
||||
tv: Boolean,
|
||||
openPinPicker: (StreamProfile) -> Unit,
|
||||
): List<GpRow> {
|
||||
val createHint = if (tv) {
|
||||
"To create or edit profiles on this device, turn off Controller-optimized UI above " +
|
||||
"and use the standard interface."
|
||||
} else {
|
||||
"Profiles are created and edited in the touch interface."
|
||||
}
|
||||
if (profiles.isEmpty()) {
|
||||
return listOf(
|
||||
GpRow(
|
||||
id = "noProfiles",
|
||||
header = "Profiles",
|
||||
label = "No profiles yet",
|
||||
value = "",
|
||||
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
|
||||
"one-press connect cards here. " + createHint,
|
||||
adjust = { false },
|
||||
activate = {},
|
||||
adjustable = false,
|
||||
enabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
return profiles.mapIndexed { i, p ->
|
||||
// Counted straight off the host records, so it agrees with what the carousel renders.
|
||||
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
|
||||
GpRow(
|
||||
id = "profile:${p.id}",
|
||||
header = if (i == 0) "Profiles" else null,
|
||||
label = p.name,
|
||||
value = when (pins) {
|
||||
0 -> "Not pinned"
|
||||
1 -> "Pinned to 1 host"
|
||||
else -> "Pinned to $pins hosts"
|
||||
},
|
||||
detail = "Pin this profile to a host and it appears as its own card — one press " +
|
||||
"connects with it. " + createHint,
|
||||
adjust = { false },
|
||||
activate = { openPinPicker(p) },
|
||||
adjustable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ data class SettingsOverlay(
|
||||
val mouseMode: MouseMode? = null,
|
||||
val invertScroll: Boolean? = null,
|
||||
val gamepad: Int? = null,
|
||||
val gamepadForwarding: Boolean? = null,
|
||||
val systemButtons: String? = null,
|
||||
val guideGesture: String? = null,
|
||||
val statsVerbosity: StatsVerbosity? = null,
|
||||
/**
|
||||
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
|
||||
@@ -76,6 +79,9 @@ data class SettingsOverlay(
|
||||
mouseMode = mouseMode ?: base.mouseMode,
|
||||
invertScroll = invertScroll ?: base.invertScroll,
|
||||
gamepad = gamepad ?: base.gamepad,
|
||||
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
|
||||
systemButtons = systemButtons ?: base.systemButtons,
|
||||
guideGesture = guideGesture ?: base.guideGesture,
|
||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||
presentPriority = presentPriority ?: base.presentPriority,
|
||||
@@ -110,6 +116,11 @@ data class SettingsOverlay(
|
||||
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
|
||||
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
|
||||
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
|
||||
gamepadForwarding =
|
||||
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
|
||||
else gamepadForwarding,
|
||||
systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons,
|
||||
guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture,
|
||||
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
||||
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
||||
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
|
||||
@@ -136,6 +147,9 @@ data class SettingsOverlay(
|
||||
"mouse_mode" -> copy(mouseMode = null)
|
||||
"invert_scroll" -> copy(invertScroll = null)
|
||||
"gamepad" -> copy(gamepad = null)
|
||||
"gamepad_forwarding" -> copy(gamepadForwarding = null)
|
||||
"system_buttons" -> copy(systemButtons = null)
|
||||
"guide_gesture" -> copy(guideGesture = null)
|
||||
"stats_verbosity" -> copy(statsVerbosity = null)
|
||||
"low_latency_mode" -> copy(lowLatencyMode = null)
|
||||
"present_priority" -> copy(presentPriority = null)
|
||||
@@ -159,6 +173,9 @@ data class SettingsOverlay(
|
||||
if (mouseMode != null) add("mouse_mode")
|
||||
if (invertScroll != null) add("invert_scroll")
|
||||
if (gamepad != null) add("gamepad")
|
||||
if (gamepadForwarding != null) add("gamepad_forwarding")
|
||||
if (systemButtons != null) add("system_buttons")
|
||||
if (guideGesture != null) add("guide_gesture")
|
||||
if (statsVerbosity != null) add("stats_verbosity")
|
||||
if (lowLatencyMode != null) add("low_latency_mode")
|
||||
if (presentPriority != null) add("present_priority")
|
||||
@@ -190,6 +207,9 @@ data class SettingsOverlay(
|
||||
mouseMode?.let { j.put("mouse_mode", it.storedName) }
|
||||
invertScroll?.let { j.put("invert_scroll", it) }
|
||||
gamepad?.let { j.put("gamepad", it) }
|
||||
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
|
||||
systemButtons?.let { j.put("system_buttons", it) }
|
||||
guideGesture?.let { j.put("guide_gesture", it) }
|
||||
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
||||
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
||||
presentPriority?.let { j.put("present_priority", it) }
|
||||
@@ -205,7 +225,9 @@ data class SettingsOverlay(
|
||||
private val KNOWN = setOf(
|
||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "stats_verbosity",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
|
||||
"system_buttons", "guide_gesture",
|
||||
"stats_verbosity",
|
||||
"low_latency_mode", "present_priority", "smooth_buffer",
|
||||
)
|
||||
|
||||
@@ -227,6 +249,9 @@ data class SettingsOverlay(
|
||||
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
|
||||
invertScroll = j.optBooleanOrNull("invert_scroll"),
|
||||
gamepad = j.optIntOrNull("gamepad"),
|
||||
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
|
||||
systemButtons = j.optStringOrNull("system_buttons"),
|
||||
guideGesture = j.optStringOrNull("guide_gesture"),
|
||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||
|
||||
@@ -34,6 +34,31 @@ data class Settings(
|
||||
val hdrEnabled: Boolean = true,
|
||||
val compositor: Int = 0,
|
||||
val gamepad: Int = 0,
|
||||
/**
|
||||
* Forward this device's controllers to the host at all. Default on — that was the
|
||||
* unconditional behaviour before this became a setting.
|
||||
*
|
||||
* Off is for a couch whose controller reaches the host another way: a USB passthrough tool
|
||||
* (VirtualHere and friends), or a pad simply plugged into the host itself. Leaving it on
|
||||
* there gives the host two controllers for one pair of hands, and games read both. It also
|
||||
* stops this device CLAIMING the pad — a device held open is one a passthrough tool can't
|
||||
* bind — which is why it gates the USB capture paths, not just the wire sends.
|
||||
*/
|
||||
val gamepadForwarding: Boolean = true,
|
||||
/**
|
||||
* Where the guide (Xbox/PS) and misc/share presses land while streaming — the
|
||||
* cross-client `system_buttons` key: `"auto"` (forward on Android — the press reaches
|
||||
* the app on most devices) | `"forward"` | `"local"`.
|
||||
*/
|
||||
val systemButtons: String = "auto",
|
||||
/**
|
||||
* The hold-Select guide gesture — the cross-client `guide_gesture` key: `"auto"` (off
|
||||
* on Android) | `"on"` | `"off"`. On: holding Select alone ≥350 ms sends the HOST's
|
||||
* guide, down until release (long hold = the host's long-press → a Gaming-Mode host's
|
||||
* QAM); a Select tap is delivered on release, slightly delayed. For devices whose
|
||||
* shell intercepts the physical guide button.
|
||||
*/
|
||||
val guideGesture: String = "auto",
|
||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||
val audioChannels: Int = 2,
|
||||
@@ -216,6 +241,9 @@ class SettingsStore(context: Context) {
|
||||
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
|
||||
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
|
||||
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
@@ -262,6 +290,9 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_HDR, s.hdrEnabled)
|
||||
.putInt(K_COMPOSITOR, s.compositor)
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
|
||||
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
|
||||
.putString(K_GUIDE_GESTURE, s.guideGesture)
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
@@ -291,6 +322,9 @@ class SettingsStore(context: Context) {
|
||||
const val K_HDR = "hdr_enabled"
|
||||
const val K_COMPOSITOR = "compositor"
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
|
||||
const val K_SYSTEM_BUTTONS = "system_buttons"
|
||||
const val K_GUIDE_GESTURE = "guide_gesture"
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
@@ -525,6 +559,15 @@ fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, Stri
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved [Settings.systemButtons]: forward the raw guide/misc presses? Auto = forward on
|
||||
* Android — the press reaches the app on most devices, and where the shell shows its own UI
|
||||
* for it that's the shell's business. */
|
||||
fun Settings.systemButtonsForward(): Boolean = systemButtons != "local"
|
||||
|
||||
/** Resolved [Settings.guideGesture]: auto = OFF on Android (the raw press already reaches the
|
||||
* host); "on" is for devices whose shell intercepts the physical guide button. */
|
||||
fun Settings.guideGestureEnabled(): Boolean = guideGesture == "on"
|
||||
|
||||
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
|
||||
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
|
||||
fun Settings.preferredCodec(): Int = when (codec) {
|
||||
@@ -607,3 +650,17 @@ val GAMEPAD_OPTIONS = listOf(
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
|
||||
)
|
||||
|
||||
/** (stored `system_buttons` value, label) — where the guide/share presses land while streaming. */
|
||||
val SYSTEM_BUTTON_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"forward" to "Send to host",
|
||||
"local" to "This device",
|
||||
)
|
||||
|
||||
/** (stored `guide_gesture` value, label) — the hold-Select guide gesture. */
|
||||
val GUIDE_GESTURE_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"on" to "On",
|
||||
"off" to "Off",
|
||||
)
|
||||
|
||||
@@ -818,14 +818,45 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
|
||||
@Composable
|
||||
private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
|
||||
SettingsGroup(footer = "Applies from the next session.") {
|
||||
// The master switch, above everything it governs. Profileable, so it shows in both
|
||||
// scopes: a "Work" profile can decline to forward what "Game" forwards.
|
||||
ToggleRow(
|
||||
title = "Forward controllers",
|
||||
subtitle = "Send this device's controllers to the host. Turn it off when your " +
|
||||
"controller already reaches the host another way — USB passthrough such as " +
|
||||
"VirtualHere, or a pad plugged into the host — so games don't see two of them",
|
||||
checked = s.gamepadForwarding,
|
||||
field = "gamepad_forwarding",
|
||||
onCheckedChange = { on -> update(s.copy(gamepadForwarding = on)) },
|
||||
)
|
||||
SettingDropdown(
|
||||
label = "Controller type",
|
||||
options = GAMEPAD_OPTIONS,
|
||||
selected = s.gamepad,
|
||||
field = "gamepad",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "The virtual pad the host creates. Automatic matches your controller; " +
|
||||
"every connected one is forwarded as its own player.",
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
SettingDropdown(
|
||||
label = "Guide button",
|
||||
options = SYSTEM_BUTTON_OPTIONS,
|
||||
selected = s.systemButtons,
|
||||
field = "system_buttons",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Where the guide (Xbox/PS) and share presses go while streaming. " +
|
||||
"Automatic sends them to the host whenever this device delivers them.",
|
||||
) { v -> update(s.copy(systemButtons = v)) }
|
||||
SettingDropdown(
|
||||
label = "Hold Select for guide",
|
||||
options = GUIDE_GESTURE_OPTIONS,
|
||||
selected = s.guideGesture,
|
||||
field = "guide_gesture",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through, " +
|
||||
"slightly delayed. For devices that intercept the real guide button.",
|
||||
) { v -> update(s.copy(guideGesture = v)) }
|
||||
DeviceScopeOnly {
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
@@ -852,6 +883,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
subtitle = "Stream a Steam Controller 2 as-is — Steam on the host drives its " +
|
||||
"trackpads, gyro and haptics directly",
|
||||
checked = s.sc2Capture,
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||
)
|
||||
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
|
||||
@@ -861,6 +893,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
|
||||
"plus adaptive triggers, lightbar and gyro",
|
||||
checked = s.dsCapture,
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
}
|
||||
@@ -1013,6 +1046,7 @@ private fun <T> SettingDropdown(
|
||||
selected: T,
|
||||
field: String? = null,
|
||||
caption: String? = null,
|
||||
enabled: Boolean = true,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
@@ -1020,18 +1054,25 @@ private fun <T> SettingDropdown(
|
||||
?: options.firstOrNull()?.second.orEmpty()
|
||||
Column {
|
||||
OverrideBadge(field)
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded && enabled,
|
||||
onExpandedChange = { if (enabled) expanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = enabled,
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded && enabled,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
options.forEach { (value, lbl) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(lbl) },
|
||||
|
||||
@@ -321,7 +321,10 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
|
||||
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
|
||||
// controller (Automatic). Built here, released on dispose.
|
||||
val router = GamepadRouter(context, handle, initialSettings.gamepad)
|
||||
val router = GamepadRouter(
|
||||
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
|
||||
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
|
||||
)
|
||||
activity?.gamepadRouter = router
|
||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
|
||||
@@ -442,7 +445,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
|
||||
// claim the interfaces; it resumes in onDispose once the stream releases them.
|
||||
activity?.stopSc2MenuNav()
|
||||
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
|
||||
val sc2 = if (initialSettings.sc2Capture && initialSettings.gamepadForwarding) {
|
||||
Sc2Capture(context, router)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var sc2UsbReceiver: BroadcastReceiver? = null
|
||||
if (sc2 != null) {
|
||||
feedback.onHidRaw = sc2::onHidRaw
|
||||
@@ -492,7 +499,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
|
||||
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
|
||||
// hands over deterministically.
|
||||
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
|
||||
val ds = if (initialSettings.dsCapture && initialSettings.gamepadForwarding) {
|
||||
DsCapture(context, router)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
|
||||
@@ -6,13 +6,23 @@ Why hand-rolled: stdlib + `openssl` only (no pip on the runner), and it prints G
|
||||
error at the stage it fails instead of a catch-all. Reuses the SERVICE_ACCOUNT_JSON secret and
|
||||
tolerates it being raw JSON *or* base64-encoded JSON.
|
||||
|
||||
Usage:
|
||||
Usage (upload a new build):
|
||||
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--aab path/to/app-release.aab --track internal --status completed [--no-commit]
|
||||
|
||||
--no-commit: do insert -> upload -> track-update -> validate, then delete the edit (publishes
|
||||
nothing). Use it to dry-run the credentials/AAB without touching the live track.
|
||||
Usage (promote a build that is already on Play, no rebuild):
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--promote 10816 --promote-from alpha --track production
|
||||
|
||||
Promotion assigns an EXISTING versionCode to another track, so what ships to production is the
|
||||
byte-identical artifact the testers ran — rebuilding would burn a fresh versionCode and ship
|
||||
something nobody has tested. --promote-from additionally asserts the code really is on that track
|
||||
(catches a typo'd versionCode before it reaches production) and empties it in the SAME edit, so
|
||||
the move is atomic: testers are never left pinned to a code that production also serves.
|
||||
|
||||
--no-commit: do insert -> upload/assign -> track-update -> validate, then delete the edit
|
||||
(publishes nothing). Use it to dry-run the credentials/AAB/notes without touching the live track.
|
||||
"""
|
||||
import argparse, base64, json, os, subprocess, sys, tempfile, time
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
@@ -104,18 +114,79 @@ def access_token(sa) -> str:
|
||||
return tok["access_token"]
|
||||
|
||||
|
||||
# Play's "What's new" is capped at 500 characters per language. The cap lives in the Console
|
||||
# (the REST reference does not state it) and the API rejects longer text at commit — i.e. AFTER
|
||||
# the AAB has uploaded — so check it up front and print the actual count.
|
||||
NOTES_MAX = 500
|
||||
|
||||
|
||||
def load_release_notes(path, language):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read().strip()
|
||||
if not text:
|
||||
sys.exit(f"ERROR: release-notes file is empty: {path}")
|
||||
if len(text) > NOTES_MAX:
|
||||
sys.exit(f"ERROR: release notes are {len(text)} chars, Play allows {NOTES_MAX}: {path}")
|
||||
print(f"release notes: {len(text)}/{NOTES_MAX} chars ({language})")
|
||||
return [{"language": language, "text": text}]
|
||||
|
||||
|
||||
def put_track(app, edit, tok, track, version_codes, status, user_fraction=None, notes=None):
|
||||
"""PUT one track. An empty version_codes list clears the track (what promotion does to the
|
||||
track it promoted OUT of)."""
|
||||
release = {"status": status, "versionCodes": [str(v) for v in version_codes]}
|
||||
if user_fraction is not None:
|
||||
release["userFraction"] = user_fraction
|
||||
if notes:
|
||||
release["releaseNotes"] = notes
|
||||
# Clearing a track means "no active releases", not "an empty release".
|
||||
body = {"track": track, "releases": [release] if version_codes else []}
|
||||
call("PUT", f"{app}/edits/{edit}/tracks/{track}", token=tok,
|
||||
data=json.dumps(body).encode(), content_type="application/json")
|
||||
|
||||
|
||||
def assert_on_track(app, edit, tok, track, vc):
|
||||
"""Fail before anything is written if --promote names a versionCode that is not actually on
|
||||
the track we claim to be promoting out of."""
|
||||
got = call("GET", f"{app}/edits/{edit}/tracks/{track}", token=tok)
|
||||
live = [c for r in got.get("releases", []) for c in r.get("versionCodes", [])]
|
||||
if str(vc) not in live:
|
||||
sys.exit(f"ERROR: versionCode {vc} is not on track '{track}' (it has: {live or 'nothing'})")
|
||||
print(f"verified versionCode={vc} is live on '{track}'")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--package", required=True)
|
||||
ap.add_argument("--aab", required=True)
|
||||
ap.add_argument("--aab", help="upload this bundle (mutually exclusive with --promote)")
|
||||
ap.add_argument("--promote", type=int, metavar="VERSIONCODE",
|
||||
help="assign an already-uploaded versionCode instead of uploading")
|
||||
ap.add_argument("--promote-from", metavar="TRACK",
|
||||
help="with --promote: assert the code is on TRACK, then clear TRACK")
|
||||
ap.add_argument("--track", default="internal")
|
||||
ap.add_argument("--status", default="completed")
|
||||
ap.add_argument("--user-fraction", type=float,
|
||||
help="staged rollout fraction, 0<f<1; required by --status inProgress")
|
||||
ap.add_argument("--release-notes-file", help="Play 'What's new' text (<=500 chars)")
|
||||
ap.add_argument("--release-notes-language", default="en-US")
|
||||
ap.add_argument("--no-commit", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(a.aab):
|
||||
if bool(a.aab) == bool(a.promote):
|
||||
sys.exit("ERROR: pass exactly one of --aab (upload) or --promote (assign an existing code)")
|
||||
if a.promote_from and not a.promote:
|
||||
sys.exit("ERROR: --promote-from only applies to --promote")
|
||||
# inProgress without a fraction is an API error; halted/completed with one is also rejected.
|
||||
if a.status == "inProgress" and a.user_fraction is None:
|
||||
sys.exit("ERROR: --status inProgress requires --user-fraction")
|
||||
if a.user_fraction is not None and not (0 < a.user_fraction < 1):
|
||||
sys.exit(f"ERROR: --user-fraction must be strictly between 0 and 1 (got {a.user_fraction})")
|
||||
if a.aab and not os.path.isfile(a.aab):
|
||||
sys.exit(f"ERROR: AAB not found: {a.aab}")
|
||||
|
||||
notes = load_release_notes(a.release_notes_file, a.release_notes_language) \
|
||||
if a.release_notes_file else None
|
||||
|
||||
sa = load_sa()
|
||||
tok = access_token(sa)
|
||||
print(f"authenticated as {sa['client_email']} (project {sa.get('project_id')})")
|
||||
@@ -123,17 +194,25 @@ def main():
|
||||
|
||||
try:
|
||||
edit = call("POST", f"{app}/edits", token=tok)["id"]
|
||||
with open(a.aab, "rb") as f:
|
||||
blob = f.read()
|
||||
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
|
||||
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
|
||||
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
|
||||
print(f"uploaded versionCode={vc}")
|
||||
call("PUT", f"{app}/edits/{edit}/tracks/{a.track}", token=tok,
|
||||
data=json.dumps({"track": a.track,
|
||||
"releases": [{"status": a.status, "versionCodes": [str(vc)]}]}).encode(),
|
||||
content_type="application/json")
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}")
|
||||
if a.promote:
|
||||
vc = a.promote
|
||||
if a.promote_from:
|
||||
assert_on_track(app, edit, tok, a.promote_from, vc)
|
||||
else:
|
||||
with open(a.aab, "rb") as f:
|
||||
blob = f.read()
|
||||
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
|
||||
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
|
||||
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
|
||||
print(f"uploaded versionCode={vc}")
|
||||
|
||||
put_track(app, edit, tok, a.track, [vc], a.status, a.user_fraction, notes)
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}"
|
||||
+ (f" userFraction={a.user_fraction}" if a.user_fraction is not None else ""))
|
||||
# Same edit as the assignment above, so the code is never active on both tracks at once.
|
||||
if a.promote_from:
|
||||
put_track(app, edit, tok, a.promote_from, [], a.status)
|
||||
print(f"cleared track '{a.promote_from}'")
|
||||
|
||||
if a.no_commit:
|
||||
call("POST", f"{app}/edits/{edit}:validate", token=tok)
|
||||
|
||||
@@ -116,7 +116,12 @@ class DsCapture(
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
// mid-rumble teardown would leave the motors running with nobody to stop them.
|
||||
// EP0-direct (the reader thread is stopping; the queue would never drain).
|
||||
usb.writeControl(stopReport(m))
|
||||
// Nothing can retry after this point, so a failure is worth saying out loud: it is
|
||||
// the difference between a quiet pad and one that buzzes until it is unplugged.
|
||||
if (!usb.writeControl(stopReport(m))) Log.w(TAG, "teardown rumble stop was not written")
|
||||
// Motors silenced above; this hands back the lightbar, player LEDs and adaptive
|
||||
// triggers the game was holding, which outlive the link just as stubbornly.
|
||||
resetRichFeedback(m)
|
||||
}
|
||||
disarmBackstop()
|
||||
usb.stop()
|
||||
@@ -145,6 +150,9 @@ class DsCapture(
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
// Release the transport too: the link only *signals* the drop, so without this an unplug
|
||||
// left its connection open, its interfaces claimed and its detach receiver registered.
|
||||
usb.stop()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
@@ -216,17 +224,20 @@ class DsCapture(
|
||||
|
||||
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
|
||||
val m = model ?: return
|
||||
if (low == 0 && high == 0) {
|
||||
disarmBackstop()
|
||||
} else {
|
||||
armBackstop(backstopMs)
|
||||
}
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
val stop = low == 0 && high == 0
|
||||
if (!stop) armBackstop(backstopMs)
|
||||
val sent = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = low
|
||||
ds4High = high
|
||||
writeDs4()
|
||||
} else {
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high), OutReportQueue.KEY_RUMBLE)
|
||||
}
|
||||
if (stop) {
|
||||
// Disarm only once the stop is actually on its way. Dropping the net *before* the
|
||||
// write — as this used to — meant a discarded stop left the motors running with
|
||||
// nothing scheduled to try again; a USB pad holds its last level until told zero.
|
||||
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +263,9 @@ class DsCapture(
|
||||
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
|
||||
}
|
||||
|
||||
// Coalescable: the DS4's write is full-state (motors AND lightbar, rebuilt from the current
|
||||
// fields on every call), so a newer one supersedes an older one wholesale — nothing is lost by
|
||||
// collapsing a backlog of them down to the last.
|
||||
private fun writeDs4() = usb.writeRaw(
|
||||
0,
|
||||
DsDevice.ds4Report(
|
||||
@@ -261,8 +275,38 @@ class DsCapture(
|
||||
(ds4Rgb shr 8) and 0xFF,
|
||||
ds4Rgb and 0xFF,
|
||||
),
|
||||
OutReportQueue.KEY_RUMBLE,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hand the pad back neutral: adaptive triggers released, lightbar dark, player LEDs clear.
|
||||
*
|
||||
* Rumble stops the moment nothing renews it, but these are LATCHED in the controller's
|
||||
* firmware — they outlive the stream, the app, and being unplugged. Ending a session while a
|
||||
* game held a weapon's trigger resistance left the physical trigger stiff afterwards, with
|
||||
* nothing to release it but another game that happens to set one.
|
||||
*
|
||||
* EP0-direct like the rumble stop above: the reader thread is stopping, so the interrupt-OUT
|
||||
* queue would never drain. Writes are best-effort — the pad may already be gone.
|
||||
*/
|
||||
private fun resetRichFeedback(m: DsDevice.Model) {
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
// No adaptive triggers or player LEDs on a DS4, and its write is full-state, so
|
||||
// blacking the lightbar is a single composed report.
|
||||
ds4Rgb = 0
|
||||
usb.writeControl(DsDevice.ds4Report(0, 0, 0, 0, 0))
|
||||
return
|
||||
}
|
||||
// An all-zero effect block is mode 0x00 — no effect — which is what releases the trigger.
|
||||
for (which in 0..1) {
|
||||
usb.writeControl(
|
||||
DsDevice.ds5TriggerReport(m, which, ByteArray(DsDevice.TRIGGER_EFFECT_LEN)),
|
||||
)
|
||||
}
|
||||
usb.writeControl(DsDevice.ds5LightbarReport(m, 0, 0, 0))
|
||||
usb.writeControl(DsDevice.ds5PlayerLedsReport(m, 0))
|
||||
}
|
||||
|
||||
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
|
||||
* composed motor state and carries the current lightbar rather than blacking it out. */
|
||||
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
@@ -284,7 +328,12 @@ class DsCapture(
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
val r = Runnable {
|
||||
backstop = null
|
||||
model?.let { usb.writeRaw(0, stopReport(it)) }
|
||||
val m = model ?: return@Runnable
|
||||
// The net itself can be refused (a full queue, a connection going away). Re-arm rather
|
||||
// than give up: this is the last thing between a stalled poll thread and a pad that
|
||||
// buzzes until it is unplugged. It stops re-arming as soon as the link closes, which
|
||||
// clears `model` and disarms.
|
||||
if (!usb.writeRaw(0, stopReport(m), OutReportQueue.KEY_RUMBLE)) armBackstop(STOP_RETRY_MS)
|
||||
}
|
||||
backstop = r
|
||||
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
|
||||
@@ -297,5 +346,9 @@ class DsCapture(
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DsCapture"
|
||||
|
||||
/** How soon to retry a rumble stop whose write was rejected. Short: the motors are running
|
||||
* and the host has already moved on, so nothing else is coming to silence them. */
|
||||
const val STOP_RETRY_MS = 100L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ class GamepadFeedback(
|
||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||
const val TAG_TRIGGER: Byte = 0x03
|
||||
const val TAG_HID_RAW: Byte = 0x05
|
||||
|
||||
/** Sparse-log cadence for swallowed render failures — see [noteRenderFailure]. */
|
||||
const val LOG_EVERY = 128L
|
||||
}
|
||||
|
||||
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||
@@ -125,6 +128,7 @@ class GamepadFeedback(
|
||||
fun start() {
|
||||
running = true
|
||||
rumbleThread = Thread({
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextRumble(handle)
|
||||
if (ev < 0L) continue // timeout / closed
|
||||
@@ -136,26 +140,50 @@ class GamepadFeedback(
|
||||
// the backstop (the hardware net under a stalled poll thread).
|
||||
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||
val backstopMs = ((ev ushr 32) and 0xFFFF)
|
||||
renderRumble(
|
||||
pad,
|
||||
((ev ushr 16) and 0xFFFF).toInt(),
|
||||
(ev and 0xFFFF).toInt(),
|
||||
backstopMs,
|
||||
)
|
||||
// Rendering is binder calls into the vibrator service, and every one of them can
|
||||
// throw unchecked — DeadSystemRuntimeException when system_server goes down, and
|
||||
// the ordinary RuntimeException a dying service wraps its RemoteException in.
|
||||
// Unguarded, ONE of those killed this thread outright: `running` stayed true, so
|
||||
// nothing noticed and nothing restarted it, and rumble was gone for the rest of
|
||||
// the session. Losing a single command is recoverable; losing the loop is not.
|
||||
runCatching {
|
||||
renderRumble(
|
||||
pad,
|
||||
((ev ushr 16) and 0xFFFF).toInt(),
|
||||
(ev and 0xFFFF).toInt(),
|
||||
backstopMs,
|
||||
)
|
||||
}.onFailure { failures = noteRenderFailure("rumble", it, failures) }
|
||||
}
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
|
||||
hidoutThread = Thread({
|
||||
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
|
||||
val buf = ByteBuffer.allocateDirect(128)
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||
if (n < 0) continue // timeout / closed
|
||||
dispatchHidout(buf, n)
|
||||
// Same hazard as the rumble loop above: lights/trigger rendering is binder and USB
|
||||
// calls, and an unchecked throw here would silently end the rich-feedback plane.
|
||||
runCatching { dispatchHidout(buf, n) }
|
||||
.onFailure { failures = noteRenderFailure("hidout", it, failures) }
|
||||
}
|
||||
}, "pf-hidout").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a render failure the poll loop swallowed, and return the updated count. Logged on the
|
||||
* first occurrence and sparsely after: a genuinely dead vibrator service fails on *every*
|
||||
* command, which at a rumble plane's rate would bury the log.
|
||||
*/
|
||||
private fun noteRenderFailure(plane: String, t: Throwable, seen: Long): Long {
|
||||
if (seen == 0L || seen % LOG_EVERY == 0L) {
|
||||
Log.w(TAG, "$plane render failed (#${seen + 1}) — command dropped, poll loop alive", t)
|
||||
}
|
||||
return seen + 1
|
||||
}
|
||||
|
||||
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
|
||||
fun stop() {
|
||||
running = false
|
||||
@@ -269,7 +297,7 @@ class GamepadFeedback(
|
||||
val m = bind.vm
|
||||
if (m != null) {
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
runCatching { m.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
@@ -294,7 +322,7 @@ class GamepadFeedback(
|
||||
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||
val lv = bind.legacy ?: return
|
||||
if (lo == 0 && hi == 0) {
|
||||
lv.cancel() // (0,0) = stop
|
||||
runCatching { lv.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
|
||||
@@ -33,12 +33,52 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
|
||||
* threads, so the slot table is a [ConcurrentHashMap].
|
||||
*/
|
||||
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
|
||||
class GamepadRouter(
|
||||
context: Context,
|
||||
private val handle: Long,
|
||||
private val setting: Int,
|
||||
/**
|
||||
* Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
|
||||
* default true). Off is for a couch whose controller reaches the host another way — USB
|
||||
* passthrough such as VirtualHere, or a pad plugged into the host itself — where forwarding
|
||||
* as well would give the host two pads for one pair of hands.
|
||||
*
|
||||
* Off still opens slots and tracks held state; it only stops the wire sends. That is
|
||||
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
|
||||
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
|
||||
* claimed by keeping a slot — the Android input stack shares controllers — unlike the USB
|
||||
* capture links, which `StreamScreen` does not start at all while this is off.
|
||||
*/
|
||||
private val forwarding: Boolean = true,
|
||||
/**
|
||||
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved — auto = forward on
|
||||
* Android, where the press reaches the app on most devices; `local` exists for
|
||||
* cross-client profile parity with the Gaming-Mode clients). Off keeps them entirely
|
||||
* with this device.
|
||||
*/
|
||||
private val systemForward: Boolean = true,
|
||||
/**
|
||||
* The hold-Select guide gesture (`Settings.guideGesture` resolved — auto = off on
|
||||
* Android): holding Select ALONE ≥ [GUIDE_HOLD_MS] sends the HOST's guide button, down
|
||||
* until release — so a long hold is the host's long-press, a Gaming-Mode host's QAM. A
|
||||
* Select tap is delivered on release (delayed by up to the threshold); a Select pressed
|
||||
* while other buttons are down passes through untouched, so the exit/mic chords keep
|
||||
* working. pf-client-core's `SelectGesture`, on the main-thread handler.
|
||||
*/
|
||||
private val guideGesture: Boolean = false,
|
||||
) {
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
|
||||
// Hold-Select→guide gesture state ([guideGesture]): the pending Select's hold
|
||||
// timer / a delivered tap's owed release (both on the main handler), and whether
|
||||
// the held Select was transformed into a synthetic guide.
|
||||
var pendingGuide: Runnable? = null
|
||||
var pendingTapUp: Runnable? = null
|
||||
var selectAsGuide = false
|
||||
}
|
||||
|
||||
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
|
||||
@@ -122,8 +162,27 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
* the mic-mute chord ([MIC_CHORD]).
|
||||
*/
|
||||
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
|
||||
if (down) {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
if (guideGesture && send) {
|
||||
// A Select pressed ALONE is held back until it resolves: a tap (delivered
|
||||
// on release), a combo member (the next button flushes it as a real
|
||||
// press), or — past GUIDE_HOLD_MS — a synthetic guide. Held state records
|
||||
// it either way, so the exit/mic chords read as if the gesture didn't
|
||||
// exist (Select+Y still fires the mic toggle: the flush sends Select's
|
||||
// down before Y's).
|
||||
if (bit == Gamepad.BTN_BACK && slot.held == 0) {
|
||||
slot.held = slot.held or bit
|
||||
armGuide(slot)
|
||||
return
|
||||
}
|
||||
flushPendingSelect(slot)
|
||||
}
|
||||
if (send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
}
|
||||
val wasHeld = slot.held
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
@@ -136,7 +195,10 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
} else {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
|
||||
if (!owned && send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
slot.held = slot.held and bit.inv()
|
||||
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||
// holds the full chord.
|
||||
@@ -146,6 +208,61 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a pending Select's hold countdown ([GUIDE_HOLD_MS] → a synthetic guide, down until release). */
|
||||
private fun armGuide(slot: Slot) {
|
||||
val r = Runnable {
|
||||
slot.pendingGuide = null
|
||||
slot.selectAsGuide = true
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, true, slot.index)
|
||||
}
|
||||
}
|
||||
slot.pendingGuide = r
|
||||
mainHandler.postDelayed(r, GUIDE_HOLD_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* A second button joined while Select was pending — it was a real Select after all; its
|
||||
* deferred down goes out before the caller sends the new button's, preserving chronology.
|
||||
*/
|
||||
private fun flushPendingSelect(slot: Slot) {
|
||||
val r = slot.pendingGuide ?: return
|
||||
mainHandler.removeCallbacks(r)
|
||||
slot.pendingGuide = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select released with gesture state outstanding — true when the gesture owned the
|
||||
* release. A transformed hold lifts the synthetic guide; a pending tap delivers its
|
||||
* held-back press now, with the release [TAP_PRESS_MS] behind it (a back-to-back pair
|
||||
* can fold into nothing in the host's per-pad input fold).
|
||||
*/
|
||||
private fun consumeSelectRelease(slot: Slot): Boolean {
|
||||
if (slot.selectAsGuide) {
|
||||
slot.selectAsGuide = false
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
|
||||
}
|
||||
return true
|
||||
}
|
||||
val r = slot.pendingGuide ?: return false
|
||||
mainHandler.removeCallbacks(r)
|
||||
slot.pendingGuide = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
|
||||
val up = Runnable {
|
||||
slot.pendingTapUp = null
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
|
||||
}
|
||||
slot.pendingTapUp = up
|
||||
mainHandler.postDelayed(up, TAP_PRESS_MS)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
|
||||
private fun armExit() {
|
||||
if (pendingExit != null) return // already counting down
|
||||
@@ -186,7 +303,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
val dev = event.device ?: return false
|
||||
if (!isForwardable(dev)) return false
|
||||
val slot = slotFor(dev) ?: return false
|
||||
slot.mapper.onMotion(event)
|
||||
if (forwarding) slot.mapper.onMotion(event)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -221,24 +338,26 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
|
||||
fun axis(id: Int, value: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
|
||||
if (slot != null && forwarding) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
|
||||
}
|
||||
|
||||
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
|
||||
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
if (slot != null && forwarding) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
}
|
||||
|
||||
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
|
||||
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
|
||||
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
|
||||
if (slot != null && forwarding) {
|
||||
NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
|
||||
* units — the host passes them straight into the virtual pad's report). Per report. */
|
||||
fun motion(gyro: IntArray, accel: IntArray) {
|
||||
if (slot != null) {
|
||||
if (slot != null && forwarding) {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
@@ -260,7 +379,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
|
||||
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
|
||||
val syntheticId = EXTERNAL_ID_BASE - index
|
||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
return ExternalPad(syntheticId, index)
|
||||
}
|
||||
@@ -317,7 +436,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
|
||||
// to that type (a single global choice — matches the handshake's session-default pref).
|
||||
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
|
||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
slots[dev.id] = slot
|
||||
return slot
|
||||
@@ -330,7 +449,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
private fun closeSlot(deviceId: Int) {
|
||||
val slot = slots.remove(deviceId) ?: return
|
||||
releaseHeld(slot)
|
||||
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
|
||||
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
|
||||
// Release this controller's feedback bindings (close its lights session / cancel rumble).
|
||||
@@ -339,14 +458,32 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
|
||||
private fun releaseHeld(slot: Slot) {
|
||||
// Gesture first: a pending (never-sent) Select just drops its timer; an owed tap
|
||||
// release goes out NOW (its down is already on the wire and the handle may not
|
||||
// outlive this slot); a transformed guide — which is not in `held` — is lifted.
|
||||
slot.pendingGuide?.let { mainHandler.removeCallbacks(it) }
|
||||
slot.pendingGuide = null
|
||||
slot.pendingTapUp?.let {
|
||||
mainHandler.removeCallbacks(it)
|
||||
slot.pendingTapUp = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
|
||||
}
|
||||
}
|
||||
if (slot.selectAsGuide) {
|
||||
slot.selectAsGuide = false
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
|
||||
}
|
||||
}
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
bits = bits and bit.inv()
|
||||
}
|
||||
slot.held = 0
|
||||
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||
if (forwarding) slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||
}
|
||||
|
||||
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
|
||||
@@ -380,5 +517,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||
const val EXTERNAL_ID_BASE = -1000
|
||||
|
||||
/** pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the host's guide goes down. */
|
||||
const val GUIDE_HOLD_MS = 350L
|
||||
|
||||
/**
|
||||
* pf-client-core's `TAP_PRESS`: a held-back Select tap's release trails its press by
|
||||
* this much, so the pair can't coalesce into no press at all.
|
||||
*/
|
||||
const val TAP_PRESS_MS = 50L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import android.hardware.usb.UsbRequest
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeoutException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Generic USB transport for a client-captured HID controller — the device-agnostic half of what
|
||||
@@ -81,14 +81,20 @@ class HidUsbLink(
|
||||
|
||||
/** Pending OUT reports, submitted by the reader thread — only one thread may drive a
|
||||
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
|
||||
* request; a second waiter would steal the reader's completions). */
|
||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
* request; a second waiter would steal the reader's completions). See [OutReportQueue] for
|
||||
* what gets discarded when it fills, and why that is not simply "the oldest". */
|
||||
private val outQueue = OutReportQueue()
|
||||
|
||||
private var reader: Thread? = null
|
||||
private var detachReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
|
||||
/** Latches on the first "this link is down" signal so [onClosed] fires exactly once, however
|
||||
* many of the racing detectors (detach broadcast, reader error streak, failed re-queue) see
|
||||
* it. Reset by [start]. */
|
||||
private val down = AtomicBoolean(false)
|
||||
|
||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||
|
||||
@@ -114,6 +120,7 @@ class HidUsbLink(
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
down.set(false)
|
||||
running = true
|
||||
Log.i(
|
||||
config.tag,
|
||||
@@ -134,10 +141,7 @@ class HidUsbLink(
|
||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (gone?.deviceName == dev.deviceName) {
|
||||
Log.i(config.tag, "USB detached (${dev.deviceName})")
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
linkDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +225,9 @@ class HidUsbLink(
|
||||
if (live.isEmpty()) {
|
||||
Log.e(config.tag, "no IN request could be queued")
|
||||
finishReader(claims)
|
||||
// `start` already returned true, so without this the owner would sit waiting on a
|
||||
// capture that never streams and never reports itself dead.
|
||||
linkDown()
|
||||
return
|
||||
}
|
||||
val scratch = ByteArray(64)
|
||||
@@ -295,10 +302,23 @@ class HidUsbLink(
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
linkDown()
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the link down, exactly once, from whichever detector noticed first — the detach
|
||||
* broadcast (main thread) or the reader thread on its way out.
|
||||
*
|
||||
* This only *signals*; releasing the connection and the interfaces stays the owner's job, via
|
||||
* the [stop] its `onClosed` handler calls. Previously nothing released them on this path: the
|
||||
* detach receiver flipped a flag and fired the callback, so an unplug left the connection open,
|
||||
* the interfaces claimed (the pad could not return to Android's own input stack) and the
|
||||
* receiver still registered — and a re-plug overwrote the field holding it, leaking a receiver
|
||||
* that stayed live for the process's lifetime.
|
||||
*/
|
||||
private fun linkDown() {
|
||||
running = false
|
||||
if (down.compareAndSet(false, true)) onClosed()
|
||||
}
|
||||
|
||||
private fun finishReader(claims: List<Claim>) {
|
||||
@@ -314,28 +334,35 @@ class HidUsbLink(
|
||||
* Write one raw report to the device: kind 0 = output report (the active interface's
|
||||
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
|
||||
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
|
||||
*
|
||||
* [coalesce] tells the pending-OUT queue whether a newer report of the same kind may replace
|
||||
* this one — [OutReportQueue.KEY_RUMBLE] for motor levels, the default [OutReportQueue.NO_COALESCE]
|
||||
* for one-shots (lightbar, player LEDs, trigger effects) the sender will not repeat.
|
||||
*
|
||||
* Returns whether the report reached the device or is queued for it. A caller that is writing
|
||||
* a **stop** needs this: a discarded stop has nothing behind it, so it must not be mistaken
|
||||
* for one that landed.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
fun writeRaw(kind: Int, data: ByteArray, coalesce: Int = OutReportQueue.NO_COALESCE): Boolean {
|
||||
if (data.isEmpty()) return false
|
||||
return when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||
// newest-wins: these are level-styled commands the sender re-sends anyway.
|
||||
while (outQueue.size >= 32) outQueue.poll()
|
||||
outQueue.offer(data)
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread.
|
||||
outQueue.offer(data, coalesce)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||
sendReport(conn, ifId, type, data)
|
||||
private fun setReport(type: Int, data: ByteArray): Boolean {
|
||||
val conn = connection ?: return false
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return false
|
||||
return sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,9 +371,8 @@ class HidUsbLink(
|
||||
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
|
||||
* thread: EP0 control transfers are independent of the reader's `requestWait`.
|
||||
*/
|
||||
fun writeControl(data: ByteArray) {
|
||||
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
fun writeControl(data: ByteArray): Boolean =
|
||||
data.isNotEmpty() && setReport(REPORT_TYPE_OUTPUT, data)
|
||||
|
||||
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
|
||||
@@ -358,27 +384,48 @@ class HidUsbLink(
|
||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||
*/
|
||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||
private fun sendReport(
|
||||
conn: UsbDeviceConnection,
|
||||
ifaceId: Int,
|
||||
type: Int,
|
||||
data: ByteArray,
|
||||
): Boolean {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
// controlTransfer returns the byte count, or a negative value on failure — a failed write
|
||||
// must be reported as such, not swallowed (a dropped rumble stop has nothing behind it).
|
||||
val n = runCatching {
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}.getOrDefault(-1)
|
||||
return n >= 0
|
||||
}
|
||||
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||
/**
|
||||
* Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed].
|
||||
*
|
||||
* Safe to call from the `onClosed` handler itself — that is how an unplug now gets cleaned up,
|
||||
* and it arrives on the reader thread, which must not try to join itself.
|
||||
*/
|
||||
fun stop() {
|
||||
running = false
|
||||
// Claim the down-latch so the reader's own exit does not report a close the owner asked for.
|
||||
down.set(true)
|
||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
detachReceiver = null
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
if (reader !== Thread.currentThread()) {
|
||||
runCatching { reader?.join(1000) }
|
||||
// Only forget the thread once it is actually gone: clearing it while it still runs
|
||||
// would let a later stop() skip the join and free the connection under it.
|
||||
reader = null
|
||||
}
|
||||
outQueue.clear()
|
||||
activeClaim = null
|
||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The pending interrupt-OUT reports for a captured controller: a bounded FIFO whose overflow
|
||||
* policy knows which reports may be thrown away and which may not.
|
||||
*
|
||||
* The queue exists because only one thread may drive a connection's `UsbRequest`s, so writes from
|
||||
* the feedback threads are handed to the reader thread rather than submitted directly. It has to
|
||||
* be bounded — a stalled or unplugged device would otherwise grow it without limit — and the
|
||||
* question is what to discard when it fills.
|
||||
*
|
||||
* The old policy was "newest wins": drop from the head until there is room. That is right for
|
||||
* rumble, which is *level-styled* — the host re-sends it continuously, so a dropped frame is
|
||||
* replaced milliseconds later and nothing is permanently lost. It is wrong for everything else.
|
||||
* A lightbar colour, a player-LED mask and an adaptive-trigger effect are **one-shots**: the host
|
||||
* sends them on change and never repeats them. Dropping one leaves the pad wrong until the next
|
||||
* time that value happens to change, which may be never.
|
||||
*
|
||||
* So eviction is driven by an explicit [key] supplied by the caller, not by inspecting the bytes.
|
||||
* That distinction cannot be recovered from the report itself: every DualSense output report
|
||||
* carries the *same* report id and differs only in its `valid_flag` bytes, so an id-keyed policy
|
||||
* would happily let a rumble supersede a lightbar — the very bug this replaces, relocated.
|
||||
*
|
||||
* Two rules:
|
||||
* - A report offered with a coalescing key **replaces** the pending report with that key, in
|
||||
* place. A burst of rumble collapses to its latest value and never displaces anything else.
|
||||
* - Only when the queue is full does anything get dropped, and then the oldest *coalescable*
|
||||
* report goes first. A one-shot is discarded only if the queue is full of nothing but
|
||||
* one-shots — which needs [cap] distinct one-shots outstanding, far beyond what a real pad
|
||||
* produces.
|
||||
*
|
||||
* Thread-safe: offered by the feedback threads, drained by the reader thread.
|
||||
*/
|
||||
internal class OutReportQueue(private val cap: Int = CAP) {
|
||||
private class Entry(val key: Int, val data: ByteArray)
|
||||
|
||||
private val items = ArrayDeque<Entry>()
|
||||
|
||||
/**
|
||||
* Queue [data] for submission. [key] is [NO_COALESCE] for a one-shot, or a caller-chosen
|
||||
* constant identifying a level-styled stream whose newer values supersede older ones.
|
||||
*
|
||||
* Returns false only if the report had to be dropped outright — the caller can then treat the
|
||||
* write as failed rather than assuming it is on its way.
|
||||
*/
|
||||
fun offer(data: ByteArray, key: Int = NO_COALESCE): Boolean = synchronized(items) {
|
||||
if (key != NO_COALESCE) {
|
||||
val at = items.indexOfFirst { it.key == key }
|
||||
if (at >= 0) {
|
||||
// Supersede in place: keeping the queue position stops a fast rumble stream from
|
||||
// repeatedly jumping the one-shots queued ahead of it.
|
||||
items[at] = Entry(key, data)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (items.size >= cap) {
|
||||
val victim = items.indexOfFirst { it.key != NO_COALESCE }
|
||||
if (victim >= 0) {
|
||||
items.removeAt(victim)
|
||||
} else if (key != NO_COALESCE) {
|
||||
// Nothing coalescable to sacrifice and this report is itself replaceable — drop it
|
||||
// rather than a one-shot that will never come again.
|
||||
return false
|
||||
} else {
|
||||
items.removeFirst()
|
||||
}
|
||||
}
|
||||
items.addLast(Entry(key, data))
|
||||
return true
|
||||
}
|
||||
|
||||
/** The next report to submit, or null when nothing is pending. */
|
||||
fun poll(): ByteArray? = synchronized(items) { items.removeFirstOrNull()?.data }
|
||||
|
||||
fun clear() = synchronized(items) { items.clear() }
|
||||
|
||||
val size: Int get() = synchronized(items) { items.size }
|
||||
|
||||
companion object {
|
||||
/** This report is a one-shot: never superseded, evicted only as a last resort. */
|
||||
const val NO_COALESCE = 0
|
||||
|
||||
/** Motor levels — re-sent continuously, so only the newest is worth keeping. */
|
||||
const val KEY_RUMBLE = 1
|
||||
|
||||
/** Deep enough to absorb a burst, small enough that a stalled device cannot bloat us. */
|
||||
const val CAP = 32
|
||||
}
|
||||
}
|
||||
@@ -273,10 +273,20 @@ class Sc2Capture(
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "SC2 link closed (unplug / power-off)")
|
||||
// Both transports share this callback, so read which one was live BEFORE clearing it —
|
||||
// releasing the other would tear down a link that never dropped.
|
||||
val dropped = activeLink
|
||||
activeLink = LINK_NONE
|
||||
dongleLink = false
|
||||
releaseSlot()
|
||||
releaseUiKeys()
|
||||
// Release the transport too — see the note in DsCapture.onLinkClosed. The Puck makes this
|
||||
// worse than a single leak: it is the pad that gets power-cycled, so the same process can
|
||||
// round-trip a link many times in one session.
|
||||
when (dropped) {
|
||||
LINK_USB -> usb.stop()
|
||||
LINK_BLE -> ble.stop()
|
||||
}
|
||||
onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The pending-OUT queue's overflow policy. What is being pinned here is the distinction the old
|
||||
* "drop from the head until there is room" policy did not make: rumble is re-sent continuously and
|
||||
* may be thrown away, while a lightbar/player-LED/trigger report is sent once and never repeated.
|
||||
*/
|
||||
class OutReportQueueTest {
|
||||
/** A report carrying a 0..255 marker so a test can tell which one came back out. */
|
||||
private fun report(marker: Int) = byteArrayOf(0x02, marker.toByte())
|
||||
|
||||
// Masked: the marker rides in a Byte, and Byte.toInt() sign-extends.
|
||||
private fun drain(q: OutReportQueue): List<Int> =
|
||||
generateSequence { q.poll() }.map { it[1].toInt() and 0xFF }.toList()
|
||||
|
||||
@Test
|
||||
fun `rumble supersedes the pending rumble instead of queueing another`() {
|
||||
val q = OutReportQueue()
|
||||
assertTrue(q.offer(report(1), OutReportQueue.KEY_RUMBLE))
|
||||
assertTrue(q.offer(report(2), OutReportQueue.KEY_RUMBLE))
|
||||
assertTrue(q.offer(report(3), OutReportQueue.KEY_RUMBLE))
|
||||
assertEquals("a rumble burst must collapse to one entry", 1, q.size)
|
||||
assertArrayEquals(report(3), q.poll())
|
||||
assertNull(q.poll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `superseding keeps the queue position so a rumble stream cannot jump one-shots`() {
|
||||
val q = OutReportQueue()
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10)) // a one-shot queued behind it
|
||||
q.offer(report(2), OutReportQueue.KEY_RUMBLE)
|
||||
// The newer rumble takes the OLD rumble's slot, so the one-shot does not get starved
|
||||
// behind an endlessly-renewed entry.
|
||||
assertEquals(listOf(2, 10), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full queue sacrifices rumble, never a one-shot`() {
|
||||
val q = OutReportQueue(cap = 4)
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
q.offer(report(12))
|
||||
assertEquals(4, q.size)
|
||||
// Full. The old policy dropped the head — here that is a rumble, but only by luck of
|
||||
// ordering; what matters is that the one-shots all survive.
|
||||
assertTrue(q.offer(report(13)))
|
||||
assertEquals(listOf(10, 11, 12, 13), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the one-shot the host never repeats survives a rumble storm`() {
|
||||
val q = OutReportQueue(cap = 4)
|
||||
// The exact regression: a lightbar colour queued once, then a flood of rumble. Under the
|
||||
// old newest-wins eviction the colour was dropped from the head and never came back,
|
||||
// leaving the pad lit wrong until the value next happened to change.
|
||||
q.offer(report(200)) // lightbar
|
||||
repeat(50) { q.offer(report(it), OutReportQueue.KEY_RUMBLE) }
|
||||
val out = drain(q)
|
||||
assertTrue("the lightbar report must still be queued, got $out", out.contains(200))
|
||||
assertEquals("rumble must not have accumulated", listOf(200, 49), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a queue full of one-shots refuses a rumble rather than dropping one`() {
|
||||
val q = OutReportQueue(cap = 2)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
assertFalse(
|
||||
"with nothing coalescable to sacrifice, the replaceable report yields",
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE),
|
||||
)
|
||||
assertEquals(listOf(10, 11), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a queue of nothing but one-shots drops one, and it is the oldest`() {
|
||||
val q = OutReportQueue(cap = 2)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
assertTrue(q.offer(report(12)))
|
||||
assertEquals(listOf(11, 12), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear empties the queue`() {
|
||||
val q = OutReportQueue()
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10))
|
||||
q.clear()
|
||||
assertEquals(0, q.size)
|
||||
assertNull(q.poll())
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,14 @@
|
||||
//! realtime callback and makes us own the buffer. So this client diverges deliberately to stop the
|
||||
//! Android-only crackle: (1) the callback is allocation/free-free — decoded buffers are recycled to
|
||||
//! the producer via a free-list instead of being freed on the audio thread (Android's Scudo `free`
|
||||
//! has unbounded tail latency); (2) the jitter ring is deeper (~40 ms prime / ~150 ms hard cap) and
|
||||
//! decoupled from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain
|
||||
//! doesn't manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and
|
||||
//! grown on XRuns (Google's anti-glitch technique).
|
||||
//! has unbounded tail latency); (2) the jitter ring is deeper than the other clients' and decoupled
|
||||
//! from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain doesn't
|
||||
//! manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and grown on
|
||||
//! XRuns (Google's anti-glitch technique).
|
||||
//!
|
||||
//! (2) is now the SHARED `punktfunk_core::audio::JitterPolicy` at `JitterTuning::AAUDIO`, which also
|
||||
//! fixed what this ring was missing: it had a hard cap but nothing that walked the depth back down,
|
||||
//! so drift and arrival bursts raised latency permanently and Android settled on its ceiling.
|
||||
|
||||
use ndk::audio::{
|
||||
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
|
||||
@@ -34,26 +38,18 @@ const SAMPLE_RATE: i32 = 48_000;
|
||||
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
|
||||
const RING_CHUNKS: usize = 64;
|
||||
|
||||
// --- Jitter-ring depths, in MILLISECONDS (scaled to interleaved-f32 samples at runtime). --------
|
||||
// The channel count is negotiated, not a compile-time const, so these are kept in ms and multiplied
|
||||
// by `ms` (interleaved-f32 samples per millisecond at the resolved layout) inside `start`.
|
||||
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
|
||||
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
|
||||
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
|
||||
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
|
||||
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
|
||||
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
|
||||
const PRIME_FLOOR_MS: usize = 40;
|
||||
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
|
||||
const PRIME_CEIL_MS: usize = 80;
|
||||
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
|
||||
/// without overflowing.
|
||||
const JITTER_HEADROOM_MS: usize = 80;
|
||||
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
|
||||
const HARD_CAP_MS: usize = 150;
|
||||
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
|
||||
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
|
||||
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
|
||||
// --- Jitter-ring depths now come from the SHARED policy (`punktfunk_core::audio::JitterTuning`). --
|
||||
// They used to be four Android-only constants here. The rationale for Android being DEEPER than the
|
||||
// other clients still holds and is preserved in `JitterTuning::AAUDIO`: unlike PipeWire, which
|
||||
// adaptively rate-matches the stream to the graph clock and masks host↔DAC drift, AAudio hands us a
|
||||
// raw callback and we own the buffer, so drift and Wi-Fi power-save bunching land as
|
||||
// underruns/overflows = crackle.
|
||||
//
|
||||
// Two things changed with the move. The prime floor drops 40 ms → 25 ms, because the policy GROWS
|
||||
// the target on the devices that actually underrun instead of every device pre-paying for the worst
|
||||
// one. And the ring finally sheds: it had a hard cap but nothing that walked the depth back down, so
|
||||
// any drift or burst raised latency permanently and Android converged on its 120 ms ceiling and
|
||||
// stayed there — the "audio latency is too high" report.
|
||||
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
|
||||
const XRUN_CHECK_EVERY: u32 = 128;
|
||||
|
||||
@@ -104,6 +100,7 @@ struct Counters {
|
||||
pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling)
|
||||
underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained)
|
||||
ring_depth: AtomicU64, // ring sample count at the last callback
|
||||
target_ms: AtomicU64, // the policy's LIVE target depth (it grows on this device's underruns)
|
||||
}
|
||||
|
||||
/// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread.
|
||||
@@ -126,10 +123,9 @@ impl AudioPlayback {
|
||||
// Interleaved f32 samples per millisecond at this layout (48 kHz × channels); the ms-
|
||||
// denominated jitter-ring depths scale by it.
|
||||
let ms = (SAMPLE_RATE as usize / 1000) * channels;
|
||||
let prime_floor = PRIME_FLOOR_MS * ms;
|
||||
let prime_ceil = PRIME_CEIL_MS * ms;
|
||||
let jitter_headroom = JITTER_HEADROOM_MS * ms;
|
||||
let hard_cap_max = HARD_CAP_MS * ms;
|
||||
let tuning = punktfunk_core::audio::JitterTuning::AAUDIO;
|
||||
// Worst transient the ring can hold before the policy trims it.
|
||||
let hard_cap_max = tuning.hard_cap_ms as usize * ms;
|
||||
let counters = Arc::new(Counters::default());
|
||||
|
||||
// One open attempt at a given sharing mode. Everything the realtime callback captures
|
||||
@@ -157,8 +153,10 @@ impl AudioPlayback {
|
||||
// `decode_loop`.
|
||||
let mut ring: VecDeque<f32> =
|
||||
VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms);
|
||||
let mut primed = false;
|
||||
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
|
||||
// Shared de-jitter policy — prime depth, drift correction, de-prime hysteresis. The
|
||||
// hysteresis this replaces was Android-only; Linux and Windows carried the instant
|
||||
// `if ring.is_empty()` re-prime until now.
|
||||
let mut policy = punktfunk_core::audio::JitterPolicy::new(tuning, channels as u8);
|
||||
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
|
||||
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
|
||||
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
||||
@@ -173,21 +171,25 @@ impl AudioPlayback {
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
}
|
||||
// Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained
|
||||
// drain; drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst
|
||||
// `want` (tiny on the LowLatency MMAP path) so the depth doesn't collapse to a single
|
||||
// quantum.
|
||||
let target = (3 * want).clamp(prime_floor, prime_ceil);
|
||||
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
|
||||
while ring.len() > hard_cap {
|
||||
ring.pop_front();
|
||||
// Jitter buffer: the shared policy decides prime/silence, trims a burst, and —
|
||||
// new here — sheds ONE crossfaded 5 ms frame when the depth average has sat above
|
||||
// target long enough to be drift rather than jitter. Without that shed this ring
|
||||
// had no way back down: it clamped at 120 ms and stayed pinned there.
|
||||
let step = policy.step(ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
punktfunk_core::audio::crossfade_drop(
|
||||
&mut ring,
|
||||
step.drop_front,
|
||||
step.crossfade,
|
||||
);
|
||||
}
|
||||
if !primed && ring.len() >= target {
|
||||
primed = true;
|
||||
}
|
||||
if primed {
|
||||
let mut ran_short = false;
|
||||
if !step.silence {
|
||||
for slot in out.iter_mut() {
|
||||
*slot = ring.pop_front().unwrap_or(0.0);
|
||||
*slot = ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
});
|
||||
}
|
||||
cb_counters
|
||||
.pcm_written
|
||||
@@ -196,20 +198,15 @@ impl AudioPlayback {
|
||||
out.fill(0.0);
|
||||
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
// Re-prime only after a RUN of empty callbacks, not a single transient one —
|
||||
// otherwise every momentary drain costs a fresh 40 ms silence (the old behaviour,
|
||||
// self-inflicted crackle on any jitter spike).
|
||||
if ring.is_empty() {
|
||||
empties += 1;
|
||||
if empties >= DEPRIME_AFTER_CALLBACKS {
|
||||
primed = false;
|
||||
}
|
||||
} else {
|
||||
empties = 0;
|
||||
}
|
||||
// No-op while un-primed, so a deliberate priming silence is never counted as an
|
||||
// underrun (which would otherwise drive the adaptive floor up for no reason).
|
||||
policy.note_read(ran_short);
|
||||
cb_counters
|
||||
.ring_depth
|
||||
.store(ring.len() as u64, Ordering::Relaxed);
|
||||
cb_counters
|
||||
.target_ms
|
||||
.store(policy.target_ms() as u64, Ordering::Relaxed);
|
||||
// Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the
|
||||
// HW buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are
|
||||
// both callback-safe / non-blocking, and set clamps to capacity so it self-limits.
|
||||
@@ -408,10 +405,11 @@ fn decode_loop(
|
||||
}
|
||||
if count % 600 == 0 {
|
||||
log::info!(
|
||||
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
|
||||
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} peak={window_peak:.3}",
|
||||
counters.pcm_written.load(Ordering::Relaxed),
|
||||
counters.underruns.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed) / ms.max(1) as u64,
|
||||
counters.target_ms.load(Ordering::Relaxed),
|
||||
);
|
||||
window_peak = 0.0;
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ pub(super) fn run_async(
|
||||
// even when the choreographer clock is absent.
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
|
||||
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
|
||||
if p.pump(&codec, clock, &tracker, &meter, &stats, now_monotonic_ns()) {
|
||||
rendered += 1;
|
||||
}
|
||||
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
|
||||
@@ -822,8 +822,21 @@ fn feed_ready(
|
||||
}
|
||||
}
|
||||
let Some(dst) = codec.input_buffer(idx) else {
|
||||
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
||||
continue;
|
||||
// Nothing was written and nothing was queued, so BOTH stay ours. Dropping the slot
|
||||
// here leaked one of the codec's input buffers per occurrence — we forget it and the
|
||||
// codec never frees what it never received, so the pipeline quietly runs out of input
|
||||
// slots, `pending_aus` overflows, and the resulting drop storm reads as a decode
|
||||
// fault. Dropping the AU on top of that punched a hole in the reference chain with no
|
||||
// keyframe request behind it, unlike every sibling path here.
|
||||
//
|
||||
// `break`, not `continue`: a codec that cannot hand out an input buffer it just
|
||||
// advertised is in no state to be fed the rest of the parked queue this pass, and
|
||||
// retrying the same index against every parked AU would burn the whole backlog. The
|
||||
// loop re-runs within the housekeeping wake (≤ 5 ms) if it was transient.
|
||||
log::warn!("decode: input_buffer({idx}) returned None — retrying next pass");
|
||||
free_inputs.push_front(idx);
|
||||
pending_aus.push_front(frame);
|
||||
break;
|
||||
};
|
||||
let au = &frame.data;
|
||||
if au.len() > dst.len() {
|
||||
|
||||
@@ -115,9 +115,14 @@ pub(crate) struct DecodeOptions {
|
||||
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
|
||||
/// Only meaningful with `present_priority` = smooth.
|
||||
pub smooth_buffer: i32,
|
||||
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
|
||||
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
|
||||
/// stream is down-rated below the panel (see `vsync.rs`).
|
||||
/// SEED for the panel's refresh period — the latch grid the presenter subdivides onto when
|
||||
/// the app's choreographer stream is down-rated below the panel (see `vsync.rs`). Kotlin
|
||||
/// resolves it from the display mode TABLE (`MainActivity.streamPanelFps`), not
|
||||
/// `display.refreshRate`, which reports a per-uid override rather than the panel. 0 = unknown.
|
||||
///
|
||||
/// ⚠ Only a seed: `preferredDisplayModeId` is a REQUEST the system may refuse, so the mode
|
||||
/// 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
|
||||
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
|
||||
//! queueing behind the display;
|
||||
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
|
||||
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
|
||||
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
|
||||
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
|
||||
//! * a **glass budget of one**: at most one undisplayed release in flight to SurfaceFlinger,
|
||||
//! reopened on the clock-predicted latch (with a 100 ms stale force-open as the liveness
|
||||
//! backstop, mirroring Apple's `PresentGate.staleAfter`), and bounded underneath by what
|
||||
//! `OnFrameRendered` actually confirmed reached glass ([`UNDISPLAYED_CAP`]) — because the
|
||||
//! prediction is only as good as the panel grid behind it, and 0.23.0 shipped a grid that
|
||||
//! could be wrong in one direction forever;
|
||||
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
|
||||
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
|
||||
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
|
||||
@@ -20,6 +22,7 @@
|
||||
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -36,9 +39,9 @@ use super::vsync::VsyncShared;
|
||||
///
|
||||
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
|
||||
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
|
||||
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
|
||||
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
|
||||
/// signal to widen, not stutter.
|
||||
/// here is a ms off every frame's display stage. A device that misses at the live margin shows it
|
||||
/// as a measured latch beyond one panel period (see the adaptation in
|
||||
/// [`Presenter::flush_log`]) — that, not a drop counter, is the signal to widen.
|
||||
const LATCH_MARGIN_NS: i64 = 2_500_000;
|
||||
|
||||
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
|
||||
@@ -71,6 +74,26 @@ fn latch_margin_ns() -> Option<i64> {
|
||||
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
|
||||
const STALE_REOPEN_NS: i64 = 100_000_000;
|
||||
|
||||
/// Releases still unconfirmed by `OnFrameRendered` at which the presenter stops handing
|
||||
/// SurfaceFlinger more work.
|
||||
///
|
||||
/// The reopen above is a PREDICTION off the learned panel grid. A grid finer than the panel
|
||||
/// (0.23.0 could pin one permanently — see [`punktfunk_core::phase::PanelGrid`]) reopens the
|
||||
/// budget before the display has consumed anything, and the presenter then releases faster than
|
||||
/// the panel scans: the BufferQueue fills, MediaCodec runs out of output buffers, the decoder
|
||||
/// stalls, and the no-output backstop starts begging for keyframes. The render callback is the
|
||||
/// ground truth about what actually reached glass, so it bounds the prediction.
|
||||
///
|
||||
/// Six, not one: the platform is explicitly allowed to deliver these callbacks BATCHED, and this
|
||||
/// module's own `RENDERED_CAP` note records them trailing a release by a vsync or two — so a
|
||||
/// healthy device sits at 1-3 outstanding and a tight cap would throttle it for nothing (a held
|
||||
/// frame in the newest-wins slot is a DROPPED frame the moment a fresher one decodes). This is
|
||||
/// not a pacing knob; it is the "something is structurally wrong" rail, and a presenter genuinely
|
||||
/// out-running its display climbs past any fixed cap within a second. If a device's BufferQueue
|
||||
/// is shallower than this the rail simply never engages and the no-output backstop handles it,
|
||||
/// exactly as before — best-effort, never worse than not having it.
|
||||
const UNDISPLAYED_CAP: i32 = 6;
|
||||
|
||||
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
|
||||
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
|
||||
|
||||
@@ -121,6 +144,14 @@ struct InFlight {
|
||||
/// a HUD-off wireless A/B readable from logcat.
|
||||
pub(super) struct PresentMeter {
|
||||
inner: Mutex<PresentMeterInner>,
|
||||
/// Frames released to SurfaceFlinger that `OnFrameRendered` has not yet confirmed reached
|
||||
/// glass. The presenter's structural rail (see [`UNDISPLAYED_CAP`]) and the pf-present line's
|
||||
/// queue-depth readout. Lock-free because the release side runs on the decode loop and the
|
||||
/// confirm side on the codec's callback thread, once per frame each.
|
||||
undisplayed: AtomicI32,
|
||||
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
|
||||
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
|
||||
confirms: AtomicBool,
|
||||
}
|
||||
|
||||
struct PresentMeterInner {
|
||||
@@ -147,11 +178,23 @@ impl PresentMeter {
|
||||
codec_us: Vec::with_capacity(256),
|
||||
e2e_us: Vec::with_capacity(256),
|
||||
}),
|
||||
undisplayed: AtomicI32::new(0),
|
||||
confirms: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
|
||||
///
|
||||
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
|
||||
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
|
||||
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
|
||||
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
|
||||
self.confirms.store(true, Ordering::Relaxed);
|
||||
let _ = self
|
||||
.undisplayed
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
|
||||
Some((v - 1).max(0))
|
||||
});
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -164,6 +207,26 @@ impl PresentMeter {
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame handed to SurfaceFlinger, awaiting its confirm. Decode thread.
|
||||
fn note_released(&self) {
|
||||
self.undisplayed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Releases still unconfirmed, and whether confirms happen on this device at all.
|
||||
fn outstanding(&self) -> (i32, bool) {
|
||||
(
|
||||
self.undisplayed.load(Ordering::Relaxed),
|
||||
self.confirms.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
|
||||
/// Write off the outstanding releases: the platform stopped confirming (it is allowed to
|
||||
/// drop callbacks under load) or SurfaceFlinger discarded the buffers without presenting
|
||||
/// them. Never stall the stream on a ledger we cannot audit.
|
||||
fn forgive_outstanding(&self) {
|
||||
self.undisplayed.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// One decoded frame's always-on measurements: the `decode`-stage split (feed =
|
||||
/// received→queued when a receipt stamp matched; codec = queued→decoded when the queued
|
||||
/// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof.
|
||||
@@ -239,6 +302,13 @@ pub(super) struct Presenter {
|
||||
no_budget: u64,
|
||||
forced: u64,
|
||||
dry: u64,
|
||||
/// Pump passes that held a frame back because too many earlier releases were still
|
||||
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
|
||||
/// the signature of a presenter out-running its display.
|
||||
queue_waits: u64,
|
||||
/// When the unconfirmed-release rail first engaged, so it can be forgiven if the confirms
|
||||
/// simply stopped coming. `None` while the rail is down.
|
||||
backed_up_since: Option<i64>,
|
||||
pace_us: Vec<u64>,
|
||||
last_flush: Instant,
|
||||
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
|
||||
@@ -280,6 +350,8 @@ impl Presenter {
|
||||
no_budget: 0,
|
||||
forced: 0,
|
||||
dry: 0,
|
||||
queue_waits: 0,
|
||||
backed_up_since: None,
|
||||
pace_us: Vec::with_capacity(256),
|
||||
last_flush: Instant::now(),
|
||||
margin_ns,
|
||||
@@ -334,6 +406,7 @@ impl Presenter {
|
||||
codec: &MediaCodec,
|
||||
clock: Option<&VsyncShared>,
|
||||
tracker: &DisplayTracker,
|
||||
meter: &PresentMeter,
|
||||
stats: &crate::stats::VideoStats,
|
||||
now_mono_ns: i64,
|
||||
) -> bool {
|
||||
@@ -346,6 +419,10 @@ impl Presenter {
|
||||
self.inflight = None;
|
||||
}
|
||||
}
|
||||
// The measured rail beneath that prediction (see `UNDISPLAYED_CAP`). Evaluated on every
|
||||
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
|
||||
// rather than how often a frame happened to be ready.
|
||||
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
|
||||
// Pick the frame this pump may release.
|
||||
let frame = if self.fifo_capacity == 0 {
|
||||
self.frames.pop_back() // submit() kept it a single slot; back == the newest
|
||||
@@ -373,9 +450,12 @@ impl Presenter {
|
||||
self.frames.pop_front()
|
||||
};
|
||||
let Some(frame) = frame else { return false };
|
||||
if self.inflight.is_some() {
|
||||
if self.inflight.is_some() || backlogged {
|
||||
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
|
||||
// vsync tick / loop pass retries the pairing.
|
||||
if backlogged {
|
||||
self.queue_waits += 1;
|
||||
}
|
||||
self.no_budget += 1;
|
||||
match self.fifo_capacity {
|
||||
0 => self.frames.push_back(frame),
|
||||
@@ -412,6 +492,7 @@ impl Presenter {
|
||||
released_at_ns: now_mono_ns,
|
||||
});
|
||||
self.released += 1;
|
||||
meter.note_released();
|
||||
let release_real_ns = now_realtime_ns();
|
||||
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
|
||||
if self.pace_us.len() < 4096 {
|
||||
@@ -422,6 +503,33 @@ impl Presenter {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether SurfaceFlinger is sitting on too many unconfirmed releases to be handed another.
|
||||
///
|
||||
/// The predicted reopen is only as good as the panel grid behind it; this is the measured
|
||||
/// rail underneath it (see [`UNDISPLAYED_CAP`]). It self-clears two ways — the confirms catch
|
||||
/// up, or [`STALE_REOPEN_NS`] passes with the backlog stuck, which means the ledger itself is
|
||||
/// unreliable (callbacks dropped under load, or SF discarded the buffers) and is written off
|
||||
/// rather than allowed to wedge the stream.
|
||||
fn unconfirmed_backlog(&mut self, meter: &PresentMeter, now_ns: i64) -> bool {
|
||||
let (outstanding, confirms_live) = meter.outstanding();
|
||||
if !confirms_live || outstanding < UNDISPLAYED_CAP {
|
||||
self.backed_up_since = None;
|
||||
return false;
|
||||
}
|
||||
match self.backed_up_since {
|
||||
Some(t) if now_ns - t > STALE_REOPEN_NS => {
|
||||
meter.forgive_outstanding();
|
||||
self.backed_up_since = None;
|
||||
self.forced += 1;
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
self.backed_up_since.get_or_insert(now_ns);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
|
||||
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
|
||||
while let Some(f) = self.frames.pop_front() {
|
||||
@@ -434,7 +542,9 @@ impl Presenter {
|
||||
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
|
||||
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
|
||||
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
|
||||
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `qDry` (FIFO underflows) / `qWait` (pumps held back by unconfirmed releases — 0 when
|
||||
/// healthy) / `unconfirmed` (releases OnFrameRendered hasn't settled) /
|
||||
/// `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
|
||||
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
|
||||
/// A/B headline) / `vsync` (the measured panel period).
|
||||
@@ -462,14 +572,15 @@ impl Presenter {
|
||||
let circ = clock.and_then(|c| {
|
||||
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
|
||||
});
|
||||
let latch_samples = latch.len();
|
||||
let (latch_p50, latch_max) = p50_max_ms(latch);
|
||||
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
|
||||
let panel_ms = clock
|
||||
.map(|c| c.panel_period_ns() as f64 / 1e6)
|
||||
.unwrap_or(0.0);
|
||||
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
|
||||
let (outstanding, _) = meter.outstanding();
|
||||
log::info!(
|
||||
target: "pf.present",
|
||||
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
|
||||
qWait={} unconfirmed={} \
|
||||
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
|
||||
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
|
||||
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
|
||||
@@ -480,6 +591,8 @@ impl Presenter {
|
||||
self.no_budget,
|
||||
self.forced,
|
||||
self.dry,
|
||||
self.queue_waits,
|
||||
outstanding,
|
||||
pace_p50,
|
||||
pace_max,
|
||||
latch_p50,
|
||||
@@ -493,25 +606,48 @@ impl Presenter {
|
||||
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
|
||||
circ.map(|(_, c)| c).unwrap_or(0),
|
||||
period_ms,
|
||||
panel_ms,
|
||||
panel_ns as f64 / 1e6,
|
||||
);
|
||||
self.released = 0;
|
||||
// Margin adaptation: repeated latch misses in one window (a miss presents a vsync
|
||||
// late and coalesces the next frame into `paced`) mean this device's SF does need
|
||||
// lead — widen toward the pre-sweep ceiling. One-way by design: a margin that once
|
||||
// proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
if !self.margin_pinned && self.paced_drops > 2 && self.margin_ns < LATCH_MARGIN_NS {
|
||||
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
|
||||
// `now + margin`, so a frame that makes its vsync is on glass within one panel period of
|
||||
// that margin; beyond it, SurfaceFlinger wanted more lead and the frame waited out an
|
||||
// extra refresh. Widen toward the pre-sweep ceiling. One-way by design: a margin that
|
||||
// once proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
//
|
||||
// ⚠ NOT `paced_drops`, which 0.23.0 used: those are the newest-wins store's own policy
|
||||
// evictions — a second frame decoding while one is held — which happen whenever the
|
||||
// stream out-runs the panel and say nothing at all about SF's latch lead. Driving the
|
||||
// margin from them widened it to the ceiling on healthy devices, re-imposing the 2.5 ms
|
||||
// of pure display latency the P2e sweep had just measured away.
|
||||
let latch_p50_ns = (latch_p50 * 1e6) as i64;
|
||||
if !self.margin_pinned
|
||||
&& self.margin_ns < LATCH_MARGIN_NS
|
||||
&& panel_ns > 0
|
||||
&& latch_samples >= 8
|
||||
&& latch_p50_ns > panel_ns + self.margin_ns
|
||||
{
|
||||
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
|
||||
log::warn!(
|
||||
"presenter: {} latch misses in 1s — margin widened to {}us",
|
||||
self.paced_drops,
|
||||
"presenter: latch p50 {:.2}ms over the {:.2}ms panel period — margin widened to {}us",
|
||||
latch_p50,
|
||||
panel_ns as f64 / 1e6,
|
||||
self.margin_ns / 1_000
|
||||
);
|
||||
}
|
||||
if self.queue_waits > 0 {
|
||||
log::warn!(
|
||||
"presenter: {} pump(s) held back — {} release(s) still unconfirmed by \
|
||||
OnFrameRendered (the display is not keeping up with the release rate)",
|
||||
self.queue_waits,
|
||||
outstanding
|
||||
);
|
||||
}
|
||||
self.paced_drops = 0;
|
||||
self.no_budget = 0;
|
||||
self.forced = 0;
|
||||
self.dry = 0;
|
||||
self.queue_waits = 0;
|
||||
circ
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,10 @@ pub(super) struct VsyncShared {
|
||||
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
|
||||
/// [`Self::next_target`].
|
||||
period_ns: AtomicI64,
|
||||
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
|
||||
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
|
||||
/// The panel's own refresh period — the grid SurfaceFlinger actually latches on (0 = unknown).
|
||||
/// Seeded from the display mode Kotlin resolved at stream start and then corrected by
|
||||
/// measurement; the learner itself is [`punktfunk_core::phase::PanelGrid`], owned by the
|
||||
/// choreographer thread (see [`CallbackCtx::panel`]) and published here for the decode loop.
|
||||
panel_period_ns: AtomicI64,
|
||||
/// Callback count, for the one-shot cadence diagnostic log.
|
||||
ticks: std::sync::atomic::AtomicU32,
|
||||
@@ -231,6 +233,11 @@ struct CallbackCtx {
|
||||
choreographer: *mut c_void,
|
||||
shared: Arc<VsyncShared>,
|
||||
on_tick: Box<dyn Fn() + Send>,
|
||||
/// The panel-period learner. `Cell` rather than an atomic because it is touched from exactly
|
||||
/// one thread — callbacks only ever fire inside this thread's looper poll (see the struct
|
||||
/// doc) — and its streak state is nobody else's business; only the settled period is
|
||||
/// published, to `shared.panel_period_ns`.
|
||||
panel: std::cell::Cell<punktfunk_core::phase::PanelGrid>,
|
||||
}
|
||||
|
||||
impl CallbackCtx {
|
||||
@@ -240,22 +247,25 @@ impl CallbackCtx {
|
||||
.shared
|
||||
.last_vsync_ns
|
||||
.swap(frame_time_ns, Ordering::Relaxed);
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
|
||||
// spacing ever observed is the panel's true period — trustworthy where the configured
|
||||
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
|
||||
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
|
||||
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
|
||||
// valid, widening on a later down-rated window never is.
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and therefore the
|
||||
// only honest witness to what the panel is doing — the configured mode is not (under a
|
||||
// per-uid frame-rate override `Display.getRefreshRate` REPORTS THE OVERRIDE, observed
|
||||
// on-glass: a 120 Hz panel read back as 60 while its timelines ran at 8.28 ms), and
|
||||
// neither is the mode Kotlin *requested* (`preferredDisplayModeId` is a hint the system
|
||||
// may refuse). Both directions matter and the asymmetry lives in `PanelGrid`.
|
||||
if timelines.len() >= 2 {
|
||||
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||
if (2_000_000..=42_000_000).contains(&spacing) {
|
||||
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
|
||||
if cur == 0 || spacing < cur - 200_000 {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(spacing, Ordering::Relaxed);
|
||||
}
|
||||
let mut grid = self.panel.get();
|
||||
if grid.observe(spacing) {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(grid.period_ns(), Ordering::Relaxed);
|
||||
log::info!(
|
||||
"vsync: panel grid now {:.2}ms",
|
||||
grid.period_ns() as f64 / 1e6
|
||||
);
|
||||
}
|
||||
self.panel.set(grid);
|
||||
}
|
||||
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
|
||||
// panel period is exactly the down-rating question, and this line answers it on-glass.
|
||||
@@ -372,8 +382,9 @@ pub(super) struct VsyncClock {
|
||||
impl VsyncClock {
|
||||
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
|
||||
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
|
||||
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
|
||||
/// `panel_hz` SEEDS the panel-grid learner (0 = unknown) — the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. A seed, not a fact: it names the display
|
||||
/// mode Kotlin *requested*, and the observed timeline spacing is what settles it. `None` when the platform surface is missing
|
||||
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
|
||||
/// budget).
|
||||
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
|
||||
@@ -383,11 +394,9 @@ impl VsyncClock {
|
||||
stop: AtomicBool::new(false),
|
||||
last_vsync_ns: AtomicI64::new(0),
|
||||
period_ns: AtomicI64::new(0),
|
||||
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
|
||||
1_000_000_000 / panel_hz as i64
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
panel_period_ns: AtomicI64::new(
|
||||
punktfunk_core::phase::PanelGrid::seeded(panel_hz).period_ns(),
|
||||
),
|
||||
ticks: std::sync::atomic::AtomicU32::new(0),
|
||||
timelines: Mutex::new(Vec::new()),
|
||||
});
|
||||
@@ -408,6 +417,7 @@ impl VsyncClock {
|
||||
choreographer,
|
||||
shared: thread_shared,
|
||||
on_tick,
|
||||
panel: std::cell::Cell::new(punktfunk_core::phase::PanelGrid::seeded(panel_hz)),
|
||||
};
|
||||
ctx.repost();
|
||||
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
|
||||
|
||||
@@ -121,7 +121,11 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
|
||||
host's virtual pad.
|
||||
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
|
||||
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
|
||||
every main push. See the script header for details.
|
||||
every main push. See the script header for details. The script's `SCENES` array is the listing
|
||||
set, in listing order; override it (`SCENES="06-gamepad-home 10-edithost" tools/screenshots.sh ios`)
|
||||
to capture any of the other scenes in `ShotScenes.all`. Mock data — hosts, adverts, profiles — is
|
||||
seeded in `ShotMock` so a capture is byte-for-byte deterministic and never browses the real LAN
|
||||
(a stranger's hostname reached the live listing that way once).
|
||||
- Deeper design notes live in the internal planning repo (punktfunk-planning:
|
||||
`apple-stage2-presenter.md`).
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ struct GamepadHomeView: View {
|
||||
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
GamepadSettingsView()
|
||||
GamepadSettingsView(store: store)
|
||||
.frame(width: 720, height: 640)
|
||||
}
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
@@ -144,7 +144,7 @@ struct GamepadHomeView: View {
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 420)
|
||||
#else
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() }
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
|
||||
.fullScreenCover(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
}
|
||||
@@ -176,18 +176,33 @@ struct GamepadHomeView: View {
|
||||
// MARK: - Chrome
|
||||
|
||||
private var titleBar: some View {
|
||||
Text("Select a Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) {
|
||||
// Which pad is driving this UI (name + battery) — quiet, and only where there's
|
||||
// room; a compact-height phone gives the pixels to the carousel instead.
|
||||
if !compact, let active = gamepads.active {
|
||||
ControllerStatusChip(controller: active)
|
||||
.padding(.trailing, 20)
|
||||
}
|
||||
}
|
||||
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
|
||||
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
|
||||
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
|
||||
// optically centred AND clear of the chip at every width; the title shrinks a little
|
||||
// before it would ever truncate.
|
||||
HStack(spacing: 12) {
|
||||
statusChip(hidden: true)
|
||||
Text("Select a Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(maxWidth: .infinity)
|
||||
statusChip(hidden: false)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
/// Which pad is driving this UI (name + battery) — quiet, and only where there's room; a
|
||||
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
|
||||
/// chip purely as a width reserve.
|
||||
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
|
||||
if !compact, let active = gamepads.active {
|
||||
ControllerStatusChip(controller: active)
|
||||
.opacity(hidden ? 0 : 1)
|
||||
.accessibilityHidden(hidden)
|
||||
}
|
||||
}
|
||||
|
||||
private var cardSpacing: CGFloat {
|
||||
|
||||
@@ -24,6 +24,13 @@ import ImageIO
|
||||
|
||||
@MainActor
|
||||
enum ScreenshotMode {
|
||||
/// This process was launched to capture a screenshot. Cheap enough to consult from the
|
||||
/// stores' persistence paths (`HostStore` / `ProfileStore`), which must NOT write their
|
||||
/// mock contents back into a real user's App Group when the harness runs on a dev Mac.
|
||||
static var isActive: Bool {
|
||||
!(ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? "").isEmpty
|
||||
}
|
||||
|
||||
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
|
||||
static var requestedScene: ShotScene? {
|
||||
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
|
||||
@@ -41,8 +48,11 @@ struct ScreenshotHostView: View {
|
||||
scene.make()
|
||||
.environment(\.colorScheme, scene.colorScheme)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
.ignoresSafeArea()
|
||||
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
|
||||
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
|
||||
// line was unreadable in every 6.9" capture); scenes that genuinely want full bleed —
|
||||
// the streamed frame itself — ignore it themselves.
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
#if os(macOS)
|
||||
.background(MacShotWindowConfigurator(scene: scene))
|
||||
#elseif os(iOS)
|
||||
@@ -129,18 +139,64 @@ enum MacSelfCapture {
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
|
||||
/// for chrome). Requires the app to allow those orientations in Info.plist.
|
||||
/// Orientation lock for the requested scene (landscape for the stream hero, portrait for chrome).
|
||||
/// Requires the app to allow those orientations in Info.plist — it does, for both.
|
||||
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
|
||||
let orientation: ShotOrientation
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
|
||||
func makeUIViewController(context: Context) -> ShotOrientationController {
|
||||
ShotOrientationController(mask: mask)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ vc: UIViewController, context: Context) {
|
||||
guard let scene = vc.view.window?.windowScene else { return }
|
||||
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
||||
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
func updateUIViewController(_ vc: ShotOrientationController, context: Context) {
|
||||
vc.mask = mask
|
||||
vc.applyGeometry()
|
||||
}
|
||||
|
||||
private var mask: UIInterfaceOrientationMask {
|
||||
orientation == .landscape ? .landscapeRight : .portrait
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the window scene to rotate, from a place where there IS a window.
|
||||
///
|
||||
/// The previous version made the request inside `updateUIViewController`, where `view.window` is
|
||||
/// still nil: SwiftUI makes exactly one update pass for a representable mounted as a `.background`,
|
||||
/// before the hierarchy is in a window, so the `guard` fell through and nothing ever asked again.
|
||||
/// Every scene declared `.landscape` — the stream hero and the trust card — was therefore captured
|
||||
/// in PORTRAIT at the portrait App Store size. Overriding `supportedInterfaceOrientations` as well
|
||||
/// keeps the scene from rotating back if the simulator reports a device orientation change.
|
||||
final class ShotOrientationController: UIViewController {
|
||||
var mask: UIInterfaceOrientationMask
|
||||
|
||||
init(mask: UIInterfaceOrientationMask) {
|
||||
self.mask = mask
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("not from a nib") }
|
||||
|
||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { mask }
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
applyGeometry()
|
||||
}
|
||||
|
||||
func applyGeometry() {
|
||||
// `view.window` once mounted; the connected-scene lookup covers the first update pass,
|
||||
// which still runs before this controller is in a window.
|
||||
let scene = view.window?.windowScene
|
||||
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
|
||||
guard let scene else { return }
|
||||
// Report a refusal instead of silently shipping the wrong orientation — that is exactly
|
||||
// how every landscape scene went out as a portrait PNG for as long as it did.
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in
|
||||
print("PF_SHOT_ORIENTATION_REFUSED \(error.localizedDescription)")
|
||||
fflush(stdout)
|
||||
}
|
||||
setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -81,24 +81,126 @@ enum ShotScenes {
|
||||
|
||||
@MainActor
|
||||
enum ShotMock {
|
||||
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
|
||||
// Stable ids so the store, the adverts and the profile bindings all point at the same things
|
||||
// across every scene and every run.
|
||||
static let battlestationID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000001")!
|
||||
static let livingRoomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000002")!
|
||||
static let workshopID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000003")!
|
||||
static let officeID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000004")!
|
||||
static let editingID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000005")!
|
||||
static let bedroomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000006")!
|
||||
|
||||
static let hdrProfileID = "a71c4e0d9f22"
|
||||
static let couchProfileID = "3e88b107c4da"
|
||||
|
||||
/// The catalog the host cards read their chips and pinned cards from. Seeded once, on the
|
||||
/// first store build — `ProfileStore` is a singleton, and in shot mode its write-back is
|
||||
/// suppressed, so this never reaches a real user's catalog.
|
||||
static func installProfiles() {
|
||||
guard !profilesInstalled else { return }
|
||||
profilesInstalled = true
|
||||
ProfileStore.shared.debugSet([
|
||||
StreamProfile(name: "4K HDR", id: hdrProfileID, accent: "#8B7BF7"),
|
||||
StreamProfile(name: "Couch 1080p", id: couchProfileID, accent: "#4FD1A5"),
|
||||
])
|
||||
}
|
||||
|
||||
private static var profilesInstalled = false
|
||||
|
||||
/// A populated saved-host grid: the most-recent host bound to a profile (its chip), a second
|
||||
/// paired machine, and one asleep box we hold a MAC for (so its card offers Wake-on-LAN). OS
|
||||
/// chains give every tile its real vendor mark instead of a letter monogram.
|
||||
///
|
||||
/// No PINNED host+profile card: it renders a second tile for the SAME host, which is the
|
||||
/// feature working as designed but reads as a duplicate to anyone meeting the app in a store
|
||||
/// listing. The binding chip carries the profile story on its own.
|
||||
static func hostStore() -> HostStore {
|
||||
installProfiles()
|
||||
let store = HostStore()
|
||||
store.hosts = [
|
||||
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
|
||||
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
||||
pinnedSHA256: fingerprint),
|
||||
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
|
||||
StoredHost(
|
||||
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420),
|
||||
macAddresses: ["a4:b1:c2:d3:e4:f5"], profileID: hdrProfileID,
|
||||
osChain: "windows/11"),
|
||||
StoredHost(
|
||||
id: livingRoomID, name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(1), lastConnected: Date().addingTimeInterval(-86_400),
|
||||
macAddresses: ["b8:27:eb:11:22:33"], osChain: "linux/fedora/bazzite"),
|
||||
StoredHost(
|
||||
id: officeID, name: "Office NUC", address: "192.168.1.33", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(4), lastConnected: Date().addingTimeInterval(-259_200),
|
||||
profileID: couchProfileID, osChain: "linux/ubuntu"),
|
||||
StoredHost(
|
||||
id: workshopID, name: "Workshop", address: "10.0.0.7", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(2), macAddresses: ["de:ad:be:ef:00:07"],
|
||||
osChain: "linux/arch"),
|
||||
StoredHost(
|
||||
id: editingID, name: "Editing Rig", address: "192.168.1.62", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(5), lastConnected: Date().addingTimeInterval(-604_800),
|
||||
osChain: "linux/nobara"),
|
||||
StoredHost(
|
||||
id: bedroomID, name: "Bedroom Mini", address: "192.168.1.77", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(6), macAddresses: ["00:1a:2b:3c:4d:5e"],
|
||||
osChain: "windows/11"),
|
||||
]
|
||||
return store
|
||||
}
|
||||
|
||||
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint)
|
||||
/// Discovery, seeded rather than live. Two saved hosts advertise (so their cards read ONLINE
|
||||
/// through the real `advertises` path, and the reachability probe skips them — no network from
|
||||
/// a capture), "Workshop" stays quiet so the grid shows an asleep machine, and one genuinely
|
||||
/// new host populates the "On this network" section.
|
||||
///
|
||||
/// A live browse made the shot non-deterministic AND leaked whatever was on the capturing
|
||||
/// machine's LAN into the App Store listing.
|
||||
static func discovery() -> HostDiscovery {
|
||||
let discovery = HostDiscovery()
|
||||
discovery.debugSet([
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "battlestation", name: "Battlestation", host: "192.168.1.20",
|
||||
fingerprintHex: fingerprint.hexLower, macAddresses: ["a4:b1:c2:d3:e4:f5"],
|
||||
osChain: "windows/11"),
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "living-room", name: "Living Room PC", host: "192.168.1.41",
|
||||
fingerprintHex: hostFingerprint(1).hexLower, macAddresses: ["b8:27:eb:11:22:33"],
|
||||
osChain: "linux/fedora/bazzite"),
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "office-nuc", name: "Office NUC", host: "192.168.1.33",
|
||||
fingerprintHex: hostFingerprint(4).hexLower, osChain: "linux/ubuntu"),
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "studio", name: "Studio PC", host: "192.168.1.58",
|
||||
fingerprintHex: hostFingerprint(3).hexLower, requiresPairing: true, allowsTofu: false,
|
||||
osChain: "windows/11"),
|
||||
])
|
||||
return discovery
|
||||
}
|
||||
|
||||
static let host = StoredHost(
|
||||
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, osChain: "windows/11")
|
||||
|
||||
/// What the pairing sheet calls THIS device. Taken from the platform, not from
|
||||
/// `UIDevice.current.name` — on a capture simulator that is the harness's own throwaway name
|
||||
/// (`pf-shot-iphone-6.9` went out on the store listing that way).
|
||||
static var clientDeviceName: String {
|
||||
#if os(tvOS)
|
||||
"Apple TV"
|
||||
#elseif os(macOS)
|
||||
"MacBook Pro"
|
||||
#else
|
||||
UIDevice.current.userInterfaceIdiom == .pad ? "iPad Pro" : "iPhone"
|
||||
#endif
|
||||
}
|
||||
|
||||
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
|
||||
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
|
||||
static let fingerprint = hostFingerprint(0)
|
||||
|
||||
/// Distinct per host — `StoredHost.matches` prefers a fingerprint comparison, so sharing one
|
||||
/// across the mock grid made a single advert light up every card.
|
||||
static func hostFingerprint(_ seed: Int) -> Data {
|
||||
Data((0..<32).map { UInt8((($0 &* 37) &+ 0x1d &+ (seed &* 91)) & 0xff) })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
@@ -106,7 +208,7 @@ enum ShotMock {
|
||||
private struct ShotHome: View {
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var discovery = ShotMock.discovery()
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
@@ -134,7 +236,7 @@ private struct ShotHome: View {
|
||||
private struct ShotGamepadHome: View {
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var discovery = ShotMock.discovery()
|
||||
@StateObject private var waker = HostWaker()
|
||||
|
||||
var body: some View {
|
||||
@@ -146,7 +248,9 @@ private struct ShotGamepadHome: View {
|
||||
}
|
||||
|
||||
private struct ShotGamepadSettings: View {
|
||||
var body: some View { GamepadSettingsView() }
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
|
||||
var body: some View { GamepadSettingsView(store: store) }
|
||||
}
|
||||
|
||||
private struct ShotGamepadAddHost: View {
|
||||
@@ -164,7 +268,7 @@ private struct ShotConnect: View {
|
||||
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var discovery = ShotMock.discovery()
|
||||
@StateObject private var waker = HostWaker()
|
||||
|
||||
var body: some View {
|
||||
@@ -241,9 +345,9 @@ private struct ShotSettings: View {
|
||||
#elseif os(iOS)
|
||||
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
|
||||
// rendered directly — a wrapping NavigationStack would nest a split view in a stack. Open
|
||||
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
|
||||
// the General page) instead of the bare category list.
|
||||
SettingsView(initialCategory: .general)
|
||||
// on Display rather than the bare category list: resolution, frame rate, bitrate, HDR and
|
||||
// codec are what someone reads a streaming app's settings shot to find out.
|
||||
SettingsView(initialCategory: .display)
|
||||
#else
|
||||
NavigationStack { SettingsView() }
|
||||
#endif
|
||||
@@ -253,16 +357,44 @@ private struct ShotSettings: View {
|
||||
// MARK: - Pair (PIN ceremony)
|
||||
|
||||
private struct ShotPair: View {
|
||||
/// The PIN as the host's web console shows it, and a device name that doesn't depend on what
|
||||
/// the capture simulator happens to be called.
|
||||
private var sheet: some View {
|
||||
PairSheet(
|
||||
host: ShotMock.host, shotPIN: "418 306",
|
||||
shotClientName: ShotMock.clientDeviceName, onPaired: { _ in })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
// PRESENT it, don't rebuild it. `PairSheet` is a bottom sheet on iOS — it carries its own
|
||||
// `.presentationDetents([.medium, .large])` and the system's Liquid Glass background, both
|
||||
// of which only exist inside a real `.sheet`. Composed into a ZStack instead (what this
|
||||
// scene used to do), the detents were inert, the grouped Form stretched to the full height
|
||||
// of the screen, and the capture was a thin strip of content over a huge black void.
|
||||
ShotHome()
|
||||
.sheet(isPresented: .constant(true)) {
|
||||
// Pinned to one detent. The sheet ships `[.medium, .large]` so it can grow over
|
||||
// the keyboard, and the resting height leaves a wide empty band between the form
|
||||
// and the button row; a capture wants the snug version.
|
||||
sheet.presentationDetents([.fraction(0.52)])
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
// tvOS pushes the ceremony as a full screen (HomeView's `navigationDestination`).
|
||||
NavigationStack { sheet }
|
||||
#else
|
||||
// macOS: a fixed-width panel (`.frame(width: 400).fixedSize()`) that hugs its content, so
|
||||
// floating it over the dimmed grid matches how the window-modal sheet reads. `screencapture
|
||||
// -l<windowID>` grabs one window, and an AppKit sheet is a child window — a real `.sheet`
|
||||
// would fall outside the capture.
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
|
||||
PairSheet(host: ShotMock.host, onPaired: { _ in })
|
||||
.frame(maxWidth: 460)
|
||||
sheet
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.shadow(radius: 40, y: 16)
|
||||
.padding(40)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -672,7 +672,16 @@ final class SessionModel: ObservableObject {
|
||||
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
|
||||
// when a pad's virtual device is a DualSense). Same trust gate as audio — nothing is
|
||||
// forwarded during the trust prompt.
|
||||
let capture = GamepadCapture(connection: conn, manager: .shared)
|
||||
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
|
||||
// (USB passthrough, or a pad plugged into the host) — capture still runs, and still
|
||||
// watches for the escape chord, but puts nothing on the wire.
|
||||
// System-button routing: whether raw guide/share presses ride the wire, and whether
|
||||
// hold-Select arms as the alternate guide route (auto = on everywhere but macOS —
|
||||
// iOS reserves the physical Home press, tvOS never delivers it).
|
||||
let capture = GamepadCapture(
|
||||
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding,
|
||||
systemForward: settings.systemButtonsForward,
|
||||
guideGesture: settings.guideGestureEnabled)
|
||||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||||
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
|
||||
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
// on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells
|
||||
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
|
||||
// with one button. Toggles read left = off, right = on — refusing a no-op with the same thud.
|
||||
//
|
||||
// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
|
||||
// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an
|
||||
// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with
|
||||
// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned.
|
||||
// Pins are presentation only: never the host's default binding, never the profile itself —
|
||||
// profiles are created and edited in the standard interface (and can't be on tvOS, whose
|
||||
// per-device catalog the detail strings are honest about).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
@@ -21,11 +29,18 @@ import CoreHaptics
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
|
||||
/// itself (ContentView owns the instance).
|
||||
@ObservedObject var store: HostStore
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
|
||||
@AppStorage(DefaultsKey.systemButtons) private var systemButtons = "auto"
|
||||
@AppStorage(DefaultsKey.guideGesture) private var guideGesture = "auto"
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@@ -51,6 +66,10 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
#endif
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
/// The profile catalog (ProfileStore.shared, like every other surface that reads it) — the
|
||||
/// Profiles rows re-derive from it each render, so a rename/delete made in the standard
|
||||
/// interface shows up live.
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so more rows fit.
|
||||
@@ -61,6 +80,9 @@ struct GamepadSettingsView: View {
|
||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||
#endif
|
||||
@State private var focusID: String?
|
||||
/// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per
|
||||
/// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows.
|
||||
@State private var pinTarget: StreamProfile?
|
||||
/// The direction of the last value step (+1 right/forward, -1 left) — picks which edge the
|
||||
/// changed value slides in from, so the animation follows the user's motion.
|
||||
@State private var lastAdjustDelta = 1
|
||||
@@ -71,7 +93,7 @@ struct GamepadSettingsView: View {
|
||||
focusID: $focusID,
|
||||
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { dismiss() }
|
||||
onBack: { back() }
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
@@ -79,7 +101,7 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
Text("Settings")
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
@@ -95,11 +117,7 @@ struct GamepadSettingsView: View {
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
])
|
||||
GamepadHintBar(hints: hints)
|
||||
}
|
||||
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
@@ -137,6 +155,43 @@ struct GamepadSettingsView: View {
|
||||
.accessibilityLabel("Close settings")
|
||||
}
|
||||
|
||||
/// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which
|
||||
/// layer the row list currently is.
|
||||
private var title: String {
|
||||
pinTarget.map { "Pin “\($0.name)”" } ?? "Settings"
|
||||
}
|
||||
|
||||
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
|
||||
/// picker — where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
|
||||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||||
private var hints: [GamepadHint] {
|
||||
guard pinTarget != nil else {
|
||||
return [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
]
|
||||
}
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
|
||||
}
|
||||
return [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
|
||||
]
|
||||
}
|
||||
|
||||
/// B peels one layer: the pin picker back to the settings rows — focus returning to the
|
||||
/// profile row it came from — then the screen itself.
|
||||
private func back() {
|
||||
if let profile = pinTarget {
|
||||
pinTarget = nil
|
||||
focusID = "profile-\(profile.id)"
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row rendering
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
@@ -163,7 +218,7 @@ struct GamepadSettingsView: View {
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||||
// Keyed by the value so a change slides the new option in instead of
|
||||
// hard-swapping the string — a QUIET horizontal slip following the user's
|
||||
// motion (a right-step enters from the right), crossfading over ~14 pt.
|
||||
@@ -184,7 +239,7 @@ struct GamepadSettingsView: View {
|
||||
.animation(.smooth(duration: 0.22), value: row.value)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
@@ -218,6 +273,9 @@ struct GamepadSettingsView: View {
|
||||
let value: String
|
||||
/// One-line explanation shown near the hint bar while this row is focused.
|
||||
let detail: String
|
||||
/// Whether left/right means anything here — false hides the value's chevrons (the
|
||||
/// Profiles rows navigate, and the placeholder rows do nothing at all).
|
||||
var adjustable = true
|
||||
/// Left/right step; returns whether the value actually changed (false ⇒ boundary thud).
|
||||
let adjust: (Int) -> Bool
|
||||
/// A — cycle forward (wrapping) / flip.
|
||||
@@ -237,6 +295,9 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
// The pin picker replaces the whole list while it's up — same screen, one layer deeper,
|
||||
// so the focus list's controller wiring (and the tvOS focus engine) carries over as is.
|
||||
if let profile = pinTarget { return pinRows(for: profile) }
|
||||
let resolution = resolutionOptions
|
||||
let refresh = SettingsOptions.refreshRates(including: hz)
|
||||
.map { (label: "\($0) Hz", tag: $0) }
|
||||
@@ -323,8 +384,15 @@ struct GamepadSettingsView: View {
|
||||
+ "speaker setups feeding the game back to the host.",
|
||||
value: $echoCancel),
|
||||
|
||||
toggleRow(
|
||||
id: "padForward", header: "Controller", icon: "gamecontroller",
|
||||
label: "Forward controllers",
|
||||
detail: "Send this device's controllers to the host. Turn it off when your "
|
||||
+ "controller already reaches the host another way — USB passthrough such "
|
||||
+ "as VirtualHere — so games don't see two of them.",
|
||||
value: $gamepadForwarding),
|
||||
choiceRow(
|
||||
id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller",
|
||||
id: "pad", icon: "gamecontroller", label: "Use controller",
|
||||
detail: "Which pad is forwarded to the host, as player 1.",
|
||||
options: controllers, current: gamepads.preferredID
|
||||
) { gamepads.preferredID = $0 },
|
||||
@@ -333,6 +401,18 @@ struct GamepadSettingsView: View {
|
||||
detail: "The virtual pad the host creates — Automatic matches this controller.",
|
||||
options: SettingsOptions.padTypes, current: gamepadType
|
||||
) { gamepadType = $0 },
|
||||
choiceRow(
|
||||
id: "systemButtons", icon: "house.circle", label: "Guide button",
|
||||
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
|
||||
+ "Automatic sends them to the host whenever this device delivers them.",
|
||||
options: SettingsOptions.systemButtons, current: systemButtons
|
||||
) { systemButtons = $0 },
|
||||
choiceRow(
|
||||
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
|
||||
detail: "Hold Select alone to press the host's guide button — keep holding "
|
||||
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
|
||||
options: SettingsOptions.guideGestures, current: guideGesture
|
||||
) { guideGesture = $0 },
|
||||
|
||||
choiceRow(
|
||||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||||
@@ -386,7 +466,98 @@ struct GamepadSettingsView: View {
|
||||
at: at + 1)
|
||||
}
|
||||
#endif
|
||||
return list
|
||||
return list + profileRows
|
||||
}
|
||||
|
||||
// MARK: - Profiles (§5.2a)
|
||||
|
||||
/// The trailing Profiles section: one row per catalog profile, its value how many saved
|
||||
/// hosts pin it, A opening the pin-to-hosts picker. Read-only beyond that — this surface
|
||||
/// pins and unpins, but profiles are created and edited elsewhere (design §5.4), so
|
||||
/// left/right is a boundary thud, not an editor.
|
||||
private var profileRows: [Row] {
|
||||
guard !profiles.profiles.isEmpty else {
|
||||
return [Row(
|
||||
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
|
||||
label: "No profiles yet", value: "",
|
||||
detail: emptyCatalogDetail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false }, activate: {})]
|
||||
}
|
||||
return profiles.profiles.enumerated().map { i, profile in
|
||||
let pins = store.hosts
|
||||
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
|
||||
return Row(
|
||||
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
|
||||
icon: "slider.horizontal.3", label: profile.name,
|
||||
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
|
||||
detail: profileDetail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false },
|
||||
activate: {
|
||||
// Focus lands on the picker's first row — the focus list's reconcile
|
||||
// follows this id when the row set swaps underneath it.
|
||||
focusID = store.hosts.first.map { "pinHost-\($0.id.uuidString)" } ?? "noHosts"
|
||||
pinTarget = profile
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The pin-to-hosts picker: one toggle row per SAVED host, sharing the settings rows'
|
||||
/// toggle semantics (left = unpin, right = pin, A flips; asking for the state it's in is a
|
||||
/// boundary thud). Writes ride `HostStore.setPinned` — pin appends, unpin removes — and
|
||||
/// NEVER the host's default binding (`profileID`): a pin is presentation only (§5.2a).
|
||||
private func pinRows(for profile: StreamProfile) -> [Row] {
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [Row(
|
||||
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
|
||||
value: "",
|
||||
detail: "Pair with a host first, then pin this profile to it.",
|
||||
adjustable: false,
|
||||
adjust: { _ in false }, activate: {})]
|
||||
}
|
||||
return store.hosts.map { host in
|
||||
let hostID = host.id
|
||||
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
|
||||
return Row(
|
||||
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
|
||||
label: host.displayName,
|
||||
value: pinned ? "Pinned" : "Off",
|
||||
detail: "A pinned profile appears as its own card on the host — one press "
|
||||
+ "connects with it.",
|
||||
adjust: { delta in
|
||||
let target = delta > 0
|
||||
guard pinned != target else { return false }
|
||||
store.setPinned(hostID, profileID: profile.id, pinned: target)
|
||||
return true
|
||||
},
|
||||
activate: { store.setPinned(hostID, profileID: profile.id, pinned: !pinned) })
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile rows' explainer. tvOS gets its own: the catalog is per-device (the App Group
|
||||
/// suite — nothing syncs it) and tvOS has no profile editor at all (§5.4), so pointing a TV
|
||||
/// user at a "standard interface" would promise profiles that can never arrive there.
|
||||
private var profileDetail: String {
|
||||
#if os(tvOS)
|
||||
return "Pin this profile to a host and it appears as its own card on the home screen — "
|
||||
+ "one press connects with it."
|
||||
#else
|
||||
return "Pin this profile to a host and it appears as its own card — one press connects "
|
||||
+ "with it. Profiles are created and edited in Punktfunk's standard interface."
|
||||
#endif
|
||||
}
|
||||
|
||||
/// What the empty catalog's placeholder explains — again honest on tvOS, where profiles
|
||||
/// cannot be created (on the device or anywhere that would reach its per-device catalog).
|
||||
private var emptyCatalogDetail: String {
|
||||
#if os(tvOS)
|
||||
return "Profiles bundle stream settings for different uses. Creating them isn't "
|
||||
+ "available on Apple TV yet."
|
||||
#else
|
||||
return "Profiles bundle stream settings for different uses. Create them in Punktfunk's "
|
||||
+ "standard interface, then pin them here as one-press connect cards."
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||||
|
||||
@@ -34,6 +34,22 @@ enum SettingsOptions {
|
||||
("DualShock 4", 4),
|
||||
]
|
||||
|
||||
/// System-button routing (the cross-client `system_buttons` key): where the guide
|
||||
/// (Xbox/PS) and share presses land while streaming. Auto = forward on Apple.
|
||||
static let systemButtons: [(label: String, tag: String)] = [
|
||||
("Automatic", "auto"),
|
||||
("Send to host", "forward"),
|
||||
("This device", "local"),
|
||||
]
|
||||
|
||||
/// The hold-Select guide gesture (the cross-client `guide_gesture` key). Auto = on
|
||||
/// everywhere but macOS.
|
||||
static let guideGestures: [(label: String, tag: String)] = [
|
||||
("Automatic", "auto"),
|
||||
("On", "on"),
|
||||
("Off", "off"),
|
||||
]
|
||||
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
|
||||
@@ -122,6 +122,18 @@ enum SettingsFields {
|
||||
.init(name: "gamepad", key: DefaultsKey.gamepadType,
|
||||
overlay: \.gamepadType, effective: \.gamepadType)
|
||||
}
|
||||
static var gamepadForwarding: SettingsField<Bool> {
|
||||
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
|
||||
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
|
||||
}
|
||||
static var systemButtons: SettingsField<String> {
|
||||
.init(name: "system_buttons", key: DefaultsKey.systemButtons,
|
||||
overlay: \.systemButtons, effective: \.systemButtons)
|
||||
}
|
||||
static var guideGesture: SettingsField<String> {
|
||||
.init(name: "guide_gesture", key: DefaultsKey.guideGesture,
|
||||
overlay: \.guideGesture, effective: \.guideGesture)
|
||||
}
|
||||
static var statsVerbosity: SettingsField<String> {
|
||||
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
|
||||
overlay: \.statsVerbosity, effective: \.statsVerbosity)
|
||||
@@ -181,6 +193,7 @@ extension SettingsView {
|
||||
base.micEnabled = micEnabled
|
||||
base.echoCancel = echoCancel
|
||||
base.gamepadType = gamepadType
|
||||
base.gamepadForwarding = gamepadForwarding
|
||||
base.statsVerbosity = statsVerbosityRaw
|
||||
base.fullscreenWhileStreaming = fullscreenWhileStreaming
|
||||
base.presentPriority = presentPriority
|
||||
|
||||
@@ -641,6 +641,15 @@ extension SettingsView {
|
||||
|
||||
@ViewBuilder var controllersSection: some View {
|
||||
Section {
|
||||
// The master switch, above everything it governs. Profileable, so it renders in
|
||||
// both scopes: a "Work" profile can decline to forward what "Game" forwards.
|
||||
described("Sends controllers connected to this device to the host. Turn it off when "
|
||||
+ "your controller already reaches the host another way — USB passthrough such "
|
||||
+ "as VirtualHere, or a pad plugged into the host itself — so games don't see "
|
||||
+ "two of them.",
|
||||
field: "gamepad_forwarding") {
|
||||
Toggle("Forward controllers", isOn: scoped(SettingsFields.gamepadForwarding))
|
||||
}
|
||||
// Which physical pad this device forwards, and what its own haptics do, are facts
|
||||
// about THIS device (tier G) — only the virtual pad the host creates is profileable.
|
||||
if !inProfileScope {
|
||||
@@ -659,6 +668,7 @@ extension SettingsView {
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
}
|
||||
described("The virtual pad created on the host. Automatic matches your controller "
|
||||
@@ -669,6 +679,30 @@ extension SettingsView {
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
described("Where the guide (Xbox/PS) and share presses go while streaming. "
|
||||
+ "Automatic sends them to the host whenever this device delivers them "
|
||||
+ "— the hold-Select gesture below reaches the host regardless.",
|
||||
field: "system_buttons") {
|
||||
Picker("Guide button", selection: scoped(SettingsFields.systemButtons)) {
|
||||
Text("Automatic").tag("auto")
|
||||
Text("Send to host").tag("forward")
|
||||
Text("This device").tag("local")
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
described("Hold Select on its own to press the host's guide button — keep "
|
||||
+ "holding for a Gaming-Mode host's quick-access menu. A Select tap still "
|
||||
+ "goes through, slightly delayed. Automatic arms it wherever the real "
|
||||
+ "button can't reach the host (this device reserves it).",
|
||||
field: "guide_gesture") {
|
||||
Picker("Hold Select for guide", selection: scoped(SettingsFields.guideGesture)) {
|
||||
Text("Automatic").tag("auto")
|
||||
Text("On").tag("on")
|
||||
Text("Off").tag("off")
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
#if os(iOS)
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
|
||||
@@ -49,6 +49,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
|
||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.gamepadForwarding) var gamepadForwarding = true
|
||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
|
||||
@@ -191,6 +191,12 @@ final class HostStore: ObservableObject {
|
||||
|
||||
|
||||
private func persist() {
|
||||
#if DEBUG
|
||||
// The screenshot harness fills a store with mock hosts (ShotMock) purely to render a
|
||||
// scene. On a dev Mac that store is the SAME App-Group suite the real app reads, so
|
||||
// persisting would replace the tester's saved hosts with "Battlestation" & co.
|
||||
if ScreenshotMode.isActive { return }
|
||||
#endif
|
||||
if let data = try? JSONEncoder().encode(hosts) {
|
||||
defaults.set(data, forKey: Self.key)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,14 @@ final class ProfileStore: ObservableObject {
|
||||
static let shared = ProfileStore()
|
||||
|
||||
@Published private(set) var catalog: ProfileCatalog {
|
||||
didSet { catalog.save() }
|
||||
didSet {
|
||||
#if DEBUG
|
||||
// Shot mode seeds this SINGLETON with mock profiles to populate the host cards.
|
||||
// Saving would write them into the tester's real catalog — see HostStore.persist().
|
||||
if ScreenshotMode.isActive { return }
|
||||
#endif
|
||||
catalog.save()
|
||||
}
|
||||
}
|
||||
|
||||
var profiles: [StreamProfile] { catalog.profiles }
|
||||
@@ -33,6 +40,14 @@ final class ProfileStore: ObservableObject {
|
||||
id.flatMap { catalog.profile(id: $0) }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Shot-mode seed: replace the catalog outright so a capture shows a known set of profiles
|
||||
/// rather than the tester's. Safe because `didSet` suppresses the write-back in shot mode.
|
||||
func debugSet(_ profiles: [StreamProfile]) {
|
||||
catalog = ProfileCatalog(profiles: profiles)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// This host's default profile, dangling ids dropped — a deleted profile resolves as "Default
|
||||
/// settings", never an error (§4.4).
|
||||
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }
|
||||
|
||||
@@ -109,7 +109,7 @@ struct PairSheet: View {
|
||||
#endif
|
||||
TextField(
|
||||
"Client name", text: $clientName,
|
||||
prompt: Text("How the host lists this Mac"))
|
||||
prompt: Text(Self.clientNamePrompt))
|
||||
#if os(tvOS)
|
||||
.labelsHidden() // prefilled → tvOS floats the label off-center
|
||||
#endif
|
||||
@@ -184,6 +184,16 @@ struct PairSheet: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The field prompt names the device you are actually on — it said "this Mac" on every
|
||||
/// platform, which on an iPhone is simply wrong.
|
||||
private static var clientNamePrompt: String {
|
||||
#if os(macOS)
|
||||
"How the host lists this Mac"
|
||||
#else
|
||||
"How the host lists this device"
|
||||
#endif
|
||||
}
|
||||
|
||||
private func runCeremony() {
|
||||
busy = true
|
||||
errorText = nil
|
||||
@@ -229,3 +239,24 @@ struct PairSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension PairSheet {
|
||||
/// Screenshot-harness seed (`ShotScenes`). A capture of the untouched sheet shows an empty PIN
|
||||
/// field, a DISABLED "Pair & Connect", and — because the client name defaults to the device's
|
||||
/// own — whatever the capture simulator happens to be called (`pf-shot-iphone-6.9` reached App
|
||||
/// Store Connect that way). Seeding both fields captures the ceremony as a user meets it,
|
||||
/// mid-entry, with a live primary button.
|
||||
///
|
||||
/// An extension so `PairSheet` keeps its memberwise initialiser, and THIS file so it can reach
|
||||
/// the private state.
|
||||
init(
|
||||
host: StoredHost, shotPIN: String, shotClientName: String,
|
||||
onPaired: @escaping (Data) -> Void
|
||||
) {
|
||||
self.init(host: host, onPaired: onPaired)
|
||||
_pin = State(initialValue: shotPIN)
|
||||
_clientName = State(initialValue: shotClientName)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -3,28 +3,66 @@ import os
|
||||
|
||||
/// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread → render
|
||||
/// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming:
|
||||
/// reads return silence until enough is buffered (at least `prefill`, and at least one
|
||||
/// reads return silence until enough is buffered (at least the target, and at least one
|
||||
/// packet more than the device's render quantum — large-buffer devices would otherwise
|
||||
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime), and an
|
||||
/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle.
|
||||
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime).
|
||||
/// All counts stay whole frames (multiples of `channels`), so the interleave can never slip.
|
||||
///
|
||||
/// **Drift correction.** Both ends run at 48 kHz but on different crystals, so backlog from a
|
||||
/// network stall or plain host-vs-DAC skew never drains on its own: without correction one 300 ms
|
||||
/// hiccup leaves audio 300 ms behind video for the rest of the session. This used to be handled by
|
||||
/// a `highWater` shed that dropped a whole `2 × prefill` at once — its own comment called that "one
|
||||
/// audible blip". It is now the same two-stage scheme the Rust clients share
|
||||
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
|
||||
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
|
||||
/// Keep the constants here in step with `JitterTuning.COREAUDIO`.
|
||||
final class AudioRing: @unchecked Sendable {
|
||||
/// Mirrors `JitterTuning::COREAUDIO` — see that type for the rationale.
|
||||
private static let targetMS = 20
|
||||
private static let headroomMS = 30
|
||||
private static let hardCapMS = 90
|
||||
private static let deprimeAfter = 4
|
||||
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
|
||||
private static let frameMS = 5
|
||||
/// Depth average must exceed target by this before drift correction fires — the middle of the
|
||||
/// headroom band, so the smooth shed always gets its chance BEFORE the hard cap trims.
|
||||
private static let shedExcessMS = 15
|
||||
/// …and must stay there for this much consumed audio. Long, because a shed is the only thing
|
||||
/// here a listener could notice; it must never fire on a transient.
|
||||
private static let shedSustainMS = 2_000
|
||||
private static let crossfadeMS = 2
|
||||
/// Time constant of the depth average.
|
||||
private static let ewmaTauMS = 1_000
|
||||
|
||||
private var buf: [Float]
|
||||
private var readIdx = 0
|
||||
private var writeIdx = 0
|
||||
private var primed = false
|
||||
private var renderQuantum = 0
|
||||
private let prefill: Int
|
||||
private let highWater: Int
|
||||
private var emptyReads = 0
|
||||
private var depthAvg: Double = 0
|
||||
private var overRun = 0
|
||||
/// Reported, not acted on: short reads that actually starved the callback, and smooth drift
|
||||
/// corrections. A rising underrun count means the ring is being starved (network or CPU),
|
||||
/// which is a different problem from the depth being wrong.
|
||||
private var underrunCount = 0
|
||||
private var shedCount = 0
|
||||
private let channels: Int
|
||||
private let perMS: Int
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
|
||||
/// `capacity`/`prefill` in samples (interleaved — `channels` per frame, both whole frames).
|
||||
init(capacity: Int, prefill: Int, channels: Int) {
|
||||
/// `capacity` in samples (interleaved — `channels` per frame, a whole number of frames).
|
||||
/// The de-jitter depth is the ring's own business (`targetMS`), not a caller's prefill.
|
||||
init(capacity: Int, channels: Int) {
|
||||
buf = [Float](repeating: 0, count: capacity)
|
||||
self.prefill = prefill
|
||||
self.channels = channels
|
||||
highWater = prefill * 4
|
||||
perMS = 48 * channels
|
||||
}
|
||||
|
||||
/// Live target depth in interleaved samples, lifted so it can always serve one device quantum
|
||||
/// plus a packet (a large-buffer device cannot sustain a target below its own quantum).
|
||||
private var target: Int {
|
||||
max(Self.targetMS * perMS, renderQuantum + Self.frameMS * perMS)
|
||||
}
|
||||
|
||||
func write(_ samples: UnsafePointer<Float>, count: Int) {
|
||||
@@ -42,12 +80,12 @@ final class AudioRing: @unchecked Sendable {
|
||||
buf[(writeIdx + i) % capacity] = samples[i]
|
||||
}
|
||||
writeIdx += count
|
||||
// Latency clamp: both ends run at 48 kHz, so backlog from a network stall (or
|
||||
// creeping host-vs-DAC clock skew) never drains on its own — without this, one
|
||||
// 300 ms hiccup leaves audio 300 ms behind video for the rest of the session.
|
||||
// Shedding down to 2× prefill costs one audible blip instead.
|
||||
if writeIdx - readIdx > highWater {
|
||||
readIdx = writeIdx - prefill * 2
|
||||
// Backstop only: the smooth shed in `read` is what normally holds the depth down.
|
||||
let cap = min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS)
|
||||
if writeIdx - readIdx > cap {
|
||||
readIdx = writeIdx - cap
|
||||
depthAvg = Double(cap)
|
||||
overRun = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,16 +95,37 @@ final class AudioRing: @unchecked Sendable {
|
||||
defer { lock.unlock() }
|
||||
renderQuantum = max(renderQuantum, count)
|
||||
let available = writeIdx - readIdx
|
||||
|
||||
// Depth average, weighted by the callback size so its time constant is independent of the
|
||||
// device quantum.
|
||||
let alpha = min(1.0, Double(count) / Double(Self.ewmaTauMS * perMS))
|
||||
depthAvg += (Double(available) - depthAvg) * alpha
|
||||
|
||||
if !primed {
|
||||
// One 5 ms host packet (240 frames × channels) of slack beyond the device's demand.
|
||||
if available >= max(prefill, renderQuantum + 240 * channels) {
|
||||
if available >= target {
|
||||
primed = true
|
||||
emptyReads = 0
|
||||
} else {
|
||||
for i in 0..<count { out[i] = 0 }
|
||||
return
|
||||
}
|
||||
}
|
||||
let n = min(available, count)
|
||||
|
||||
// Drift correction: shed exactly one frame, crossfaded, once the AVERAGE has sat above
|
||||
// the threshold for the sustain window. Anything shorter is jitter and must be left alone.
|
||||
if depthAvg > Double(target + Self.shedExcessMS * perMS) {
|
||||
overRun += count
|
||||
if overRun >= Self.shedSustainMS * perMS {
|
||||
overRun = 0
|
||||
shedOneFrame()
|
||||
shedCount += 1
|
||||
depthAvg = Double(writeIdx - readIdx)
|
||||
}
|
||||
} else {
|
||||
overRun = 0
|
||||
}
|
||||
|
||||
let n = min(writeIdx - readIdx, count)
|
||||
let capacity = buf.count
|
||||
for i in 0..<n {
|
||||
out[i] = buf[(readIdx + i) % capacity]
|
||||
@@ -74,9 +133,63 @@ final class AudioRing: @unchecked Sendable {
|
||||
readIdx += n
|
||||
if n < count {
|
||||
for i in n..<count { out[i] = 0 }
|
||||
primed = false // underrun — re-prime before resuming
|
||||
// De-prime only after a RUN of short reads: a single transient drain must not
|
||||
// manufacture a whole target's worth of fresh silence.
|
||||
emptyReads += 1
|
||||
underrunCount += 1
|
||||
if emptyReads >= Self.deprimeAfter { primed = false }
|
||||
} else {
|
||||
emptyReads = 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one protocol frame from the front, linearly crossfading the seam so the correction is
|
||||
/// inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; caller holds
|
||||
/// the lock.
|
||||
private func shedOneFrame() {
|
||||
let drop = Self.frameMS * perMS
|
||||
let available = writeIdx - readIdx
|
||||
guard available > drop else { return }
|
||||
let fade = min(Self.crossfadeMS * perMS, min(drop, available - drop))
|
||||
let capacity = buf.count
|
||||
if fade > 0 {
|
||||
// The tail of what we discard fades out into the head of what survives.
|
||||
for i in 0..<fade {
|
||||
let old = buf[(readIdx + drop - fade + i) % capacity]
|
||||
let new = buf[(readIdx + drop + i) % capacity]
|
||||
let t = Float(i + 1) / Float(fade + 1)
|
||||
buf[(readIdx + drop + i) % capacity] = old * (1 - t) + new * t
|
||||
}
|
||||
}
|
||||
readIdx += drop
|
||||
}
|
||||
|
||||
/// Current buffered depth in milliseconds — for the stats overlay and the drain thread's
|
||||
/// periodic log.
|
||||
var bufferedMS: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (writeIdx - readIdx) / max(perMS, 1)
|
||||
}
|
||||
|
||||
/// One consistent snapshot of the ring's vitals, taken under a single lock so the numbers in
|
||||
/// a log line describe the same instant. Mirrors what the three Rust clients report.
|
||||
struct Stats {
|
||||
let bufferedMS: Int
|
||||
let targetMS: Int
|
||||
let underruns: Int
|
||||
let sheds: Int
|
||||
}
|
||||
|
||||
var stats: Stats {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return Stats(
|
||||
bufferedMS: (writeIdx - readIdx) / max(perMS, 1),
|
||||
targetMS: target / max(perMS, 1),
|
||||
underruns: underrunCount,
|
||||
sheds: shedCount)
|
||||
}
|
||||
}
|
||||
|
||||
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
|
||||
|
||||
@@ -317,10 +317,10 @@ public final class SessionAudio {
|
||||
// Build the playback layout from the host-RESOLVED channel count (never the request):
|
||||
// 2 = stereo / 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
|
||||
let channels = Int(connection.resolvedAudioChannels)
|
||||
// 1 s interleaved capacity, ~20 ms prefill (four 5 ms host packets of jitter absorption
|
||||
// before the first sample plays), both scaled by the channel count.
|
||||
let ring = self.ring ?? AudioRing(
|
||||
capacity: 48_000 * channels, prefill: 960 * channels, channels: channels)
|
||||
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
|
||||
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
|
||||
// rather than a prefill passed in here.
|
||||
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
self.ring = ring
|
||||
|
||||
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
|
||||
@@ -403,6 +403,7 @@ public final class SessionAudio {
|
||||
stateLock.unlock()
|
||||
let thread = Thread { [connection, flag, drainDone] in
|
||||
defer { drainDone.signal() }
|
||||
var drained = 0
|
||||
// Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is
|
||||
// stereo-only — and is handed back as interleaved f32 PCM in wire channel order.
|
||||
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
|
||||
@@ -421,6 +422,17 @@ public final class SessionAudio {
|
||||
ring.write(base, count: pcm.frameCount * pcm.channels)
|
||||
}
|
||||
}
|
||||
// Periodic vitals (~10 s at the protocol's 5 ms frames). The other three clients
|
||||
// log buffer depth and underruns; without this an Apple audio report — latency or
|
||||
// dropout — arrives with no numbers at all, which is the position every platform
|
||||
// was in before the 2026-08 audio work.
|
||||
drained += 1
|
||||
if drained % 2_000 == 0 {
|
||||
let s = ring.stats
|
||||
log.info(
|
||||
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds)"
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,9 @@ public final class HostDiscovery: ObservableObject {
|
||||
|
||||
/// Start browsing `_punktfunk._udp`. Idempotent — a second call while live is a no-op.
|
||||
public func start() {
|
||||
#if DEBUG
|
||||
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
|
||||
#endif
|
||||
guard browser == nil else { return }
|
||||
let browser = NWBrowser(
|
||||
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
||||
@@ -92,6 +95,35 @@ public final class HostDiscovery: ObservableObject {
|
||||
for conn in connections.values { conn.cancel() }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// A seeded advert set is in force — `start()` must not replace it with the live browse.
|
||||
private var debugPinned = false
|
||||
|
||||
/// Screenshot/preview seam, the discovery counterpart to `HostWaker.debugSet`: publish a FIXED
|
||||
/// set of adverts and keep browsing off. Without it a capture shows whatever happens to be on
|
||||
/// the machine's LAN — the App Store screenshots shipped a stranger's hostname more than once —
|
||||
/// and every mock host reads Offline because nothing advertises it.
|
||||
public func debugSet(_ adverts: [DiscoveredHost]) {
|
||||
stop()
|
||||
debugPinned = true
|
||||
hosts = adverts
|
||||
}
|
||||
|
||||
/// Builds one advert. `DiscoveredHost`'s memberwise init is internal (a public struct's is), and
|
||||
/// making it public would expose a wire-shaped model's construction to every consumer just to
|
||||
/// serve the harness.
|
||||
public static func debugAdvert(
|
||||
id: String, name: String, host: String, port: UInt16 = 9777,
|
||||
fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true,
|
||||
macAddresses: [String] = [], osChain: String = ""
|
||||
) -> DiscoveredHost {
|
||||
DiscoveredHost(
|
||||
id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex,
|
||||
requiresPairing: requiresPairing, allowsTofu: allowsTofu,
|
||||
macAddresses: macAddresses, osChain: osChain)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func restart() {
|
||||
stop()
|
||||
start()
|
||||
|
||||
@@ -21,8 +21,12 @@ import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
|
||||
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
|
||||
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
///
|
||||
/// A caller that owns a particular pad passes the location id it wants (see
|
||||
/// `open(preferringLocationID:)`); the renderer takes that from the `GCController` it is bound to,
|
||||
/// so with two DualSenses attached each renderer drives its own device. Without a preference the
|
||||
/// lowest location id wins — an arbitrary but *stable* choice, where `Set.first` was neither.
|
||||
final class DualSenseHID {
|
||||
private let manager: IOHIDManager
|
||||
private var device: IOHIDDevice?
|
||||
@@ -43,9 +47,57 @@ final class DualSenseHID {
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Find and open the first connected DualSense. Returns false if none is present or it can't
|
||||
/// be opened (caller then falls back to CoreHaptics).
|
||||
func open() -> Bool {
|
||||
/// The IOKit location id of the device this instance opened — the handle a caller correlates
|
||||
/// with its `GCController`. `nil` until a successful `open`.
|
||||
private(set) var locationID: UInt32?
|
||||
|
||||
/// A device's location id, or `nil` if IOKit does not report one.
|
||||
static func locationID(of dev: IOHIDDevice) -> UInt32? {
|
||||
IOHIDDeviceGetProperty(dev, kIOHIDLocationIDKey as CFString) as? UInt32
|
||||
}
|
||||
|
||||
/// Every connected DualSense/Edge, by location id — what a caller pairs against its controllers.
|
||||
static func attachedLocationIDs() -> [UInt32] {
|
||||
let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
let matches = productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
IOHIDManagerSetDeviceMatchingMultiple(mgr, matches as CFArray)
|
||||
guard IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
|
||||
return []
|
||||
}
|
||||
defer { IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) }
|
||||
let devices = IOHIDManagerCopyDevices(mgr) as? Set<IOHIDDevice> ?? []
|
||||
return devices.compactMap(locationID(of:)).sorted()
|
||||
}
|
||||
|
||||
/// Which attached device to drive, as an index into `ids` — the whole selection rule, pure so
|
||||
/// it can be tested without an `IOHIDDevice` (which cannot be constructed).
|
||||
///
|
||||
/// `IOHIDManagerCopyDevices` returns an unordered `Set`, so the previous `Set.first` was not
|
||||
/// merely arbitrary — it can differ between two calls in one process. With two DualSenses that
|
||||
/// made each renderer's pad→device binding a coin flip: both could land on the same device
|
||||
/// (one pad's rumble coming out of the other, and the two per-instance write dedupes fighting
|
||||
/// over it) or split by luck. An explicit location id makes the binding deterministic; the
|
||||
/// lowest-id fallback at least makes it stable. `nil` ids sort last so a device IOKit cannot
|
||||
/// place never displaces one it can.
|
||||
static func preferredIndex(among ids: [UInt32?], preferring wanted: UInt32?) -> Int? {
|
||||
if let wanted, let hit = ids.firstIndex(where: { $0 == wanted }) { return hit }
|
||||
return ids.indices.min { (ids[$0] ?? .max) < (ids[$1] ?? .max) }
|
||||
}
|
||||
|
||||
/// Pick the device to drive from everything attached (see [`preferredIndex`]).
|
||||
static func pick(_ devices: Set<IOHIDDevice>, preferring wanted: UInt32?) -> IOHIDDevice? {
|
||||
let ordered = Array(devices)
|
||||
guard let i = preferredIndex(among: ordered.map(locationID(of:)), preferring: wanted) else {
|
||||
return nil
|
||||
}
|
||||
return ordered[i]
|
||||
}
|
||||
|
||||
/// Find and open a connected DualSense, preferring the one at `preferredLocationID`. Returns
|
||||
/// false if none is present or it can't be opened (caller then falls back to CoreHaptics).
|
||||
func open(preferringLocationID preferred: UInt32? = nil) -> Bool {
|
||||
let matches = Self.productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
@@ -55,13 +107,21 @@ final class DualSenseHID {
|
||||
return false
|
||||
}
|
||||
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
|
||||
let dev = devices.first
|
||||
let dev = Self.pick(devices, preferring: preferred)
|
||||
else {
|
||||
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
|
||||
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
return false
|
||||
}
|
||||
device = dev
|
||||
locationID = Self.locationID(of: dev)
|
||||
if let preferred, locationID != preferred {
|
||||
// Not fatal — one pad still gets rumble — but with two pads attached it means this
|
||||
// renderer is driving the wrong one, and it is invisible without the log line.
|
||||
log.error(
|
||||
"rumble: wanted DualSense at location \(preferred, privacy: .public) but opened \(self.locationID.map(String.init) ?? "unknown", privacy: .public)"
|
||||
)
|
||||
}
|
||||
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
|
||||
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
|
||||
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
|
||||
@@ -70,8 +130,16 @@ final class DualSenseHID {
|
||||
|
||||
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
|
||||
/// each 0...255. (0, 0) stops.
|
||||
func rumble(low: UInt8, high: UInt8) {
|
||||
guard let dev = device else { return }
|
||||
///
|
||||
/// Returns whether the write reached the device. The caller needs this: it used to be logged
|
||||
/// and swallowed, so a failed write still counted as a successful render. That matters most
|
||||
/// for a **stop**, which has nothing behind it — the renderer stamps its write clock even on
|
||||
/// failure, the keepalive re-write only fires for non-zero levels, and the ticker is cancelled
|
||||
/// once the target is `(0, 0)`. On USB there is no firmware timeout either, so a swallowed
|
||||
/// stop left the motors running with nothing scheduled to try again.
|
||||
@discardableResult
|
||||
func rumble(low: UInt8, high: UInt8) -> Bool {
|
||||
guard let dev = device else { return false }
|
||||
let report = bluetooth
|
||||
? Self.bluetoothReport(low: low, high: high)
|
||||
: Self.usbReport(low: low, high: high)
|
||||
@@ -81,7 +149,9 @@ final class DualSenseHID {
|
||||
}
|
||||
if rc != kIOReturnSuccess {
|
||||
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func close() {
|
||||
|
||||
@@ -67,6 +67,17 @@ public final class GamepadCapture {
|
||||
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||
var fingerActive: [Bool] = [false, false]
|
||||
var lastMotionNs: UInt64 = 0
|
||||
// Hold-Select→guide gesture state (pf-client-core's `SelectGesture`, adapted to
|
||||
// this class's mask-diff model): a Select pressed ALONE is held out of the mask
|
||||
// until it resolves into a tap (delivered on release) or — past `guideHold` — a
|
||||
// synthetic guide, down until release.
|
||||
var selectPending = false
|
||||
var selectAsGuide = false
|
||||
/// A delivered tap's release is owed (`tapTimer` scheduled) — its down went out
|
||||
/// outside `buttons`, so `flush` must know to lift it.
|
||||
var tapReleaseOwed = false
|
||||
var gestureTimer: Timer?
|
||||
var tapTimer: Timer?
|
||||
init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) {
|
||||
self.controller = controller
|
||||
self.pad = pad
|
||||
@@ -91,6 +102,16 @@ public final class GamepadCapture {
|
||||
GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back
|
||||
/// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client.
|
||||
private static let disconnectHold: TimeInterval = 1.5
|
||||
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the HOST's guide goes
|
||||
/// down (until release, so a long hold is the host's long-press — a Gaming-Mode
|
||||
/// host's QAM). The gesture exists because iOS reserves the physical Home press (the
|
||||
/// Game Overlay; sanctioned opt-out only via the user's iOS 27+ Home-button setting)
|
||||
/// and tvOS never delivers it at all.
|
||||
private static let guideHold: TimeInterval = 0.35
|
||||
/// pf-client-core's `TAP_PRESS`: a held-back Select tap is delivered as a press with
|
||||
/// its release this far behind — back-to-back transitions can fold into nothing in
|
||||
/// the host's per-pad input fold.
|
||||
private static let tapPress: TimeInterval = 0.05
|
||||
private var chordTimer: Timer?
|
||||
/// Fired ON MAIN once the escape chord has been held `disconnectHold` — the session owner
|
||||
/// disconnects. On tvOS this (plus the Siri Remote's hold-Back) is the ONLY way out of a
|
||||
@@ -98,9 +119,40 @@ public final class GamepadCapture {
|
||||
/// gameplay can't end it (see ContentView's tvOS session branch).
|
||||
public var onDisconnectRequest: (() -> Void)?
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||
/// Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
|
||||
/// default true). Off is for a couch whose controller reaches the host another way — USB
|
||||
/// passthrough such as VirtualHere, or a pad plugged into the host itself — where
|
||||
/// forwarding as well would give the host two pads for one pair of hands.
|
||||
///
|
||||
/// Off still opens slots and tracks button state; it just sends nothing (see `wire`). That
|
||||
/// is deliberate, not laziness: the escape chord is read off the same slots, and on tvOS it
|
||||
/// is the ONLY controller way out of a stream — a session that silently lost its exit
|
||||
/// because a forwarding preference was off would be a worse bug than the one this fixes.
|
||||
/// Unlike pf-client-core's slots, GameController claims nothing exclusive, so holding one
|
||||
/// open costs the host nothing and blocks no passthrough tool.
|
||||
public let forwarding: Bool
|
||||
|
||||
/// The connection, or nil while forwarding is off — every wire send goes through this, so
|
||||
/// "don't forward" is one fact in one place rather than a condition at twelve call sites.
|
||||
private var wire: PunktfunkConnection? { forwarding ? connection : nil }
|
||||
|
||||
/// Forward the raw guide + share/QAM presses (`EffectiveSettings.systemButtonsForward`,
|
||||
/// default true on Apple — where the OS shows its own overlay for them, that's the OS's
|
||||
/// business; local mode exists for profile parity with the Gaming-Mode clients).
|
||||
public let systemForward: Bool
|
||||
/// The hold-Select guide gesture (`EffectiveSettings.guideGestureEnabled` — auto = on
|
||||
/// everywhere but macOS). See `guideHold`.
|
||||
public let guideGesture: Bool
|
||||
|
||||
public init(
|
||||
connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true,
|
||||
systemForward: Bool = true, guideGesture: Bool = false
|
||||
) {
|
||||
self.connection = connection
|
||||
self.manager = manager
|
||||
self.forwarding = forwarding
|
||||
self.systemForward = systemForward
|
||||
self.guideGesture = guideGesture
|
||||
}
|
||||
|
||||
public func start() {
|
||||
@@ -188,7 +240,14 @@ public final class GamepadCapture {
|
||||
element.preferredSystemGestureState = .disabled
|
||||
}
|
||||
// The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit,
|
||||
// BTN_MODE on the virtual xpad — the Steam-overlay button). Driven DIRECTLY from this
|
||||
// BTN_MODE on the virtual xpad — the Steam-overlay button). On iOS 26 the OS opens its
|
||||
// Game Overlay for this press regardless of the gesture claim below (the app is
|
||||
// LSApplicationCategoryType=games, which enrolls it); the sanctioned per-controller
|
||||
// opt-out is the USER's iOS 27+ Home-button setting. TODO(iOS 27 SDK): read
|
||||
// `GCControllerHomeButtonSettingsManager` and surface a one-time
|
||||
// `openControllerHomeButtonSettings(for:)` deep-link so users can hand the button to
|
||||
// the stream — the class is Swift-only and 27.0+, so it needs the Xcode 27 SDK to
|
||||
// even compile. Until then hold-Select is the reliable route. Driven DIRECTLY from this
|
||||
// handler's pressed value (not via buttonMask), because the legacy
|
||||
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
|
||||
// exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops.
|
||||
@@ -205,8 +264,8 @@ public final class GamepadCapture {
|
||||
// core re-sends it a few times against datagram loss; an older host ignores it and uses
|
||||
// the session-default kind. Then wake the host pad (pads are created lazily from the first
|
||||
// event; a DualSense's UHID handshake + initial lightbar write only start then).
|
||||
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||
wire?.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||
wire?.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||
sync(slot, ext)
|
||||
|
||||
if let tp = Self.touchpad(ext) {
|
||||
@@ -233,7 +292,7 @@ public final class GamepadCapture {
|
||||
flush(slot)
|
||||
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
|
||||
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
|
||||
connection.send(.gamepadRemove(pad: slot.pad))
|
||||
wire?.send(.gamepadRemove(pad: slot.pad))
|
||||
let c = slot.controller
|
||||
if let ext = c.extendedGamepad {
|
||||
ext.valueChangedHandler = nil
|
||||
@@ -271,11 +330,18 @@ public final class GamepadCapture {
|
||||
// as "changed" — otherwise the first stick/button move after a guide press would emit a
|
||||
// spurious guide-UP while the button is still physically held (and drop the bit from
|
||||
// `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it.
|
||||
let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide)
|
||||
var raw = Self.buttonMask(g)
|
||||
// Raw system buttons stay local when passthrough is off: misc1 (share/QAM) is
|
||||
// masked here, guide is gated at its own handler.
|
||||
if !systemForward { raw &= ~GamepadWire.misc1 }
|
||||
// The hold-Select gesture rewrites the mask: a Select pressed alone is held out
|
||||
// until it resolves (tap on release / synthetic guide past the threshold).
|
||||
if guideGesture { raw = gestureFiltered(slot, raw) }
|
||||
let newButtons = raw | (slot.buttons & GamepadWire.guide)
|
||||
let changed = newButtons ^ slot.buttons
|
||||
if changed != 0 {
|
||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||
}
|
||||
slot.buttons = newButtons
|
||||
}
|
||||
@@ -288,21 +354,117 @@ public final class GamepadCapture {
|
||||
Int32(g.rightTrigger.value * 255),
|
||||
]
|
||||
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
|
||||
wire?.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
|
||||
slot.axes[i] = v
|
||||
}
|
||||
updateEscapeChord()
|
||||
}
|
||||
|
||||
/// The hold-Select→guide state machine over one sync's raw mask (pf-client-core's
|
||||
/// `SelectGesture` rules): Select pressed ALONE is suppressed while pending; another
|
||||
/// button joining makes it real (unsuppressed — the diff sends its down); released
|
||||
/// inside `guideHold` it's a tap, delivered out-of-band on release with the release
|
||||
/// `tapPress` behind; past the threshold `gestureHoldFired` turned it into a synthetic
|
||||
/// guide, lifted here when Select physically releases.
|
||||
///
|
||||
/// One deliberate divergence from the Rust worker: while transformed into a guide the
|
||||
/// Select stays OUT of `slot.buttons`, so the escape chord doesn't complete on top of
|
||||
/// an in-flight guide-hold — release Select and press the chord plainly instead (the
|
||||
/// chord's four-at-once press never lingers in pending long enough to be affected).
|
||||
private func gestureFiltered(_ slot: Slot, _ raw: UInt32) -> UInt32 {
|
||||
let back = GamepadWire.back
|
||||
let backDown = raw & back != 0
|
||||
let othersDown = raw & ~back != 0
|
||||
if slot.selectAsGuide {
|
||||
if backDown { return raw & ~back }
|
||||
slot.selectAsGuide = false
|
||||
sendGuide(slot, down: false, raw: false)
|
||||
return raw
|
||||
}
|
||||
if slot.selectPending {
|
||||
if !backDown {
|
||||
endPending(slot)
|
||||
deliverTap(slot)
|
||||
return raw
|
||||
}
|
||||
if othersDown {
|
||||
// A combo after all — Select unsuppresses and the diff sends its down.
|
||||
endPending(slot)
|
||||
return raw
|
||||
}
|
||||
return raw & ~back
|
||||
}
|
||||
if backDown, !othersDown, slot.buttons & back == 0 {
|
||||
// Newly pressed, alone: hold it back. An owed tap release goes out first so
|
||||
// the host never sees two downs in a row.
|
||||
if slot.tapReleaseOwed { finishTap(slot) }
|
||||
slot.selectPending = true
|
||||
let timer = Timer(timeInterval: Self.guideHold, repeats: false) { [weak self, weak slot] _ in
|
||||
Task { @MainActor in
|
||||
if let self, let slot { self.gestureHoldFired(slot) }
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
slot.gestureTimer?.invalidate()
|
||||
slot.gestureTimer = timer
|
||||
return raw & ~back
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/// The hold threshold passed with Select still pending → it IS the guide now, down
|
||||
/// until the physical release (`gestureFiltered`'s `selectAsGuide` branch lifts it).
|
||||
private func gestureHoldFired(_ slot: Slot) {
|
||||
guard slot.selectPending else { return }
|
||||
slot.selectPending = false
|
||||
slot.gestureTimer = nil
|
||||
slot.selectAsGuide = true
|
||||
sendGuide(slot, down: true, raw: false)
|
||||
}
|
||||
|
||||
private func endPending(_ slot: Slot) {
|
||||
slot.selectPending = false
|
||||
slot.gestureTimer?.invalidate()
|
||||
slot.gestureTimer = nil
|
||||
}
|
||||
|
||||
/// Deliver a held-back Select tap: the press now, its release `tapPress` behind. Both
|
||||
/// sends bypass `slot.buttons` (the raw mask no longer carries Select, so the diff
|
||||
/// stays consistent); `tapReleaseOwed` is what `flush` checks so the press can't
|
||||
/// outlive the slot.
|
||||
private func deliverTap(_ slot: Slot) {
|
||||
wire?.send(.gamepadButton(GamepadWire.back, down: true, pad: slot.pad))
|
||||
slot.tapReleaseOwed = true
|
||||
let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self, weak slot] _ in
|
||||
Task { @MainActor in
|
||||
if let self, let slot { self.finishTap(slot) }
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
slot.tapTimer?.invalidate()
|
||||
slot.tapTimer = timer
|
||||
}
|
||||
|
||||
private func finishTap(_ slot: Slot) {
|
||||
guard slot.tapReleaseOwed else { return }
|
||||
slot.tapReleaseOwed = false
|
||||
slot.tapTimer?.invalidate()
|
||||
slot.tapTimer = nil
|
||||
wire?.send(.gamepadButton(GamepadWire.back, down: false, pad: slot.pad))
|
||||
}
|
||||
|
||||
/// Forward the guide (Home/PS) transition directly — it's kept out of `buttonMask` (the legacy
|
||||
/// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is
|
||||
/// released by `flush` on focus loss / close just like the others.
|
||||
private func sendGuide(_ slot: Slot, down: Bool) {
|
||||
/// released by `flush` on focus loss / close just like the others. `raw: true` marks the
|
||||
/// physical Home handler's calls, which the system-buttons policy can keep local; the
|
||||
/// gesture's synthetic transitions pass `raw: false` and always go out.
|
||||
private func sendGuide(_ slot: Slot, down: Bool, raw: Bool = true) {
|
||||
if raw, !systemForward { return }
|
||||
guard !suspended else { return }
|
||||
let bit = GamepadWire.guide
|
||||
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
|
||||
guard now != slot.buttons else { return }
|
||||
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
|
||||
wire?.send(.gamepadButton(bit, down: down, pad: slot.pad))
|
||||
slot.buttons = now
|
||||
}
|
||||
|
||||
@@ -365,13 +527,13 @@ public final class GamepadCapture {
|
||||
if lifted {
|
||||
if slot.fingerActive[finger] {
|
||||
slot.fingerActive[finger] = false
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
slot.fingerActive[finger] = true
|
||||
let w = GamepadWire.touchpad(x: x, y: y)
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||
}
|
||||
|
||||
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
|
||||
@@ -394,7 +556,7 @@ public final class GamepadCapture {
|
||||
}
|
||||
let gs = GamepadWire.gyroLSBPerRadS
|
||||
let as_ = GamepadWire.accelLSBPerG
|
||||
connection.sendMotion(
|
||||
wire?.sendMotion(
|
||||
pad: UInt8(slot.pad),
|
||||
gyro: (
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
|
||||
@@ -431,16 +593,22 @@ public final class GamepadCapture {
|
||||
/// (no GC calls) — safe against an already-removed device. Does NOT close the slot or send
|
||||
/// GamepadRemove (that's `closeSlot`).
|
||||
private func flush(_ slot: Slot) {
|
||||
// Gesture first: a pending (never-sent) Select just drops, an owed tap release
|
||||
// goes out, and a transformed guide's bit — folded into `buttons` by `sendGuide`
|
||||
// — is lifted by the loop below like any held button.
|
||||
endPending(slot)
|
||||
slot.selectAsGuide = false
|
||||
if slot.tapReleaseOwed { finishTap(slot) }
|
||||
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||
}
|
||||
slot.buttons = 0
|
||||
for (i, v) in slot.axes.enumerated() where v != 0 {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
|
||||
wire?.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
|
||||
slot.axes[i] = 0
|
||||
}
|
||||
for (f, active) in slot.fingerActive.enumerated() where active {
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
slot.fingerActive[f] = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,15 @@ public final class GamepadFeedback {
|
||||
reset(slot.controller)
|
||||
slots[pad] = nil
|
||||
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
|
||||
renderer?.stop()
|
||||
// OFF the main actor. `RumbleRenderer.stop()` is a `queue.sync`, and its body is a
|
||||
// per-motor `CHHapticEngine.stop()` — an XPC round trip to gamecontrollerd, which the
|
||||
// renderer's own notes record as able to hang — plus `DualSenseHID.close()`, whose
|
||||
// blocking `IOHIDDeviceSetReport` goes to a device that has just departed. It also
|
||||
// queues behind any in-flight `setup()`. This runs on every unplug and every pin
|
||||
// change, and the main thread is what drives the presenter's CADisplayLink, so
|
||||
// blocking here hitches the picture mid-stream. The renderer is already detached from
|
||||
// routing above, so nothing observes it after this point.
|
||||
if let renderer { Task.detached { renderer.stop() } }
|
||||
}
|
||||
for (pad, controller) in want {
|
||||
if let slot = slots[pad] {
|
||||
@@ -282,6 +290,12 @@ public final class GamepadFeedback {
|
||||
private func reset(_ controller: GCController?) {
|
||||
guard let c = controller else { return }
|
||||
c.playerIndex = .indexUnset
|
||||
// Put the lightbar out too. This class is what turned it on (see the `Led` and
|
||||
// `PlayerLeds` arms), and every DS write is valid-flag-selective, so a colour the game
|
||||
// set stays lit in firmware after the stream ends — back at the launcher, or for a pad
|
||||
// that merely left the forwarded set. A DS4 is cleared incidentally because its player
|
||||
// indicator IS the lightbar; a DualSense is not.
|
||||
c.light?.color = GCColor(red: 0, green: 0, blue: 0)
|
||||
if let ds = c.extendedGamepad as? GCDualSenseGamepad {
|
||||
ds.leftTrigger.setModeOff()
|
||||
ds.rightTrigger.setModeOff()
|
||||
|
||||
@@ -459,6 +459,18 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
if split {
|
||||
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
|
||||
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
|
||||
// HALF a split is worse than none, and it used to pass silently: only the all-nil case
|
||||
// below counts as failure, so one surviving handle left `ok` true and `reportHealth(nil)`
|
||||
// announced HEALTHY. What actually rendered was wrong in a direction that depends on
|
||||
// which handle died — lose `high` and `render` falls to the combined branch (selected
|
||||
// purely by `high != nil`), playing max(low, high) on the LEFT handle at the combined
|
||||
// sharpness; lose `low` and the split branch's reconcile no-ops on the nil slot, so the
|
||||
// heavy motor is discarded outright. Tear the survivor down and take the combined path,
|
||||
// which at least renders both motors somewhere.
|
||||
if low == nil || high == nil {
|
||||
log.warning("rumble: only one split-handle engine came up — falling back to combined")
|
||||
teardown() // disarms handlers, stops the survivor's players + engine, nils both
|
||||
}
|
||||
} else {
|
||||
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
|
||||
}
|
||||
@@ -587,7 +599,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#if os(macOS)
|
||||
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
|
||||
let hid = DualSenseHID()
|
||||
guard hid.open() else { return false }
|
||||
// Ask for the device this renderer's controller actually is, so two attached DualSenses
|
||||
// do not both get driven through whichever one an unordered Set happened to yield first.
|
||||
guard hid.open(preferringLocationID: Self.hidLocationID(for: c)) else { return false }
|
||||
dualSenseHID = hid
|
||||
return true
|
||||
#else
|
||||
@@ -595,6 +609,24 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Correlate a `GCController` with an IOKit location id.
|
||||
///
|
||||
/// GameController exposes no location id, so there is no direct mapping. What it does expose is
|
||||
/// a stable per-controller ordering, and IOKit's location ids are stable per port: pairing the
|
||||
/// two by rank makes each renderer pick a *distinct* device, which is the property that was
|
||||
/// missing. With one pad attached this is the same device it always was.
|
||||
static func hidLocationID(for c: GCController) -> UInt32? {
|
||||
let ids = DualSenseHID.attachedLocationIDs()
|
||||
guard ids.count > 1 else { return ids.first }
|
||||
let peers = GCController.controllers().filter { $0.extendedGamepad is GCDualSenseGamepad }
|
||||
guard let rank = peers.firstIndex(where: { $0 === c }), rank < ids.count else {
|
||||
return ids.first
|
||||
}
|
||||
return ids[rank]
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Write the target to the DualSense over HID if that's the active backend; false → not a
|
||||
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
|
||||
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
|
||||
@@ -605,8 +637,20 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
let keepalive = levels != (0, 0)
|
||||
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
|
||||
if levels != lastHidWrite.levels || keepalive {
|
||||
hid.rumble(low: levels.0, high: levels.1)
|
||||
lastHidWrite = (levels, .now())
|
||||
if hid.rumble(low: levels.0, high: levels.1) {
|
||||
lastHidWrite = (levels, .now())
|
||||
} else {
|
||||
// The write did not reach the device. Do NOT stamp the clock — that would claim a
|
||||
// render that never happened, and for a stop there is nothing behind it: the
|
||||
// keepalive only re-writes non-zero levels and the ticker is cancelled once the
|
||||
// target is (0, 0), so the motors would keep running with nothing scheduled.
|
||||
// Drop the handle instead: the pad reverts to CoreHaptics, and a reconnect
|
||||
// rebuilds it. Health is reported so the state is visible rather than silent.
|
||||
log.error("rumble: HID write failed — dropping the handle, falling back")
|
||||
closeHID()
|
||||
reportHealth("Lost the direct connection to this DualSense; using the system path.")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
#else
|
||||
|
||||
@@ -175,6 +175,56 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
|
||||
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
// MARK: Escape-drop re-lock
|
||||
//
|
||||
// iPadOS releases the pointer lock BY ITSELF when the user presses Escape — the platform's
|
||||
// built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing
|
||||
// in our code does it: a bare Esc never touches `captured`, so it keeps forwarding to the host
|
||||
// as the game key it is. But the lock going away flips the mouse onto the absolute UIKit path
|
||||
// and un-hides the iPadOS cursor, so hitting Esc for an in-game menu silently costs the capture
|
||||
// until the user clicks to win it back. Esc is a GAME key here, not a request to hand the
|
||||
// pointer back to iPadOS, so an unwanted drop is re-requested below. The DELIBERATE releases
|
||||
// (⌘⎋, ⌃⌥⇧Q, the Stream menu, backgrounding) all clear `captured` first, so `wantsPointerLock`
|
||||
// is already false when their drop is observed and none of them are fought here.
|
||||
//
|
||||
// Recovery is TWO-STAGE, because either stage alone leaves a hole:
|
||||
// 1. the burst below, fired the instant the drop is observed — wins back a lock the system
|
||||
// is willing to return immediately (a transient drop that wasn't Escape at all);
|
||||
// 2. a CLICK into the video while still captured (`onPointerButton`) — the fallback for the
|
||||
// Escape case proper, where the platform declines during the moment right after its own
|
||||
// release gesture and the burst therefore expires having achieved nothing.
|
||||
// Stage 2 is what keeps a lost burst from being permanent: `captured` is still true, so no
|
||||
// other path would ever ask again, and the capture would spend the rest of its life on the
|
||||
// absolute pointer — clicking correctly, aiming not at all.
|
||||
/// Whether this capture ever actually held the lock. Only a lock we HELD is worth winning back
|
||||
/// — never having been granted one means the scene doesn't qualify, not that Esc took it.
|
||||
/// Cleared when capture ends, so each capture starts from a clean slate.
|
||||
private var pointerLockWasEngaged = false
|
||||
/// Attempts spent in the current re-lock burst, and when the burst began.
|
||||
private var pointerRelockAttempt = 0
|
||||
private var pointerRelockBurstStart: CFTimeInterval = 0
|
||||
/// True from an unwanted drop until the lock is back (or the burst gives up). While pending,
|
||||
/// the local cursor stays hidden and absolute pointer MOTION stays muted, so a re-lock that
|
||||
/// lands a frame or two later is invisible instead of flashing the iPadOS cursor and
|
||||
/// teleporting the host's to the pointer's absolute position.
|
||||
private var pointerRelockPending = false
|
||||
/// Forces `prefersPointerLocked` to report false for one resolve pass, so the escalated attempt
|
||||
/// presents the system with a genuine false→true transition instead of re-asserting a value it
|
||||
/// already holds. See `requestPointerRelock()`.
|
||||
private var pointerLockForcedOff = false
|
||||
/// A burst is 3 attempts, and a burst can't restart inside 2 s. A scene the system will never
|
||||
/// lock (Stage Manager, Split View) therefore costs three cheap re-resolves and then falls back
|
||||
/// to today's click-to-recapture, rather than retrying forever.
|
||||
private static let pointerRelockAttemptLimit = 3
|
||||
private static let pointerRelockBurstWindow: CFTimeInterval = 2
|
||||
/// Gap between attempts in a burst — long enough for the system to answer the previous
|
||||
/// re-resolve, short enough that the whole burst fits in ~0.6 s. Must exceed
|
||||
/// `pointerLockForcedOffHold` so an escalated attempt is back to preferring the lock before the
|
||||
/// next attempt evaluates.
|
||||
private static let pointerRelockRetryDelay: TimeInterval = 0.2
|
||||
/// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back,
|
||||
/// so the system observes a real transition instead of coalescing the flip away.
|
||||
private static let pointerLockForcedOffHold: TimeInterval = 0.05
|
||||
#endif
|
||||
|
||||
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
||||
@@ -260,7 +310,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
captured && pointerCaptureEnabled && UIDevice.current.userInterfaceIdiom == .pad
|
||||
}
|
||||
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock }
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock && !pointerLockForcedOff }
|
||||
public override var prefersHomeIndicatorAutoHidden: Bool { true }
|
||||
|
||||
// NOTE: we deliberately do NOT override `childViewControllerForPointerLock`. The default
|
||||
@@ -383,6 +433,11 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// is the exact mirror of the GCMouse handlers, which fire only while locked.
|
||||
streamView.onPointerMoveAbs = { [weak self] p in
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
// A re-lock is in flight after an Esc-drop: the absolute path would teleport the host
|
||||
// cursor to wherever the local pointer sits, undoing the relative aiming we're about to
|
||||
// resume. Motion only — BUTTONS still forward (they carry no position, so a click during
|
||||
// the couple of frames a re-lock takes must not be swallowed mid-firefight).
|
||||
guard !self.pointerRelockPending else { return }
|
||||
self.inputCapture?.sendMouseAbs(
|
||||
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
||||
}
|
||||
@@ -401,6 +456,31 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
}
|
||||
guard self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendMouseButton(button, pressed: down)
|
||||
// …and if we're captured but NOT locked, this click is also the recovery gesture for an
|
||||
// Escape-drop the burst lost. iPadOS refuses to re-lock in the moment right after its
|
||||
// own "let me out" gesture, so the burst fired at the drop can spend its whole budget
|
||||
// and give up while the capture is still wanted. Nothing else would ever re-ask —
|
||||
// setCaptured is the only other requester and a bare Esc never clears `captured` — so
|
||||
// without this the session stays on the absolute path for the rest of the capture:
|
||||
// clicks still land where you aim (absolute positions keep forwarding) but the game
|
||||
// gets no relative deltas, so camera look is dead. A click is a real user gesture,
|
||||
// which is exactly what the platform wants before it will hand the lock back.
|
||||
//
|
||||
// On the button UP, so the click has fully forwarded on ONE transport first: asking on
|
||||
// the DOWN can flip `gcMouseForwarding` mid-click and strand the release on the GCMouse
|
||||
// path. Gated on `pointerLockWasEngaged` exactly as the drop path is, so a scene that
|
||||
// never qualifies (Stage Manager, Split View) is never bursted at, and on a burst not
|
||||
// already being in flight — a pending burst mutes absolute motion, so re-arming one on
|
||||
// every click of a menu the user is still aiming around would freeze the cursor between
|
||||
// clicks. Only once it has settled does a further click buy a fresh budget (clearing the
|
||||
// attempt counter, so a gesture isn't refused inside the 2 s window the drop's own burst
|
||||
// may have just spent).
|
||||
if !down, self.wantsPointerLock, self.pointerLockWasEngaged,
|
||||
!self.pointerRelockPending, self.pointerLockEngaged() != true {
|
||||
self.pointerRelockAttempt = 0
|
||||
self.updatePointerLockChain() // a reparent since the drop would break the walk to us
|
||||
self.requestPointerRelock()
|
||||
}
|
||||
}
|
||||
// Scroll is the ONE indirect channel that is NOT gated on the lock. The scroll pan keeps
|
||||
// firing while the scene is pointer-locked (it is the only way trackpad two-finger scrolling
|
||||
@@ -693,6 +773,24 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// change and capture toggle. Main queue.
|
||||
private func syncPointerLock() {
|
||||
let locked = pointerLockEngaged() == true
|
||||
// Wanted, previously HELD, and now gone is the Esc-drop signature. The "previously held"
|
||||
// half matters: a lock that was never granted is a scene that doesn't qualify (Stage
|
||||
// Manager, Split View), and burst-requesting there would hide the cursor for the burst's
|
||||
// duration to win a lock that isn't coming. A first grant is already driven by the chain
|
||||
// engage in setCaptured/viewDidAppear.
|
||||
if locked {
|
||||
pointerLockWasEngaged = true
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
} else if wantsPointerLock, pointerLockWasEngaged {
|
||||
requestPointerRelock()
|
||||
} else {
|
||||
// Capture is gone (or the lock was never ours) — settle, and let the next capture
|
||||
// start from a clean "never held" slate.
|
||||
if !wantsPointerLock { pointerLockWasEngaged = false }
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
let useGCMouse = captured && locked
|
||||
// Lock dropped (or capture ended) while the GCMouse path held a button down: once
|
||||
// gcMouseForwarding flips false its release handler is gated off, so flush any held
|
||||
@@ -704,7 +802,83 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
pointerInteraction?.invalidate() // re-resolve the hidden/visible cursor for the state
|
||||
if iosInputDebug {
|
||||
iosInputLog.debug(
|
||||
"pointer lock isLocked=\(locked, privacy: .public) captured=\(self.captured, privacy: .public)")
|
||||
"""
|
||||
pointer lock isLocked=\(locked, privacy: .public) \
|
||||
captured=\(self.captured, privacy: .public) \
|
||||
relockPending=\(self.pointerRelockPending, privacy: .public) \
|
||||
relockAttempt=\(self.pointerRelockAttempt, privacy: .public)
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the system for the lock back after it dropped one we still want (see the Escape-drop
|
||||
/// note on the state above). Bounded to a short burst; idempotent within it. Main queue.
|
||||
private func requestPointerRelock() {
|
||||
// Only a frontmost scene can hold the lock at all. Anywhere else the drop is the system
|
||||
// saying we don't qualify, not the Esc key — re-asking would be noise, and the qualifying
|
||||
// states (foreground, appearance, reparent) each re-resolve on their own already.
|
||||
guard view.window?.windowScene?.activationState == .foregroundActive else {
|
||||
pointerRelockPending = false
|
||||
return
|
||||
}
|
||||
let now = CACurrentMediaTime()
|
||||
// attempt == 0 is a fresh burst (first drop, or one the settle branch cleared); the window
|
||||
// is the backstop for the pathological case where a grant is immediately revoked again and
|
||||
// re-arms us. Even then this stays timer-driven at a few Hz — never a spin.
|
||||
if pointerRelockAttempt == 0 || now - pointerRelockBurstStart > Self.pointerRelockBurstWindow {
|
||||
pointerRelockBurstStart = now
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else {
|
||||
// Out of budget: fall back to exactly today's behavior — the iPadOS cursor comes back
|
||||
// and a click into the video re-captures. The caller invalidates the interaction, so
|
||||
// the cursor can never stay hidden on a lock the system won't grant.
|
||||
pointerRelockPending = false
|
||||
return
|
||||
}
|
||||
pointerRelockAttempt += 1
|
||||
pointerRelockPending = true
|
||||
let escalate = pointerRelockAttempt > 1
|
||||
// Deferred a turn so a ⌘⎋ whose GC keystroke lands after the system's unlock notification
|
||||
// has already cleared `captured` — then the guard below drops this attempt instead of
|
||||
// fighting the user's own release.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.pointerRelockPending else { return }
|
||||
guard self.wantsPointerLock, self.pointerLockEngaged() != true else {
|
||||
// The grant landed, or the capture went away under us (⌘⎋ / ⌃⌥⇧Q / resign).
|
||||
// Settle through the one decision point rather than returning with `pending` still
|
||||
// set — that flag hides the cursor, so it must never outlive the burst.
|
||||
self.syncPointerLock()
|
||||
return
|
||||
}
|
||||
if escalate {
|
||||
// Re-asserting a value the system already holds didn't take. Present a real
|
||||
// false→true transition instead — the documented way to change your mind about the
|
||||
// lock — and re-anchor the chain in case a reparent broke the downward walk to us.
|
||||
// Held for a beat rather than cleared on the next turn: the system resolves the
|
||||
// property asynchronously, and a same-turn flip back to true can be coalesced into
|
||||
// no transition at all. We are already unlocked, so the false pass costs nothing.
|
||||
self.pointerLockForcedOff = true
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
self.updatePointerLockChain()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) {
|
||||
[weak self] in
|
||||
guard let self else { return }
|
||||
self.pointerLockForcedOff = false
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
} else {
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
// A GRANT arrives as a didChange → syncPointerLock, which settles the burst and makes
|
||||
// this retry a no-op. Routed back through syncPointerLock (not straight into another
|
||||
// requestPointerRelock) so the give-up path re-resolves the cursor through the one
|
||||
// place that does it.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerRelockRetryDelay) {
|
||||
[weak self] in
|
||||
guard let self, self.pointerRelockPending else { return }
|
||||
self.syncPointerLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -724,7 +898,11 @@ extension StreamViewController: UIPointerInteractionDelegate {
|
||||
// host renders its own cursor from GCMouse deltas and a visible local one would just
|
||||
// diverge. When the lock isn't held the cursor stays VISIBLE so the user can aim; the
|
||||
// pointer is forwarded as an absolute position, both cursors tracking together.
|
||||
captured && pointerLockEngaged() == true ? .hidden() : nil
|
||||
// …except across an Esc-drop we're actively re-locking (`pointerRelockPending`): staying
|
||||
// hidden for those couple of frames is what turns the fix into "Esc did nothing to my
|
||||
// mouse" rather than a cursor that blinks in and out. The burst is bounded and clears
|
||||
// itself on give-up, so the cursor can never stay hidden on a lock that isn't coming.
|
||||
captured && (pointerLockEngaged() == true || pointerRelockPending) ? .hidden() : nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -32,6 +32,23 @@ public enum DefaultsKey {
|
||||
public static let compositor = "punktfunk.compositor"
|
||||
public static let gamepadType = "punktfunk.gamepadType"
|
||||
public static let gamepadID = "punktfunk.gamepadID"
|
||||
/// Forward this device's controllers to the host at all (default true). Off is for a
|
||||
/// couch whose controller reaches the host another way — USB passthrough such as
|
||||
/// VirtualHere, or a pad plugged into the host — where forwarding as well would give the
|
||||
/// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts
|
||||
/// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built.
|
||||
public static let gamepadForwarding = "punktfunk.gamepadForwarding"
|
||||
/// Where a controller's SYSTEM buttons (guide + the share/QAM misc) land while streaming:
|
||||
/// `"auto"` | `"forward"` | `"local"` — the cross-client `system_buttons` key. Auto
|
||||
/// forwards on every Apple platform: the local Game Overlay is the OS's business (and on
|
||||
/// iOS 27+ the user can hand the Home button to the app in Settings), so suppressing our
|
||||
/// send would gain nothing.
|
||||
public static let systemButtons = "punktfunk.systemButtons"
|
||||
/// The hold-Select guide gesture: `"auto"` | `"on"` | `"off"` — the cross-client
|
||||
/// `guide_gesture` key. Auto arms it everywhere but macOS: iOS reserves the physical Home
|
||||
/// press for the Game Overlay (uncapturable pre-27) and tvOS never delivers it at all, so
|
||||
/// holding Select is the controller route to the host's guide there.
|
||||
public static let guideGesture = "punktfunk.guideGesture"
|
||||
public static let bitrateKbps = "punktfunk.bitrateKbps"
|
||||
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
|
||||
|
||||
@@ -34,6 +34,11 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
public var mouseMode = "capture"
|
||||
public var invertScroll = false
|
||||
public var gamepadType = 0
|
||||
public var gamepadForwarding = true
|
||||
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
|
||||
public var systemButtons = "auto"
|
||||
/// Cross-client `guide_gesture`: "auto" | "on" | "off".
|
||||
public var guideGesture = "auto"
|
||||
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
|
||||
public var statsVerbosity = "normal"
|
||||
public var fullscreenWhileStreaming = true
|
||||
@@ -93,6 +98,9 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
|
||||
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
|
||||
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
|
||||
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
|
||||
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
|
||||
guideGesture = str(DefaultsKey.guideGesture, guideGesture)
|
||||
statsVerbosity = Self.storedStatsVerbosity(defaults)
|
||||
fullscreenWhileStreaming = bool(
|
||||
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
|
||||
@@ -119,6 +127,36 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
return "normal"
|
||||
}
|
||||
|
||||
/// The `system_buttons` policy resolved for this platform: forward the raw guide (and
|
||||
/// share/QAM misc) presses? Auto = forward on every Apple platform — where the OS shows
|
||||
/// its own overlay for the press that is the OS's business, and suppressing our send
|
||||
/// would only break users who handed the button to the app (iOS 27's Home-button
|
||||
/// setting; macOS with the gestures claimed).
|
||||
public var systemButtonsForward: Bool {
|
||||
switch systemButtons {
|
||||
case "local": return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
/// The hold-Select guide gesture resolved for this platform ([`guideGesture`]). Auto =
|
||||
/// on everywhere but macOS: iOS reserves the physical Home press (the Game Overlay,
|
||||
/// uncapturable pre-27) and tvOS never delivers it, so holding Select is the controller
|
||||
/// route to the host's guide — and, held on, to a Gaming-Mode host's QAM. On macOS the
|
||||
/// raw press reaches the host, so auto stays off and Select keeps its exact timing.
|
||||
public var guideGestureEnabled: Bool {
|
||||
switch guideGesture {
|
||||
case "on": return true
|
||||
case "off": return false
|
||||
default:
|
||||
#if os(macOS)
|
||||
return false
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// The one resolution seam: this overlay on top of these settings. Pure — no store reads, no
|
||||
/// clock — so it is testable field by field. A `.some` that happens to equal the base is a
|
||||
/// legitimate PIN: it keeps its value when the global later moves.
|
||||
@@ -140,6 +178,9 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
if let v = overlay.mouseMode { s.mouseMode = v }
|
||||
if let v = overlay.invertScroll { s.invertScroll = v }
|
||||
if let v = overlay.gamepadType { s.gamepadType = v }
|
||||
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
|
||||
if let v = overlay.systemButtons { s.systemButtons = v }
|
||||
if let v = overlay.guideGesture { s.guideGesture = v }
|
||||
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
|
||||
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
|
||||
if let v = overlay.enable444 { s.enable444 = v }
|
||||
|
||||
@@ -110,6 +110,9 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
public var mouseMode: String?
|
||||
public var invertScroll: Bool?
|
||||
public var gamepadType: Int?
|
||||
public var gamepadForwarding: Bool?
|
||||
public var systemButtons: String?
|
||||
public var guideGesture: String?
|
||||
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") — the enum lives in
|
||||
/// PunktfunkKit, which this module must not depend on.
|
||||
public var statsVerbosity: String?
|
||||
@@ -151,6 +154,9 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
case mouseMode = "mouse_mode"
|
||||
case invertScroll = "invert_scroll"
|
||||
case gamepadType = "gamepad"
|
||||
case gamepadForwarding = "gamepad_forwarding"
|
||||
case systemButtons = "system_buttons"
|
||||
case guideGesture = "guide_gesture"
|
||||
case statsVerbosity = "stats_verbosity"
|
||||
case fullscreenWhileStreaming = "fullscreen_on_stream"
|
||||
case enable444 = "enable_444"
|
||||
@@ -184,6 +190,9 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
mouseMode = str(.mouseMode)
|
||||
invertScroll = bool(.invertScroll)
|
||||
gamepadType = int(.gamepadType)
|
||||
gamepadForwarding = bool(.gamepadForwarding)
|
||||
systemButtons = str(.systemButtons)
|
||||
guideGesture = str(.guideGesture)
|
||||
statsVerbosity = str(.statsVerbosity)
|
||||
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
|
||||
enable444 = bool(.enable444)
|
||||
@@ -219,6 +228,10 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
|
||||
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
|
||||
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
|
||||
try c.encodeIfPresent(systemButtons, forKey: AnyKey(Key.systemButtons.rawValue))
|
||||
try c.encodeIfPresent(guideGesture, forKey: AnyKey(Key.guideGesture.rawValue))
|
||||
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
|
||||
@@ -271,6 +284,9 @@ public enum OverlayField {
|
||||
case "mouse_mode": overlay.mouseMode = nil
|
||||
case "invert_scroll": overlay.invertScroll = nil
|
||||
case "gamepad": overlay.gamepadType = nil
|
||||
case "gamepad_forwarding": overlay.gamepadForwarding = nil
|
||||
case "system_buttons": overlay.systemButtons = nil
|
||||
case "guide_gesture": overlay.guideGesture = nil
|
||||
case "stats_verbosity": overlay.statsVerbosity = nil
|
||||
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
|
||||
case "enable_444": overlay.enable444 = nil
|
||||
@@ -306,6 +322,9 @@ public enum OverlayField {
|
||||
case "mouse_mode": return o.mouseMode != nil
|
||||
case "invert_scroll": return o.invertScroll != nil
|
||||
case "gamepad": return o.gamepadType != nil
|
||||
case "gamepad_forwarding": return o.gamepadForwarding != nil
|
||||
case "system_buttons": return o.systemButtons != nil
|
||||
case "guide_gesture": return o.guideGesture != nil
|
||||
case "stats_verbosity": return o.statsVerbosity != nil
|
||||
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
|
||||
case "enable_444": return o.enable444 != nil
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// The Apple half of the shared de-jitter policy (`punktfunk_core::audio::JitterPolicy`, whose
|
||||
// constants `AudioRing` mirrors). These pin the two behaviours a listener actually notices, in the
|
||||
// one client where the policy is hand-written in a second language rather than shared as code — so
|
||||
// a divergence from the Rust side shows up here rather than as a field report.
|
||||
//
|
||||
// The defect being pinned: the ring primed *up* to a target and clamped at a ceiling, with nothing
|
||||
// walking the depth back *down*. Host-vs-DAC clock skew of a few dozen ppm therefore added latency
|
||||
// permanently, and the only correction was a `highWater` shed that dropped `2 x prefill` at once —
|
||||
// its own comment called that "one audible blip".
|
||||
|
||||
#if !os(tvOS)
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class AudioRingDriftTests: XCTestCase {
|
||||
private let channels = 2
|
||||
private var perMS: Int { 48 * channels }
|
||||
|
||||
/// Run `ms` of audio through the ring at a `quantumMS` device where the producer delivers
|
||||
/// `driftPPM` more than the consumer takes. Returns `(final ms, peak ms, silent callbacks)`.
|
||||
private func simulate(ms: Int, quantumMS: Int, driftPPM: Int) -> (Int, Int, Int) {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = quantumMS * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
// Non-zero so a silent callback is distinguishable from real audio.
|
||||
let producer = [Float](repeating: 0.25, count: want + 8)
|
||||
var carry = 0, peak = 0, final = 0, silent = 0
|
||||
|
||||
for i in 0..<(ms / quantumMS) {
|
||||
carry += want * driftPPM
|
||||
let extra = carry / 1_000_000
|
||||
carry -= extra * 1_000_000
|
||||
producer.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want + extra) }
|
||||
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
// Skip the priming window at the very start.
|
||||
if i > 20, scratch.allSatisfy({ $0 == 0 }) { silent += 1 }
|
||||
peak = max(peak, ring.bufferedMS)
|
||||
final = ring.bufferedMS
|
||||
}
|
||||
return (final, peak, silent)
|
||||
}
|
||||
|
||||
/// THE regression: with the host clock running fast, buffered latency must return to target
|
||||
/// instead of climbing to the hard cap and staying pinned there. +200 ppm is deliberately
|
||||
/// harsher than real hardware (tens of ppm).
|
||||
func testDriftDoesNotRatchetLatencyToTheCeiling() {
|
||||
let (final, peak, silent) = simulate(ms: 5 * 60 * 1_000, quantumMS: 5, driftPPM: 200)
|
||||
// Must settle inside the headroom band (target 20 + headroom 30), never near the 90 ms cap.
|
||||
XCTAssertLessThanOrEqual(final, 50, "settled at \(final) ms — that is the ratchet")
|
||||
XCTAssertLessThanOrEqual(peak, 50, "peaked at \(peak) ms")
|
||||
XCTAssertEqual(silent, 0, "drift correction must never starve the callback")
|
||||
}
|
||||
|
||||
/// The mirror case: a host clock running SLOW must keep audio flowing rather than being
|
||||
/// "corrected" into a stutter.
|
||||
func testNegativeDriftKeepsPlaying() {
|
||||
let (_, _, silent) = simulate(ms: 2 * 60 * 1_000, quantumMS: 5, driftPPM: -200)
|
||||
XCTAssertEqual(silent, 0, "a draining ring must re-prime, not chatter")
|
||||
}
|
||||
|
||||
/// A device that pulls a large quantum cannot sustain a target below it — the ring must lift
|
||||
/// its target rather than oscillating prime → dropout → re-prime forever.
|
||||
func testLargeDeviceQuantumStillPlays() {
|
||||
let (_, _, silent) = simulate(ms: 60 * 1_000, quantumMS: 40, driftPPM: 0)
|
||||
XCTAssertEqual(silent, 0, "a 40 ms quantum must not starve a 20 ms target")
|
||||
}
|
||||
|
||||
/// One transient drain must not manufacture a whole target's worth of fresh silence: the ring
|
||||
/// de-primes only after a RUN of short reads.
|
||||
func testSingleShortReadDoesNotDeprime() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
// Prime well past target.
|
||||
let big = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
big.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: big.count) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(scratch.contains { $0 != 0 }, "should be playing after priming")
|
||||
|
||||
// Drain it dry with one oversized read, then feed a normal quantum again. The length comes
|
||||
// off the buffer pointer, not off `huge`: touching the array inside the closure that is
|
||||
// already holding it exclusively is an exclusivity violation.
|
||||
var huge = [Float](repeating: 0, count: 200 * perMS)
|
||||
huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: $0.count) }
|
||||
let feed = [Float](repeating: 0.5, count: want)
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(
|
||||
scratch.contains { $0 != 0 },
|
||||
"a single short read must not force a full re-prime")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -43,5 +43,33 @@ final class DualSenseHIDTests: XCTestCase {
|
||||
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
|
||||
XCTAssertEqual(crc, 0xCBF4_3926)
|
||||
}
|
||||
|
||||
// MARK: - Device selection (B14)
|
||||
|
||||
/// With two DualSenses attached, each renderer must drive its OWN device. The old code took
|
||||
/// `Set.first` from an unordered set, so the pad→device binding was a coin flip that could
|
||||
/// point both renderers at the same pad.
|
||||
func testPreferredIndexHonoursAnExplicitLocation() {
|
||||
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1420_0000), 1)
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1D18_0000), 0)
|
||||
}
|
||||
|
||||
/// No preference (or one the pad no longer has): fall back to the LOWEST id — arbitrary, but
|
||||
/// stable across calls, which `Set.first` was not.
|
||||
func testPreferredIndexFallsBackToTheLowestIdDeterministically() {
|
||||
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: nil), 2)
|
||||
// A wanted id that is gone (pad unplugged between enumeration and open) must not fail the
|
||||
// open — it degrades to the same stable fallback.
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0xDEAD_BEEF), 2)
|
||||
}
|
||||
|
||||
/// A device IOKit reports no location for must never displace one it can place.
|
||||
func testPreferredIndexSortsUnplaceableDevicesLast() {
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, 0x1420_0000], preferring: nil), 1)
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, nil], preferring: nil), 0)
|
||||
XCTAssertNil(DualSenseHID.preferredIndex(among: [], preferring: nil))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# App Store copy
|
||||
|
||||
Source of truth for what goes into App Store Connect. Every character-limited field in here has
|
||||
been counted with `check-limits.py`; run it after any edit.
|
||||
|
||||
```sh
|
||||
python3 clients/apple/store/check-limits.py
|
||||
```
|
||||
|
||||
| File | Covers |
|
||||
|------|--------|
|
||||
| [`ios.md`](ios.md) | iOS/iPadOS Promotional Text (DE + EN), with alternates |
|
||||
| [`macos.md`](macos.md) | macOS Promotional Text, Description, Keywords (DE + EN) |
|
||||
| [`tvos.md`](tvos.md) | tvOS Promotional Text, Description, Keywords (DE + EN) |
|
||||
| [`review-notes.md`](review-notes.md) | App Review notes template + pre-submission checklist |
|
||||
| [`privacy-app-addendum.md`](privacy-app-addendum.md) | App-specific privacy text to add to the existing policy page |
|
||||
|
||||
German is primary throughout and uses the same informal "du" voice as the website
|
||||
(`punktfunk-website/messages/de.json`). English is a localisation, not a translation exercise — a
|
||||
few lines diverge where the German idiom does not carry.
|
||||
|
||||
## Three things that contradicted the original brief
|
||||
|
||||
1. **A Mac cannot be a host.** The brief suggested Mac copy could cover "running as a host/server
|
||||
or client on Mac". There is no macOS host — `punktfunk-host` has no macOS capture, virtual
|
||||
display, or encode backend. The macOS copy is client-only and says so explicitly.
|
||||
2. **The existing privacy policy is website-only.** It covers server logs, Plausible, and a
|
||||
language cookie, and never mentions the apps. Linking it unchanged from App Store Connect is
|
||||
the kind of thing that draws a reviewer's attention to analytics that have nothing to do with
|
||||
the app. See `privacy-app-addendum.md` for the text to append.
|
||||
3. **App Review notes cap at 4000 characters**, not the unlimited field the brief implied. The
|
||||
template is 3919 and fits.
|
||||
|
||||
## Claims used, and where they come from
|
||||
|
||||
Everything asserted in the copy was checked against the source rather than the marketing site:
|
||||
|
||||
- Hardware decode, HDR/4:4:4, controller and input support — `clients/apple/README.md`
|
||||
- Entitlements and their justifications — `Config/Punktfunk.entitlements`,
|
||||
`Config/Punktfunk-macOS.entitlements` (both carry detailed rationale comments)
|
||||
- Background audio mode and its 2.5.4 constraints — `Config/Info.plist`
|
||||
- "Collects no data" — verified by absence: no analytics SDK in `Package.swift`, no telemetry
|
||||
symbols in `Sources/`, `URLSession` used only against the paired host
|
||||
- Host platforms and protocol details — root `README.md`, `docs/releases/v0.24.0.md`
|
||||
- Feature ship dates — `git tag --contains` on the relevant commits
|
||||
|
||||
## Not done here
|
||||
|
||||
`clients/apple` has no `PrivacyInfo.xcprivacy`. The app uses `UserDefaults`, which is a
|
||||
required-reason API, so a manifest is expected. Flagged at the end of `review-notes.md`; left
|
||||
alone because it is a code change, not copy.
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check every App Store copy block in this directory against its field limit.
|
||||
|
||||
App Store Connect silently truncates or hard-rejects over-long fields, and the German copy is the
|
||||
easy one to get wrong because umlauts read as one character but two bytes. Apple counts characters,
|
||||
so `len()` on a `str` is the right measure — do not switch this to a byte count.
|
||||
|
||||
Each fenced code block in the .md files here is one field. Which limit applies is inferred from the
|
||||
nearest heading above it. Exit status is non-zero if anything is over, so CI can gate on it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
LIMITS = {"PROMO": 170, "DESC": 4000, "KW": 100, "NOTES": 4000}
|
||||
|
||||
|
||||
def blocks(text: str):
|
||||
"""Yield (heading, body) for every fenced block, tagged with the heading above it."""
|
||||
heading = None
|
||||
buf: list[str] | None = None
|
||||
for line in text.split("\n"):
|
||||
if line.startswith("#") and buf is None:
|
||||
heading = line.lstrip("#").strip()
|
||||
if line.strip() == "```":
|
||||
if buf is None:
|
||||
buf = []
|
||||
else:
|
||||
yield heading or "", "\n".join(buf)
|
||||
buf = None
|
||||
continue
|
||||
if buf is not None:
|
||||
buf.append(line)
|
||||
|
||||
|
||||
def kind_of(heading: str, body: str) -> str:
|
||||
low = heading.lower()
|
||||
if "keyword" in low or re.fullmatch(r"(de|en) \(\d+\)", low):
|
||||
return "KW"
|
||||
if "template" in low:
|
||||
return "NOTES"
|
||||
return "DESC" if len(body) > 400 else "PROMO"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
here = pathlib.Path(__file__).parent
|
||||
failures = 0
|
||||
stale = 0
|
||||
for path in sorted(here.glob("*.md")):
|
||||
found = list(blocks(path.read_text(encoding="utf-8")))
|
||||
if not found:
|
||||
continue
|
||||
print(f"\n=== {path.name} ===")
|
||||
for heading, body in found:
|
||||
kind = kind_of(heading, body)
|
||||
limit = LIMITS[kind]
|
||||
n = len(body)
|
||||
over = n > limit
|
||||
failures += over
|
||||
# Headings carry the count in parentheses; flag any that drifted from the real length.
|
||||
claimed = re.search(r"\((\d+)\)\s*$", heading)
|
||||
drift = ""
|
||||
if claimed and int(claimed.group(1)) != n:
|
||||
drift = f" [heading claims {claimed.group(1)}]"
|
||||
stale += 1
|
||||
status = "OVER" if over else "ok"
|
||||
print(f" [{kind:5}] {status:>4} {n:>4}/{limit} {heading[:48]}{drift}")
|
||||
|
||||
if failures:
|
||||
print(f"\n{failures} block(s) OVER the limit")
|
||||
elif stale:
|
||||
print(f"\nAll within limits, but {stale} heading count(s) are stale")
|
||||
else:
|
||||
print("\nAll blocks within limits, all heading counts accurate")
|
||||
return 1 if failures or stale else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,71 @@
|
||||
# iOS / iPadOS — App Store metadata
|
||||
|
||||
Existing, unchanged:
|
||||
|
||||
- **Name:** Punktfunk
|
||||
- **Subtitle (DE):** Schnell, lokal & offen.
|
||||
|
||||
Only the Promotional Text is new here. It is the one field that can be changed **without** a new
|
||||
build or a review, so it is the right place for "what landed most recently".
|
||||
|
||||
---
|
||||
|
||||
## Promotional Text (DE) — max 170 characters
|
||||
|
||||
### Primary (160)
|
||||
|
||||
```
|
||||
Neu: Profile pro Host – Auflösung, Bitrate und Ton einmal einstellen, dann mit einem Tipp verbinden. Dazu Live Activity, Sperrbildschirm-Widget und Wake-on-LAN.
|
||||
```
|
||||
|
||||
### Alternate A — evergreen hook, no "new" claim (156)
|
||||
|
||||
```
|
||||
Dein Gaming-PC auf dem iPhone, in dessen exakter Auflösung – ohne Konto, ohne Cloud, nur dein Netzwerk. Hardware-Decoding, HDR und dein DualSense mit allem.
|
||||
```
|
||||
|
||||
### Alternate B — leads on the DualSense (161)
|
||||
|
||||
```
|
||||
Dein DualSense, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN vom Sofa aus.
|
||||
```
|
||||
|
||||
### Alternate C — leads on latency (153)
|
||||
|
||||
```
|
||||
Kein Konto, keine Cloud, kein Umweg: punktfunk/1 fährt über QUIC direkt zu deinem PC. Auflösungswechsel mitten im Stream, ohne die Verbindung zu trennen.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Promotional Text (EN) — max 170 characters
|
||||
|
||||
### Primary (152)
|
||||
|
||||
```
|
||||
New: per-host profiles — set resolution, bitrate and audio once, then connect with one tap. Plus Live Activities, a Lock Screen widget, and Wake-on-LAN.
|
||||
```
|
||||
|
||||
### Alternate A — evergreen hook (159)
|
||||
|
||||
```
|
||||
Your gaming PC on your iPhone, at your iPhone's exact resolution — no account, no cloud, just your network. Hardware decoding, HDR, and your DualSense in full.
|
||||
```
|
||||
|
||||
### Alternate B — leads on the DualSense (160)
|
||||
|
||||
```
|
||||
Your DualSense, in full: rumble, adaptive triggers, lightbar, touchpad and gyro all reach the game. Plus per-host profiles and Wake-on-LAN from across the room.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes on the claims
|
||||
|
||||
- "Profile pro Host" shipped in **v0.22.0** (`25b12780`, `80c0ca69`) and is in every tag since. It is
|
||||
the strongest recent user-facing Apple feature, so "Neu" is defensible for one release cycle — but
|
||||
drop the word once 0.25 ships something newer.
|
||||
- Live Activities and the Hosts widget shipped long ago (`ba1caf02`, in v0.15.0+). They are safe to
|
||||
*mention* but should not be called "neu".
|
||||
- The only Apple-visible feature unique to **v0.24.0** is the "Forward controllers" off switch
|
||||
(`b297542c`), which is too niche to headline.
|
||||
@@ -0,0 +1,159 @@
|
||||
# macOS — App Store metadata
|
||||
|
||||
> **Scope correction.** The Mac app is a **client only**. There is no macOS host: `punktfunk-host`
|
||||
> has no macOS capture, virtual-display, or encode backend (the two `cfg!(target_os = "macos")` hits
|
||||
> in the host crate are OS *detection* for the host tile and a path helper; the loopback-test host
|
||||
> is a synthetic frame source for `test-loopback.sh`, not a shippable host). A macOS host is a
|
||||
> feasibility study — it needs four new backends and the private `CGVirtualDisplay` API.
|
||||
> None of the copy below claims a Mac can host, and it should not until that ships.
|
||||
|
||||
- **Name:** Punktfunk
|
||||
- **Subtitle (DE):** Schnell, lokal & offen.
|
||||
- **Subtitle (EN):** Fast, local & open.
|
||||
|
||||
---
|
||||
|
||||
## Promotional Text (DE) — max 170 characters
|
||||
|
||||
### Primary (164)
|
||||
|
||||
```
|
||||
Neu: Profile pro Host – ein Mac, mehrere Gaming-PCs, jeder mit eigenen Einstellungen. Dazu AV1-Hardware-Decoding auf M3 und neuer, HDR und volles 4:4:4 für Schrift.
|
||||
```
|
||||
|
||||
### Alternate (156)
|
||||
|
||||
```
|
||||
Dein Gaming-PC im Fenster oder im Vollbild, in der exakten Auflösung deines Displays. Maus und Tastatur gehen durch, Auflösungswechsel ohne neue Verbindung.
|
||||
```
|
||||
|
||||
## Promotional Text (EN) — max 170 characters
|
||||
|
||||
### Primary (161)
|
||||
|
||||
```
|
||||
New: per-host profiles — one Mac, several gaming PCs, each with its own settings. Plus AV1 hardware decoding on M3 and later, HDR, and full 4:4:4 for crisp text.
|
||||
```
|
||||
|
||||
### Alternate (156)
|
||||
|
||||
```
|
||||
Your gaming PC in a window or full screen, at your display's exact resolution. Mouse and keyboard pass straight through; resize without dropping the stream.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Description (DE) — max 4000 characters
|
||||
|
||||
```
|
||||
Punktfunk streamt deinen Gaming-PC auf den Mac – in der exakten Auflösung und Bildwiederholrate deines Displays, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
|
||||
|
||||
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 – auf dem Gaming-Rig unterm Schreibtisch, auf einem Laptop oder headless auf einem Server, an dem gar kein Monitor hängt.
|
||||
|
||||
DEIN MAC BEKOMMT SEIN EIGENES DISPLAY
|
||||
|
||||
Für jede Verbindung legt der Host ein echtes virtuelles Display an – in genau der Auflösung und Bildrate, die dein Mac meldet. Kein Skalieren, keine schwarzen Balken, kein Umsortieren deiner echten Monitore. Änderst du mitten im Stream die Fenstergröße oder gehst auf Vollbild, wird die Auflösung neu ausgehandelt, ohne die Verbindung zu trennen. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display.
|
||||
|
||||
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
|
||||
|
||||
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur, die Auflösung und Bildrate mitten im Stream wechselt, ohne neu zu verbinden. Dekodiert wird in Hardware über VideoToolbox – H.264, HEVC und AV1 auf Macs, die AV1 in Hardware können (M3 und neuer).
|
||||
|
||||
FÜR DEN MAC GEMACHT
|
||||
|
||||
• Im Fenster oder im Vollbild, auf jedem angeschlossenen Display
|
||||
• Maus und Tastatur gehen vollständig durch – Klick zum Fangen, Cmd+Esc oder Ctrl+Alt+Shift+Q zum Freigeben
|
||||
• Ein Stream-Menü in der Menüleiste: Maus freigeben, Trennen, Statistik einblenden
|
||||
• Mikrofon-Uplink mit Echounterdrückung – dein Mac wird zum Headset am PC
|
||||
• HDR mit PQ-Passthrough und ein optionaler Vollchroma-Modus (4:4:4), damit kleine Schrift und feine Linien scharf bleiben
|
||||
|
||||
CONTROLLER, VOLLSTÄNDIG
|
||||
|
||||
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt.
|
||||
|
||||
DEINE BIBLIOTHEK, DEIN NETZWERK
|
||||
|
||||
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt. Hosts findet die App im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Mac über eine gepinnte Identität aus deinem Schlüsselbund – kein Konto, kein Login. Einen schlafenden PC weckt Punktfunk per Wake-on-LAN.
|
||||
|
||||
MESSEN STATT GLAUBEN
|
||||
|
||||
Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz – über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate vor. Profile halten pro Host fest, wie gestreamt werden soll.
|
||||
|
||||
WAS DU BRAUCHST
|
||||
|
||||
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos – Anleitungen und Quellcode findest du auf punktfunk.unom.io. Diese App ist der Client: ein Mac kann derzeit nicht selbst Host sein.
|
||||
|
||||
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Description (EN) — max 4000 characters
|
||||
|
||||
```
|
||||
Punktfunk streams your gaming PC to your Mac — at your display's exact resolution and refresh rate, over your own network, with no account and no cloud.
|
||||
|
||||
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — on the gaming rig under your desk, on a laptop, or headless on a server with no monitor attached at all.
|
||||
|
||||
YOUR MAC GETS A DISPLAY OF ITS OWN
|
||||
|
||||
For every connection, the host creates a real virtual display at exactly the resolution and refresh rate your Mac reports. No scaling, no black bars, no rearranging your actual monitors. Resize the window mid-stream or go full screen and the resolution is renegotiated without dropping the connection. Several devices can stream at once, each on its own display.
|
||||
|
||||
FAST, BECAUSE WE OWN THE WHOLE PATH
|
||||
|
||||
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction, able to change resolution and frame rate mid-stream without reconnecting. Decoding is done in hardware through VideoToolbox — H.264, HEVC, and AV1 on Macs with an AV1 hardware decoder (M3 and later).
|
||||
|
||||
BUILT FOR THE MAC
|
||||
|
||||
• In a window or full screen, on any attached display
|
||||
• Mouse and keyboard pass straight through — click to capture, Cmd+Esc or Ctrl+Alt+Shift+Q to release
|
||||
• A Stream menu in the menu bar: release the mouse, disconnect, toggle the stats overlay
|
||||
• Microphone uplink with echo cancellation — your Mac becomes the headset on your PC
|
||||
• HDR with PQ passthrough, plus an optional full-chroma (4:4:4) mode that keeps small text and fine UI lines sharp
|
||||
|
||||
CONTROLLERS, IN FULL
|
||||
|
||||
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands.
|
||||
|
||||
YOUR LIBRARY, YOUR NETWORK
|
||||
|
||||
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Mac reconnects on a pinned identity stored in your keychain — no account, no login. Punktfunk can wake a sleeping PC over Wake-on-LAN.
|
||||
|
||||
MEASURED, NOT PROMISED
|
||||
|
||||
A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your link. Profiles remember how each host should be streamed.
|
||||
|
||||
WHAT YOU NEED
|
||||
|
||||
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io. This app is the client: a Mac cannot currently act as a host.
|
||||
|
||||
No account. No cloud. No telemetry. This app collects no data about you.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keywords — max 100 characters
|
||||
|
||||
Comma-separated, **no spaces after the commas** (spaces count against the limit). The app name and
|
||||
the subtitle are already indexed, so `punktfunk`, `schnell`, `lokal`, and `offen` are deliberately
|
||||
absent — repeating them would waste characters.
|
||||
|
||||
### DE (97)
|
||||
|
||||
```
|
||||
streaming,spiele,remote,desktop,fernzugriff,pc,linux,windows,controller,gamepad,latenz,quelloffen
|
||||
```
|
||||
|
||||
### EN (95)
|
||||
|
||||
```
|
||||
streaming,remote,desktop,pc,linux,windows,gaming,controller,gamepad,latency,selfhosted,lan,play
|
||||
```
|
||||
|
||||
**Deliberately excluded:** `Moonlight`, `GameStream`, `NVIDIA`, `Steam`. Punktfunk genuinely is
|
||||
GameStream-compatible and does read your Steam library, but App Store Review Guideline 4.1 and the
|
||||
metadata rules disallow third-party app, product, and company names in the **keyword** field — it is
|
||||
a routine rejection. Saying it in the description is fine; the current descriptions avoid naming
|
||||
Moonlight and mention Steam only as a factual statement about your own library.
|
||||
|
||||
The previous keyword set (`Game-Streaming, Lokal, Open-Source, Gaming`) spent characters on spaces,
|
||||
on `Lokal` (already in the subtitle), and on both `Game-Streaming` and `Gaming`, which share a stem.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Privacy — what to link from App Store Connect
|
||||
|
||||
## The situation
|
||||
|
||||
You already have a privacy policy at **punktfunk.unom.io/legal/privacy**. It is good, current
|
||||
(Stand: 28. Juni 2026), and localised DE/EN. But it is a **website** privacy policy: it covers
|
||||
server log files, Plausible Analytics on `analytics.unom.io`, the `PARAGLIDE_LOCALE` cookie, and
|
||||
self-hosted fonts. It does not mention the apps at all.
|
||||
|
||||
That is a problem for App Store Connect in two directions:
|
||||
|
||||
1. Apple requires the linked policy to describe **the app's** data practices. A reviewer following
|
||||
the link finds a page about a website.
|
||||
2. It reads as *contradicting* a "Data Not Collected" declaration. The page prominently describes
|
||||
analytics and a cookie. A reviewer who skims it sees "Reichweitenmessung mit Plausible
|
||||
Analytics" and has every reason to question the App Privacy answers.
|
||||
|
||||
**Recommendation:** keep the existing page and append an app-specific section to it (the text
|
||||
below), so one URL covers both. The alternative — a separate `/legal/privacy-apps` route — also
|
||||
works, but one URL is less to keep in sync.
|
||||
|
||||
The page is CMS-driven (`src/routes/legal/privacy.tsx` renders Payload `RichText` blocks from the
|
||||
`pages` collection, slug `legal/privacy`, tenant `punktfunk`), so this is a CMS edit rather than a
|
||||
code change.
|
||||
|
||||
## Confirming the "collects no data" framing
|
||||
|
||||
Checked against the source rather than taken on trust, and it holds:
|
||||
|
||||
- **No analytics, telemetry, or crash-reporting SDK.** `Package.swift` declares no such dependency.
|
||||
A case-insensitive sweep of `Sources/` for `sentry|firebase|analytics|telemetry|amplitude|
|
||||
mixpanel|crashlytics|posthog|plausible` returns 43 hits — 43 of them the word "amplitude" in
|
||||
haptics code (rumble amplitude), and one the English word "plausible" in a comment.
|
||||
- **No outbound calls to us.** The only `URLSession` use is `LibraryClient`, fetching cover art
|
||||
**from the paired host**, over a TLS session that pins the host's own certificate. The only
|
||||
external URLs anywhere in the Swift sources are three UI links the user can tap: the docs site,
|
||||
the source on `git.unom.io`, and the Discord invite.
|
||||
- **No account system.** Identity is a client keypair in the device keychain
|
||||
(`keychain-access-groups`, `ClientIdentityStore`); pairing is SPAKE2 with a PIN, host-to-device.
|
||||
- **Data stays on device.** Saved hosts and settings live in a shared `UserDefaults` suite
|
||||
(`group.io.unom.punktfunk`) so the widget can read them. Nothing syncs; there is no CloudKit
|
||||
entitlement.
|
||||
- **No ATT.** No `NSUserTrackingUsageDescription` anywhere, consistent with no tracking.
|
||||
|
||||
So **App Privacy → "Data Not Collected"** is accurate for all four platforms. Two caveats worth
|
||||
stating in the policy text anyway, because they are true and pre-empt questions:
|
||||
|
||||
- The microphone uplink **is** audio leaving the device — but only to the host the user paired with,
|
||||
encrypted, and never to us. Apple's questionnaire asks about data collected *by you or your
|
||||
third-party partners*; streaming to the user's own machine is not collection. Saying so plainly
|
||||
is better than staying silent about a microphone permission.
|
||||
- The apps are distributed through the App Store, so **Apple** collects its own analytics. That is
|
||||
Apple's processing, not yours, but naming it avoids looking like an omission.
|
||||
|
||||
---
|
||||
|
||||
## Text to append — Deutsch
|
||||
|
||||
> ## Die Punktfunk-Apps
|
||||
>
|
||||
> Dieser Abschnitt betrifft die Punktfunk-Apps für iPhone, iPad, Apple TV, Mac, Windows, Linux und
|
||||
> Android – im Unterschied zu den vorstehenden Abschnitten, die sich auf diese Website beziehen.
|
||||
>
|
||||
> **Die Apps erheben keine personenbezogenen Daten.** Es gibt keine Benutzerkonten, keine
|
||||
> Registrierung und keine Anmeldung. Die Apps enthalten keine Analyse-, Tracking-, Werbe- oder
|
||||
> Absturzbericht-Bibliotheken von Drittanbietern. Es findet kein Tracking im Sinne des App
|
||||
> Tracking Transparency Frameworks statt, und es werden keine Daten an uns oder an Dritte
|
||||
> übermittelt.
|
||||
>
|
||||
> **Wohin die Daten fließen.** Punktfunk verbindet Ihr Gerät direkt mit einem Host-Rechner, den Sie
|
||||
> selbst betreiben – in der Regel in Ihrem eigenen Netzwerk. Video, Ton, Maus-, Tastatur- und
|
||||
> Controller-Eingaben sowie – sofern Sie ihn einschalten – Ihr Mikrofon werden ausschließlich
|
||||
> zwischen Ihrem Gerät und diesem Host übertragen, verschlüsselt und ohne Umweg über einen Server
|
||||
> von uns. Wir betreiben für den Streaming-Betrieb keine Vermittlungs-, Relay- oder Cloud-Dienste
|
||||
> und haben zu keinem Zeitpunkt Zugriff auf die Inhalte einer Sitzung.
|
||||
>
|
||||
> **Was auf dem Gerät bleibt.** Die App speichert lokal auf Ihrem Gerät: die von Ihnen
|
||||
> hinzugefügten oder im Netzwerk gefundenen Hosts, Ihre Einstellungen und Profile sowie einen
|
||||
> kryptografischen Schlüssel, mit dem sich Ihr Gerät gegenüber einem gekoppelten Host ausweist
|
||||
> (auf Apple-Geräten im Schlüsselbund). Diese Daten verlassen Ihr Gerät nicht und werden gelöscht,
|
||||
> wenn Sie die App entfernen.
|
||||
>
|
||||
> **Berechtigungen.** Die App fragt nur Berechtigungen ab, die für den Betrieb nötig sind: den
|
||||
> Zugriff auf das lokale Netzwerk, um Hosts zu finden und sich mit ihnen zu verbinden, und – nur
|
||||
> wenn Sie die Mikrofonübertragung nutzen – das Mikrofon. Das Mikrofonsignal wird an den von Ihnen
|
||||
> gekoppelten Host übertragen, wo es als virtuelles Mikrofon erscheint; es wird nicht
|
||||
> aufgezeichnet und nicht an uns gesendet.
|
||||
>
|
||||
> **Verteilung über App-Stores.** Wenn Sie die App über den App Store oder Google Play beziehen,
|
||||
> verarbeiten Apple bzw. Google im Rahmen der Auslieferung eigene Daten (etwa Kauf-, Installations-
|
||||
> und Absturzstatistiken). Darauf haben wir keinen Einfluss; es gelten die
|
||||
> Datenschutzbestimmungen des jeweiligen Anbieters. Aggregierte Statistiken, die uns Apple oder
|
||||
> Google in ihren Entwicklerkonsolen anzeigen, lassen keinen Rückschluss auf einzelne Personen zu.
|
||||
>
|
||||
> **Der Host.** Der Punktfunk-Host ist quelloffene Software, die Sie selbst auf Ihrem eigenen
|
||||
> Rechner betreiben. Welche Daten dabei anfallen – etwa lokale Protokolldateien –, bleibt
|
||||
> vollständig unter Ihrer Kontrolle; wir erhalten davon nichts. Der Quellcode ist unter
|
||||
> git.unom.io/unom/punktfunk einsehbar.
|
||||
|
||||
---
|
||||
|
||||
## Text to append — English
|
||||
|
||||
> ## The Punktfunk apps
|
||||
>
|
||||
> This section concerns the Punktfunk apps for iPhone, iPad, Apple TV, Mac, Windows, Linux, and
|
||||
> Android — as distinct from the sections above, which concern this website.
|
||||
>
|
||||
> **The apps collect no personal data.** There are no user accounts, no registration, and no sign-in.
|
||||
> The apps contain no third-party analytics, tracking, advertising, or crash-reporting libraries.
|
||||
> No tracking within the meaning of Apple's App Tracking Transparency framework takes place, and no
|
||||
> data is transmitted to us or to any third party.
|
||||
>
|
||||
> **Where your data goes.** Punktfunk connects your device directly to a host machine that you run
|
||||
> yourself, normally on your own network. Video, audio, mouse, keyboard, and controller input — and
|
||||
> your microphone, if you switch it on — travel only between your device and that host, encrypted,
|
||||
> without passing through any server of ours. We operate no brokering, relay, or cloud service for
|
||||
> streaming, and we have no access to the contents of a session at any point.
|
||||
>
|
||||
> **What stays on your device.** The app stores locally on your device: the hosts you have added or
|
||||
> discovered on your network, your settings and profiles, and a cryptographic key your device uses
|
||||
> to identify itself to a paired host (in the keychain, on Apple devices). This data does not leave
|
||||
> your device and is removed when you delete the app.
|
||||
>
|
||||
> **Permissions.** The app requests only the permissions it needs to work: access to the local
|
||||
> network, in order to find hosts and connect to them, and — only if you use microphone streaming —
|
||||
> the microphone. The microphone signal is sent to the host you paired with, where it appears as a
|
||||
> virtual microphone; it is not recorded and is not sent to us.
|
||||
>
|
||||
> **Distribution through app stores.** If you obtain the app from the App Store or Google Play,
|
||||
> Apple or Google process their own data as part of distributing it (such as purchase, installation,
|
||||
> and crash statistics). We have no influence over this, and the respective provider's privacy
|
||||
> policy applies. The aggregated statistics Apple and Google show us in their developer consoles do
|
||||
> not allow any individual to be identified.
|
||||
>
|
||||
> **The host.** The Punktfunk host is open source software that you run on your own machine. Any
|
||||
> data it produces — local log files, for instance — remains entirely under your control, and none
|
||||
> of it reaches us. The source is available at git.unom.io/unom/punktfunk.
|
||||
|
||||
---
|
||||
|
||||
## Also update
|
||||
|
||||
- Bump **Stand: / Effective date:** on the page when you add this.
|
||||
- App Store Connect → App Privacy → **Data Not Collected** for all four platforms.
|
||||
- The same URL works for Google Play's Data safety declaration; the wording above already covers it.
|
||||
@@ -0,0 +1,132 @@
|
||||
# App Review notes
|
||||
|
||||
## The core problem, stated plainly
|
||||
|
||||
Punktfunk is the client half of a two-part system. Without a reachable host it shows a host list, a
|
||||
pairing sheet, and settings — and nothing else. There is **no demo or offline mode in a release
|
||||
build**: the mock-data screens in `Sources/PunktfunkClient/Screenshots/` are wrapped in `#if DEBUG`
|
||||
and are compiled out of anything you ship. A reviewer who launches the App Store build with no host
|
||||
on their network sees an empty "On this network" list.
|
||||
|
||||
Guideline 2.1 requires you to supply whatever is needed to fully exercise the app. So you must
|
||||
attach **one** of:
|
||||
|
||||
- **(a) A reachable demo host.** Best outcome — the reviewer sees the real thing. Requires a host
|
||||
exposed to the internet with its UDP ports forwarded, plus a pairing PIN in the notes. The client
|
||||
can add a host by IP or hostname, so mDNS discovery is not required for this path.
|
||||
- **(b) A demo video.** Apple accepts this for hardware- or setup-dependent apps. Less good: a
|
||||
reviewer who cannot reproduce is a reviewer who can reject on something unrelated.
|
||||
|
||||
**Attach (a) if you can keep a host up for the review window; (b) is the fallback.** Whichever you
|
||||
pick, fill in the placeholders before submitting — the template assumes (a) and marks the spots.
|
||||
|
||||
> **⚠ Decide before submitting:** if you go with (b), replace the "CONNECTING TO OUR DEMO HOST"
|
||||
> section with the video URL and say explicitly that no host can be provided.
|
||||
|
||||
---
|
||||
|
||||
## Notes template — paste into App Store Connect
|
||||
|
||||
The App Review Information "Notes" field caps at **4000 characters**. The block below is **3919**,
|
||||
and filling the five placeholders in shortens it further (the literal `[[FILL IN: …]]` text is
|
||||
longer than the values that replace it). If you add to it, re-check the count — an over-long note
|
||||
is silently truncated, and what gets cut is the end, where the privacy and entitlement answers
|
||||
live.
|
||||
|
||||
```
|
||||
WHAT THIS APP IS
|
||||
|
||||
Punktfunk is a low-latency game- and desktop-streaming client. It streams from a "host" the user
|
||||
installs on their own gaming PC (Linux, or Windows 11 22H2+), over their own network. The host is
|
||||
separate open-source software we publish at https://git.unom.io/unom/punktfunk; it is not sold,
|
||||
and this app has no purchases.
|
||||
|
||||
This app is the client half only: it renders video and audio from the user's own machine and
|
||||
sends input back. There is no content library and no server of ours in a session.
|
||||
|
||||
IMPORTANT: THIS APP NEEDS A HOST
|
||||
|
||||
With no reachable host, the app can only show its host list, the pairing screen and settings --
|
||||
inherent to what it is, not an incomplete build. We have provided a live host for review.
|
||||
|
||||
CONNECTING TO OUR DEMO HOST
|
||||
|
||||
1. Launch Punktfunk. The main screen lists hosts on the local network. Ours is not on yours, so
|
||||
add it by hand: "+" (top right) then "Add host"; on Apple TV, "Add host" on the main screen.
|
||||
2. Enter: Host: [[FILL IN: hostname or IP]] Port: [[FILL IN: port, default 47998]]
|
||||
Name it anything, then confirm.
|
||||
3. The app connects and asks for a pairing PIN. Enter: [[FILL IN: PIN]]
|
||||
A one-time SPAKE2 pairing; afterwards the device is remembered and needs no PIN.
|
||||
4. The host's game library appears as a grid. Select any title to stream; video and audio start
|
||||
within a few seconds.
|
||||
5. While streaming: stats overlay = Ctrl+Alt+Shift+S (or three-finger tap on iOS/iPadOS); release
|
||||
mouse = Cmd+Esc or Ctrl+Alt+Shift+Q; disconnect = Ctrl+Alt+Shift+D.
|
||||
6. Settings (gear) covers decoder, bitrate, HDR, audio, controllers and profiles; the per-host
|
||||
"Speed test" suggests a bitrate for the link.
|
||||
|
||||
The host stays reachable throughout review. If you cannot reach it, please contact
|
||||
[[FILL IN: contact email]] and we will restore it promptly.
|
||||
|
||||
WHY THE APP ASKS FOR WHAT IT ASKS FOR
|
||||
|
||||
- Local Network: finds hosts via Bonjour (_punktfunk._udp) and connects to them -- the app's
|
||||
entire purpose.
|
||||
- Microphone (optional, off by default): audio goes to the user's own paired host, appearing
|
||||
there as a virtual microphone for voice chat. Never recorded, never sent to us.
|
||||
- networking.multicast: sends the Wake-on-LAN magic packet, which must go to a broadcast address:
|
||||
a sleeping PC has no ARP entry, so unicast cannot reach it. Used for nothing else.
|
||||
- device.usb / device.bluetooth (macOS): the GameController framework reaches wired controllers
|
||||
through IOHIDLibUserClient and wireless ones through startWirelessControllerDiscovery. USB also
|
||||
drives DualSense rumble, which CoreHaptics will not. Without these, no controller input.
|
||||
- network.server (macOS): the app is outbound-only, but the App Sandbox gates bind() itself. Our
|
||||
QUIC endpoint and UDP socket each bind a local port to receive host-to-client datagrams;
|
||||
without this, no video, audio or rumble arrives.
|
||||
- UIBackgroundModes "audio" (iPhone/iPad): a session carries real, audible audio from the host,
|
||||
and this keeps it alive if the user steps away briefly. Backgrounded, video decoding stops, only
|
||||
the real audio keeps rendering, and a bounded timer disconnects automatically. We never play
|
||||
silence to stay alive, nor use the mode outside an audible session.
|
||||
|
||||
REGARDING BUILD 0.4.2 (3384)
|
||||
|
||||
That build was rejected under 2.4.5(i) for a temporary-exception entitlement
|
||||
(mach-lookup.global-name, com.apple.audioanalyticsd), added on a mistaken belief about CoreHaptics
|
||||
rumble under the App Sandbox. We have since verified rumble works without it; this build carries
|
||||
no temporary exception.
|
||||
|
||||
ACCOUNTS, PURCHASES, DATA
|
||||
|
||||
No account, no sign-in, no in-app purchase. The app collects no personal data: no analytics,
|
||||
tracking, advertising or crash-reporting SDKs, and no connection to any server of ours during a
|
||||
session. Device identity is a keychain keypair used only to authenticate to the user's own host.
|
||||
|
||||
Privacy policy: [[FILL IN: https://punktfunk.unom.io/legal/privacy]]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Before you submit — checklist
|
||||
|
||||
- [ ] Fill every `[[FILL IN: …]]` placeholder. There are five.
|
||||
- [ ] Confirm the demo host is reachable **from outside your own network** — test it on cellular,
|
||||
not on the LAN it lives on. This is the failure mode that wastes a review cycle.
|
||||
- [ ] Confirm the pairing PIN in the notes is the one the host will actually accept during the
|
||||
review window, and that pairing is left open (it is on-demand in the web console).
|
||||
- [ ] Put at least one launchable title in the demo host's library. An empty grid after a
|
||||
successful pairing looks like a broken app.
|
||||
- [ ] If submitting tvOS, verify the whole flow is reachable with the **Siri Remote alone**. A
|
||||
reviewer will not have a controller paired, and "requires an accessory to navigate" is a
|
||||
tvOS rejection.
|
||||
- [ ] Attach the demo video as a URL in the notes if you are going the (b) route.
|
||||
|
||||
## Separately worth checking: the privacy manifest
|
||||
|
||||
There is **no `PrivacyInfo.xcprivacy`** anywhere in `clients/apple`. The app does use
|
||||
`UserDefaults` (`HostStore` reads the `group.io.unom.punktfunk` suite), and `UserDefaults` is one of
|
||||
Apple's "required reason" APIs, which are expected to be declared in a privacy manifest. Apps
|
||||
missing a declaration typically get an automated **ITMS-91053** notice on upload.
|
||||
|
||||
This is adjacent to the copy work rather than part of it, so nothing has been changed here — but it
|
||||
is worth adding a manifest declaring `NSPrivacyAccessedAPICategoryUserDefaults` with reason code
|
||||
`CA92.1` (access to an app group container) and `NSPrivacyTracking` set to `false`, before the next
|
||||
submission. Confirm the current reason codes against Apple's documentation rather than taking the
|
||||
code above on trust; the list has changed since it was introduced.
|
||||
@@ -0,0 +1,145 @@
|
||||
# tvOS — App Store metadata
|
||||
|
||||
Client only, living-room framing. Things the other platforms have that the **Apple TV does not**,
|
||||
and which the copy therefore avoids claiming:
|
||||
|
||||
- **No microphone uplink.** There is no usable audio input on tvOS, so the "your Mac becomes the
|
||||
headset" line does not transfer.
|
||||
- **No gamepad console shell.** `ShotScenes` builds the gamepad home/settings screens for iOS and
|
||||
macOS only — tvOS uses the native focus engine instead.
|
||||
- **No AV1.** Apple TV 4K has no AV1 hardware decoder; HEVC and H.264 only.
|
||||
- Mouse/keyboard capture exists on tvOS but is not a living-room story, so it stays out.
|
||||
|
||||
Kept, and genuinely tvOS-shaped: Siri Remote pointer navigation (`SiriRemotePointer`), controllers
|
||||
including the full DualSense feedback set, HDR passthrough, and Wake-on-LAN — which is the single
|
||||
best Apple TV feature, because it is what removes the trip to the other room.
|
||||
|
||||
- **Name:** Punktfunk
|
||||
- **Subtitle (DE):** Schnell, lokal & offen.
|
||||
- **Subtitle (EN):** Fast, local & open.
|
||||
|
||||
---
|
||||
|
||||
## Promotional Text (DE) — max 170 characters
|
||||
|
||||
### Primary (161)
|
||||
|
||||
```
|
||||
Anschalten, Host wählen, spielen: Punktfunk weckt deinen Gaming-PC per Wake-on-LAN und verbindet sich, sobald er wach ist. In 4K, mit HDR, mit deinem Controller.
|
||||
```
|
||||
|
||||
### Alternate A — leads on the picture (157)
|
||||
|
||||
```
|
||||
Dein Gaming-PC am großen Bildschirm – in genau der Auflösung und Bildrate deines Fernsehers, mit HDR. Ohne Konto, ohne Cloud, nur über dein eigenes Netzwerk.
|
||||
```
|
||||
|
||||
### Alternate B — leads on the DualSense (160)
|
||||
|
||||
```
|
||||
Dein DualSense am Apple TV, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN.
|
||||
```
|
||||
|
||||
## Promotional Text (EN) — max 170 characters
|
||||
|
||||
### Primary (160)
|
||||
|
||||
```
|
||||
Turn on, pick a host, play: Punktfunk wakes your gaming PC over Wake-on-LAN and connects as soon as it's up. In 4K, with HDR, with the controller in your hands.
|
||||
```
|
||||
|
||||
### Alternate A — leads on the picture (148)
|
||||
|
||||
```
|
||||
Your gaming PC on the big screen — at your TV's exact resolution and refresh rate, with HDR. No account, no cloud, nothing leaving your own network.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Description (DE) — max 4000 characters
|
||||
|
||||
```
|
||||
Punktfunk macht aus deinem Apple TV die Konsole für den Gaming-PC, der ohnehin schon im Haus steht – in 4K, mit HDR, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
|
||||
|
||||
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 – auch headless auf einem Rechner, an dem gar kein Monitor hängt.
|
||||
|
||||
VOM SOFA AUS, VON ANFANG BIS ENDE
|
||||
|
||||
Anschalten, Host auswählen, spielen. Die App findet Hosts im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Apple TV über eine gepinnte Identität – kein Konto, kein Login, kein Abtippen von IP-Adressen. Steht dein Gaming-PC im Standby, weckt ihn Punktfunk per Wake-on-LAN und verbindet sich, sobald er wach ist. Niemand muss dafür aufstehen.
|
||||
|
||||
DAS BILD, DAS DEIN FERNSEHER WIRKLICH KANN
|
||||
|
||||
Für den Apple TV legt der Host ein echtes virtuelles Display an – in genau der Auflösung und Bildrate, die dein Fernseher meldet, bis 4K. Kein Skalieren, keine schwarzen Balken, und die Monitore am PC werden nicht umsortiert. Dekodiert wird in Hardware über VideoToolbox (HEVC und H.264), HDR wird als PQ durchgereicht, statt es flach zu rechnen.
|
||||
|
||||
CONTROLLER, VOLLSTÄNDIG
|
||||
|
||||
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt. Bedienen lässt sich alles mit der Siri Remote oder komplett mit dem Controller – die Oberfläche ist für die Fernbedienung gebaut, nicht für eine Maus.
|
||||
|
||||
DEINE BIBLIOTHEK AUF DEM FERNSEHER
|
||||
|
||||
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt vom Sofa aus. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display – der Apple TV im Wohnzimmer stört also niemanden, der am Schreibtisch weiterarbeitet.
|
||||
|
||||
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
|
||||
|
||||
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur. Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz – über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate für dein Netzwerk vor.
|
||||
|
||||
WAS DU BRAUCHST
|
||||
|
||||
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Für die beste Erfahrung hängt der Apple TV am Kabel oder an einem guten 5-GHz-WLAN. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos – Anleitungen und Quellcode findest du auf punktfunk.unom.io.
|
||||
|
||||
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Description (EN) — max 4000 characters
|
||||
|
||||
```
|
||||
Punktfunk turns your Apple TV into a console for the gaming PC you already own — in 4K, with HDR, over your own network, with no account and no cloud.
|
||||
|
||||
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — including headless, on a machine with no monitor attached at all.
|
||||
|
||||
FROM THE COUCH, START TO FINISH
|
||||
|
||||
Turn on, pick a host, play. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Apple TV reconnects on a pinned identity — no account, no login, no typing IP addresses with a remote. If your gaming PC is asleep, Punktfunk wakes it over Wake-on-LAN and connects as soon as it is up. Nobody has to get up to make that happen.
|
||||
|
||||
THE PICTURE YOUR TV CAN ACTUALLY SHOW
|
||||
|
||||
For your Apple TV, the host creates a real virtual display at exactly the resolution and refresh rate your TV reports, up to 4K. No scaling, no black bars, and the monitors on your PC are left where they are. Decoding is done in hardware through VideoToolbox (HEVC and H.264), and HDR is passed through as PQ rather than flattened.
|
||||
|
||||
CONTROLLERS, IN FULL
|
||||
|
||||
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands. Everything is navigable with the Siri Remote or entirely with a controller — the interface is built for a remote, not for a mouse.
|
||||
|
||||
YOUR LIBRARY ON THE BIG SCREEN
|
||||
|
||||
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch from the couch. Several devices can stream at once, each on its own display — so the Apple TV in the living room does not disturb anyone still working at the desk.
|
||||
|
||||
FAST, BECAUSE WE OWN THE WHOLE PATH
|
||||
|
||||
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction. A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your network.
|
||||
|
||||
WHAT YOU NEED
|
||||
|
||||
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. For the best experience, put your Apple TV on Ethernet or on good 5 GHz Wi-Fi. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io.
|
||||
|
||||
No account. No cloud. No telemetry. This app collects no data about you.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keywords — max 100 characters
|
||||
|
||||
### DE (93)
|
||||
|
||||
```
|
||||
streaming,spiele,gaming,controller,gamepad,wohnzimmer,fernseher,pc,linux,windows,4k,hdr,couch
|
||||
```
|
||||
|
||||
### EN (91)
|
||||
|
||||
```
|
||||
streaming,gaming,controller,gamepad,livingroom,tv,pc,linux,windows,4k,hdr,couch,remote,play
|
||||
```
|
||||
|
||||
Same exclusions as macOS: no `Moonlight`, `GameStream`, `NVIDIA`, or `Steam` in the keyword field.
|
||||
@@ -11,9 +11,15 @@
|
||||
# The captured pixels are exactly App Store Connect's required sizes:
|
||||
# mac 2880×1800 (a 1× display yields 1440×900 — also accepted)
|
||||
# iphone-6.9 1320×2868 (portrait) / 2868×1320 (the landscape hero)
|
||||
# ipad-13 2064×2752 (portrait) / 2752×2064 (the landscape hero)
|
||||
# ipad-13 2064×2752 (portrait)
|
||||
# appletv 1920×1080
|
||||
#
|
||||
# A `.landscape` scene rotates on iPhone but NOT on iPad: an iPad app that supports multitasking
|
||||
# is resizable, and iPadOS ignores `requestGeometryUpdate` orientation requests for it — the app
|
||||
# follows the device, and simctl cannot rotate a simulated device. The iPad set is therefore
|
||||
# portrait throughout (a valid App Store size, and uniform, which the gallery prefers). To get a
|
||||
# landscape iPad hero, rotate the Simulator by hand (⌘←) and re-run just that scene.
|
||||
#
|
||||
# Requirements:
|
||||
# • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant
|
||||
# for your terminal (System Settings → Privacy & Security → Screen Recording).
|
||||
@@ -35,7 +41,11 @@ cd "$APPLE_DIR"
|
||||
|
||||
OUT="${OUT:-$APPLE_DIR/screenshots}"
|
||||
BUNDLE_ID="io.unom.punktfunk"
|
||||
SCENES=(01-stream 02-hosts 03-pair 04-trust 05-settings)
|
||||
|
||||
# The App Store set, in listing order — the first three are what most people ever see, so they are
|
||||
# the stream itself, the machines it found, and the couch/controller mode. Everything else in
|
||||
# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`.
|
||||
SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
|
||||
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
|
||||
|
||||
mkdir -p "$OUT"
|
||||
@@ -89,13 +99,20 @@ shoot_macos() {
|
||||
|
||||
# $1 device-type regex (matches both existing device names and the device-type catalog)
|
||||
# $2 scheme $3 sdk $4 file prefix $5 runtime platform (iOS|tvOS — for the create fallback)
|
||||
# $6 name for a device we have to create — MUST satisfy $1 (see below)
|
||||
shoot_sim() {
|
||||
require_xcode
|
||||
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5"
|
||||
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5" createname="$6"
|
||||
|
||||
# Reuse an existing device of this type; else create a throwaway one against the newest
|
||||
# available runtime for the platform. CI runners commonly ship a runtime but not every device
|
||||
# (the iPhone 16 Pro Max is absent on ours), so create-on-demand is what makes it reproducible.
|
||||
# Reuse an existing device of this type; else create one against the newest available runtime
|
||||
# for the platform. CI runners commonly ship a runtime but not every device (the iPhone 16 Pro
|
||||
# Max is absent on ours), so create-on-demand is what makes it reproducible.
|
||||
#
|
||||
# The created device is named after the DEVICE, not after this script, for two reasons. It used
|
||||
# to be "pf-shot-<prefix>", which `$match` never matches — so every run created another
|
||||
# simulator and none was ever reused (they piled up on the runner). And the name is user-visible:
|
||||
# `UIDevice.current.name` is what the pairing sheet prefills as this device's name, so
|
||||
# "pf-shot-iphone-6.9" was rendered into an App Store screenshot.
|
||||
local udid
|
||||
udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)"
|
||||
if [ -z "$udid" ]; then
|
||||
@@ -105,8 +122,8 @@ shoot_sim() {
|
||||
rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \
|
||||
| grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)"
|
||||
if [ -n "$devtype" ] && [ -n "$rt" ]; then
|
||||
udid="$(xcrun simctl create "pf-shot-$prefix" "$devtype" "$rt" 2>/dev/null || true)"
|
||||
[ -n "$udid" ] && log "$prefix — created Simulator $udid ($devtype)"
|
||||
udid="$(xcrun simctl create "$createname" "$devtype" "$rt" 2>/dev/null || true)"
|
||||
[ -n "$udid" ] && log "$prefix — created Simulator \"$createname\" $udid ($devtype)"
|
||||
fi
|
||||
fi
|
||||
[ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created
|
||||
@@ -114,6 +131,11 @@ shoot_sim() {
|
||||
log "$prefix — Simulator $udid"
|
||||
xcrun simctl boot "$udid" 2>/dev/null || true
|
||||
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true
|
||||
# Every scene is a dark-mode scene. The in-app `.environment(\.colorScheme, .dark)` override
|
||||
# does NOT cross a presentation boundary — a `.sheet` gets its own environment and follows the
|
||||
# DEVICE appearance — so the pairing sheet came out light grey over the dark app. Set the
|
||||
# simulator itself to dark and the whole hierarchy, presentations included, agrees.
|
||||
xcrun simctl ui "$udid" appearance dark >/dev/null 2>&1 || true
|
||||
|
||||
log "$prefix — building ($scheme)…"
|
||||
# PF_SHOT_DERIVED_DATA (optional): a STABLE DerivedData root, so repeat runs reuse the
|
||||
@@ -150,15 +172,15 @@ pixels() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null | awk '/pixel/{pri
|
||||
for target in "$@"; do
|
||||
case "$target" in
|
||||
macos) shoot_macos ;;
|
||||
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS ;;
|
||||
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS ;;
|
||||
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS ;;
|
||||
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max' ;;
|
||||
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)' ;;
|
||||
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K' ;;
|
||||
all)
|
||||
shoot_macos
|
||||
if xcrun --find simctl >/dev/null 2>&1; then
|
||||
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS
|
||||
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS
|
||||
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS
|
||||
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max'
|
||||
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)'
|
||||
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K'
|
||||
else
|
||||
warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)."
|
||||
fi
|
||||
|
||||
+399
-12
@@ -41,16 +41,23 @@ mod cli {
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// The handshake budget `--request-access` runs on. Matches the host's `PENDING_APPROVAL_WAIT`
|
||||
/// — the connect is PARKED for that long while an operator decides, so anything shorter would
|
||||
/// give up while the approval prompt is still on their screen.
|
||||
const REQUEST_ACCESS_TIMEOUT_SECS: u64 = 185;
|
||||
|
||||
const USAGE: &str = "\
|
||||
punktfunk — the Punktfunk client, headless
|
||||
|
||||
punktfunk discover [--json] [--timeout SECS]
|
||||
punktfunk pair <host[:port]> [--pin N] [--name LABEL]
|
||||
punktfunk hosts list [--probe] [--json]
|
||||
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
|
||||
punktfunk hosts forget <host-ref>
|
||||
punktfunk wake <host-ref> [--wait]
|
||||
punktfunk library <host-ref> [--json]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
|
||||
[--exec] [--fullscreen]
|
||||
punktfunk open <punktfunk://…>
|
||||
punktfunk reachable <host-ref>
|
||||
punktfunk speed-test <host-ref>
|
||||
@@ -68,6 +75,24 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
/// (what goes to stdout vs stderr, and which exit codes mean what).
|
||||
fn verb_help(verb: &str) -> Option<&'static str> {
|
||||
Some(match verb {
|
||||
"discover" => {
|
||||
"\
|
||||
punktfunk discover [--json] [--timeout SECS] — browse the LAN for hosts
|
||||
|
||||
Listens for Punktfunk hosts advertising over mDNS and prints what answered:
|
||||
name TAB addr:port TAB saved|new TAB paired|unpaired. `saved` means this
|
||||
device already has a record for it, matched by fingerprint first and address
|
||||
second — the same rule every other surface joins the two lists by.
|
||||
|
||||
--timeout SECS how long to browse (default 3, capped at 30) — a bounded
|
||||
call, so a panel can wait for it
|
||||
--json {\"hosts\":[{\"name\",\"addr\",\"port\",\"fp\",\"pair\",\"id\",\"mgmt\",
|
||||
\"os\",\"saved\",\"paired\"}]}
|
||||
|
||||
Nothing answering is an answer, not a failure: an empty list exits 0. A host
|
||||
mDNS never sees (Tailscale, another subnet) will not appear here — save it by
|
||||
address with `punktfunk hosts add` and it shows in `hosts list --probe`."
|
||||
}
|
||||
"pair" => {
|
||||
"\
|
||||
punktfunk pair <host[:port]> — enrol this device with a host (PIN ceremony)
|
||||
@@ -96,6 +121,13 @@ punktfunk hosts — the saved-hosts store (shared with the desktop client)
|
||||
another subnet). Without --fp it is a placeholder to pair later; with a
|
||||
64-hex fingerprint it is pinned immediately (still unpaired).
|
||||
|
||||
Idempotent, and keyed on the FINGERPRINT once there is one: re-running it
|
||||
for a host already saved is a no-op, and giving a known fingerprint a new
|
||||
address MOVES that host's record there rather than filing a second one
|
||||
(which is how a host that changed DHCP lease stays reachable by its id).
|
||||
A different fingerprint for an address already saved is refused, exit 3 —
|
||||
a changed identity is a decision for a person.
|
||||
|
||||
punktfunk hosts forget <host-ref>
|
||||
Remove a saved host, its pinned fingerprint included. A later connect
|
||||
must pair or trust it again."
|
||||
@@ -119,7 +151,8 @@ this. Needs a paired host (exit 6 otherwise)."
|
||||
}
|
||||
"launch" => {
|
||||
"\
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
|
||||
[--exec] [--fullscreen]
|
||||
|
||||
Start a stream — waking the host first if it is asleep and its MAC is known.
|
||||
The stream runs in the punktfunk-session renderer; this command supervises it
|
||||
@@ -132,6 +165,16 @@ and relays its lifecycle to stderr.
|
||||
--exec become the session process instead of supervising it — the
|
||||
gamescope-wrapper mode, where the launched process must BE
|
||||
the streaming one for focus and lifecycle to work
|
||||
--request-access
|
||||
ask the host's operator to let this device in instead of
|
||||
typing a PIN. The host PARKS the connect until somebody
|
||||
approves it in its console or web UI (up to ~185 s), then
|
||||
admits it and the stream starts by itself; the host is
|
||||
recorded as paired once that happens, so later streams are
|
||||
silent. Needs the host's fingerprint pinned already
|
||||
(`punktfunk hosts add <addr> --fp <hex>`), and cannot be
|
||||
combined with --exec — under --exec there is no process
|
||||
left to record the approval.
|
||||
|
||||
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
|
||||
trusts this device (re-pair), 4 the renderer could not start."
|
||||
@@ -222,7 +265,7 @@ from the config directory for a true factory reset."
|
||||
fn flag_takes_value(flag: &str) -> bool {
|
||||
matches!(
|
||||
flag,
|
||||
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port"
|
||||
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port" | "--timeout"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -269,6 +312,7 @@ from the config directory for a true factory reset."
|
||||
return OK;
|
||||
}
|
||||
match verb.as_str() {
|
||||
"discover" => discover(&rest),
|
||||
"pair" => pair(&rest),
|
||||
"hosts" => hosts(&rest),
|
||||
"wake" => wake(&rest),
|
||||
@@ -306,6 +350,104 @@ from the config directory for a true factory reset."
|
||||
}
|
||||
}
|
||||
|
||||
/// How long `discover` browses when nobody says, and the ceiling on what they can ask for.
|
||||
/// The cap is not politeness: this verb is called from a Quick Access panel, and a typo'd
|
||||
/// `--timeout 3000` would hang that panel with no way to cancel it.
|
||||
const DISCOVER_DEFAULT_SECS: f64 = 3.0;
|
||||
const DISCOVER_MAX_SECS: f64 = 30.0;
|
||||
|
||||
/// `discover [--json] [--timeout SECS]` — browse the LAN over mDNS and print what answered,
|
||||
/// annotated against the saved-hosts store.
|
||||
///
|
||||
/// The annotation is the point: a caller wants "can I stream this", which is a question
|
||||
/// about BOTH lists, and joining them itself is how two surfaces end up disagreeing about
|
||||
/// the same host. So the match rule lives here, once, and is the same one every other
|
||||
/// surface uses — fingerprint first (survives a DHCP move), address second.
|
||||
fn discover(args: &[String]) -> u8 {
|
||||
let secs = value(args, "--timeout")
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.filter(|s| *s > 0.0)
|
||||
.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).
|
||||
let known = KnownHosts::read();
|
||||
let rows: Vec<(
|
||||
&pf_client_core::discovery::DiscoveredHost,
|
||||
Option<&KnownHost>,
|
||||
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
|
||||
if has(args, "--json") {
|
||||
let hosts: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
.map(|(d, saved)| {
|
||||
serde_json::json!({
|
||||
"name": d.name,
|
||||
"addr": d.addr,
|
||||
"port": d.port,
|
||||
"fp": d.fp_hex,
|
||||
"pair": d.pair,
|
||||
"id": d.advertised_id(),
|
||||
// 0 = not advertised, which is what a consumer's own "no mgmt port"
|
||||
// already means — an older host simply omits the TXT.
|
||||
"mgmt": d.mgmt_port.unwrap_or(0),
|
||||
"os": d.os,
|
||||
"saved": saved.is_some(),
|
||||
"paired": saved.is_some_and(|h| h.paired),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::json!({ "hosts": hosts }));
|
||||
} else {
|
||||
for (d, saved) in &rows {
|
||||
println!(
|
||||
"{}\t{}:{}\t{}\t{}",
|
||||
d.name,
|
||||
d.addr,
|
||||
d.port,
|
||||
if saved.is_some() { "saved" } else { "new" },
|
||||
if saved.is_some_and(|h| h.paired) {
|
||||
"paired"
|
||||
} else {
|
||||
"unpaired"
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// An empty LAN is an answer, not a failure — a caller branching on the exit code is
|
||||
// asking "did the browse run", and it did.
|
||||
OK
|
||||
}
|
||||
|
||||
/// The saved record an advert belongs to, if any: fingerprint first, address second.
|
||||
///
|
||||
/// Fingerprint FIRST is deliberate and load-bearing — a host that moved to a new DHCP lease
|
||||
/// still matches its record, and a *different* host that inherited the old address does not
|
||||
/// inherit its pairing. This is the rule the plugin's `mergeHosts` and the shells' hosts
|
||||
/// pages already use; keeping one copy is what stops two surfaces disagreeing about whether
|
||||
/// the box in front of you is paired.
|
||||
fn match_saved<'a>(
|
||||
known: &'a KnownHosts,
|
||||
advert: &pf_client_core::discovery::DiscoveredHost,
|
||||
) -> Option<&'a KnownHost> {
|
||||
known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| {
|
||||
!h.fp_hex.is_empty()
|
||||
&& !advert.fp_hex.is_empty()
|
||||
&& h.fp_hex.eq_ignore_ascii_case(&advert.fp_hex)
|
||||
})
|
||||
.or_else(|| {
|
||||
known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == advert.addr && h.port == advert.port)
|
||||
})
|
||||
}
|
||||
|
||||
/// `pair <host[:port]> [--pin N]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
|
||||
/// is the interactive shape; with one it is scriptable. Refuses rather than prompting when
|
||||
/// stdin isn't a terminal and no PIN was given: a pairing that silently blocks a CI job
|
||||
@@ -424,16 +566,69 @@ from the config directory for a true factory reset."
|
||||
return UNRESOLVED;
|
||||
};
|
||||
let (addr, port) = split_host_port(&target);
|
||||
let fp = value(args, "--fp").unwrap_or_default();
|
||||
let name = value(args, "--name");
|
||||
let mut known = KnownHosts::load();
|
||||
if known.hosts.iter().any(|h| h.addr == addr && h.port == port) {
|
||||
eprintln!("{addr}:{port} is already saved");
|
||||
return OK;
|
||||
if let Some(i) = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.addr == addr && h.port == port)
|
||||
{
|
||||
return match merge_saved_host(&mut known, i, &fp, name.as_deref()) {
|
||||
AddOutcome::Unchanged => {
|
||||
eprintln!("{addr}:{port} is already saved");
|
||||
OK
|
||||
}
|
||||
AddOutcome::Conflict => {
|
||||
eprintln!(
|
||||
"{addr}:{port} is already saved with a different fingerprint — \
|
||||
forget it first if you really mean to replace it \
|
||||
(punktfunk hosts forget {addr}:{port})"
|
||||
);
|
||||
TRUST_REJECTED
|
||||
}
|
||||
AddOutcome::Pinned => match known.save() {
|
||||
Ok(()) => {
|
||||
println!("updated {addr}:{port}");
|
||||
OK
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("saving: {e:#}");
|
||||
CONNECT_FAILED
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
// No record at this address — but a record carrying this exact FINGERPRINT is
|
||||
// this same host at a new one. Re-point it rather than filing a second record:
|
||||
// the fingerprint is the identity, and a host that changed DHCP lease is the
|
||||
// whole reason `hosts add --fp` is idempotent in the first place. Without this a
|
||||
// moved host accumulates one record per address it has ever held, and the one a
|
||||
// stable id resolves to keeps the address it can no longer be reached at.
|
||||
if let Some(i) = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| !fp.is_empty() && h.fp_hex.eq_ignore_ascii_case(&fp))
|
||||
{
|
||||
let was = format!("{}:{}", known.hosts[i].addr, known.hosts[i].port);
|
||||
known.hosts[i].addr = addr.clone();
|
||||
known.hosts[i].port = port;
|
||||
return match known.save() {
|
||||
Ok(()) => {
|
||||
println!("moved {was} to {addr}:{port}");
|
||||
OK
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("saving: {e:#}");
|
||||
CONNECT_FAILED
|
||||
}
|
||||
};
|
||||
}
|
||||
known.hosts.push(KnownHost {
|
||||
name: value(args, "--name").unwrap_or_else(|| addr.clone()),
|
||||
name: name.unwrap_or_else(|| addr.clone()),
|
||||
addr: addr.clone(),
|
||||
port,
|
||||
fp_hex: value(args, "--fp").unwrap_or_default(),
|
||||
fp_hex: fp,
|
||||
..Default::default()
|
||||
});
|
||||
match known.save() {
|
||||
@@ -475,6 +670,55 @@ from the config directory for a true factory reset."
|
||||
}
|
||||
}
|
||||
|
||||
/// What `hosts add` did to a record that was ALREADY saved for this address.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum AddOutcome {
|
||||
/// Nothing to do — no fingerprint was offered, or the record already carries this one.
|
||||
/// Exits 0 on purpose: a panel retrying step 1 of request access must not have to
|
||||
/// invent an error to show for a state that is already correct.
|
||||
Unchanged,
|
||||
/// The record had no fingerprint and now has this one.
|
||||
Pinned,
|
||||
/// The record carries a DIFFERENT fingerprint. Refused, never overwritten.
|
||||
Conflict,
|
||||
}
|
||||
|
||||
/// `hosts add --fp` against an address that is already saved. The difference between these
|
||||
/// three is a trust decision, not bookkeeping.
|
||||
///
|
||||
/// Filling in an empty fingerprint is step 1 of request access (design §5): a host found by
|
||||
/// advert is saved by address first and pinned second. Without it the `--fp` is dropped on
|
||||
/// the floor and the launch that follows refuses for want of a pin — which is what this did
|
||||
/// before, silently and with exit 0.
|
||||
///
|
||||
/// A *different* fingerprint is refused because a changed identity is a decision for a
|
||||
/// person, at a surface that can show them both. That is what `upsert_trusted` exists to
|
||||
/// enforce; quietly overwriting it here would be a back door through the pinning the rest
|
||||
/// of the client is built on.
|
||||
fn merge_saved_host(
|
||||
known: &mut KnownHosts,
|
||||
i: usize,
|
||||
fp: &str,
|
||||
name: Option<&str>,
|
||||
) -> AddOutcome {
|
||||
let existing = known.hosts[i].fp_hex.clone();
|
||||
if fp.is_empty() || existing.eq_ignore_ascii_case(fp) {
|
||||
return AddOutcome::Unchanged;
|
||||
}
|
||||
if !existing.is_empty() {
|
||||
return AddOutcome::Conflict;
|
||||
}
|
||||
known.hosts[i].fp_hex = fp.to_string();
|
||||
// Only a record still named after its own address is renamed: a label the user chose is
|
||||
// theirs, and an advert's name must not quietly overwrite it.
|
||||
if let Some(label) = name {
|
||||
if known.hosts[i].name == known.hosts[i].addr {
|
||||
known.hosts[i].name = label.to_string();
|
||||
}
|
||||
}
|
||||
AddOutcome::Pinned
|
||||
}
|
||||
|
||||
/// `wake <host-ref> [--wait]` — a magic packet, and with `--wait` the same bounded
|
||||
/// wake-and-wait the shells run (`WakeWait`: a packet every 6 s, presence polled every
|
||||
/// second, 90 s budget).
|
||||
@@ -585,6 +829,19 @@ from the config directory for a true factory reset."
|
||||
eprintln!("usage: punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec]");
|
||||
return UNRESOLVED;
|
||||
};
|
||||
let exec = has(args, "--exec");
|
||||
let request_access = has(args, "--request-access");
|
||||
// Refused rather than silently downgraded: under `--exec` this process BECOMES the
|
||||
// session, so nothing survives to see `Ready` and record the approval. A launch that
|
||||
// quietly dropped the persistence would leave hosts reading "trusted" forever with
|
||||
// nobody able to say why.
|
||||
if request_access && exec {
|
||||
eprintln!(
|
||||
"--request-access can't be combined with --exec: under --exec there is no \
|
||||
process left to record the host's approval"
|
||||
);
|
||||
return UNRESOLVED;
|
||||
}
|
||||
let (known, i) = match resolve(&reference) {
|
||||
Ok(v) => v,
|
||||
Err(code) => return code,
|
||||
@@ -597,7 +854,10 @@ from the config directory for a true factory reset."
|
||||
if has(args, "--fullscreen") {
|
||||
plan.settings.fullscreen_on_stream = true;
|
||||
}
|
||||
run_plan(plan, has(args, "--exec"))
|
||||
if request_access {
|
||||
plan.connect_timeout_secs = Some(REQUEST_ACCESS_TIMEOUT_SECS);
|
||||
}
|
||||
run_plan(plan, exec, request_access)
|
||||
}
|
||||
|
||||
/// `open <url>` — the `punktfunk://` grammar, headless. Same parser, same refusal rules and
|
||||
@@ -622,7 +882,7 @@ from the config directory for a true factory reset."
|
||||
&trust::Settings::load(),
|
||||
);
|
||||
match outcome {
|
||||
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec")),
|
||||
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec"), false),
|
||||
// A URL may never pair or trust on its own — that is a decision for a person, at a
|
||||
// surface that can show them the fingerprint.
|
||||
Ok(PlanOutcome::ConfirmUnknown(u)) => {
|
||||
@@ -646,7 +906,13 @@ from the config directory for a true factory reset."
|
||||
}
|
||||
|
||||
/// Wake if needed, then run the session — supervising it, or becoming it under `--exec`.
|
||||
fn run_plan(plan: ConnectPlan, exec: bool) -> u8 {
|
||||
///
|
||||
/// `persist_paired` records the host as *paired* when the child reports ready. Only
|
||||
/// `launch --request-access` passes true: there, the host parked the connect until an
|
||||
/// operator approved this device, so `Ready` IS the approval arriving — the same thing
|
||||
/// `SpawnOpts::persist_paired` means in the GTK shell. Every other launch records nothing,
|
||||
/// which is correct: a plain connect proves reachability, not a new trust decision.
|
||||
fn run_plan(plan: ConnectPlan, exec: bool, persist_paired: bool) -> u8 {
|
||||
if plan.host.fp_hex.is_none() {
|
||||
eprintln!(
|
||||
"{} has no pinned fingerprint — punktfunk pair {}",
|
||||
@@ -708,7 +974,24 @@ from the config directory for a true factory reset."
|
||||
let mut failure: Option<(String, bool)> = None;
|
||||
while let Ok(ev) = rx.recv() {
|
||||
match ev {
|
||||
SessionEvent::Ready => eprintln!("streaming"),
|
||||
SessionEvent::Ready => {
|
||||
eprintln!("streaming");
|
||||
// The pin we connected WITH, not one re-derived from the store: the record
|
||||
// is what we are about to rewrite, and the session proved the host holds
|
||||
// exactly this identity by completing a pinned handshake against it.
|
||||
if persist_paired {
|
||||
if let Some(fp_hex) = &plan.host.fp_hex {
|
||||
trust::persist_host(
|
||||
&plan.host.name,
|
||||
&plan.host.addr,
|
||||
plan.host.port,
|
||||
fp_hex,
|
||||
true,
|
||||
);
|
||||
trust::forget_placeholder(&plan.host.addr, plan.host.port);
|
||||
}
|
||||
}
|
||||
}
|
||||
SessionEvent::Error {
|
||||
msg,
|
||||
trust_rejected,
|
||||
@@ -967,6 +1250,7 @@ from the config directory for a true factory reset."
|
||||
#[test]
|
||||
fn every_usage_verb_has_help() {
|
||||
for verb in [
|
||||
"discover",
|
||||
"pair",
|
||||
"hosts",
|
||||
"wake",
|
||||
@@ -988,6 +1272,109 @@ from the config directory for a true factory reset."
|
||||
assert!(verb_help("bogus").is_none());
|
||||
}
|
||||
|
||||
fn saved(name: &str, addr: &str, fp: &str) -> KnownHost {
|
||||
KnownHost {
|
||||
name: name.into(),
|
||||
addr: addr.into(),
|
||||
port: 9777,
|
||||
fp_hex: fp.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 1 of request access: a host saved by address gains the fingerprint its advert
|
||||
/// carried. Before this, `hosts add --fp` on an existing record exited 0 having done
|
||||
/// NOTHING — the launch that followed then refused for want of a pin, and the panel had
|
||||
/// no way to tell why.
|
||||
#[test]
|
||||
fn adding_a_fingerprint_to_a_placeholder_fills_it_in() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("192.168.1.9", "192.168.1.9", "")],
|
||||
};
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "abc123", Some("living-room")),
|
||||
AddOutcome::Pinned
|
||||
);
|
||||
assert_eq!(known.hosts[0].fp_hex, "abc123");
|
||||
assert_eq!(
|
||||
known.hosts[0].name, "living-room",
|
||||
"a record still named after its address takes the offered label"
|
||||
);
|
||||
}
|
||||
|
||||
/// A label the user chose is theirs — an advert's name must not overwrite it.
|
||||
#[test]
|
||||
fn filling_in_a_fingerprint_keeps_a_user_chosen_name() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("Basement rig", "192.168.1.9", "")],
|
||||
};
|
||||
merge_saved_host(&mut known, 0, "abc123", Some("living-room"));
|
||||
assert_eq!(known.hosts[0].name, "Basement rig");
|
||||
}
|
||||
|
||||
/// Idempotent: the panel may retry step 1, and re-offering the fingerprint a record
|
||||
/// already carries is a state that is already correct, not an error to render.
|
||||
#[test]
|
||||
fn re_adding_the_same_fingerprint_changes_nothing() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("desk", "192.168.1.9", "ABC123")],
|
||||
};
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "abc123", None),
|
||||
AddOutcome::Unchanged,
|
||||
"fingerprints compare case-insensitively"
|
||||
);
|
||||
// And a bare `hosts add` with no --fp at all leaves the pin alone.
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "", None),
|
||||
AddOutcome::Unchanged
|
||||
);
|
||||
assert_eq!(known.hosts[0].fp_hex, "ABC123");
|
||||
}
|
||||
|
||||
/// A changed identity is a decision for a person. Never a silent overwrite — this is the
|
||||
/// same rule `upsert_trusted` enforces, and a back door here would defeat it everywhere.
|
||||
#[test]
|
||||
fn a_different_fingerprint_is_refused_not_overwritten() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
|
||||
};
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "deadbeef", None),
|
||||
AddOutcome::Conflict
|
||||
);
|
||||
assert_eq!(
|
||||
known.hosts[0].fp_hex, "abc123",
|
||||
"the pin must survive intact"
|
||||
);
|
||||
}
|
||||
|
||||
/// A host that changed DHCP lease is re-pointed, not filed a second time. Without this
|
||||
/// the record a stable id resolves to keeps an address the host has left, so a launch
|
||||
/// dials into the void while the panel shows the live one.
|
||||
#[test]
|
||||
fn a_known_fingerprint_at_a_new_address_moves_the_record() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
|
||||
};
|
||||
// Simulates `hosts add 192.168.1.50 --fp abc123` finding no record at that address.
|
||||
let by_addr = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.addr == "192.168.1.50" && h.port == 9777);
|
||||
assert!(
|
||||
by_addr.is_none(),
|
||||
"the new address is not yet on any record"
|
||||
);
|
||||
let by_fp = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.fp_hex.eq_ignore_ascii_case("abc123"));
|
||||
assert_eq!(by_fp, Some(0), "the fingerprint still identifies the host");
|
||||
known.hosts[0].addr = "192.168.1.50".into();
|
||||
assert_eq!(known.hosts.len(), 1, "one host, one record");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_reads_the_argument_after_its_flag() {
|
||||
let a = argv(&["--game", "steam:570", "--exec"]);
|
||||
|
||||
@@ -67,3 +67,21 @@ fn unknown_verbs_refuse_with_the_not_found_code() {
|
||||
let out = punktfunk(&["help", "frobnicate"]);
|
||||
assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5");
|
||||
}
|
||||
|
||||
/// `discover` and `launch --request-access` document themselves. Help only — the verbs
|
||||
/// themselves browse the LAN and dial a host, which no runner may be asked to do.
|
||||
///
|
||||
/// The Decky panel detects a too-old client by exactly the signature the test above pins
|
||||
/// (exit 5 + `unknown command`), so this is the other half of that contract: on a client new
|
||||
/// enough, `discover` is a verb with help rather than an unknown word.
|
||||
#[test]
|
||||
fn the_request_access_surfaces_document_themselves() {
|
||||
let out = punktfunk(&["help", "discover"]);
|
||||
assert!(out.status.success(), "discover has its own help topic");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(stdout.contains("--timeout"), "discover documents --timeout");
|
||||
assert!(stdout.contains("--json"), "discover documents --json");
|
||||
|
||||
let out = punktfunk(&["launch", "--help"]);
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("--request-access"));
|
||||
}
|
||||
|
||||
+86
-49
@@ -2,42 +2,61 @@
|
||||
|
||||
Stream to your **Steam Deck** without ever leaving Gaming Mode. This
|
||||
**[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu
|
||||
(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch
|
||||
a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable.
|
||||
(the `…` button): the hosts you can stream, the pinned cards you set up, and one tap into each.
|
||||
|
||||
The video itself is the native GTK4 Linux client (the `io.unom.Punktfunk` flatpak); the plugin
|
||||
discovers, pairs, configures, and *launches it the right way* so gamescope fullscreens it — the same
|
||||
Steam-shortcut trick MoonDeck uses. Because it's built from real Steam UI primitives (`@decky/ui`),
|
||||
the panel looks and feels native to Gaming Mode.
|
||||
The plugin is a **launcher**, not a client. It doesn't decode video, browse your library, or hold
|
||||
any settings of its own — the Rust client does all of that, and the plugin's job is to start it
|
||||
*the right way* so gamescope fullscreens and focuses it (the same Steam-shortcut trick MoonDeck
|
||||
uses). Everything the panel doesn't do is one tap away in the client's own gamepad UI.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a
|
||||
fullscreen page; each host row opens a details view (address, pairing policy, certificate
|
||||
fingerprint to cross-check against the host's log).
|
||||
2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing
|
||||
ceremony headlessly, then remembers the host so future streams connect silently.
|
||||
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses it.
|
||||
4. **Games** — each host row has a games button that opens its **library picker**: pin titles as
|
||||
one-tap "Stream <Game>" rows in the QAM (jump straight into e.g. Playnite on the host), or
|
||||
**"Open library on screen"** to launch the client's controller-driven, console-style library
|
||||
browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive
|
||||
plugin reinstalls (stored next to the client's config) and follow a host across IP changes
|
||||
(matched by certificate fingerprint).
|
||||
5. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written
|
||||
to the client's config.
|
||||
6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
|
||||
a force-stop for a wedged stream client.
|
||||
1. **Hosts** — the hosts on your network plus the ones you've saved, in one list. Discovery is
|
||||
mDNS; saved hosts are also probed directly, so a box reached over Tailscale or a VPN shows as
|
||||
online even though it never advertises. Rows sort online-first, then most recently used.
|
||||
2. **Trust** — an unpaired host opens a small sheet with two ways in:
|
||||
- **Request access** (the default) — no PIN. The host's operator approves this Deck in its
|
||||
console or web UI and the stream starts by itself. See [Request access](#request-access).
|
||||
- **Use a PIN instead** — the gamepad-navigable keypad, running the same SPAKE2 ceremony.
|
||||
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses
|
||||
it. A sleeping host is woken first (the client runs the real wake-and-wait loop, then dials).
|
||||
4. **Pinned cards** — a *(host, profile)* pair renders nested under its host as `▸ <Profile name>`
|
||||
and streams with that settings profile applied. Cards are the **shared** pinning model every
|
||||
other client speaks, stored on the host's record — so one you make in the desktop client shows
|
||||
up here, and vice versa. The plugin renders them; it doesn't create or edit them.
|
||||
5. **Open Punktfunk** — launches the client's **console home**: the host picker, add-host by
|
||||
address, PIN pairing, the game library browser, and the **full settings screen**. This is where
|
||||
everything the panel no longer does now lives.
|
||||
6. **About** — plugin version, "Check for updates", "Recreate library shortcut", and a force-stop
|
||||
for a wedged stream.
|
||||
|
||||
To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the
|
||||
"game" from the Steam overlay — either returns you to Gaming Mode.
|
||||
|
||||
### Request access
|
||||
|
||||
Request access is not a second pairing ceremony — it is a **launch**. The plugin saves the host
|
||||
with the fingerprint it **advertised**, then starts an ordinary identified connect with the
|
||||
handshake budget stretched to 185 s. The host *parks* that connection until its operator approves
|
||||
the device, then admits the same connection; the stream starts on its own, and the record flips
|
||||
to **paired** so every later stream is silent.
|
||||
|
||||
**No advertised fingerprint, no request access.** That pinned fingerprint is the only thing
|
||||
standing between a 185-second wait and an impostor answering for the host, so a host you typed in
|
||||
by address gets the PIN path only — and the sheet says why. The plugin never trusts-on-first-use
|
||||
past a missing fingerprint.
|
||||
|
||||
## Install on the Deck
|
||||
|
||||
You need **[Decky Loader](https://decky.xyz/)** and the **`io.unom.Punktfunk` flatpak**
|
||||
([`packaging/flatpak`](../../packaging/flatpak/README.md)) installed on the Deck — SteamOS `/usr` is
|
||||
read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client. Discovery uses
|
||||
`avahi-browse`, which ships on SteamOS/Bazzite.
|
||||
You need **[Decky Loader](https://decky.xyz/)** and a **Punktfunk client** on the Deck. On a normal
|
||||
Deck that's the `io.unom.Punktfunk` flatpak ([`packaging/flatpak`](../../packaging/flatpak/README.md)) —
|
||||
SteamOS `/usr` is read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client.
|
||||
A native install (sysext, distro package, nix profile, your own build) works too.
|
||||
|
||||
**The client must be v0.22.0 or newer** — that is when the headless `punktfunk` CLI shipped, and
|
||||
the panel drives everything through it. An older client says so in the panel, with the update
|
||||
button that fixes it right there. (Discovery no longer needs `avahi-browse` on the Deck; the
|
||||
client's own mDNS does it.)
|
||||
|
||||
**Recommended — install from URL** (published by CI): in Decky → Settings → **Developer Mode** →
|
||||
**Install Plugin from URL**, paste:
|
||||
@@ -48,17 +67,15 @@ https://unom.io/pf-decky
|
||||
|
||||
(short link for `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip`;
|
||||
for a pinned version use `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/<version>/punktfunk.zip`
|
||||
directly). The plugin then **self-updates** without
|
||||
the Decky store — when a newer build exists, an **Update** button appears and drives Decky
|
||||
Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
|
||||
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
|
||||
before the actual download proceeds.
|
||||
directly). The plugin then **self-updates** without the Decky store — when a newer build exists, an
|
||||
**Update** button appears and drives Decky Loader's own (SHA-256-verified) install. Installs and
|
||||
updates can take a couple of minutes on some networks: Decky's installer also contacts its plugin
|
||||
store first, which may be slow or blackholed before the actual download proceeds.
|
||||
|
||||
### Updating the client
|
||||
|
||||
The plugin also reports — and where it can, installs — updates for the **client** it launches.
|
||||
What is possible depends on how that client was installed, and the About tab names the install
|
||||
kind so the answer is never a mystery:
|
||||
What is possible depends on how that client was installed:
|
||||
|
||||
| Install | Update |
|
||||
| --- | --- |
|
||||
@@ -81,6 +98,8 @@ pnpm install
|
||||
pnpm build # rollup → dist/index.js
|
||||
pnpm run package # → out/punktfunk/ + out/punktfunk-v<ver>.zip
|
||||
DECK=deck@<deck-ip> pnpm run deploy # rsync → /tmp, sudo-install into the root-owned plugins dir, restart loader
|
||||
|
||||
python3.13 scripts/test-backend.py # backend unit checks (needs Python ≥3.10)
|
||||
```
|
||||
|
||||
`~/homebrew/plugins/` is root-owned (the loader runs as root), so `deploy.sh` stages to a temp dir
|
||||
@@ -89,28 +108,46 @@ restart is required for an out-of-band install to appear.
|
||||
|
||||
## Architecture
|
||||
|
||||
Everything below the panel is the CLI. `main.py` builds argv and maps exit codes; it parses none of
|
||||
the client's data files and re-implements none of its rules.
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
|
||||
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
|
||||
| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
|
||||
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
|
||||
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
|
||||
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
|
||||
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: `PF_LAUNCH=<id>` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=<port>` (on-screen library); ids are validated space/quote-free at pin AND launch time. |
|
||||
| `src/backend.ts` | Typed `callable` bridges to `main.py`. |
|
||||
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable); maps `PF_LAUNCH`/`PF_BROWSE`/`PF_MGMT` to `--launch`/`--browse`/`--mgmt`. An older flatpak ignores the flags harmlessly (plain stream / hosts page). |
|
||||
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / `library` (headless flatpak `--library`, TSV) / pins store (`decky-pinned.json`) / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
|
||||
| `scripts/test-backend.py` | Stdlib-only checks for the backend's pure parsers (TSV, error classes, avahi TXT) + the pins round trip. |
|
||||
| `src/index.tsx` | Plugin entry + the QAM panel: update banner, hosts (with nested pinned cards), the console-home door, about. |
|
||||
| `src/hooks.ts` | `useHosts` (one call merging discovery and the saved store), the update hooks, and the launch action. Also the trust-state model the rows render. |
|
||||
| `src/trust.tsx` · `src/pair.tsx` | The trust sheet (Request access / Use a PIN instead / Cancel) and the gamepad-navigable PIN keypad. |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). |
|
||||
| `src/backend.ts` · `src/boundary.tsx` · `src/os-icon.tsx` | Typed `callable` bridges to `main.py`; the render error boundary; the host row's OS mark. |
|
||||
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). Reads `PF_REF` / `PF_PROFILE` / `PF_REQUEST_ACCESS` / `PF_BROWSE` and runs `punktfunk launch` — or the session's `--browse` for console home. |
|
||||
| `main.py` | Backend: four thin CLI shells (`discover` / `hosts` / `pair` / `trust_host`) plus the Steam-side work only a plugin can do — `runner_info`, `shortcut_art`, `apply_controller_config`, `kill_stream`, `check_update` / `update_client` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
|
||||
| `scripts/test-backend.py` | Stdlib-only checks: argv shape, the CLI exit-code mapping, and the Steam configset editor. |
|
||||
| `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
|
||||
|
||||
### Why the launch goes through Steam
|
||||
|
||||
gamescope only gives focus and fullscreen to the window tree Steam launched via `reaper` (it
|
||||
detects the "current app" by AppID — gamescope#484). A client spawned from the plugin's own
|
||||
backend comes up invisible and unfocused. So the plugin registers non-Steam shortcuts whose exe is
|
||||
`/bin/sh` running `bin/punktfunkrun.sh`, and starts them with `RunGame`.
|
||||
|
||||
There are **two** shortcuts, both named `Punktfunk` so Steam keys them to one Steam Input
|
||||
configset (the key is the lowercase name): a hidden, stateful one that carries the stream, and the
|
||||
visible, stateless library entry that opens console home.
|
||||
|
||||
## Limitations / next steps
|
||||
|
||||
- No manual "add host by IP" entry yet (discovery is mDNS-only).
|
||||
- No in-stream overlay inside the plugin — the client owns the session once launched.
|
||||
- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm
|
||||
it remotely.
|
||||
- **Profiles and pinned cards can't be created here** — the panel renders them; making one needs
|
||||
the desktop client, or the client's own gamepad UI once that work lands. A Deck with no profiles
|
||||
simply sees host rows, and nothing is broken.
|
||||
- **Per-game pins are on hold.** The shared model pins *host+profile*; nothing in the shared store
|
||||
persists a pinned *game* yet. The old `decky-pinned.json` is left on disk untouched so a later
|
||||
migration can read it.
|
||||
- Pairing with a PIN needs the operator to **arm pairing on the host** so it shows the PIN; the
|
||||
plugin can't arm it remotely. Request access needs no arming — just an approval.
|
||||
- **A parked connect looks like a hanging one.** The plugin toasts before launching a request-access
|
||||
stream to set expectations, which is a patch rather than a fix; teaching the session's connect
|
||||
screen the same "waiting for approval" copy the console shell already has would pay off for every
|
||||
shell.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates.
|
||||
# punktfunk stream runner — the target of the non-Steam shortcuts the plugin creates.
|
||||
#
|
||||
# WHY A WRAPPER SCRIPT (load-bearing, from MoonDeck's hard-won knowledge): the stream client
|
||||
# must be a descendant of the process Steam launches via `reaper`, or gamescope never gives
|
||||
# its window focus/fullscreen in Gaming Mode (gamescope detects the "current app" by AppID,
|
||||
# which only attaches to reaper's descendants — see gamescope#484). So the Decky plugin
|
||||
# launches THIS script through SteamClient.Apps.RunGame; the script then execs the flatpak
|
||||
# client, which inherits the shortcut's AppID and is focused. Launching the flatpak directly
|
||||
# from the (root) Decky backend produces an unfocused, invisible window.
|
||||
# launches THIS script through SteamClient.Apps.RunGame; the script then runs the client,
|
||||
# which inherits the shortcut's AppID and is focused. Launching the client directly from the
|
||||
# (root) Decky backend produces an unfocused, invisible window.
|
||||
#
|
||||
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
|
||||
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
|
||||
# every host (and every pinned game):
|
||||
# PF_HOST host[:port] to connect to (required for streaming; optional for browse)
|
||||
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
|
||||
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
||||
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
||||
# PF_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after
|
||||
# firing Wake-on-LAN so the connect survives the host's resume)
|
||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||
# every host:
|
||||
# PF_REF host reference — a saved host's stable id, or addr[:port] (required to stream)
|
||||
# PF_PROFILE settings-profile id for a pinned card (optional)
|
||||
# PF_REQUEST_ACCESS non-empty = ask the host's operator to admit this device instead of
|
||||
# pairing with a PIN. The connect PARKS until somebody approves it.
|
||||
# PF_BROWSE non-empty = open the client's console home instead of streaming
|
||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||
# PF_CLIENT_BIN absolute path of a NATIVE client (optional; set by the plugin when it
|
||||
# resolved a non-flatpak install — then the client is exec'd directly and
|
||||
# resolved a non-flatpak install — then the client is run directly and
|
||||
# PF_APPID/PF_FLATPAK are unused)
|
||||
#
|
||||
# Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before
|
||||
# they ever reach Steam launch options). An older flatpak without --launch/--browse ignores
|
||||
# the unknown flags harmlessly (hand-scanned argv): PF_LAUNCH degrades to the plain desktop
|
||||
# session, PF_BROWSE to the client's hosts page.
|
||||
# A REFERENCE, NEVER A VALUE. Host refs and profile ids are the only things that ride this
|
||||
# channel; no resolution, bitrate or codec ever does. The client resolves both against its own
|
||||
# stores, which is what keeps a Steam launch option from becoming a second settings surface.
|
||||
# The plugin validates them to space/quote-free ASCII before they reach Steam's tokenizer.
|
||||
#
|
||||
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
|
||||
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
|
||||
@@ -42,13 +41,22 @@ APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||
FLATPAK="${PF_FLATPAK:-flatpak}"
|
||||
|
||||
# The client is not always the flatpak: a sysext, a .deb/.rpm, an AUR build or a nix profile
|
||||
# installs a native `punktfunk-client`, and the plugin passes its absolute path here when that
|
||||
# is what it resolved. Both kinds take the same argv and share ~/.config/punktfunk, so the only
|
||||
# difference is the prefix in front of it.
|
||||
# installs a native `punktfunk-client` with the CLI as its sibling, and the plugin passes the
|
||||
# client's absolute path here when that is what it resolved.
|
||||
#
|
||||
# exec so the client IS the game process — when it exits, Steam ends the "game" and Gaming Mode
|
||||
# reclaims focus automatically (no manual refocus needed).
|
||||
run_client() {
|
||||
# run_cli execs the HEADLESS CLI (`punktfunk`); run_session execs the GTK/console shell
|
||||
# (`punktfunk-client`). Both live in the same place in both install kinds — /app/bin inside the
|
||||
# flatpak, reachable with `--command=`, and one bindir natively.
|
||||
run_cli() {
|
||||
if [ -n "${PF_CLIENT_BIN:-}" ]; then
|
||||
# `${VAR%/*}` rather than `dirname`: pure parameter expansion, so this works with no
|
||||
# PATH at all — which is the environment a Steam launch option can leave us in.
|
||||
exec "${PF_CLIENT_BIN%/*}/punktfunk" "$@"
|
||||
fi
|
||||
exec "$FLATPAK" run --arch=x86_64 --command=punktfunk "$APPID" "$@"
|
||||
}
|
||||
|
||||
run_session() {
|
||||
if [ -n "${PF_CLIENT_BIN:-}" ]; then
|
||||
exec "$PF_CLIENT_BIN" "$@"
|
||||
fi
|
||||
@@ -58,40 +66,35 @@ run_client() {
|
||||
# What we are about to run, for the log line each branch prints.
|
||||
CLIENT_LABEL="${PF_CLIENT_BIN:-$APPID}"
|
||||
|
||||
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
|
||||
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
|
||||
# The console home: the client's own gamepad UI (host picker, pairing, add-host by address, the
|
||||
# library browser and the full settings screen). UNCHANGED from before this rework — the shell
|
||||
# binary already execs the session for `--browse`, so there is nothing to repoint here.
|
||||
if [ -n "${PF_BROWSE:-}" ]; then
|
||||
# The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained
|
||||
# host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible
|
||||
# library shortcut launches. `--browse <host>` opens straight into that host's library (the
|
||||
# per-host "open on screen" action). A streams a game, session end returns here, B quits.
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
|
||||
run_client --browse --fullscreen
|
||||
fi
|
||||
echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2
|
||||
if [ -n "${PF_MGMT:-}" ]; then
|
||||
run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
||||
fi
|
||||
run_client --browse "$PF_HOST" --fullscreen
|
||||
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
|
||||
run_session --browse --fullscreen
|
||||
fi
|
||||
|
||||
# Streaming modes need a host (browse above is the only host-less path).
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
||||
if [ -z "${PF_REF:-}" ]; then
|
||||
echo "punktfunkrun: PF_REF is not set (the plugin sets it as a launch option)" >&2
|
||||
exit 2
|
||||
fi
|
||||
# Trailing args shared by both streaming execs. A stretched connect budget rides along when the
|
||||
# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak
|
||||
# without --connect-timeout ignores the flag harmlessly (hand-scanned argv).
|
||||
|
||||
set -- --fullscreen
|
||||
if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then
|
||||
set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@"
|
||||
if [ -n "${PF_PROFILE:-}" ]; then
|
||||
set -- --profile "$PF_PROFILE" "$@"
|
||||
fi
|
||||
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||
run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
|
||||
|
||||
# REQUEST ACCESS RUNS SUPERVISED — no `--exec`. Under --exec the CLI BECOMES the session, so no
|
||||
# process survives to see the stream come up and record the host as paired; the CLI refuses the
|
||||
# combination outright rather than downgrading silently. This is safe for gamescope because
|
||||
# focus follows reaper's DESCENDANT TREE, not a single process, and `flatpak run`/`bwrap`
|
||||
# already sit between reaper and the client on every other path.
|
||||
if [ -n "${PF_REQUEST_ACCESS:-}" ]; then
|
||||
echo "punktfunkrun: request access $CLIENT_LABEL launch $PF_REF (waiting for approval)" >&2
|
||||
run_cli launch "$PF_REF" --request-access "$@"
|
||||
fi
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2
|
||||
run_client --connect "$PF_HOST" "$@"
|
||||
|
||||
# The ordinary stream. `--exec` is the documented gamescope-wrapper mode: the CLI becomes the
|
||||
# session, so the process tree stays flat and Steam's "game" ends exactly when the stream does.
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL launch $PF_REF" >&2
|
||||
run_cli launch "$PF_REF" --exec "$@"
|
||||
|
||||
+274
-584
@@ -6,33 +6,39 @@ STREAM is NOT launched here — it is launched by the frontend through Steam
|
||||
(SteamClient.Apps.RunGame on a hidden non-Steam shortcut that points at ``bin/punktfunkrun.sh``),
|
||||
because gamescope only focuses/fullscreens windows in the process tree Steam launched via
|
||||
``reaper``. A flatpak spawned from this backend would be invisible/unfocused (gamescope#484).
|
||||
The backend's jobs are the things Steam can't do:
|
||||
This backend is a THIN SHELL OVER THE HEADLESS CLI (``punktfunk``, shipped in every package
|
||||
since v0.22.0), plus the handful of things that are genuinely Steam's business. It used to be
|
||||
a second client — its own mDNS parser, its own host-store editor, its own settings writer —
|
||||
and every one of those was a copy of a rule that already lives in Rust, drifting from it. The
|
||||
rule now has one home; this file builds argv and maps exit codes.
|
||||
|
||||
* **discover()** — browse the LAN over mDNS (``avahi-browse``) for ``_punktfunk._udp`` hosts.
|
||||
* **pair(host, port, pin, name)** — run the SPAKE2 PIN ceremony headlessly via the flatpak
|
||||
client's ``--pair`` mode, capturing the result. Pairing uses the SAME flatpak (so the same
|
||||
identity store the stream uses), so once paired the stream connects silently.
|
||||
* **library(host, mgmt_port, fp)** — fetch a paired host's game library headlessly via the
|
||||
flatpak client's ``--library`` mode (mTLS with the client's own identity; TSV on stdout),
|
||||
so the picker UI can offer games to pin.
|
||||
* **get_pins() / set_pins()** — the pinned-games store (``decky-pinned.json`` next to the
|
||||
client's config, so pins survive plugin reinstalls), annotated with live pairing state.
|
||||
* **runner_info()** — the absolute path to the launch wrapper + the flatpak app id, handed to
|
||||
the frontend so it can create/point the Steam shortcut.
|
||||
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
|
||||
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
|
||||
* **kill_stream()** — force-stop a wedged stream (``flatpak kill``).
|
||||
* **check_update()** — report pending updates for BOTH the plugin and the client. The plugin's
|
||||
comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own
|
||||
install RPC to apply it); the client's depends on how it was installed — a flatpak is compared
|
||||
by OSTree commit here, anything else is asked of the client itself
|
||||
(``punktfunk-client --check-update``, which verifies a signed manifest).
|
||||
* **update_client()** — apply the client update by whichever route that install supports:
|
||||
``flatpak update --user``, ``punktfunk-client --apply-update`` (the packaged root helper), or
|
||||
a refusal carrying the command to run by hand.
|
||||
Thin CLI shells — each is build argv, run, parse JSON, map the exit code:
|
||||
|
||||
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by
|
||||
the host advert in ``crates/punktfunk-host/src/discovery.rs``.
|
||||
* **discover()** — ``punktfunk discover --json``: the LAN's hosts, already annotated with
|
||||
whether this device has them saved and paired.
|
||||
* **hosts()** — ``punktfunk hosts list --probe --json``: the saved hosts with a live,
|
||||
mDNS-independent reachability probe, and their profile bindings and pinned cards already
|
||||
resolved against the profile catalog.
|
||||
* **pair(addr, port, pin, name)** — ``punktfunk pair``: the SPAKE2 PIN ceremony.
|
||||
* **trust_host(addr, port, fp, name)** — ``punktfunk hosts add --fp``: step 1 of request
|
||||
access, and the ONLY write this backend makes to the client's store.
|
||||
|
||||
Kept because only a Decky plugin can do them:
|
||||
|
||||
* **runner_info()** — resolve flatpak vs native and hand the frontend the wrapper path.
|
||||
* **shortcut_art()** — base64 grid/hero/logo + icon path for the Steam shortcut.
|
||||
* **apply_controller_config()** — write the native-touch layout into every Steam account's
|
||||
configset dir, chowned back to the user (this backend is root; Steam is not).
|
||||
* **check_update() / update_client()** — the plugin's own registry manifest (Decky's install
|
||||
RPC needs artifact + SHA-256) and the client's update route.
|
||||
* **kill_stream()** — force-stop a wedged client.
|
||||
|
||||
What is deliberately NOT here: the stream launch. It goes through Steam
|
||||
(SteamClient.Apps.RunGame on a non-Steam shortcut pointing at ``bin/punktfunkrun.sh``),
|
||||
because gamescope only focuses/fullscreens windows in the process tree Steam launched via
|
||||
``reaper`` — a client spawned from this backend would come up invisible and unfocused
|
||||
(gamescope#484). Settings, add-host-by-address, the library browser and profile editing are
|
||||
not here either: they are one shortcut away in the client's own console home.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -50,51 +56,11 @@ import decky
|
||||
# Flatpak application id of the GTK client (packaging/flatpak/io.unom.Punktfunk.yml).
|
||||
APP_ID = "io.unom.Punktfunk"
|
||||
|
||||
# Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host).
|
||||
SERVICE_TYPE = "_punktfunk._udp"
|
||||
|
||||
# The flatpak client persists identity / known-hosts / settings under HOME/.config/punktfunk.
|
||||
# The sandbox HOME resolves to the REAL user home (== DECKY_USER_HOME), NOT the per-app
|
||||
# ~/.var/app/<APP_ID> dir — verified on-device (`flatpak run … sh -c 'echo $HOME'` prints
|
||||
# /home/deck, and the manifest's `--filesystem=~/.config/punktfunk` grants exactly that path;
|
||||
# we also pass HOME=DECKY_USER_HOME into `flatpak run`, see _flatpak_env). Pointing here is what
|
||||
# lets plugin settings actually reach the client AND lets us read the client's known-hosts to
|
||||
# tell whether THIS device is already paired with a given host.
|
||||
def _client_config_dir() -> Path:
|
||||
return Path(decky.DECKY_USER_HOME) / ".config" / "punktfunk"
|
||||
|
||||
|
||||
def _settings_path() -> Path:
|
||||
return _client_config_dir() / "client-gtk-settings.json"
|
||||
|
||||
|
||||
def _paired_fingerprints() -> set[str]:
|
||||
"""Host cert fingerprints (lowercase hex) this client has PIN-paired, from the client's
|
||||
known-hosts store. Keyed by fingerprint so it survives a host changing IP address."""
|
||||
try:
|
||||
data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return set()
|
||||
hosts = data.get("hosts", []) if isinstance(data, dict) else []
|
||||
return {
|
||||
h["fp_hex"].lower()
|
||||
for h in hosts
|
||||
if isinstance(h, dict) and h.get("paired") and isinstance(h.get("fp_hex"), str)
|
||||
}
|
||||
|
||||
|
||||
def _runner_path() -> str:
|
||||
"""Absolute path to the launch wrapper shipped with the plugin (bin/punktfunkrun.sh)."""
|
||||
return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh")
|
||||
|
||||
|
||||
def _pins_path() -> Path:
|
||||
"""The pinned-games store — plugin-owned, but deliberately in the CLIENT's config dir
|
||||
(like everything else we persist): the plugins dir is root-owned and wiped on
|
||||
reinstall, while ``~/.config/punktfunk`` survives both."""
|
||||
return _client_config_dir() / "decky-pinned.json"
|
||||
|
||||
|
||||
# --- Steam Input controller config injection (native touchscreen via the ts_n command) --------
|
||||
# The Deck's touchscreen only reaches the app as native wl_touch when a Steam Input layout with
|
||||
# the "Touchscreen Native Support" (controller_action ts_n) command is active for the game. We
|
||||
@@ -182,39 +148,6 @@ def _upsert_configset_entry(text: str, key: str, source_type: str, source_val: s
|
||||
return text[:last_close] + block + text[last_close:]
|
||||
|
||||
|
||||
def _parse_library_tsv(stdout: str) -> list[dict]:
|
||||
"""Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per
|
||||
game plus a trailing ``N game(s)`` count line (no tabs — it self-skips here). A title
|
||||
may itself contain tabs, so split at most twice."""
|
||||
games: list[dict] = []
|
||||
for line in stdout.splitlines():
|
||||
parts = line.split("\t", 2)
|
||||
if len(parts) == 3:
|
||||
games.append({"id": parts[0], "store": parts[1], "title": parts[2]})
|
||||
return games
|
||||
|
||||
|
||||
def _classify_library_error(stderr: str) -> str:
|
||||
"""Map the client's ``library: <LibraryError Display>`` stderr line to a stable error
|
||||
code for the UI. Substring-matched against the Display strings in
|
||||
``crates/pf-client-core/src/library.rs`` — a wording change degrades to ``client-error``
|
||||
(generic copy), never a crash."""
|
||||
s = stderr.lower()
|
||||
if "didn't recognize this device" in s:
|
||||
return "not-paired"
|
||||
if "pinned fingerprint" in s:
|
||||
return "pin-mismatch"
|
||||
if "couldn't reach the host" in s:
|
||||
return "unreachable"
|
||||
if "management api returned http" in s:
|
||||
return "http"
|
||||
if "display" in s or "gtk" in s:
|
||||
# A flatpak so old it predates --library falls through to GTK init, which fails
|
||||
# headless from this backend.
|
||||
return "client-outdated"
|
||||
return "client-error"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
# Self-update check (no Decky store). The plugin is distributed via "Install Plugin from
|
||||
# URL" pointing at our Gitea generic registry, so the official store never sees it and
|
||||
@@ -343,6 +276,10 @@ def _flatpak() -> str | None:
|
||||
# settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real
|
||||
# home), so nothing else in this file has to care which one answered.
|
||||
NATIVE_BIN = "punktfunk-client"
|
||||
# The headless CLI — the door this backend does almost everything through (discover, hosts,
|
||||
# pair, trust). Shipped beside the GTK client in every package since v0.22.0: /app/bin in the
|
||||
# flatpak, the same bindir as `punktfunk-client` natively.
|
||||
CLI_BIN = "punktfunk"
|
||||
|
||||
# Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and
|
||||
# SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix.
|
||||
@@ -398,6 +335,109 @@ def _client_argv() -> list[str] | None:
|
||||
return [native] if native else None
|
||||
|
||||
|
||||
def _cli_argv() -> list[str] | None:
|
||||
"""The argv PREFIX that runs the headless CLI, or None when no client is installed.
|
||||
|
||||
Exactly the shape the old ``_session_argv`` used, pointed at ``punktfunk`` instead: the
|
||||
flatpak ships both binaries in /app/bin so ``--command=`` picks the other one (**the app id
|
||||
stays LAST** — flatpak treats everything after it as the app's own argv), and a native
|
||||
install puts the CLI in the same bindir as ``punktfunk-client``, so it is its sibling.
|
||||
"""
|
||||
prefix = _client_argv()
|
||||
if not prefix:
|
||||
return None
|
||||
if prefix[0] == _flatpak():
|
||||
return [*prefix[:-1], f"--command={CLI_BIN}", prefix[-1]]
|
||||
sibling = Path(prefix[0]).with_name(CLI_BIN)
|
||||
return [str(sibling)] if sibling.exists() else None
|
||||
|
||||
|
||||
async def _run_cli(args: list[str], timeout: float = 20.0) -> tuple[int, str, str]:
|
||||
"""Run the headless CLI, returning ``(returncode, stdout, stderr)``. SEPARATE pipes: stdout
|
||||
is the machine interface (JSON/TSV) and stderr carries the log lines, and merging them would
|
||||
corrupt every payload. ``(-1, "", "")`` when no client is installed or the call times out.
|
||||
|
||||
The same ``_flatpak_env`` repair the client runs needed applies here unchanged — Decky's
|
||||
PyInstaller ``LD_LIBRARY_PATH`` leak breaks the flatpak's libcurl whatever binary inside the
|
||||
sandbox is being started."""
|
||||
prefix = _cli_argv()
|
||||
if not prefix:
|
||||
return -1, "", ""
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*prefix, *args,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
rc = proc.returncode if proc.returncode is not None else -1
|
||||
return (
|
||||
rc,
|
||||
(out or b"").decode("utf-8", "replace"),
|
||||
(err or b"").decode("utf-8", "replace"),
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
decky.logger.warning("cli %s timed out", " ".join(args))
|
||||
if proc:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return -1, "", ""
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("cli %s failed", " ".join(args))
|
||||
return -1, "", ""
|
||||
|
||||
|
||||
# The CLI's exit-code contract (clients/cli/src/main.rs): 0 ok, 2 connect failed, 3 trust
|
||||
# rejected, 4 renderer, 5 could not resolve what was asked for, 6 needs a person. Mapped to the
|
||||
# stable strings the panel renders, so a reworded message can never change what the UI shows.
|
||||
_CLI_ERRORS = {
|
||||
2: "unreachable",
|
||||
3: "refused",
|
||||
5: "unresolved",
|
||||
6: "needs-pairing",
|
||||
}
|
||||
|
||||
|
||||
def _cli_error(rc: int, stderr: str) -> str:
|
||||
"""One stable error code for a nonzero CLI exit.
|
||||
|
||||
The interesting case is a client too old for the verb we just used. That announces itself
|
||||
DETERMINISTICALLY — exit 5 plus ``unknown command "<verb>"`` on stderr — rather than by the
|
||||
guesswork the GTK headless modes needed, so the panel can say "update the client" with
|
||||
confidence and offer the button that fixes it."""
|
||||
if rc == -1:
|
||||
return "client-unavailable"
|
||||
if rc == 5 and "unknown command" in stderr:
|
||||
return "client-outdated"
|
||||
return _CLI_ERRORS.get(rc, "client-error")
|
||||
|
||||
|
||||
async def _cli_json(args: list[str], timeout: float = 20.0) -> dict:
|
||||
"""Run the CLI and parse its stdout as JSON. ``{"ok": True, **payload}`` on success, else
|
||||
``{"ok": False, "error": <code>, "detail": <the CLI's last stderr line>}``.
|
||||
|
||||
A zero exit with unparseable stdout is a failure, not an empty result: silently returning
|
||||
"no hosts" for a broken client is exactly the answer a user cannot debug."""
|
||||
rc, out, err = await _run_cli(args, timeout=timeout)
|
||||
if rc == 0:
|
||||
try:
|
||||
data = json.loads(out)
|
||||
if isinstance(data, dict):
|
||||
# `ok` last: a payload that ever grows its own `ok` key must not be able to
|
||||
# report failure through the field this layer owns.
|
||||
return {**data, "ok": True}
|
||||
except json.JSONDecodeError:
|
||||
decky.logger.warning("cli %s: unparseable output: %s", args[0], out[:200])
|
||||
return {"ok": False, "error": "client-error", "detail": "unreadable output"}
|
||||
code = _cli_error(rc, err)
|
||||
detail = (err.strip().splitlines() or [f"{args[0]} failed"])[-1]
|
||||
decky.logger.warning("cli %s failed (rc=%s, %s): %s", args[0], rc, code, detail)
|
||||
return {"ok": False, "error": code, "detail": detail}
|
||||
|
||||
|
||||
def _client_is_flatpak() -> bool:
|
||||
"""Is the client this plugin actually drives the FLATPAK one?
|
||||
|
||||
@@ -511,59 +551,6 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in
|
||||
return -1, "", ""
|
||||
|
||||
|
||||
# The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the
|
||||
# QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability
|
||||
# probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any
|
||||
# mutation (add/edit/forget/reset/pair) invalidates it so a change shows up immediately.
|
||||
_HOSTS_TTL_S = 12.0
|
||||
_hosts_cache: dict = {"at": 0.0, "probed": None, "data": None}
|
||||
|
||||
|
||||
def _invalidate_hosts_cache() -> None:
|
||||
_hosts_cache["data"] = None
|
||||
|
||||
|
||||
def _read_known_hosts() -> list[dict]:
|
||||
"""The saved-hosts store read straight off disk — the fallback for a client too old to have
|
||||
``--list-hosts``. Same file the desktop client owns; `online` is left ``None`` (unknown)
|
||||
because a direct read has no reachability signal."""
|
||||
try:
|
||||
data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
hosts = data.get("hosts", []) if isinstance(data, dict) else []
|
||||
out: list[dict] = []
|
||||
for h in hosts:
|
||||
if not isinstance(h, dict) or not h.get("addr"):
|
||||
continue
|
||||
out.append({
|
||||
"name": str(h.get("name") or h.get("addr", "")),
|
||||
"addr": str(h.get("addr", "")),
|
||||
"port": int(h.get("port", 9777) or 9777),
|
||||
"fp_hex": str(h.get("fp_hex", "")),
|
||||
"paired": bool(h.get("paired", False)),
|
||||
"mac": h.get("mac") if isinstance(h.get("mac"), list) else [],
|
||||
"last_used": h.get("last_used"),
|
||||
"online": None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _mutation_result(rc: int, err: str, op: str) -> dict:
|
||||
"""Map a headless host-store mutation's exit status to a UI-stable result. ``rc == -1`` means
|
||||
the flatpak call never ran (missing/timed out); a nonzero rc from a client that PREDATES the
|
||||
mode falls through to GTK init and fails headless — classified ``client-outdated`` so the UI
|
||||
can prompt an update instead of showing a cryptic error."""
|
||||
if rc == 0:
|
||||
return {"ok": True}
|
||||
if rc == -1:
|
||||
return {"ok": False, "error": "client-unavailable"}
|
||||
code = _classify_library_error(err)
|
||||
detail = (err.strip().splitlines() or [f"{op} failed"])[-1]
|
||||
decky.logger.warning("%s failed (rc=%s): %s", op, rc, detail)
|
||||
return {"ok": False, "error": code, "detail": detail}
|
||||
|
||||
|
||||
def _field_from(text: str, name: str) -> str:
|
||||
"""Pull ``<name>: value`` out of ``flatpak info`` / ``remote-info`` output (e.g. ``Commit``,
|
||||
``Origin``)."""
|
||||
@@ -575,6 +562,18 @@ def _field_from(text: str, name: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _looks_outdated(stderr: str) -> bool:
|
||||
"""Does this stderr have the signature of a client too old for the headless flag it was just
|
||||
handed? Such a client ignores the unknown flag and falls through to GTK init, which fails
|
||||
with no display — so the give-away is display/GTK noise rather than anything about the flag.
|
||||
|
||||
Narrow on purpose: the CLI announces the same condition deterministically (exit 5 plus
|
||||
``unknown command``, see :func:`_cli_error`), and this heuristic is only still here because
|
||||
the update check drives the GTK client's ``--check-update``, not the CLI."""
|
||||
s = stderr.lower()
|
||||
return "display" in s or "gtk" in s
|
||||
|
||||
|
||||
async def _client_update_state() -> dict:
|
||||
"""Is a newer commit of the flatpak client available in the remote it tracks? The client is a
|
||||
**per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and
|
||||
@@ -642,312 +641,103 @@ async def _native_update_state() -> dict:
|
||||
if rc == -1:
|
||||
return {}
|
||||
# A client predating `--check-update` ignores the flag and falls through to GTK init, which
|
||||
# fails headless — the same signature the other headless modes classify.
|
||||
code = _classify_library_error(err)
|
||||
decky.logger.info("native check-update unavailable (rc=%s, %s)", rc, code)
|
||||
return {"error": code} if code == "client-outdated" else {}
|
||||
# fails headless — that is the signature, and it is the one thing worth reporting here.
|
||||
outdated = _looks_outdated(err)
|
||||
decky.logger.info("native check-update unavailable (rc=%s, outdated=%s)", rc, outdated)
|
||||
return {"error": "client-outdated"} if outdated else {}
|
||||
|
||||
|
||||
def _split_txt(txt: str) -> list[str]:
|
||||
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
|
||||
tokens: list[str] = []
|
||||
cur: list[str] = []
|
||||
in_quote = False
|
||||
for ch in txt:
|
||||
if ch == '"':
|
||||
if in_quote:
|
||||
tokens.append("".join(cur))
|
||||
cur = []
|
||||
in_quote = not in_quote
|
||||
elif in_quote:
|
||||
cur.append(ch)
|
||||
if cur:
|
||||
tokens.append("".join(cur))
|
||||
return tokens
|
||||
|
||||
|
||||
def _parse_avahi_browse(stdout: str) -> list[dict]:
|
||||
"""Parse ``avahi-browse -rpt`` output into a list of host dicts (deduped on the TXT ``id``)."""
|
||||
out: dict[str, dict] = {}
|
||||
for raw in stdout.splitlines():
|
||||
line = raw.strip()
|
||||
if not line.startswith("="):
|
||||
continue
|
||||
parts = line.replace("\\;", "\x00").split(";")
|
||||
parts = [p.replace("\x00", ";") for p in parts]
|
||||
if len(parts) < 9:
|
||||
continue
|
||||
|
||||
name = parts[3]
|
||||
address = parts[7]
|
||||
port_str = parts[8]
|
||||
txt = parts[9] if len(parts) > 9 else ""
|
||||
|
||||
try:
|
||||
port = int(port_str)
|
||||
except ValueError:
|
||||
port = 0
|
||||
|
||||
props: dict[str, str] = {}
|
||||
for token in _split_txt(txt):
|
||||
if "=" in token:
|
||||
k, v = token.split("=", 1)
|
||||
props[k] = v
|
||||
|
||||
if props.get("proto") and not props["proto"].startswith("punktfunk/"):
|
||||
continue
|
||||
|
||||
try:
|
||||
mgmt = int(props.get("mgmt", ""))
|
||||
except ValueError:
|
||||
mgmt = 0 # not advertised (standalone punktfunk1-host) — callers default 47990
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"host": address,
|
||||
"port": port,
|
||||
"pair": props.get("pair", "optional"),
|
||||
"fp": props.get("fp", ""),
|
||||
"proto": props.get("proto", ""),
|
||||
"id": props.get("id", ""),
|
||||
"mgmt": mgmt,
|
||||
# OS-identity chain for the host row's icon (e.g. "linux/fedora/bazzite");
|
||||
# empty on an older host that doesn't advertise it.
|
||||
"os": props.get("os", ""),
|
||||
}
|
||||
key = props.get("id") or f"{address}:{port}"
|
||||
existing = out.get(key)
|
||||
# Prefer IPv4 over IPv6 for the user-facing host string.
|
||||
if existing is None or (":" in existing["host"] and ":" not in address):
|
||||
out[key] = entry
|
||||
|
||||
return list(out.values())
|
||||
def _ctl_sockets() -> list[Path]:
|
||||
"""Candidate paths of the streaming client's control socket (guide/QAM injection):
|
||||
the flatpak app runtime dir first (the one runtime path the sandbox and this backend
|
||||
see identically), then the plain runtime dir (native installs). Mirrors the session
|
||||
binary's ``ctl_socket::path``."""
|
||||
uid = os.environ.get("PF_UID") or "1000"
|
||||
run = Path(f"/run/user/{uid}")
|
||||
return [
|
||||
run / "app" / APP_ID / "punktfunk-session-ctl.sock",
|
||||
run / "punktfunk-session-ctl.sock",
|
||||
]
|
||||
|
||||
|
||||
class Plugin:
|
||||
async def discover(self) -> list[dict]:
|
||||
"""Browse the LAN for punktfunk/1 hosts. Returns ``[{name, host, port, pair, fp}]``."""
|
||||
avahi = shutil.which("avahi-browse")
|
||||
if not avahi:
|
||||
decky.logger.error("avahi-browse not found; install avahi for host discovery")
|
||||
return []
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
avahi, "-rpt", SERVICE_TYPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=8.0)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
decky.logger.warning("avahi-browse timed out")
|
||||
return []
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("avahi-browse failed")
|
||||
return []
|
||||
if stderr:
|
||||
decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace"))
|
||||
hosts = _parse_avahi_browse(stdout.decode(errors="replace"))
|
||||
# Mark which hosts THIS device has already paired (by cert fingerprint), so the UI can
|
||||
# show "Stream" instead of "Pair" — the mDNS `pair` field is the host's policy, not our
|
||||
# per-device pairing state.
|
||||
paired = _paired_fingerprints()
|
||||
for h in hosts:
|
||||
fp = h.get("fp") or ""
|
||||
h["paired"] = bool(fp) and fp.lower() in paired
|
||||
decky.logger.info("discovered %d punktfunk host(s)", len(hosts))
|
||||
return hosts
|
||||
# ---- Thin shells over the headless CLI -------------------------------------------------
|
||||
#
|
||||
# Each is "build argv, run, parse JSON, map the exit code". No parsing of the client's data
|
||||
# files happens here and no trust rule is re-implemented here: this backend exists because
|
||||
# Decky's frontend cannot spawn processes, not because it knows anything the client doesn't.
|
||||
|
||||
async def pair(self, host: str, port: int, pin: str, name: str = "Steam Deck") -> dict:
|
||||
"""Run the SPAKE2 PIN ceremony headlessly via the flatpak client's ``--pair`` mode.
|
||||
async def discover(self) -> dict:
|
||||
"""Browse the LAN for hosts (``punktfunk discover --json``).
|
||||
|
||||
The user arms pairing on the HOST (which displays a 4-digit PIN) and enters it here.
|
||||
On success the flatpak persists the host to its known-hosts as paired, so a later
|
||||
stream connects silently. Returns ``{ok, fp?, error?}``.
|
||||
"""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
return {"ok": False, "error": "flatpak-not-found"}
|
||||
argv = [
|
||||
flatpak, "run", "--arch=x86_64", APP_ID,
|
||||
"--pair", str(pin).strip(),
|
||||
"--connect", f"{host}:{port}",
|
||||
"--name", name,
|
||||
"--host-label", host,
|
||||
]
|
||||
decky.logger.info("pairing: %s", " ".join(argv[:6] + ["<pin>", "--connect", f"{host}:{port}"]))
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=100.0)
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "error": "pairing timed out"}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
decky.logger.exception("pairing failed to launch")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
``{ok: True, hosts: [{name, addr, port, fp, pair, id, mgmt, os, saved, paired}]}``, or
|
||||
``{ok: False, error}`` — ``client-outdated`` when the installed client predates the
|
||||
verb, which the panel renders as one explanatory row plus the update button.
|
||||
|
||||
out = stdout.decode(errors="replace")
|
||||
err = stderr.decode(errors="replace")
|
||||
if proc.returncode == 0 and "paired " in out:
|
||||
The 12 s budget covers a cold flatpak start on top of the CLI's own 3 s browse."""
|
||||
return await _cli_json(["discover", "--json"], timeout=12.0)
|
||||
|
||||
async def hosts(self) -> dict:
|
||||
"""The saved hosts with a live reachability probe
|
||||
(``punktfunk hosts list --probe --json``).
|
||||
|
||||
``--probe`` asks each host directly rather than waiting for an advert, so a host reached
|
||||
over a routed network (Tailscale/VPN) reports online instead of looking dead. Profile
|
||||
bindings and pinned cards come back already resolved against the profile catalog —
|
||||
dangling ids dropped, names attached — so the panel renders them without ever opening
|
||||
``client-profiles.json``."""
|
||||
return await _cli_json(["hosts", "list", "--probe", "--json"], timeout=30.0)
|
||||
|
||||
async def pair(self, addr: str, port: int, pin: str, name: str = "Steam Deck") -> dict:
|
||||
"""The PIN ceremony (``punktfunk pair <addr:port> --pin N --name LABEL``).
|
||||
|
||||
The operator arms pairing on the host, which shows a 4-digit PIN; entering it here
|
||||
verifies the host end to end and pins its fingerprint, so every later connect is silent.
|
||||
``{ok: True}``, or ``{ok: False, error}`` where ``refused`` is a wrong PIN or a host
|
||||
that isn't armed, and ``unreachable`` is a host that never answered.
|
||||
|
||||
The budget is generous because the ceremony waits on a person at the other end."""
|
||||
rc, out, err = await _run_cli(
|
||||
[
|
||||
"pair", f"{addr}:{int(port)}",
|
||||
"--pin", str(pin).strip(),
|
||||
"--name", name,
|
||||
],
|
||||
timeout=100.0,
|
||||
)
|
||||
if rc == 0:
|
||||
fp = ""
|
||||
for tok in out.split():
|
||||
if tok.startswith("fp="):
|
||||
fp = tok[3:]
|
||||
decky.logger.info("paired %s:%s", host, port)
|
||||
_invalidate_hosts_cache() # the store gained a paired entry — reflect it next list
|
||||
for token in out.split():
|
||||
if token.startswith("fp="):
|
||||
fp = token[3:]
|
||||
decky.logger.info("paired %s:%s", addr, port)
|
||||
return {"ok": True, "fp": fp}
|
||||
decky.logger.warning("pairing failed (rc=%s): %s", proc.returncode, err.strip() or out.strip())
|
||||
# Surface the client's own one-line reason (wrong PIN / not armed) to the UI.
|
||||
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
|
||||
return {"ok": False, "error": reason}
|
||||
detail = (err.strip().splitlines() or ["pairing failed"])[-1]
|
||||
decky.logger.warning("pairing failed (rc=%s): %s", rc, detail)
|
||||
return {"ok": False, "error": _cli_error(rc, err), "detail": detail}
|
||||
|
||||
async def wake(self, host: str, port: int = 9777) -> dict:
|
||||
"""Send a Wake-on-LAN magic packet to a saved host via the flatpak client's headless
|
||||
``--wake`` mode, so a sleeping host is up by the time the stream ``--connect`` runs.
|
||||
async def trust_host(self, addr: str, port: int, fp: str, name: str = "") -> dict:
|
||||
"""Step 1 of request access: save the host with the fingerprint it ADVERTISED
|
||||
(``punktfunk hosts add <addr:port> --fp <hex> --name <label>``).
|
||||
|
||||
The MAC comes from the flatpak client's OWN known-hosts store (learned from the host's
|
||||
mDNS ``mac`` TXT while it was online) — no MAC handling here — so this is a no-op if none
|
||||
has been learned yet. Fire it just before launching a stream; it's fast and best-effort.
|
||||
Returns ``{ok, error?}`` (``ok: False`` when no MAC is known / flatpak missing).
|
||||
"""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
return {"ok": False, "error": "flatpak-not-found"}
|
||||
argv = [flatpak, "run", "--arch=x86_64", APP_ID, "--wake", f"{host}:{port}"]
|
||||
decky.logger.info("wake: %s:%s", host, port)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "error": "wake timed out"}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
decky.logger.exception("wake failed to launch")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
if proc.returncode == 0:
|
||||
The record lands pinned but unpaired — "trusted" — which is exactly what a
|
||||
discovered-but-unapproved host is. The stream launched right after pins that same
|
||||
fingerprint, so the 185 s wait for an operator's approval cannot be answered by an
|
||||
impostor. Idempotent: re-running it with the same fingerprint is a no-op that still
|
||||
exits 0. A DIFFERENT fingerprint comes back ``refused`` and is never overwritten.
|
||||
|
||||
This is the ONLY write this backend makes to the client's store, and it goes through
|
||||
the CLI — which writes temp+rename into a user-owned directory, so a root backend
|
||||
driving it cannot lock the desktop client out of its own files."""
|
||||
args = ["hosts", "add", f"{addr}:{int(port)}", "--fp", fp.strip()]
|
||||
if name.strip():
|
||||
args += ["--name", name.strip()]
|
||||
rc, _out, err = await _run_cli(args, timeout=20.0)
|
||||
if rc == 0:
|
||||
return {"ok": True}
|
||||
reason = (stderr.decode(errors="replace").strip().splitlines() or
|
||||
["no MAC known for this host yet"])[-1]
|
||||
decky.logger.info("wake skipped (rc=%s): %s", proc.returncode, reason)
|
||||
return {"ok": False, "error": reason}
|
||||
|
||||
async def library(self, host: str, mgmt_port: int = 0, fp: str = "") -> dict:
|
||||
"""Fetch a paired host's game library via the flatpak client's headless
|
||||
``--library`` mode (the client's own mTLS identity + pinned-fingerprint transport —
|
||||
no trust logic reimplemented here). ``fp`` is passed through whenever the caller
|
||||
knows the host's cert fingerprint so an IP change can never degrade the pin to a
|
||||
TOFU accept. Returns ``{ok, games: [{id, store, title}]}`` or
|
||||
``{ok: False, error: <code>, detail}`` (codes: ``flatpak-not-found`` / ``timeout`` /
|
||||
``not-paired`` / ``pin-mismatch`` / ``unreachable`` / ``http`` /
|
||||
``client-outdated`` / ``client-error``)."""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
return {"ok": False, "error": "flatpak-not-found", "detail": ""}
|
||||
target = f"{host}:{int(mgmt_port) or 47990}"
|
||||
argv = [flatpak, "run", "--arch=x86_64", APP_ID, "--library", target]
|
||||
if fp:
|
||||
argv += ["--fp", fp]
|
||||
decky.logger.info("library: fetching %s", target)
|
||||
proc = None
|
||||
try:
|
||||
# Separate pipes (unlike _flatpak_capture): the TSV comes on stdout, the
|
||||
# client's one-line error reason on stderr. Cold flatpak start on a Deck can
|
||||
# take seconds — generous timeout, spinner in the UI.
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=45.0)
|
||||
except asyncio.TimeoutError:
|
||||
if proc:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return {"ok": False, "error": "timeout", "detail": ""}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
decky.logger.exception("library fetch failed to launch")
|
||||
return {"ok": False, "error": "client-error", "detail": str(exc)}
|
||||
|
||||
err = stderr.decode(errors="replace")
|
||||
if proc.returncode != 0:
|
||||
detail = (err.strip().splitlines() or ["library fetch failed"])[-1]
|
||||
code = _classify_library_error(err)
|
||||
decky.logger.warning("library fetch failed (%s): %s", code, detail)
|
||||
return {"ok": False, "error": code, "detail": detail}
|
||||
games = _parse_library_tsv(stdout.decode(errors="replace"))
|
||||
decky.logger.info("library: %d game(s) from %s", len(games), target)
|
||||
return {"ok": True, "games": games}
|
||||
|
||||
async def get_pins(self) -> dict:
|
||||
"""The pinned games, each annotated with the LIVE ``paired`` state of its host (by
|
||||
cert fingerprint — an unpaired-since host renders "pairing required" in the QAM)."""
|
||||
try:
|
||||
data = json.loads(_pins_path().read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"pins": []}
|
||||
pins = data.get("pins", []) if isinstance(data, dict) else []
|
||||
paired = _paired_fingerprints()
|
||||
out = []
|
||||
for p in pins:
|
||||
if not isinstance(p, dict) or not p.get("game_id"):
|
||||
continue
|
||||
p = dict(p)
|
||||
p["paired"] = str(p.get("host_fp", "")).lower() in paired
|
||||
out.append(p)
|
||||
return {"pins": out}
|
||||
|
||||
async def set_pins(self, pins: list) -> dict:
|
||||
"""Persist the pinned-games list (the frontend sends the whole list — add, remove,
|
||||
and address-refresh all funnel through here). Validated + deduped on
|
||||
``(host_fp, game_id)``; written atomically (tmp + rename) — pins are long-lived
|
||||
user data."""
|
||||
clean: list[dict] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for p in pins if isinstance(pins, list) else []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
game_id = str(p.get("game_id", ""))
|
||||
host_fp = str(p.get("host_fp", ""))
|
||||
if not game_id or not (host_fp or p.get("host")):
|
||||
continue
|
||||
key = (host_fp, game_id)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
clean.append({
|
||||
"game_id": game_id,
|
||||
"title": str(p.get("title", game_id)),
|
||||
"store": str(p.get("store", "")),
|
||||
"host_fp": host_fp,
|
||||
"host_id": str(p.get("host_id", "")),
|
||||
"host_name": str(p.get("host_name", p.get("host", ""))),
|
||||
"host": str(p.get("host", "")),
|
||||
"port": int(p.get("port", 9777) or 9777),
|
||||
"mgmt": int(p.get("mgmt", 0) or 0),
|
||||
"added_at": int(p.get("added_at", 0) or 0),
|
||||
})
|
||||
try:
|
||||
d = _client_config_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
tmp = _pins_path().with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps({"version": 1, "pins": clean}, indent=2))
|
||||
os.replace(tmp, _pins_path())
|
||||
return {"ok": True}
|
||||
except OSError as exc:
|
||||
decky.logger.exception("could not write pins")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
detail = (err.strip().splitlines() or ["could not save the host"])[-1]
|
||||
decky.logger.warning("trust_host failed (rc=%s): %s", rc, detail)
|
||||
return {"ok": False, "error": _cli_error(rc, err), "detail": detail}
|
||||
|
||||
async def shortcut_art(self) -> dict:
|
||||
"""The Steam-shortcut artwork shipped with the plugin (committed under ``assets/``):
|
||||
@@ -1039,148 +829,8 @@ class Plugin:
|
||||
"client_bin": prefix[0] if native else "",
|
||||
}
|
||||
|
||||
async def get_settings(self) -> dict:
|
||||
"""Read the flatpak client's stream settings (resolution/bitrate/gamepad…)."""
|
||||
try:
|
||||
return json.loads(_settings_path().read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
# The client's own defaults (native display, host-default bitrate, auto pad).
|
||||
return {
|
||||
"width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
|
||||
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto",
|
||||
"inhibit_shortcuts": True, "mic_enabled": False,
|
||||
}
|
||||
|
||||
async def set_settings(self, settings: dict) -> dict:
|
||||
"""Write the stream settings JSON the (sandboxed) client reads on launch."""
|
||||
try:
|
||||
d = _client_config_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
_settings_path().write_text(json.dumps(settings, indent=2))
|
||||
return {"ok": True}
|
||||
except OSError as exc:
|
||||
decky.logger.exception("could not write settings")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
# ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ----
|
||||
|
||||
async def list_hosts(self, probe: bool = True) -> dict:
|
||||
"""The saved-hosts store as a list — the SAME ``client-known-hosts.json`` the desktop
|
||||
client owns, so a host added/renamed/paired in either surface shows in both. With
|
||||
``probe`` each host carries a live ``online`` bool from a mDNS-INDEPENDENT reachability
|
||||
probe (a Tailscale/VPN host is no longer shown offline just because it doesn't advertise);
|
||||
``online`` is ``None`` when reachability is unknown. Prefers the client's ``--list-hosts``
|
||||
mode; falls back to reading the JSON directly when the installed client predates it."""
|
||||
now = time.monotonic()
|
||||
cache = _hosts_cache
|
||||
if (
|
||||
cache["data"] is not None
|
||||
and cache["probed"] == bool(probe)
|
||||
and (now - cache["at"]) < _HOSTS_TTL_S
|
||||
):
|
||||
return cache["data"]
|
||||
|
||||
args = ["--list-hosts"] + (["--probe"] if probe else [])
|
||||
rc, out, err = await _run_client(args, timeout=30.0)
|
||||
result: dict | None = None
|
||||
if rc == 0:
|
||||
try:
|
||||
data = json.loads(out)
|
||||
hosts = data.get("hosts", []) if isinstance(data, dict) else []
|
||||
result = {"ok": True, "hosts": hosts, "probed": bool(probe)}
|
||||
except json.JSONDecodeError:
|
||||
decky.logger.warning("list-hosts: unparseable output: %s", out[:200])
|
||||
elif rc != -1:
|
||||
decky.logger.info(
|
||||
"list-hosts unavailable (%s); reading store directly",
|
||||
_classify_library_error(err),
|
||||
)
|
||||
if result is None:
|
||||
# Fallback: read the store off disk (old client / no --list-hosts) — no reachability.
|
||||
result = {"ok": True, "hosts": _read_known_hosts(), "probed": False, "fallback": True}
|
||||
cache.update(at=now, probed=bool(probe), data=result)
|
||||
return result
|
||||
|
||||
async def add_host(self, target: str, name: str = "", fp: str = "") -> dict:
|
||||
"""Save a host by address so it can be paired/streamed even when mDNS never sees it (a
|
||||
Tailscale/VPN box). Without ``fp`` it's an unpaired placeholder the user pairs next; a
|
||||
later pair replaces it with the fingerprinted entry. Returns ``{ok, error?, detail?}``."""
|
||||
args = ["--add-host", target.strip()]
|
||||
if name.strip():
|
||||
args += ["--host-label", name.strip()]
|
||||
if fp.strip():
|
||||
args += ["--fp", fp.strip()]
|
||||
rc, _out, err = await _run_client(args, timeout=20.0)
|
||||
_invalidate_hosts_cache()
|
||||
return _mutation_result(rc, err, "add-host")
|
||||
|
||||
async def edit_host(
|
||||
self, selector: str, name: str = "", addr: str = "", port: int = 0
|
||||
) -> dict:
|
||||
"""Edit a saved host — rename and/or re-point its address. ``selector`` is the host's
|
||||
cert fingerprint (survives IP changes) or its current ``addr[:port]``. Empty fields are
|
||||
left untouched. Returns ``{ok, error?, detail?}``."""
|
||||
args = ["--set-host", selector]
|
||||
if name.strip():
|
||||
args += ["--host-label", name.strip()]
|
||||
if addr.strip():
|
||||
args += ["--addr", addr.strip()]
|
||||
if port:
|
||||
args += ["--port", str(int(port))]
|
||||
rc, _out, err = await _run_client(args, timeout=20.0)
|
||||
_invalidate_hosts_cache()
|
||||
return _mutation_result(rc, err, "set-host")
|
||||
|
||||
async def forget_host(self, selector: str) -> dict:
|
||||
"""Remove a saved host (by fingerprint or ``addr[:port]``) — drops the pinned
|
||||
fingerprint, so a later connect must re-pair/trust. Idempotent."""
|
||||
rc, _out, err = await _run_client(["--forget-host", selector], timeout=20.0)
|
||||
_invalidate_hosts_cache()
|
||||
return _mutation_result(rc, err, "forget-host")
|
||||
|
||||
async def reset_config(self) -> dict:
|
||||
"""Reset this device's Punktfunk state: saved hosts, stream settings, and the plugin's
|
||||
pinned games. The client's persistent IDENTITY (client-cert/key.pem) is KEPT so the box
|
||||
isn't seen as brand-new everywhere (re-pairing re-adds hosts). Prefers the client's
|
||||
``--reset``; if that's unavailable (old client / no flatpak) it clears the shared JSON
|
||||
stores directly. The plugin-owned pins file is always cleared here (``--reset`` never
|
||||
touches it)."""
|
||||
rc, _out, _err = await _run_client(["--reset"], timeout=20.0)
|
||||
_invalidate_hosts_cache()
|
||||
errors: list[str] = []
|
||||
if rc != 0:
|
||||
decky.logger.info("reset: --reset unavailable (rc=%s); clearing stores directly", rc)
|
||||
for name in ("client-known-hosts.json", "client-gtk-settings.json"):
|
||||
try:
|
||||
(_client_config_dir() / name).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
errors.append(f"{name}: {exc}")
|
||||
try:
|
||||
_pins_path().unlink() # plugin-owned; the client's --reset leaves it alone
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
errors.append(f"pins: {exc}")
|
||||
if errors:
|
||||
return {"ok": False, "error": "; ".join(errors)}
|
||||
return {"ok": True}
|
||||
|
||||
async def probe_host(self, target: str) -> dict:
|
||||
"""Reachability of one ``host[:port]`` via the client's mDNS-independent QUIC probe —
|
||||
for a "test this address" check. ``{ok: True, online: bool}`` when determined, else
|
||||
``{ok: False, error}`` (flatpak missing / client too old)."""
|
||||
rc, _out, err = await _run_client(["--reachable", target.strip()], timeout=8.0)
|
||||
if rc == 0:
|
||||
return {"ok": True, "online": True}
|
||||
if rc == 1:
|
||||
return {"ok": True, "online": False}
|
||||
return {
|
||||
"ok": False,
|
||||
"error": _classify_library_error(err) if err.strip() else "client-unavailable",
|
||||
}
|
||||
|
||||
async def kill_stream(self) -> dict:
|
||||
"""Force-stop a wedged stream client — ``flatpak kill`` for the sandboxed one, a plain
|
||||
SIGTERM by name for a native install (which has no flatpak instance to kill)."""
|
||||
@@ -1205,6 +855,46 @@ class Plugin:
|
||||
return {"ok": False}
|
||||
return {"ok": True}
|
||||
|
||||
async def stream_running(self) -> dict:
|
||||
"""Whether the streaming client's control socket exists — i.e. the client is up.
|
||||
|
||||
The socket appears at the client's first stream and lives for the process, so
|
||||
between console-mode streams it lingers; that only leaves the panel's host
|
||||
buttons harmlessly visible.
|
||||
"""
|
||||
return {"running": any(p.is_socket() for p in _ctl_sockets())}
|
||||
|
||||
async def host_action(self, action: str) -> dict:
|
||||
"""Press a HOST system button on the running stream: ``guide`` (the Steam/Xbox/PS
|
||||
menu button) or ``qam`` (the quick-access ``…``).
|
||||
|
||||
Talks to the session binary's control socket (one text verb per connection,
|
||||
``ok``/``err`` back) — the flatpak app runtime dir first (the sandboxed client;
|
||||
that dir is the one runtime path host and sandbox see identically), then the
|
||||
plain runtime dir (native installs). No socket = no running stream.
|
||||
"""
|
||||
if action not in ("guide", "qam"):
|
||||
return {"ok": False, "error": f"unknown action {action!r}"}
|
||||
for sock in _ctl_sockets():
|
||||
if not sock.is_socket():
|
||||
continue
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_unix_connection(str(sock)), timeout=2.0
|
||||
)
|
||||
except Exception: # noqa: BLE001 — a stale socket file; try the next path
|
||||
continue
|
||||
try:
|
||||
writer.write(f"{action}\n".encode())
|
||||
await writer.drain()
|
||||
reply = await asyncio.wait_for(reader.readline(), timeout=2.0)
|
||||
return {"ok": reply.strip() == b"ok"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "error": str(e)}
|
||||
finally:
|
||||
writer.close()
|
||||
return {"ok": False, "error": "no-stream"}
|
||||
|
||||
async def _update_native_client(self) -> dict:
|
||||
"""The non-flatpak leg of :meth:`update_client` — drive the client's own
|
||||
``--apply-update``, which starts the packaged root helper.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit checks for main.py's pure helpers — stdlib only, no Decky runtime needed.
|
||||
|
||||
Stubs the ``decky`` module (main.py imports it at module level), then asserts the
|
||||
avahi/TSV/error parsers against fixture strings. The LibraryError fixtures are pinned to
|
||||
the REAL Display strings in clients/linux/src/library.rs — if those are reworded, the
|
||||
classifier degrades to ``client-error`` and the matching assertion here fails on purpose.
|
||||
Stubs the ``decky`` module (main.py imports it at module level), then asserts the argv
|
||||
shapes, the exit-code mapping and the Steam VDF editor against fixtures.
|
||||
|
||||
python3 clients/decky/scripts/test-backend.py
|
||||
Needs Python >= 3.10 for `X | None` annotations — macOS ships 3.9, so run it explicitly:
|
||||
|
||||
python3.13 clients/decky/scripts/test-backend.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -40,109 +40,135 @@ def check(name: str, cond: bool):
|
||||
failures += 1
|
||||
|
||||
|
||||
# ---- _parse_library_tsv -----------------------------------------------------------------
|
||||
tsv = (
|
||||
"steam:570\tsteam\tDota 2\n"
|
||||
"custom:abc\tcustom\tTabs\tin\ttitle\n" # tabs inside the title survive (split max 2)
|
||||
"2 game(s)\n" # the count trailer has no tabs — self-skips
|
||||
# ---- _cli_argv: the flatpak app id must stay LAST ---------------------------------------
|
||||
#
|
||||
# `flatpak run --command=X <app-id> ARGS` — everything after the app id is the APP's argv, so
|
||||
# an app id that drifts left silently turns our flags into the client's. This is the shape the
|
||||
# deleted _session_argv used and the one thing about it that is easy to get wrong.
|
||||
main._client_argv = lambda: ["/usr/bin/flatpak", "run", "--arch=x86_64", "io.unom.Punktfunk"]
|
||||
main._flatpak = lambda: "/usr/bin/flatpak"
|
||||
check(
|
||||
"cli argv: flatpak form, app id last",
|
||||
main._cli_argv()
|
||||
== [
|
||||
"/usr/bin/flatpak",
|
||||
"run",
|
||||
"--arch=x86_64",
|
||||
"--command=punktfunk",
|
||||
"io.unom.Punktfunk",
|
||||
],
|
||||
)
|
||||
games = main._parse_library_tsv(tsv)
|
||||
check("tsv: two games parsed", len(games) == 2)
|
||||
check("tsv: fields", games[0] == {"id": "steam:570", "store": "steam", "title": "Dota 2"})
|
||||
check("tsv: tabs in title preserved", games[1]["title"] == "Tabs\tin\ttitle")
|
||||
check("tsv: empty input", main._parse_library_tsv("0 game(s)\n") == [])
|
||||
|
||||
# ---- _classify_library_error (fixtures = library.rs Display strings) --------------------
|
||||
check(
|
||||
"err: not-paired",
|
||||
main._classify_library_error(
|
||||
"library: The host didn't recognize this device. Pair with the host first — the "
|
||||
"library is authorized by this device's certificate (no token needed)."
|
||||
)
|
||||
== "not-paired",
|
||||
)
|
||||
check(
|
||||
"err: pin-mismatch",
|
||||
main._classify_library_error(
|
||||
"library: The host's certificate doesn't match the pinned fingerprint. "
|
||||
"Re-pair with a PIN to re-establish trust."
|
||||
)
|
||||
== "pin-mismatch",
|
||||
)
|
||||
check(
|
||||
"err: unreachable",
|
||||
main._classify_library_error(
|
||||
"library: Couldn't reach the host's management API: connection refused. Check the "
|
||||
"host is updated and reachable."
|
||||
)
|
||||
== "unreachable",
|
||||
)
|
||||
check(
|
||||
"err: http",
|
||||
main._classify_library_error("library: The management API returned HTTP 500.") == "http",
|
||||
)
|
||||
check(
|
||||
"err: outdated client (GTK init noise)",
|
||||
main._classify_library_error("cannot open display: \nGtk-WARNING: init failed")
|
||||
== "client-outdated",
|
||||
)
|
||||
check("err: generic fallback", main._classify_library_error("boom") == "client-error")
|
||||
|
||||
# ---- _parse_avahi_browse (incl. the new id/mgmt TXT keys) --------------------------------
|
||||
avahi = (
|
||||
"+;eth0;IPv4;living-room;_punktfunk._udp;local\n"
|
||||
"=;eth0;IPv4;living-room;_punktfunk._udp;local;lr.local;192.168.1.42;9777;"
|
||||
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
|
||||
"=;eth0;IPv6;living-room;_punktfunk._udp;local;lr.local;fe80::1;9777;"
|
||||
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
|
||||
"=;eth0;IPv4;bare-host;_punktfunk._udp;local;bh.local;192.168.1.77;9777;"
|
||||
'"proto=punktfunk/1" "fp=ddeeff" "pair=optional"\n'
|
||||
)
|
||||
hosts = main._parse_avahi_browse(avahi)
|
||||
check("avahi: two hosts (id-dedup, IPv4 preferred)", len(hosts) == 2)
|
||||
lr = next(h for h in hosts if h["name"] == "living-room")
|
||||
check("avahi: ipv4 wins", lr["host"] == "192.168.1.42")
|
||||
check("avahi: mgmt parsed", lr["mgmt"] == 47990)
|
||||
check("avahi: id parsed", lr["id"] == "abc123")
|
||||
bare = next(h for h in hosts if h["name"] == "bare-host")
|
||||
check("avahi: mgmt absent -> 0", bare["mgmt"] == 0)
|
||||
check("avahi: id absent -> empty", bare["id"] == "")
|
||||
|
||||
# ---- pins store (round-trip through the real methods, isolated HOME) --------------------
|
||||
import asyncio # noqa: E402
|
||||
# A native install: the CLI is the client binary's sibling. Absent => no CLI at all, which the
|
||||
# caller must see as "unavailable" rather than as an empty result.
|
||||
#
|
||||
# The fixture dir is torn down FIRST, not just created: leaving the sibling behind made the
|
||||
# "absent" assertion below pass only on the first run of the day and fail on every rerun.
|
||||
import shutil # noqa: E402
|
||||
|
||||
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
||||
plugin = main.Plugin()
|
||||
pin = {
|
||||
"game_id": "steam:570",
|
||||
"title": "Dota 2",
|
||||
"store": "steam",
|
||||
"host_fp": "AABBCC",
|
||||
"host_id": "abc123",
|
||||
"host_name": "living-room",
|
||||
"host": "192.168.1.42",
|
||||
"port": 9777,
|
||||
"mgmt": 47990,
|
||||
"added_at": 1780000000,
|
||||
}
|
||||
dupe = dict(pin, title="Dota 2 again")
|
||||
junk = {"title": "no game id"}
|
||||
res = asyncio.run(plugin.set_pins([pin, dupe, junk]))
|
||||
check("pins: write ok", res.get("ok") is True)
|
||||
got = asyncio.run(plugin.get_pins())["pins"]
|
||||
check("pins: dedup + junk dropped", len(got) == 1)
|
||||
check("pins: unpaired without known-hosts", got[0]["paired"] is False)
|
||||
# Mark the host paired in the client's known-hosts store — get_pins must pick it up.
|
||||
cfg = main._client_config_dir()
|
||||
cfg.mkdir(parents=True, exist_ok=True)
|
||||
(cfg / "client-known-hosts.json").write_text(
|
||||
'{"hosts": [{"name": "living-room", "addr": "192.168.1.42", "port": 9777, '
|
||||
'"fp_hex": "aabbcc", "paired": true}]}'
|
||||
shutil.rmtree("/tmp/pf-test-native", ignore_errors=True)
|
||||
tmp = Path("/tmp/pf-test-native/bin")
|
||||
tmp.mkdir(parents=True, exist_ok=True)
|
||||
(tmp / "punktfunk-client").write_text("")
|
||||
main._client_argv = lambda: [str(tmp / "punktfunk-client")]
|
||||
check("cli argv: native without a sibling CLI is None", main._cli_argv() is None)
|
||||
(tmp / "punktfunk").write_text("")
|
||||
check("cli argv: native sibling found", main._cli_argv() == [str(tmp / "punktfunk")])
|
||||
|
||||
# ---- _cli_error: the CLI's exit-code contract -------------------------------------------
|
||||
#
|
||||
# Exit 5 + `unknown command` is how a client too old for a verb announces itself — the ONE
|
||||
# signature the panel turns into "update the client" plus the button that fixes it. Getting it
|
||||
# wrong makes an out-of-date client look like a broken plugin.
|
||||
check(
|
||||
"err: unknown verb => client-outdated",
|
||||
main._cli_error(5, 'unknown command "discover"\n\npunktfunk — the Punktfunk client')
|
||||
== "client-outdated",
|
||||
)
|
||||
got = asyncio.run(plugin.get_pins())["pins"]
|
||||
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
|
||||
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
||||
check(
|
||||
"err: exit 5 without that phrase is NOT outdated",
|
||||
main._cli_error(5, 'no saved host matches "desk"') == "unresolved",
|
||||
)
|
||||
check("err: connect failed", main._cli_error(2, "unreachable 10.0.0.1:9777") == "unreachable")
|
||||
check("err: trust rejected", main._cli_error(3, "wrong PIN") == "refused")
|
||||
check("err: needs a person", main._cli_error(6, "pair it first") == "needs-pairing")
|
||||
check("err: nothing ran", main._cli_error(-1, "") == "client-unavailable")
|
||||
check("err: unmapped code falls back", main._cli_error(4, "renderer") == "client-error")
|
||||
|
||||
# ---- _cli_json: a zero exit with junk on stdout is a FAILURE, not an empty result --------
|
||||
import asyncio # noqa: E402
|
||||
|
||||
|
||||
def _fake_cli(rc: int, out: str, err: str = ""):
|
||||
async def run(_args, timeout=20.0):
|
||||
return rc, out, err
|
||||
|
||||
return run
|
||||
|
||||
|
||||
main._run_cli = _fake_cli(0, '{"hosts": [{"name": "desk"}]}')
|
||||
got = asyncio.run(main._cli_json(["discover", "--json"]))
|
||||
check("json: payload merged under ok", got == {"ok": True, "hosts": [{"name": "desk"}]})
|
||||
|
||||
main._run_cli = _fake_cli(0, "not json at all")
|
||||
got = asyncio.run(main._cli_json(["discover", "--json"]))
|
||||
check("json: unparseable stdout is an error, not an empty list", got["ok"] is False)
|
||||
check("json: ...and says so specifically", got["error"] == "client-error")
|
||||
|
||||
main._run_cli = _fake_cli(5, "", 'unknown command "discover"')
|
||||
got = asyncio.run(main._cli_json(["discover", "--json"]))
|
||||
check("json: old client surfaces as client-outdated", got["error"] == "client-outdated")
|
||||
check("json: detail carries the CLI's own last line", "unknown command" in got["detail"])
|
||||
|
||||
# ---- _field_from (flatpak info parsing, drives the client update check) ------------------
|
||||
info = " ID: io.unom.Punktfunk\n Origin: punktfunk-origin\n Commit: abc123def\n"
|
||||
check("field: commit", main._field_from(info, "Commit") == "abc123def")
|
||||
check("field: origin", main._field_from(info, "Origin") == "punktfunk-origin")
|
||||
check("field: absent", main._field_from(info, "Nope") == "")
|
||||
|
||||
# ---- _looks_outdated (the GTK-init signature of a client predating a headless flag) ------
|
||||
check("outdated: gtk init noise", main._looks_outdated("cannot open display: \nGtk-WARNING") is True)
|
||||
check("outdated: an ordinary error is not", main._looks_outdated("connection refused") is False)
|
||||
|
||||
# ---- _semver_tuple (plugin update comparison) --------------------------------------------
|
||||
check("semver: plain", main._semver_tuple("1.2.3") == (1, 2, 3))
|
||||
check("semver: pre-release suffix dropped", main._semver_tuple("1.2.3-rc1") == (1, 2, 3))
|
||||
check("semver: short forms pad", main._semver_tuple("2") == (2, 0, 0))
|
||||
check("semver: ordering", main._semver_tuple("0.10.0") > main._semver_tuple("0.9.9"))
|
||||
|
||||
# ---- _upsert_configset_entry (Steam Input layout binding) --------------------------------
|
||||
#
|
||||
# Untested until now, and the riskiest thing that survived the cut: it edits a file holding
|
||||
# HUNDREDS of other games' controller bindings, in place. Every assertion below is about not
|
||||
# touching them.
|
||||
empty = main._upsert_configset_entry("", "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: builds the skeleton when the file is new", '"controller_config"' in empty)
|
||||
check("vdf: the entry lands", '"punktfunk"' in empty and '"punktfunk.vdf"' in empty)
|
||||
|
||||
existing = (
|
||||
'"controller_config"\n'
|
||||
"{\n"
|
||||
'\t"halflife2"\n'
|
||||
"\t{\n"
|
||||
'\t\t"template"\t\t"other.vdf"\n'
|
||||
"\t}\n"
|
||||
"}\n"
|
||||
)
|
||||
added = main._upsert_configset_entry(existing, "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: an existing game's entry survives insertion", '"halflife2"' in added)
|
||||
check("vdf: ours is inserted", '"punktfunk"' in added)
|
||||
|
||||
# Re-running must REPLACE our block, not accumulate a second one (this runs on every plugin
|
||||
# session gated only by a localStorage marker, so idempotence is the whole contract).
|
||||
twice = main._upsert_configset_entry(added, "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: idempotent", twice.count('"punktfunk"\n') == 1)
|
||||
check("vdf: neighbour still intact after the rewrite", '"halflife2"' in twice)
|
||||
|
||||
# Steam keys non-Steam games by their LOWERCASE name, and files on disk may carry either case —
|
||||
# a case-sensitive match would append a duplicate the game never reads.
|
||||
mixed = existing.replace('"halflife2"', '"Punktfunk"')
|
||||
replaced = main._upsert_configset_entry(mixed, "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: matches an existing key case-insensitively", replaced.count("unktfunk\"\n") == 1)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
|
||||
+100
-139
@@ -1,95 +1,94 @@
|
||||
// Bridge to the Python backend (main.py) + shared types.
|
||||
//
|
||||
// Every call here is a thin shell over the headless `punktfunk` CLI, so these types are the
|
||||
// CLI's JSON shapes rather than anything this plugin invents. That is deliberate: the plugin
|
||||
// used to model the client's stores itself and drifted from them with every field the client
|
||||
// added.
|
||||
|
||||
import { callable } from "@decky/api";
|
||||
|
||||
export interface Host {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
pair: string; // "required" | "optional" — the HOST's policy
|
||||
fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
|
||||
proto: string; // advertised protocol, e.g. "punktfunk/1"
|
||||
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
|
||||
id: string; // the host's stable instance id (mDNS TXT `id`; "" when not advertised)
|
||||
mgmt: number; // management-API port (mDNS TXT `mgmt`; 0 = not advertised → default 47990)
|
||||
os: string; // OS-identity chain (mDNS TXT `os`, e.g. "linux/fedora/bazzite"); "" on older hosts
|
||||
}
|
||||
|
||||
// One title from a host's game library (the flatpak client's --library TSV, parsed by the
|
||||
// backend). `id` is store-qualified (steam:<appid> / custom:<id>) and doubles as the
|
||||
// launch handle (PF_LAUNCH → the session Hello).
|
||||
export interface GameEntry {
|
||||
/** A settings profile as the CLI resolves it — ids are dangling-checked and names attached. */
|
||||
export interface Profile {
|
||||
id: string;
|
||||
store: string; // "steam" | "custom" | "heroic" | "lutris" | …
|
||||
title: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface LibraryResult {
|
||||
ok: boolean;
|
||||
games?: GameEntry[];
|
||||
// "flatpak-not-found" | "timeout" | "not-paired" | "pin-mismatch" | "unreachable" |
|
||||
// "http" | "client-outdated" | "client-error"
|
||||
error?: string;
|
||||
detail?: string; // the client's own one-line reason, for the generic error copy
|
||||
}
|
||||
|
||||
// A pinned game — a one-tap stream row in the QAM. The host is identified primarily by
|
||||
// cert fingerprint (survives IP changes; pairing is fp-keyed too), with the stored
|
||||
// address as the launch fallback when the host isn't currently advertising.
|
||||
export interface PinnedGame {
|
||||
game_id: string;
|
||||
title: string;
|
||||
store: string;
|
||||
host_fp: string;
|
||||
host_id: string;
|
||||
host_name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
mgmt: number;
|
||||
added_at: number; // unix seconds
|
||||
paired?: boolean; // annotated by get_pins from the client's known-hosts store
|
||||
}
|
||||
|
||||
export interface PairResult {
|
||||
ok: boolean;
|
||||
fp?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// A host in the SHARED saved-hosts store (client-known-hosts.json) — the same file the desktop
|
||||
// client reads/writes, so add/rename/pair in either surface shows up in both. `online` comes
|
||||
// from a mDNS-INDEPENDENT reachability probe (a Tailscale/VPN host isn't shown offline just
|
||||
// because it doesn't advertise); `null` means reachability is unknown (probe skipped or a client
|
||||
// too old for `--list-hosts`, which then also can't probe).
|
||||
export interface SavedHost {
|
||||
/**
|
||||
* A host answering on mDNS right now (`punktfunk discover --json`).
|
||||
*
|
||||
* `saved`/`paired` are annotated BY THE CLI against the saved-hosts store — fingerprint first,
|
||||
* address second. The plugin does not join the two lists itself; that rule living in one place
|
||||
* is what stops this surface disagreeing with the desktop client about the same box.
|
||||
*/
|
||||
export interface DiscoveredHost {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp_hex: string; // host cert fingerprint (lowercase hex); "" for a not-yet-paired manual entry
|
||||
fp: string; // advertised cert fingerprint (lowercase hex); "" when not advertised
|
||||
pair: string; // the HOST's policy: "required" | "optional"
|
||||
id: string; // the host's advertised stable id; "" when not advertised
|
||||
mgmt: number; // management-API port; 0 = not advertised
|
||||
os: string; // OS-identity chain, e.g. "linux/fedora/bazzite"; "" on older hosts
|
||||
saved: boolean;
|
||||
paired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A host in the shared saved-hosts store (`punktfunk hosts list --probe --json`) — the same
|
||||
* `client-known-hosts.json` the desktop client owns.
|
||||
*
|
||||
* `online` comes from a mDNS-INDEPENDENT probe, so a host reached over Tailscale/VPN is not
|
||||
* shown offline merely because it never advertises; `null` means the probe was skipped.
|
||||
*
|
||||
* `profile` is the host's DEFAULT binding, which a plain connect applies silently. It is not
|
||||
* the same thing as `pinned_profiles`, which are the cards a user chose to surface. Both come
|
||||
* back already resolved against the profile catalog, so this plugin never opens it.
|
||||
*/
|
||||
export interface SavedHost {
|
||||
id: string | null; // the record's stable id — the reference a launch should use
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp_hex: string; // "" for a placeholder saved by address with no pin yet
|
||||
paired: boolean;
|
||||
mac: string[];
|
||||
// OS-identity chain learned by the desktop client; optional because the installed
|
||||
// flatpak client may predate the field.
|
||||
os?: string;
|
||||
os: string;
|
||||
last_used: number | null;
|
||||
clipboard_sync: boolean;
|
||||
profile: Profile | null;
|
||||
pinned_profiles: Profile[];
|
||||
online: boolean | null;
|
||||
}
|
||||
|
||||
export interface HostsResult {
|
||||
ok: boolean;
|
||||
hosts: SavedHost[];
|
||||
probed: boolean;
|
||||
fallback?: boolean; // true when read straight off disk (client too old for --list-hosts)
|
||||
}
|
||||
|
||||
// The result of a host-store mutation (add/edit/forget). `error` is a stable code:
|
||||
// "client-unavailable" (flatpak missing) | "client-outdated" (client predates the mode) |
|
||||
// "unreachable"/"http"/… (from the client) | "client-error" (generic; see `detail`).
|
||||
export interface MutationResult {
|
||||
/**
|
||||
* Every backend call answers in this shape. `error` is a stable code, never prose:
|
||||
*
|
||||
* - `client-unavailable` — no client is installed, or the call never ran
|
||||
* - `client-outdated` — the installed client predates the verb (exit 5 + `unknown command`)
|
||||
* - `unreachable` — the host did not answer
|
||||
* - `refused` — trust rejected: a wrong PIN, or a fingerprint that already differs
|
||||
* - `needs-pairing` — the CLI refused because it needs a person
|
||||
* - `unresolved` — nothing matched what was named
|
||||
* - `client-error` — anything else; `detail` carries the CLI's own last line
|
||||
*/
|
||||
export interface CliResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface DiscoverResult extends CliResult {
|
||||
hosts?: DiscoveredHost[];
|
||||
}
|
||||
|
||||
export interface HostsResult extends CliResult {
|
||||
hosts?: SavedHost[];
|
||||
}
|
||||
|
||||
export interface PairResult extends CliResult {
|
||||
fp?: string;
|
||||
}
|
||||
|
||||
export interface RunnerInfo {
|
||||
runner: string; // absolute path to bin/punktfunkrun.sh
|
||||
app_id: string; // flatpak app id
|
||||
@@ -101,26 +100,6 @@ export interface RunnerInfo {
|
||||
client_bin?: string;
|
||||
}
|
||||
|
||||
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
|
||||
// keys (decoder, … set from the desktop client's own UI) — they round-trip untouched
|
||||
// because get_settings returns the whole parsed file and patches are object spreads.
|
||||
export interface StreamSettings {
|
||||
width: number; // 0 = native
|
||||
height: number; // 0 = native
|
||||
refresh_hz: number; // 0 = native
|
||||
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
|
||||
bitrate_kbps: number; // 0 = host default
|
||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
// Round-trips only — deliberately NOT offered as a row here. It decides whether the session
|
||||
// grabs the keyboard so Alt+Tab/Super reach the host, and Game Mode is gamescope: it has no
|
||||
// compositor shortcuts to inhibit and hands the focused window every key already. A toggle
|
||||
// here would be a dead one. The desktop client's row still edits this same file.
|
||||
inhibit_shortcuts: boolean;
|
||||
mic_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
current: string; // installed PLUGIN version (package.json)
|
||||
latest: string; // newest plugin version in our registry for this channel
|
||||
@@ -156,21 +135,30 @@ export interface ShortcutArt {
|
||||
icon_path: string;
|
||||
}
|
||||
|
||||
export const discover = callable<[], Host[]>("discover");
|
||||
// ---- The four CLI shells --------------------------------------------------------------
|
||||
|
||||
/** Browse the LAN over mDNS. Bounded by the CLI (3 s) plus a cold-start allowance. */
|
||||
export const discover = callable<[], DiscoverResult>("discover");
|
||||
/** The saved hosts, probed for reachability, with profiles and pinned cards resolved. */
|
||||
export const hosts = callable<[], HostsResult>("hosts");
|
||||
/** The PIN ceremony. `refused` = wrong PIN or a host that isn't armed. */
|
||||
export const pair = callable<
|
||||
[host: string, port: number, pin: string, name: string],
|
||||
[addr: string, port: number, pin: string, name: string],
|
||||
PairResult
|
||||
>("pair");
|
||||
// Fetch a paired host's game library (headless flatpak --library; can take seconds on a
|
||||
// cold client start — show a spinner). Pass fp whenever known so the pin can't degrade.
|
||||
export const library = callable<
|
||||
[host: string, mgmt_port: number, fp: string],
|
||||
LibraryResult
|
||||
>("library");
|
||||
export const getPins = callable<[], { pins: PinnedGame[] }>("get_pins");
|
||||
export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: string }>(
|
||||
"set_pins",
|
||||
);
|
||||
/**
|
||||
* Step 1 of request access: save the host with its ADVERTISED fingerprint, pinned but unpaired.
|
||||
* The launch that follows pins the same fingerprint, which is the only thing standing between a
|
||||
* 185 s wait for approval and an impostor answering for the host. Idempotent; a host already
|
||||
* saved under a DIFFERENT fingerprint comes back `refused` rather than being overwritten.
|
||||
*/
|
||||
export const trustHost = callable<
|
||||
[addr: string, port: number, fp: string, name: string],
|
||||
CliResult
|
||||
>("trust_host");
|
||||
|
||||
// ---- Steam / plugin business (only a Decky plugin can do these) ------------------------
|
||||
|
||||
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
||||
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
|
||||
// Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our
|
||||
@@ -181,43 +169,16 @@ export const applyControllerConfig = callable<
|
||||
[name: string],
|
||||
{ ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string }
|
||||
>("apply_controller_config");
|
||||
export const getSettings = callable<[], StreamSettings>("get_settings");
|
||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||
"set_settings",
|
||||
);
|
||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
|
||||
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
|
||||
// known-hosts store; `ok: false` (no-op) when none has been learned yet. Fire before launching.
|
||||
export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>(
|
||||
"wake",
|
||||
// Whether the streaming client's control socket exists (a stream/console client is up) —
|
||||
// gates the QAM panel's host-button section.
|
||||
export const streamRunning = callable<[], { running: boolean }>("stream_running");
|
||||
// Press a HOST system button on the running stream: "guide" | "qam". The raw Steam/QAM
|
||||
// presses stay on the Deck by default (the client's Controllers settings), so this — and
|
||||
// holding Select — is how the host's own menus are reached.
|
||||
export const hostAction = callable<[action: string], { ok: boolean; error?: string }>(
|
||||
"host_action",
|
||||
);
|
||||
// ---- Shared saved-hosts store (the SAME client-known-hosts.json the desktop client owns) ----
|
||||
// The saved hosts, each annotated with a live (mDNS-independent) `online` probe when `probe` is
|
||||
// true. Falls back to a direct JSON read (no reachability) on a client too old for --list-hosts.
|
||||
export const listHosts = callable<[probe: boolean], HostsResult>("list_hosts");
|
||||
// Save a host by address (survives mDNS-blind networks). `fp` empty = unpaired placeholder to
|
||||
// pair next; a later pair replaces it with the fingerprinted entry.
|
||||
export const addHost = callable<[target: string, name: string, fp: string], MutationResult>(
|
||||
"add_host",
|
||||
);
|
||||
// Rename and/or re-point a saved host. `selector` = its fingerprint (survives IP change) or
|
||||
// current addr[:port]; empty fields are left untouched.
|
||||
export const editHost = callable<
|
||||
[selector: string, name: string, addr: string, port: number],
|
||||
MutationResult
|
||||
>("edit_host");
|
||||
// Remove a saved host by fingerprint or addr[:port] (idempotent).
|
||||
export const forgetHost = callable<[selector: string], MutationResult>("forget_host");
|
||||
// Reset this device's Punktfunk state (saved hosts + stream settings + pins); KEEPS the client
|
||||
// identity so the box isn't seen as new everywhere (re-pairing re-adds hosts).
|
||||
export const resetConfig = callable<[], { ok: boolean; error?: string }>("reset_config");
|
||||
// Reachability of one host[:port] via the client's mDNS-independent QUIC probe (a "test address"
|
||||
// check). `{ ok: true, online }` when determined, else `{ ok: false, error }`.
|
||||
export const probeHost = callable<
|
||||
[target: string],
|
||||
{ ok: boolean; online?: boolean; error?: string }
|
||||
>("probe_host");
|
||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||
// Update the client by whichever route its install supports: `flatpak update --user` for the
|
||||
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
|
||||
|
||||
+194
-347
@@ -1,18 +1,14 @@
|
||||
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
|
||||
// Shared state hooks + user actions for the QAM panel.
|
||||
import { toaster } from "@decky/api";
|
||||
import { Navigation } from "@decky/ui";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
checkUpdate,
|
||||
discover,
|
||||
GameEntry,
|
||||
getPins,
|
||||
Host,
|
||||
listHosts,
|
||||
PinnedGame,
|
||||
resetConfig,
|
||||
DiscoveredHost,
|
||||
hosts as listHosts,
|
||||
Profile,
|
||||
SavedHost,
|
||||
setPins as setPinsBackend,
|
||||
updateClient,
|
||||
UpdateInfo,
|
||||
} from "./backend";
|
||||
@@ -37,19 +33,191 @@ declare global {
|
||||
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
|
||||
const INSTALL_TYPE_UPDATE = 2;
|
||||
|
||||
/**
|
||||
* How far this device has got with a host. The three states are what the row says under the
|
||||
* name, and which of them a host is in decides whether pressing it streams or opens the trust
|
||||
* sheet.
|
||||
*
|
||||
* - `paired` — the host approved this device (a PIN ceremony, or request access).
|
||||
* - `trusted` — its fingerprint is pinned but nobody has approved us yet. Streams work if
|
||||
* the host's policy is `optional`; under `required` the connect parks.
|
||||
* - `needs-access` — no pinned fingerprint. Not streamable until the trust sheet runs.
|
||||
*/
|
||||
export type TrustState = "paired" | "trusted" | "needs-access";
|
||||
|
||||
/**
|
||||
* One host as the panel shows it — the union of the saved store and the live mDNS browse.
|
||||
*
|
||||
* A saved host is ONLINE when it either advertises or answers the reachability probe, so a box
|
||||
* reached over Tailscale/VPN stops reading as offline. Discovered hosts that aren't saved are
|
||||
* appended as extra rows.
|
||||
*/
|
||||
export interface HostView {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
/**
|
||||
* The fingerprint PINNED ON THE RECORD. "" means nothing is pinned, which is exactly what
|
||||
* makes a host unstreamable — the session binary refuses a pinless connect.
|
||||
*
|
||||
* Deliberately NOT filled in from a live advert. A host saved by address that happens to be
|
||||
* advertising right now still has an empty pin on disk, and borrowing the advert's here would
|
||||
* draw it as ready to stream while every launch refused for want of a fingerprint. What the
|
||||
* advert offers is [`advertisedFp`], and moving it onto the record is a trust decision the
|
||||
* user makes in the sheet.
|
||||
*/
|
||||
fp: string;
|
||||
/** What the host is advertising right now, if anything — what request access would pin. */
|
||||
advertisedFp: string;
|
||||
/**
|
||||
* The host is answering at an address its record does not carry — it changed DHCP lease.
|
||||
*
|
||||
* This matters because a launch names the host by [`ref`], and the CLI dials whatever address
|
||||
* the RECORD holds. So the row would show the live address and dial the dead one. The record
|
||||
* has to be re-pointed before such a host can stream; `startStream` does it.
|
||||
*/
|
||||
moved: boolean;
|
||||
paired: boolean;
|
||||
online: boolean;
|
||||
saved: boolean;
|
||||
/** The advert's policy ("required"|"optional"); "" when the host isn't advertising. */
|
||||
pairPolicy: string;
|
||||
/** OS-identity chain (live advert preferred, else the stored one); "" unknown. */
|
||||
os: string;
|
||||
/**
|
||||
* What a launch should NAME this host by: the record's stable id, which survives renames and
|
||||
* DHCP moves, falling back to `addr:port` for a row that has no record yet (a discovered host
|
||||
* the trust sheet is about to save, or a client too old to have minted ids).
|
||||
*/
|
||||
ref: string;
|
||||
/** The host's default profile binding — applied silently by a plain connect, not a card. */
|
||||
profile: Profile | null;
|
||||
/** The cards to render nested under this host; already resolved against the catalog. */
|
||||
pinnedProfiles: Profile[];
|
||||
lastUsed: number | null;
|
||||
}
|
||||
|
||||
export function trustState(v: HostView): TrustState {
|
||||
if (v.paired) return "paired";
|
||||
return v.fp ? "trusted" : "needs-access";
|
||||
}
|
||||
|
||||
/**
|
||||
* Must this host go through the trust sheet before it can stream?
|
||||
*
|
||||
* A pinned fingerprint is the ONLY rule. The session binary refuses a pinless connect, so a row
|
||||
* without one can offer nothing but a button that fails; with one, the connect is verified and
|
||||
* the host either admits it or parks it for an operator. The old rule also consulted the
|
||||
* advertised policy for unsaved hosts, which made the answer depend on which of two lists a row
|
||||
* came from — the same box could read differently before and after being saved.
|
||||
*/
|
||||
export function needsPair(v: HostView): boolean {
|
||||
return v.fp === "";
|
||||
}
|
||||
|
||||
function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
|
||||
return (
|
||||
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
|
||||
(s.addr === a.addr && s.port === a.port)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the saved store and the live browse into the rows the panel draws.
|
||||
*
|
||||
* Fingerprint first, address second — a host that moved DHCP lease still matches its record,
|
||||
* and a different box that inherited the old address does not inherit its pairing. The CLI's
|
||||
* `discover` annotates `saved`/`paired` by exactly this rule too, so the two can't disagree.
|
||||
*/
|
||||
export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): HostView[] {
|
||||
const views: HostView[] = saved.map((s) => {
|
||||
// Prefer a live advert's address: the host may have moved since it was last saved.
|
||||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||||
return {
|
||||
name: s.name || s.addr,
|
||||
addr: advert?.addr ?? s.addr,
|
||||
port: advert?.port ?? s.port,
|
||||
fp: s.fp_hex,
|
||||
advertisedFp: advert?.fp ?? "",
|
||||
moved: !!advert && (advert.addr !== s.addr || advert.port !== s.port),
|
||||
paired: s.paired,
|
||||
online: !!advert || s.online === true,
|
||||
saved: true,
|
||||
pairPolicy: advert?.pair ?? "",
|
||||
os: advert?.os || s.os || "",
|
||||
ref: s.id || `${advert?.addr ?? s.addr}:${advert?.port ?? s.port}`,
|
||||
profile: s.profile,
|
||||
pinnedProfiles: s.pinned_profiles ?? [],
|
||||
lastUsed: s.last_used,
|
||||
};
|
||||
});
|
||||
for (const a of discovered) {
|
||||
if (saved.some((s) => advertMatchesSaved(a, s))) {
|
||||
continue; // already rendered as its saved row, with a live pip
|
||||
}
|
||||
views.push({
|
||||
name: a.name,
|
||||
addr: a.addr,
|
||||
port: a.port,
|
||||
// No record, so nothing is pinned — whatever it advertises is an OFFER, not a pin.
|
||||
fp: "",
|
||||
advertisedFp: a.fp,
|
||||
moved: false, // no record, so nothing to be stale
|
||||
paired: a.paired,
|
||||
online: true,
|
||||
saved: false,
|
||||
pairPolicy: a.pair,
|
||||
os: a.os,
|
||||
ref: `${a.addr}:${a.port}`,
|
||||
profile: null,
|
||||
pinnedProfiles: [],
|
||||
lastUsed: null,
|
||||
});
|
||||
}
|
||||
return views.sort(sortRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Online first, then most recently used, then by name. The host you streamed last night should
|
||||
* be the first thing under your thumb; a host that is off right now should never be.
|
||||
*/
|
||||
function sortRows(a: HostView, b: HostView): number {
|
||||
if (a.online !== b.online) return a.online ? -1 : 1;
|
||||
if ((a.lastUsed ?? 0) !== (b.lastUsed ?? 0)) return (b.lastUsed ?? 0) - (a.lastUsed ?? 0);
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Discovery — mDNS scan state shared by the QAM panel and the full page.
|
||||
// Hosts — ONE call site for both lists. They were separate hooks when the plugin had two
|
||||
// views mounting them independently; the panel is the only view now, and merging them means
|
||||
// the "scanning" state covers the whole row set rather than half of it flickering in first.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useHosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [views, setViews] = useState<HostView[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
|
||||
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// "client-outdated" — the installed client predates `punktfunk discover`
|
||||
// "client-unavailable" — there is no client installed at all
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
setHosts(await discover());
|
||||
// Both in flight at once: the browse is time-bounded and the probe is network-bound, so
|
||||
// running them in sequence would cost the sum of two waits for no benefit.
|
||||
const [d, s] = await Promise.all([discover(), listHosts()]);
|
||||
// Both calls run the same binary, so they fail the same way; take whichever answered.
|
||||
setProblem(
|
||||
d.error === "client-unavailable" || s.error === "client-unavailable"
|
||||
? "client-unavailable"
|
||||
: d.error === "client-outdated" || s.error === "client-outdated"
|
||||
? "client-outdated"
|
||||
: null,
|
||||
);
|
||||
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
|
||||
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
@@ -59,157 +227,7 @@ export function useHosts() {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { hosts, scanning, refresh };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Saved hosts — the SHARED known-hosts store (client-known-hosts.json), the same file the
|
||||
// desktop client reads/writes. Fetched WITH a reachability probe so a host reached over a
|
||||
// routed network (Tailscale/VPN) reports online without ever appearing on mDNS.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useSavedHosts() {
|
||||
const [saved, setSaved] = useState<SavedHost[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await listHosts(true);
|
||||
setSaved(r.hosts ?? []);
|
||||
} catch {
|
||||
/* backend unavailable — keep the current view */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { saved, loading, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* One host as the UI shows it — the union of the saved store and the live mDNS scan. A saved
|
||||
* host is ONLINE when it either advertises on mDNS OR answers the reachability probe (so
|
||||
* mDNS-blind-but-reachable hosts stop reading as offline). Discovered hosts not in the store
|
||||
* are appended as unsaved rows.
|
||||
*/
|
||||
export interface HostView {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp: string; // "" for a saved-but-unpaired placeholder
|
||||
paired: boolean; // PIN-paired specifically (a TOFU host has fp but paired=false)
|
||||
online: boolean;
|
||||
saved: boolean; // present in the known-hosts store
|
||||
pairPolicy: string; // the advert's policy ("required"|"optional"), "" when not advertising
|
||||
mgmt: number; // advertised mgmt-API port (0 = not advertised → default)
|
||||
id: string; // advertised stable host id ("" when not advertising)
|
||||
os: string; // OS-identity chain (live advert preferred, else the stored one); "" unknown
|
||||
}
|
||||
|
||||
function advertMatchesSaved(a: Host, s: SavedHost): boolean {
|
||||
return (
|
||||
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
|
||||
(s.addr === a.host && s.port === a.port)
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeHosts(saved: SavedHost[], discovered: Host[]): HostView[] {
|
||||
const views: HostView[] = saved.map((s) => {
|
||||
// Prefer a live advert's address (a host may have moved DHCP leases since it was saved).
|
||||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||||
return {
|
||||
name: s.name || s.addr,
|
||||
addr: advert?.host ?? s.addr,
|
||||
port: advert?.port ?? s.port,
|
||||
fp: s.fp_hex || advert?.fp || "",
|
||||
paired: s.paired,
|
||||
online: !!advert || s.online === true,
|
||||
saved: true,
|
||||
pairPolicy: advert?.pair ?? "",
|
||||
mgmt: advert?.mgmt ?? 0,
|
||||
id: advert?.id ?? "",
|
||||
os: advert?.os || s.os || "",
|
||||
};
|
||||
});
|
||||
for (const a of discovered) {
|
||||
if (saved.some((s) => advertMatchesSaved(a, s))) {
|
||||
continue; // already rendered as its saved card (with a live pip)
|
||||
}
|
||||
views.push({
|
||||
name: a.name,
|
||||
addr: a.host,
|
||||
port: a.port,
|
||||
fp: a.fp,
|
||||
paired: a.paired,
|
||||
online: true,
|
||||
saved: false,
|
||||
pairPolicy: a.pair,
|
||||
mgmt: a.mgmt,
|
||||
id: a.id,
|
||||
os: a.os,
|
||||
});
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this host must be paired before it can stream. A saved host is streamable once it
|
||||
* has a pinned fingerprint (PIN-paired OR TOFU-trusted); a saved placeholder (no fp yet) must be
|
||||
* paired. For an unsaved discovered host we keep the advertised-policy rule the UI always used.
|
||||
*/
|
||||
export function needsPair(v: HostView): boolean {
|
||||
return v.saved ? v.fp === "" : v.pairPolicy === "required" && !v.paired;
|
||||
}
|
||||
|
||||
/** Adapt a merged view back into the `Host` shape the pair/library/stream helpers consume. */
|
||||
export function toHost(v: HostView): Host {
|
||||
return {
|
||||
name: v.name,
|
||||
host: v.addr,
|
||||
port: v.port,
|
||||
pair: v.pairPolicy || (needsPair(v) ? "required" : "optional"),
|
||||
fp: v.fp,
|
||||
proto: "",
|
||||
paired: v.paired,
|
||||
id: v.id,
|
||||
mgmt: v.mgmt,
|
||||
os: v.os,
|
||||
};
|
||||
}
|
||||
|
||||
/** Is a pinned game's host currently online, considering BOTH the live scan and saved probe? */
|
||||
export function pinIsOnline(pin: PinnedGame, views: HostView[]): boolean {
|
||||
const fp = pin.host_fp.toLowerCase();
|
||||
return views.some(
|
||||
(v) =>
|
||||
v.online &&
|
||||
((!!fp && v.fp.toLowerCase() === fp) ||
|
||||
(!!pin.host_id && v.id === pin.host_id) ||
|
||||
(v.addr === pin.host && v.port === pin.port)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all Punktfunk state (saved hosts + stream settings + pins), keeping the client identity.
|
||||
* Refreshes whatever views are passed so the UI clears immediately. Ends in a toast.
|
||||
*/
|
||||
export async function resetAll(refreshers: Array<() => void | Promise<void>>): Promise<void> {
|
||||
try {
|
||||
const r = await resetConfig();
|
||||
for (const fn of refreshers) void fn();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.ok
|
||||
? "Reset — saved hosts, settings, and pins cleared."
|
||||
: `Reset failed${r.error ? ` (${r.error})` : ""}.`,
|
||||
});
|
||||
} catch {
|
||||
toaster.toast({ title: "Punktfunk", body: "Reset failed." });
|
||||
}
|
||||
return { views, scanning, problem, refresh };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
@@ -260,36 +278,6 @@ export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boole
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How the client got onto this box, in words a Deck user recognises. The raw kind comes from
|
||||
* the client's own detector (`pf_update_check::detect`); anything unmapped falls through as
|
||||
* itself rather than as "unknown", because the raw word is still more useful than a shrug.
|
||||
*/
|
||||
export function clientInstallLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "flatpak":
|
||||
return "Flatpak (per-user)";
|
||||
case "apt":
|
||||
return "System package (apt)";
|
||||
case "dnf":
|
||||
return "System package (dnf)";
|
||||
case "rpm-ostree":
|
||||
return "Layered package (rpm-ostree)";
|
||||
case "pacman":
|
||||
return "System package (pacman)";
|
||||
case "sysext":
|
||||
return "System extension (sysext)";
|
||||
case "nix":
|
||||
return "Nix profile";
|
||||
case "steamos-source":
|
||||
return "On-device build";
|
||||
case "source":
|
||||
return "Built from source";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the only pending update is one this Deck can't apply itself. */
|
||||
export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean {
|
||||
return !!info && info.client_update_available && !clientUpdateIsOneTap(info);
|
||||
@@ -427,167 +415,26 @@ export async function applyUpdate(
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
|
||||
// Stream launch — via the hidden Steam shortcut (see steam.ts for why it can't be direct).
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stream this host. `opts.profileId` streams one of its pinned cards; `opts.requestAccess`
|
||||
* runs the supervised launch that waits for the host's operator to approve this Deck.
|
||||
*
|
||||
* The host is named by REFERENCE (`v.ref`), never by value — no resolution, bitrate or codec
|
||||
* ever rides the launch path, which is the same rule the deep-link grammar enforces.
|
||||
*/
|
||||
export async function startStream(
|
||||
h: Host,
|
||||
v: HostView,
|
||||
opts: LaunchOpts = {},
|
||||
label?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await launchStream(h.host, h.port, opts);
|
||||
await launchStream(v.ref, opts);
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${h.name}` });
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${v.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the GTK client's gamepad library launcher for a host (`--browse` via PF_BROWSE). */
|
||||
export async function startBrowse(h: Host): Promise<void> {
|
||||
try {
|
||||
await launchStream(h.host, h.port, { browse: true, mgmt: h.mgmt });
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Opening library — ${h.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Pinned games — the QAM's one-tap game rows, persisted by the backend next to the
|
||||
// client's config (survives plugin reinstalls).
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export interface PinsApi {
|
||||
pins: PinnedGame[];
|
||||
addPin: (h: Host, g: GameEntry) => void;
|
||||
removePin: (hostFp: string, gameId: string) => void;
|
||||
isPinned: (hostFp: string, gameId: string) => boolean;
|
||||
/** Refresh a pin's stored address from a live advert (hosts change IPs). */
|
||||
updatePinHost: (pin: PinnedGame, h: Host) => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePins(): PinsApi {
|
||||
const [pins, setPins] = useState<PinnedGame[]>([]);
|
||||
// A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a
|
||||
// detached portal that captures this hook's callbacks ONCE and never re-renders with fresh
|
||||
// props, so a mutator closing over the `pins` array reads a frozen base — pinning a second
|
||||
// game in the same session would compute from the stale `[]` and clobber the first (silent
|
||||
// data loss). Reading the ref keeps every mutation based on the current set, and lets the
|
||||
// callbacks keep a stable identity (deps free of `pins`).
|
||||
const pinsRef = useRef<PinnedGame[]>([]);
|
||||
pinsRef.current = pins;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setPins((await getPins()).pins);
|
||||
} catch {
|
||||
/* backend unavailable — keep the current view */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Optimistic local state; the backend validates/dedups and is re-read on failure.
|
||||
const save = useCallback(
|
||||
(next: PinnedGame[]) => {
|
||||
pinsRef.current = next;
|
||||
setPins(next);
|
||||
setPinsBackend(next).catch(() => void refresh());
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const addPin = useCallback(
|
||||
(h: Host, g: GameEntry) => {
|
||||
const pin: PinnedGame = {
|
||||
game_id: g.id,
|
||||
title: g.title,
|
||||
store: g.store,
|
||||
host_fp: h.fp,
|
||||
host_id: h.id,
|
||||
host_name: h.name,
|
||||
host: h.host,
|
||||
port: h.port,
|
||||
mgmt: h.mgmt,
|
||||
added_at: Math.floor(Date.now() / 1000),
|
||||
paired: h.paired,
|
||||
};
|
||||
save([
|
||||
...pinsRef.current.filter(
|
||||
(p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id),
|
||||
),
|
||||
pin,
|
||||
]);
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
const removePin = useCallback(
|
||||
(hostFp: string, gameId: string) => {
|
||||
save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
const isPinned = useCallback(
|
||||
(hostFp: string, gameId: string) =>
|
||||
pins.some((p) => p.host_fp === hostFp && p.game_id === gameId),
|
||||
[pins],
|
||||
);
|
||||
|
||||
const updatePinHost = useCallback(
|
||||
(pin: PinnedGame, h: Host) => {
|
||||
if (pin.host === h.host && pin.port === h.port && pin.mgmt === h.mgmt) {
|
||||
return;
|
||||
}
|
||||
save(
|
||||
pinsRef.current.map((p) =>
|
||||
p.host_fp === pin.host_fp && p.game_id === pin.game_id
|
||||
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
|
||||
: p,
|
||||
),
|
||||
);
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* The host a pin should launch against right now: match the live mDNS scan by cert
|
||||
* fingerprint first (pairing is fp-keyed, survives IP changes), then by the host's stable
|
||||
* id, else fall back to the stored address (host offline or scan flaky — still launch).
|
||||
*/
|
||||
export function resolvePinHost(
|
||||
pin: PinnedGame,
|
||||
live: Host[],
|
||||
): { host: Host; online: boolean } {
|
||||
const fp = pin.host_fp.toLowerCase();
|
||||
const match =
|
||||
(fp && live.find((h) => h.fp && h.fp.toLowerCase() === fp)) ||
|
||||
(pin.host_id && live.find((h) => h.id && h.id === pin.host_id)) ||
|
||||
undefined;
|
||||
if (match) {
|
||||
return { host: match, online: true };
|
||||
}
|
||||
return {
|
||||
host: {
|
||||
name: pin.host_name || pin.host,
|
||||
host: pin.host,
|
||||
port: pin.port,
|
||||
pair: pin.paired ? "optional" : "required",
|
||||
fp: pin.host_fp,
|
||||
proto: "",
|
||||
paired: !!pin.paired,
|
||||
id: pin.host_id,
|
||||
mgmt: pin.mgmt,
|
||||
os: "", // pins don't store the chain; the icon is a hosts-tab affordance
|
||||
},
|
||||
online: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
// Add / edit host dialogs for the fullscreen page. These mutate the SHARED known-hosts store
|
||||
// (client-known-hosts.json) through the flatpak client's headless modes, so a host saved or
|
||||
// renamed here shows up in the desktop client too. Text entry uses @decky/ui's TextField, which
|
||||
// brings up Steam's on-screen keyboard on focus (the digit-grid trick in pair.tsx is only needed
|
||||
// for the numeric PIN).
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner, TextField } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { ChangeEvent, FC, useState } from "react";
|
||||
import { addHost, editHost, MutationResult } from "./backend";
|
||||
import { HostView } from "./hooks";
|
||||
import { actionButton } from "./ui";
|
||||
|
||||
/** Stable copy for a failed host-store mutation. */
|
||||
export function mutationError(r: MutationResult): string {
|
||||
switch (r.error) {
|
||||
case "client-unavailable":
|
||||
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
|
||||
case "client-outdated":
|
||||
return "The installed client is too old for host management — update it from the About tab.";
|
||||
default:
|
||||
return r.detail || "Couldn't save the host.";
|
||||
}
|
||||
}
|
||||
|
||||
// Split a typed address: a pasted `host:port` wins over the separate port field. IPv6 literals
|
||||
// aren't supported by the host advert/known-hosts format, so a bare colon is treated as host:port.
|
||||
function targetFrom(addr: string, port: string): string {
|
||||
const a = addr.trim();
|
||||
if (a.includes(":")) {
|
||||
return a;
|
||||
}
|
||||
const p = port.trim() || "9777";
|
||||
return `${a}:${p}`;
|
||||
}
|
||||
|
||||
const field: React.CSSProperties = { marginBottom: "0.8em" };
|
||||
|
||||
const HostForm: FC<{
|
||||
title: string;
|
||||
submitLabel: string;
|
||||
initial: { addr: string; port: string; name: string };
|
||||
addrDisabled?: boolean;
|
||||
onSubmit: (addr: string, port: string, name: string) => Promise<MutationResult>;
|
||||
onDone: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ title, submitLabel, initial, addrDisabled, onSubmit, onDone, closeModal }) => {
|
||||
const [addr, setAddr] = useState(initial.addr);
|
||||
const [port, setPort] = useState(initial.port);
|
||||
const [name, setName] = useState(initial.name);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (!addr.trim()) {
|
||||
setError("Enter an address.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await onSubmit(addr.trim(), port.trim(), name.trim());
|
||||
if (r.ok) {
|
||||
onDone();
|
||||
closeModal?.();
|
||||
} else {
|
||||
setError(mutationError(r));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.6em" }}>{title}</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Address"
|
||||
description="IP or hostname (a Tailscale/VPN name works too). Add :port to override."
|
||||
value={addr}
|
||||
disabled={addrDisabled || busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setAddr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Port"
|
||||
value={port}
|
||||
mustBeNumeric
|
||||
disabled={busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setPort(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Name (optional)"
|
||||
value={name}
|
||||
disabled={busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
|
||||
)}
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
<DialogButton style={actionButton} disabled={busy} onClick={() => closeModal?.()}>
|
||||
Cancel
|
||||
</DialogButton>
|
||||
<DialogButton style={actionButton} disabled={busy} onClick={submit}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : submitLabel}
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
/** "+" — save a new host by address (unpaired placeholder; the user pairs it next). */
|
||||
export const AddHostModal: FC<{ onDone: () => void; closeModal?: () => void }> = ({
|
||||
onDone,
|
||||
closeModal,
|
||||
}) => (
|
||||
<HostForm
|
||||
title="Add host"
|
||||
submitLabel="Add"
|
||||
initial={{ addr: "", port: "9777", name: "" }}
|
||||
onSubmit={async (addr, port, name) => {
|
||||
const r = await addHost(targetFrom(addr, port), name, "");
|
||||
if (r.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Added ${name || addr}` });
|
||||
}
|
||||
return r;
|
||||
}}
|
||||
onDone={onDone}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
);
|
||||
|
||||
/** Rename / re-point a saved host. Identified by fingerprint when it has one (survives IP
|
||||
* changes), else by its current address. */
|
||||
export const EditHostModal: FC<{
|
||||
host: HostView;
|
||||
onDone: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, onDone, closeModal }) => {
|
||||
const selector = host.fp || `${host.addr}:${host.port}`;
|
||||
return (
|
||||
<HostForm
|
||||
title={`Edit ${host.name}`}
|
||||
submitLabel="Save"
|
||||
initial={{ addr: host.addr, port: String(host.port), name: host.name }}
|
||||
onSubmit={async (addr, port, name) => {
|
||||
const r = await editHost(selector, name, addr, parseInt(port, 10) || 0);
|
||||
if (r.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Updated ${name || addr}` });
|
||||
}
|
||||
return r;
|
||||
}}
|
||||
onDone={onDone}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+200
-114
@@ -1,5 +1,10 @@
|
||||
// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives
|
||||
// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts.
|
||||
// Plugin entry: the Quick Access Menu panel. That is the whole plugin now — the fullscreen
|
||||
// route, the settings screen, the host editor and the games picker are gone, because the
|
||||
// client's own console home does all four one shortcut away (and is gamepad-navigable, which
|
||||
// a QAM panel re-implementing them never quite was).
|
||||
//
|
||||
// What is left is what only a Decky plugin can do: start a stream through Steam so gamescope
|
||||
// focuses it (see steam.ts), and stand in front of the trust decision that gates it.
|
||||
import {
|
||||
ButtonItem,
|
||||
Field,
|
||||
@@ -10,37 +15,35 @@ import {
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||
import { FC } from "react";
|
||||
import { definePlugin, toaster } from "@decky/api";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import {
|
||||
FaDownload,
|
||||
FaGamepad,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPlay,
|
||||
FaPlus,
|
||||
FaStopCircle,
|
||||
FaSyncAlt,
|
||||
FaTv,
|
||||
} from "react-icons/fa";
|
||||
import { hostAction, killStream, streamRunning } from "./backend";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import {
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
clientUpdateIsManualOnly,
|
||||
hasUpdate,
|
||||
mergeHosts,
|
||||
HostView,
|
||||
needsPair,
|
||||
pinIsOnline,
|
||||
startStream,
|
||||
toHost,
|
||||
trustState,
|
||||
useHosts,
|
||||
usePins,
|
||||
useSavedHosts,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
import { streamPin } from "./library";
|
||||
import { PunktfunkRoute, ROUTE } from "./page";
|
||||
import { PairModal } from "./pair";
|
||||
import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam";
|
||||
import { OsMark } from "./os-icon";
|
||||
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
|
||||
import { TrustSheet } from "./trust";
|
||||
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
|
||||
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
|
||||
@@ -54,22 +57,106 @@ async function recreatePunktfunkShortcut(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
||||
// and pinned games.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const QamPanel: FC = () => {
|
||||
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
|
||||
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const pins = usePins();
|
||||
/** Force-stop a wedged stream: end Steam's "game", then make sure the client itself is gone. */
|
||||
async function forceStop(): Promise<void> {
|
||||
stopStream();
|
||||
try {
|
||||
await killStream();
|
||||
} catch {
|
||||
/* best-effort — the TerminateApp above is usually enough */
|
||||
}
|
||||
toaster.toast({ title: "Punktfunk", body: "Stopped the stream" });
|
||||
}
|
||||
|
||||
const hosts = mergeHosts(saved, discovered);
|
||||
const busy = scanning || loadingSaved;
|
||||
const refresh = () => {
|
||||
void refreshDiscovered();
|
||||
void refreshSaved();
|
||||
};
|
||||
// Press a host system button (guide/QAM) on the running stream, then hand the screen back
|
||||
// to it — closing the local menus is what lets the HOST's overlay show through. The raw
|
||||
// Steam/··· presses stay on the Deck by default (both overlays would open at once), so this
|
||||
// is the panel route to the host's menus; holding Select is the controller route.
|
||||
async function pressHost(action: "guide" | "qam"): Promise<void> {
|
||||
const r = await hostAction(action).catch(() => ({ ok: false as const, error: "backend" }));
|
||||
if (r.ok) {
|
||||
Navigation.CloseSideMenus();
|
||||
} else {
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.error === "no-stream" ? "No stream is running" : "Couldn't reach the stream",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The line under a host's name: where it is, whether it's up, and how far trust has got. */
|
||||
function hostDescription(v: HostView): string {
|
||||
const trust = {
|
||||
paired: "paired",
|
||||
trusted: "trusted",
|
||||
"needs-access": "needs access",
|
||||
}[trustState(v)];
|
||||
return `${v.addr}:${v.port} · ${v.online ? "online" : "offline"} · ${trust}`;
|
||||
}
|
||||
|
||||
const HostRow: FC<{ host: HostView; refresh: () => void }> = ({ host, refresh }) => {
|
||||
const gated = needsPair(host);
|
||||
const stream = (opts: { requestAccess?: boolean } = {}) => void startStream(host, opts);
|
||||
return (
|
||||
<>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() =>
|
||||
gated
|
||||
? showModal(
|
||||
<TrustSheet host={host} onStream={stream} onChanged={refresh} />,
|
||||
)
|
||||
: stream()
|
||||
}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{gated ? <FaLock /> : <OsMark os={host.os} />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={hostDescription(host)}
|
||||
>
|
||||
{gated ? "Connect…" : "Stream"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{/* Pinned cards, nested under their host rather than in a section of their own: a card
|
||||
IS a (host, profile) pair, and a row that floats free of its host is the "a pinned
|
||||
tile reads as a duplicate host" problem the desktop shells still have. The host's
|
||||
own BOUND profile is deliberately not a card — it applies silently on the plain row
|
||||
above, and showing it twice would suggest they do different things. */}
|
||||
{!gated &&
|
||||
host.pinnedProfiles.map((p) => (
|
||||
<PanelSectionRow key={`${host.ref}:${p.id}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => void startStream(host, { profileId: p.id }, `“${p.name}”`)}
|
||||
label={`▸ ${p.name}`}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.5em" }} />
|
||||
Stream
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const QamPanel: FC = () => {
|
||||
const { views, scanning, problem, refresh } = useHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
// The host-buttons section shows only while the streaming client is up (checked per
|
||||
// panel open — the QAM panel mounts fresh each time).
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void streamRunning()
|
||||
.then((r) => live && setStreaming(r.running))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -110,15 +197,62 @@ const QamPanel: FC = () => {
|
||||
</PanelSection>
|
||||
))}
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={() => void refresh()} disabled={scanning}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{/* A client that is missing or too old explains itself rather than rendering an empty
|
||||
list — "no hosts on your LAN" would blame the network for the plugin's problem, and
|
||||
for the outdated case the button that fixes it is in this same panel. */}
|
||||
{problem && (
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label={
|
||||
problem === "client-unavailable"
|
||||
? "Punktfunk isn’t installed"
|
||||
: "Update the Punktfunk client"
|
||||
}
|
||||
description={
|
||||
problem === "client-unavailable"
|
||||
? "This panel launches the Punktfunk app, which isn’t on this Deck yet. Install it in Desktop Mode."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{views.length === 0 && scanning && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description="Scanning your network…" />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{views.length === 0 && !scanning && !problem && (
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts yet"
|
||||
description="Open Punktfunk to find and pair one."
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{views.map((v) => (
|
||||
<HostRow key={v.ref} host={v} refresh={refresh} />
|
||||
))}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Punktfunk">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Host details, stream settings, and help"
|
||||
onClick={() => {
|
||||
Navigation.Navigate(ROUTE);
|
||||
Navigation.CloseSideMenus();
|
||||
}}
|
||||
description="Settings, adding a host by address, and browsing a host's games all live here."
|
||||
onClick={() => void launchGamepadUi()}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.5em" }} />
|
||||
Open Punktfunk
|
||||
@@ -126,85 +260,31 @@ const QamPanel: FC = () => {
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
{/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's
|
||||
picker (fullscreen page → host row → games button). */}
|
||||
{pins.pins.length > 0 && (
|
||||
<PanelSection title="Pinned Games">
|
||||
{pins.pins.map((pin) => {
|
||||
const online = pinIsOnline(pin, hosts);
|
||||
return (
|
||||
<PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
|
||||
label={pin.title}
|
||||
description={`${pin.host_name}${online ? "" : " · offline?"}${
|
||||
pin.paired ? "" : " · pairing required"
|
||||
}`}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.5em" }} />
|
||||
Stream
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
);
|
||||
})}
|
||||
{streaming && (
|
||||
<PanelSection title="Host menus">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Press the Steam/guide button on the host"
|
||||
onClick={() => void pressHost("guide")}
|
||||
>
|
||||
<FaGamepad style={{ marginRight: "0.5em" }} />
|
||||
Steam menu on host
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Open the host's Quick Access Menu"
|
||||
onClick={() => void pressHost("qam")}
|
||||
>
|
||||
<FaGamepad style={{ marginRight: "0.5em" }} />
|
||||
Quick access on host
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={refresh} disabled={busy}>
|
||||
{busy ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{busy ? "Scanning…" : "Refresh"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{hosts.length === 0 && busy && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description="Scanning your network…" />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.length === 0 && !busy && (
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts found"
|
||||
description="Open Punktfunk to add a host by address, or start a host on this network and refresh."
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.map((v) => {
|
||||
const pair = needsPair(v);
|
||||
const h = toHost(v);
|
||||
return (
|
||||
<PanelSectionRow key={v.fp || `${v.addr}:${v.port}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() =>
|
||||
pair
|
||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||
: startStream(h)
|
||||
}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{pair ? <FaLock /> : <FaLockOpen />}
|
||||
{v.name}
|
||||
</span>
|
||||
}
|
||||
description={`${v.addr}:${v.port} · ${v.online ? "online" : "offline"}${
|
||||
pair ? " · pairing required" : v.paired ? " · paired" : ""
|
||||
}`}
|
||||
>
|
||||
{pair ? "Pair & Stream" : "Stream"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
);
|
||||
})}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="About">
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
@@ -236,13 +316,22 @@ const QamPanel: FC = () => {
|
||||
Recreate library shortcut
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Ends a stream that stopped responding."
|
||||
onClick={() => void forceStop()}
|
||||
>
|
||||
<FaStopCircle style={{ marginRight: "0.5em" }} />
|
||||
Force-stop
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
||||
// Ensure the visible, stateless "Punktfunk" library entry (opens the gamepad UI / console
|
||||
// home) exists and is repointed to the current plugin dir — also installs the native-touch
|
||||
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
|
||||
@@ -260,8 +349,5 @@ export default definePlugin(() => {
|
||||
</PluginErrorBoundary>
|
||||
),
|
||||
icon: <FaTv />,
|
||||
onDismount() {
|
||||
routerHook.removeRoute(ROUTE);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
// The per-host game picker + pinned-game launch helper. The picker fetches a paired
|
||||
// host's library through the backend (headless flatpak --library — a cold client start
|
||||
// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in
|
||||
// the QAM's Games section; its header also launches the GTK client's on-screen gamepad
|
||||
// library (`--browse`).
|
||||
import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { FaThLarge, FaTv } from "react-icons/fa";
|
||||
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
|
||||
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
|
||||
import { isSafeLaunchId } from "./steam";
|
||||
import { PairModal } from "./pair";
|
||||
import { RowActions, actionButton } from "./ui";
|
||||
|
||||
/** Human store tag (mirrors the GTK client's `store_label`). */
|
||||
export function storeLabel(store: string): string {
|
||||
switch (store) {
|
||||
case "steam":
|
||||
return "Steam";
|
||||
case "custom":
|
||||
return "Custom";
|
||||
case "heroic":
|
||||
return "Heroic";
|
||||
case "lutris":
|
||||
return "Lutris";
|
||||
case "epic":
|
||||
return "Epic";
|
||||
case "gog":
|
||||
return "GOG";
|
||||
case "xbox":
|
||||
return "Xbox";
|
||||
default:
|
||||
return "Game";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a pinned game: resolve the host from the live scan (fp → id → stored address),
|
||||
* opportunistically refresh a drifted stored address, and route through pairing first if
|
||||
* this device is no longer paired with the host.
|
||||
*/
|
||||
export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
|
||||
const { host, online } = resolvePinHost(pin, live);
|
||||
if (online) {
|
||||
pins.updatePinHost(pin, host); // no-op unless the address actually drifted
|
||||
}
|
||||
if (!pin.paired) {
|
||||
showModal(
|
||||
<PairModal
|
||||
host={host}
|
||||
onPaired={() => {
|
||||
void pins.refresh(); // pick up the now-paired annotation
|
||||
void startStream(host, { launchId: pin.game_id }, pin.title);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
void startStream(host, { launchId: pin.game_id }, pin.title);
|
||||
}
|
||||
|
||||
// Copy per backend error code (LibraryResult.error); `detail` covers the generic case.
|
||||
function errorCopy(res: LibraryResult): string {
|
||||
switch (res.error) {
|
||||
case "not-paired":
|
||||
return "This Deck isn't paired with the host — pair first, then browse its library.";
|
||||
case "pin-mismatch":
|
||||
return "The host's identity changed — re-pair to re-establish trust.";
|
||||
case "unreachable":
|
||||
return "Couldn't reach the host's management API. Is the host online and up to date?";
|
||||
case "timeout":
|
||||
return "Timed out talking to the host — try again.";
|
||||
case "flatpak-not-found":
|
||||
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
|
||||
case "client-outdated":
|
||||
return "The installed client is too old for library browsing — update it from the About tab.";
|
||||
default:
|
||||
return res.detail || "Couldn't fetch the library.";
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The picker modal: "open on screen" + a pin-toggle list of the host's games.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export const GamePickerModal: FC<{
|
||||
host: Host;
|
||||
pins: PinsApi;
|
||||
clientUpdatePending?: boolean;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
|
||||
const [result, setResult] = useState<LibraryResult | null>(null);
|
||||
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
|
||||
// The modal is a detached `showModal` portal that never re-renders from the page's pin
|
||||
// state, so `pins.isPinned` would read a frozen snapshot and the Pin/Unpin label would
|
||||
// never flip within a session. Track this host's pinned ids locally, seeded once from the
|
||||
// snapshot at open; persistence still goes through the (stale-closure-safe) pins API.
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(
|
||||
() => new Set(pins.pins.filter((p) => p.host_fp === host.fp).map((p) => p.game_id)),
|
||||
);
|
||||
const togglePin = (g: GameEntry) => {
|
||||
const wasPinned = pinnedIds.has(g.id);
|
||||
setPinnedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasPinned) next.delete(g.id);
|
||||
else next.add(g.id);
|
||||
return next;
|
||||
});
|
||||
if (wasPinned) pins.removePin(host.fp, g.id);
|
||||
else pins.addPin(host, g);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false;
|
||||
setResult(null);
|
||||
library(host.host, host.mgmt, host.fp)
|
||||
.then((res) => {
|
||||
if (!stale) setResult(res);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!stale) setResult({ ok: false, error: "client-error", detail: String(e) });
|
||||
});
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
}, [host.host, host.mgmt, host.fp, attempt]);
|
||||
|
||||
const games = (result?.ok && result.games) || [];
|
||||
const sorted = [...games].sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
|
||||
{host.name} — Games
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Open library on screen"
|
||||
description="Browse this host's games with the controller, full screen"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
void startBrowse(host);
|
||||
}}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
|
||||
{clientUpdatePending && (
|
||||
<Field
|
||||
focusable={false}
|
||||
description="A client update is available — direct game launch and on-screen browsing need the latest client."
|
||||
/>
|
||||
)}
|
||||
|
||||
{result === null && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.6em" }}>
|
||||
<Spinner style={{ height: "1em" }} />
|
||||
Fetching the library…
|
||||
</span>
|
||||
}
|
||||
description="This starts the client headlessly — a cold start can take a few seconds."
|
||||
/>
|
||||
)}
|
||||
|
||||
{result !== null && !result.ok && (
|
||||
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
{result.error === "not-paired" && (
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() =>
|
||||
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
|
||||
}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={actionButton} onClick={() => setAttempt((n) => n + 1)}>
|
||||
Retry
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{result?.ok && sorted.length === 0 && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No games found"
|
||||
description="Install Steam titles or add custom entries in the host's web console."
|
||||
/>
|
||||
)}
|
||||
|
||||
{sorted.length > 0 && (
|
||||
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
|
||||
{sorted.map((g: GameEntry) => {
|
||||
const pinned = pinnedIds.has(g.id);
|
||||
const safe = isSafeLaunchId(g.id);
|
||||
return (
|
||||
<Field
|
||||
key={g.id}
|
||||
label={g.title}
|
||||
description={
|
||||
storeLabel(g.store) + (safe ? "" : " · unsupported id — can't be pinned")
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={!safe} onClick={() => togglePin(g)}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
{pinned ? "Unpin" : "Pin"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
@@ -1,590 +0,0 @@
|
||||
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
|
||||
import {
|
||||
ConfirmModal,
|
||||
DialogButton,
|
||||
Field,
|
||||
Focusable,
|
||||
ModalRoot,
|
||||
Navigation,
|
||||
Spinner,
|
||||
Tabs,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { RowActions, actionButton, iconButton } from "./ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { CSSProperties, FC, useState } from "react";
|
||||
import {
|
||||
FaArrowLeft,
|
||||
FaDownload,
|
||||
FaExternalLinkAlt,
|
||||
FaInfoCircle,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPen,
|
||||
FaPlay,
|
||||
FaPlus,
|
||||
FaSyncAlt,
|
||||
FaThLarge,
|
||||
FaTrashAlt,
|
||||
} from "react-icons/fa";
|
||||
import { UpdateInfo, forgetHost, killStream } from "./backend";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import { OsMark } from "./os-icon";
|
||||
import {
|
||||
DOCS_URL,
|
||||
HostView,
|
||||
PinsApi,
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
clientInstallLabel,
|
||||
clientUpdateIsManualOnly,
|
||||
hasUpdate,
|
||||
mergeHosts,
|
||||
needsPair,
|
||||
pinIsOnline,
|
||||
resetAll,
|
||||
startStream,
|
||||
toHost,
|
||||
useHosts,
|
||||
usePins,
|
||||
useSavedHosts,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
import { AddHostModal, EditHostModal, mutationError } from "./hostmgmt";
|
||||
import { GamePickerModal, storeLabel, streamPin } from "./library";
|
||||
import { PairModal } from "./pair";
|
||||
import { SettingsSection } from "./settings";
|
||||
import { stopStream } from "./steam";
|
||||
|
||||
export const ROUTE = "/punktfunk";
|
||||
|
||||
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
|
||||
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
|
||||
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
|
||||
const SAFE_BOTTOM = "80px";
|
||||
|
||||
// Each tab is its own scroll area so long content is always reachable above the footer.
|
||||
const tabScroll: CSSProperties = {
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
padding: "0.5em 2.5em",
|
||||
paddingBottom: SAFE_BOTTOM,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
// The one-line status under a host name: address, live presence, and trust state.
|
||||
function hostSubtitle(v: HostView): string {
|
||||
const parts = [`${v.addr}:${v.port}`, v.online ? "online" : "offline"];
|
||||
if (needsPair(v)) {
|
||||
parts.push("pairing required");
|
||||
} else if (v.paired) {
|
||||
parts.push("paired");
|
||||
} else if (v.saved) {
|
||||
parts.push("trusted");
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Confirm + forget a saved host, then refresh the list. */
|
||||
function confirmForget(v: HostView, refresh: () => void): void {
|
||||
const selector = v.fp || `${v.addr}:${v.port}`;
|
||||
showModal(
|
||||
<ConfirmModal
|
||||
strTitle={`Forget ${v.name}?`}
|
||||
strDescription="You'll need to pair or trust it again to reconnect."
|
||||
strOKButtonText="Forget"
|
||||
bDestructiveWarning
|
||||
onOK={async () => {
|
||||
const r = await forgetHost(selector);
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.ok ? `Forgot ${v.name}` : mutationError(r),
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Host details — everything we know, plus (for a saved host) rename / edit / forget.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostDetailsModal: FC<{
|
||||
host: HostView;
|
||||
onChanged: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, onChanged, closeModal }) => {
|
||||
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not known yet";
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
|
||||
{host.name}
|
||||
</div>
|
||||
<Field focusable={false} label="Address">
|
||||
{host.addr}:{host.port}
|
||||
</Field>
|
||||
<Field focusable={false} label="Presence">
|
||||
{host.online ? "Online" : "Offline"}
|
||||
</Field>
|
||||
<Field focusable={false} label="This Deck">
|
||||
{host.paired ? "Paired" : host.fp ? "Trusted" : "Not paired yet"}
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Certificate fingerprint (SHA-256)"
|
||||
description={
|
||||
<span
|
||||
style={{ fontFamily: "monospace", fontSize: "0.85em", wordBreak: "break-word" }}
|
||||
>
|
||||
{fp}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{host.saved && (
|
||||
<Field label="Manage" childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
showModal(<EditHostModal host={host} onDone={onChanged} />);
|
||||
}}
|
||||
>
|
||||
<FaPen style={{ marginRight: "0.4em" }} />
|
||||
Edit
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
confirmForget(host, onChanged);
|
||||
}}
|
||||
>
|
||||
<FaTrashAlt style={{ marginRight: "0.4em" }} />
|
||||
Forget
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// One host row: status icon + address, details / pair / stream actions.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostRow: FC<{
|
||||
host: HostView;
|
||||
onChanged: () => void;
|
||||
onGames: () => void;
|
||||
}> = ({ host, onChanged, onGames }) => {
|
||||
const pair = needsPair(host);
|
||||
const h = toHost(host);
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
<OsMark os={host.os} />
|
||||
{pair ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={hostSubtitle(host)}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={iconButton}
|
||||
onClick={() => showModal(<HostDetailsModal host={host} onChanged={onChanged} />)}
|
||||
>
|
||||
<FaInfoCircle />
|
||||
</DialogButton>
|
||||
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
|
||||
library browser, and controller nav has no hover tooltip to explain a bare icon. */}
|
||||
<DialogButton style={actionButton} onClick={onGames}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
Games
|
||||
</DialogButton>
|
||||
{pair && (
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => showModal(<PairModal host={h} onPaired={onChanged} />)}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() =>
|
||||
pair
|
||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||
: startStream(h)
|
||||
}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
const HostsTab: FC<{
|
||||
hosts: HostView[];
|
||||
scanning: boolean;
|
||||
refresh: () => void;
|
||||
pins: PinsApi;
|
||||
clientUpdatePending: boolean;
|
||||
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Hosts"
|
||||
description={
|
||||
scanning
|
||||
? "Scanning the LAN…"
|
||||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} — saved and on your network`
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => showModal(<AddHostModal onDone={refresh} />)}
|
||||
>
|
||||
<FaPlus style={{ marginRight: "0.5em" }} />
|
||||
Add
|
||||
</DialogButton>
|
||||
<DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts yet"
|
||||
description="Add one by address with +, or start a Punktfunk host on this network and refresh. The setup guide (About tab) covers installing a host."
|
||||
/>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow
|
||||
key={h.fp || `${h.addr}:${h.port}`}
|
||||
host={h}
|
||||
onChanged={refresh}
|
||||
onGames={() =>
|
||||
showModal(
|
||||
<GamePickerModal
|
||||
host={toHost(h)}
|
||||
pins={pins}
|
||||
clientUpdatePending={clientUpdatePending}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Pinned games — also the cleanup surface for pins whose host is gone from the scan. */}
|
||||
{pins.pins.length > 0 && (
|
||||
<>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Pinned games"
|
||||
description="One-tap streams — they also live in the quick-access menu"
|
||||
bottomSeparator="standard"
|
||||
/>
|
||||
{pins.pins.map((pin) => {
|
||||
const online = pinIsOnline(pin, hosts);
|
||||
return (
|
||||
<Field
|
||||
key={`${pin.host_fp}:${pin.game_id}`}
|
||||
label={pin.title}
|
||||
description={`${storeLabel(pin.store)} · ${pin.host_name}${
|
||||
online ? "" : " · offline?"
|
||||
}${pin.paired ? "" : " · pairing required"}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Play
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
|
||||
>
|
||||
Remove
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={tabScroll}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop,
|
||||
// and the destructive "reset everything" action.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
async function forceStopStream(): Promise<void> {
|
||||
stopStream(); // ask Steam to end the "game" first (clean path)
|
||||
const res = await killStream(); // then the flatpak-level hammer for a wedged client
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: res.ok ? "Stream client stopped." : "Couldn’t stop the stream client.",
|
||||
});
|
||||
}
|
||||
|
||||
function confirmReset(refreshers: Array<() => void | Promise<void>>): void {
|
||||
showModal(
|
||||
<ConfirmModal
|
||||
strTitle="Reset Punktfunk?"
|
||||
strDescription="Clears every saved host, your stream settings, and all pinned games on this Deck. Your client identity is kept, so you'll re-pair hosts to reconnect. This can't be undone."
|
||||
strOKButtonText="Reset"
|
||||
bDestructiveWarning
|
||||
onOK={() => void resetAll(refreshers)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const AboutTab: FC<{
|
||||
update: UpdateInfo | null;
|
||||
checking: boolean;
|
||||
check: (force: boolean) => Promise<UpdateInfo | null>;
|
||||
onReset: () => void;
|
||||
}> = ({ update, checking, check, onReset }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Version"
|
||||
description={
|
||||
update
|
||||
? `v${update.current}${
|
||||
update.channel ? ` · ${update.channel} channel` : " · development build"
|
||||
}`
|
||||
: "…"
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
disabled={checking}
|
||||
onClick={() => void checkForUpdatesNow(check)}
|
||||
>
|
||||
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
{/* What the client IS, so "why is there no Update button?" has a visible answer. The
|
||||
install kind decides everything below it. */}
|
||||
{!!update?.client_install && (
|
||||
<Field
|
||||
label="Client"
|
||||
description={`${clientInstallLabel(update.client_install)}${
|
||||
update.client_current ? ` · ${update.client_current}` : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{hasUpdate(update) && (
|
||||
<Field
|
||||
label={
|
||||
update!.update_available
|
||||
? `Plugin update — v${update!.latest}${
|
||||
update!.client_update_available ? " + client" : ""
|
||||
}`
|
||||
: `Client update — ${update!.client_latest || "available"}`
|
||||
}
|
||||
description={
|
||||
// Only promise a one-tap install when there is one. On a notify-only install the
|
||||
// row becomes the command itself, which is the whole answer for that box.
|
||||
clientUpdateIsManualOnly(update) && !update!.update_available
|
||||
? update!.client_opt_in || update!.client_command
|
||||
: "Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
{clientUpdateIsManualOnly(update) && !update!.update_available ? null : (
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{!!update?.client_error && (
|
||||
<Field
|
||||
label="Client update check"
|
||||
description={
|
||||
update.client_error === "client-outdated"
|
||||
? "This client predates update checks — update it once by hand and the check starts working."
|
||||
: "Couldn’t check the client for updates."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Setup guide"
|
||||
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
|
||||
>
|
||||
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Leaving a stream"
|
||||
description="Hold L1 + R1 + Start + Select inside the stream, or close the “game” from the Steam overlay — either returns you to Gaming Mode."
|
||||
/>
|
||||
<Field
|
||||
label="Stream stuck?"
|
||||
description="Force-stop the stream client if a session wedges"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => void forceStopStream()}>
|
||||
Force-stop
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Reset Punktfunk"
|
||||
description="Clear saved hosts, stream settings, and pinned games on this Deck (keeps your client identity)"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={onReset}>
|
||||
<FaTrashAlt style={{ marginRight: "0.4em" }} />
|
||||
Reset
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
|
||||
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const pins = usePins();
|
||||
const [tab, setTab] = useState("hosts");
|
||||
|
||||
const hosts = mergeHosts(saved, discovered);
|
||||
// A host action (pair/add/edit/forget) can change either store, so refresh both.
|
||||
const refreshHosts = () => {
|
||||
void refreshDiscovered();
|
||||
void refreshSaved();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "40px",
|
||||
height: "calc(100% - 40px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Header is title + back only — updates live on the About tab (and the QAM banner). */}
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1em",
|
||||
padding: "0 2.5em",
|
||||
marginBottom: "0.4em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DialogButton style={iconButton} onClick={() => Navigation.NavigateBack()}>
|
||||
<FaArrowLeft />
|
||||
</DialogButton>
|
||||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||||
Punktfunk
|
||||
</div>
|
||||
</Focusable>
|
||||
|
||||
{/* Two things fight each other on an L1/R1 tab switch:
|
||||
1. Valve's Tabs slides the incoming panel in from the right with a CSS transform.
|
||||
2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which
|
||||
fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll
|
||||
position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks
|
||||
up and pans the whole page — the "screen jumps right, then animates back" glitch.
|
||||
Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the
|
||||
slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while
|
||||
focus is anywhere inside — including the tab strip); after a switch, focus stays on the
|
||||
strip and Down enters the content, which is how Steam's own tabbed pages behave.
|
||||
The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
<Tabs
|
||||
activeTab={tab}
|
||||
onShowTab={(id: string) => setTab(id)}
|
||||
tabs={[
|
||||
{
|
||||
id: "hosts",
|
||||
title: "Hosts",
|
||||
content: (
|
||||
<HostsTab
|
||||
hosts={hosts}
|
||||
scanning={scanning || loadingSaved}
|
||||
refresh={refreshHosts}
|
||||
pins={pins}
|
||||
clientUpdatePending={!!update?.client_update_available}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
content: <SettingsTab />,
|
||||
},
|
||||
{
|
||||
id: "about",
|
||||
title: "About",
|
||||
content: (
|
||||
<AboutTab
|
||||
update={update}
|
||||
checking={checking}
|
||||
check={check}
|
||||
onReset={() => confirmReset([refreshHosts, pins.refresh])}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Full page behind the boundary — registered as the /punktfunk route.
|
||||
export const PunktfunkRoute: FC = () => (
|
||||
<PluginErrorBoundary>
|
||||
<PunktfunkPage />
|
||||
</PluginErrorBoundary>
|
||||
);
|
||||
@@ -3,10 +3,32 @@
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { FC, useState } from "react";
|
||||
import { Host, pair } from "./backend";
|
||||
import { pair } from "./backend";
|
||||
import { HostView } from "./hooks";
|
||||
|
||||
/**
|
||||
* User-facing copy for a failed ceremony. The CLI's stable exit codes say WHICH failure it was,
|
||||
* so the keypad can name the fix instead of echoing a log line: `refused` is overwhelmingly a
|
||||
* mistyped PIN or a host nobody armed, and telling someone to check their network for that
|
||||
* would send them the wrong way entirely.
|
||||
*/
|
||||
function pairErrorBody(error: string | undefined, name: string): string {
|
||||
switch (error) {
|
||||
case "refused":
|
||||
return "Wrong PIN, or the host isn’t showing one. Arm pairing again and retry.";
|
||||
case "unreachable":
|
||||
return `Couldn’t reach ${name}.`;
|
||||
case "client-outdated":
|
||||
return "Update the Punktfunk client to pair from here.";
|
||||
case "client-unavailable":
|
||||
return "Couldn’t reach the Punktfunk client — is it still installed?";
|
||||
default:
|
||||
return "Pairing failed.";
|
||||
}
|
||||
}
|
||||
|
||||
export const PairModal: FC<{
|
||||
host: Host;
|
||||
host: HostView;
|
||||
closeModal?: () => void;
|
||||
onPaired: () => void;
|
||||
}> = ({ host, closeModal, onPaired }) => {
|
||||
@@ -21,13 +43,13 @@ export const PairModal: FC<{
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await pair(host.host, host.port, pin, "Steam Deck");
|
||||
const res = await pair(host.addr, host.port, pin, "Steam Deck");
|
||||
if (res.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` });
|
||||
onPaired();
|
||||
closeModal?.();
|
||||
} else {
|
||||
setError(res.error ?? "pairing failed");
|
||||
setError(pairErrorBody(res.error, host.name));
|
||||
setPin("");
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to
|
||||
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
|
||||
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
|
||||
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
|
||||
import { CSSProperties, FC, useEffect, useState } from "react";
|
||||
import { getSettings, setSettings, StreamSettings } from "./backend";
|
||||
import { RowActions } from "./ui";
|
||||
|
||||
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
|
||||
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
|
||||
// (inside the right-aligned RowActions) shrinks the control to its selected label, with a floor
|
||||
// so short values like "60 Hz" don't collapse to a nub and a ceiling so nothing runs edge to
|
||||
// edge. Matches the right-aligned, content-sized buttons everywhere else.
|
||||
const selectShell: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "10em",
|
||||
maxWidth: "24em",
|
||||
};
|
||||
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
[1280, 720, "1280 × 720"],
|
||||
[1280, 800, "1280 × 800 (Deck)"],
|
||||
[1920, 1080, "1920 × 1080"],
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
];
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
|
||||
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
const renderScaleLabel = (x: number): string =>
|
||||
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
|
||||
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
|
||||
const GAMEPAD_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
xbox360: "Xbox 360",
|
||||
xboxone: "Xbox One",
|
||||
dualsense: "DualSense",
|
||||
dualshock4: "DualShock 4",
|
||||
steamdeck: "Steam Deck",
|
||||
};
|
||||
// Mirrors the desktop client's picker (ui_settings.rs CODECS) — a soft preference the host
|
||||
// falls back from when its GPU can't encode it.
|
||||
const CODECS = ["auto", "hevc", "h264", "av1"];
|
||||
const CODEC_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
hevc: "HEVC (H.265)",
|
||||
h264: "H.264 (AVC)",
|
||||
av1: "AV1",
|
||||
};
|
||||
const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
const COMPOSITOR_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
kwin: "KDE Plasma (KWin)",
|
||||
wlroots: "Sway (wlroots)",
|
||||
mutter: "GNOME (Mutter)",
|
||||
gamescope: "gamescope",
|
||||
};
|
||||
|
||||
export const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void getSettings().then(setS);
|
||||
}, []);
|
||||
|
||||
const patch = (p: Partial<StreamSettings>) => {
|
||||
setS((cur) => {
|
||||
if (!cur) return cur;
|
||||
const next = { ...cur, ...p };
|
||||
void setSettings(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!s) return <Spinner style={{ height: "1.5em" }} />;
|
||||
|
||||
const resIdx = Math.max(
|
||||
0,
|
||||
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
label="Resolution"
|
||||
description="The host creates a virtual output at exactly this size"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
|
||||
selectedOption={resIdx}
|
||||
onChange={(o) => {
|
||||
const [w, h] = RESOLUTIONS[o.data as number];
|
||||
patch({ width: w, height: h });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field label="Refresh rate" childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
|
||||
selectedOption={s.refresh_hz}
|
||||
onChange={(o) => patch({ refresh_hz: o.data as number })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Render scale"
|
||||
description="Supersample for sharpness (> 1×, more bandwidth) or render below native (< 1×) — the Deck resamples to its screen"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={RENDER_SCALES.map((x) => ({ data: x, label: renderScaleLabel(x) }))}
|
||||
// Snap the stored value to the nearest preset so the dropdown always shows a match.
|
||||
selectedOption={RENDER_SCALES.reduce((best, x) =>
|
||||
Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best,
|
||||
)}
|
||||
onChange={(o) => patch({ render_scale: o.data as number })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="Mbit/s · 0 = host default"
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<Field
|
||||
label="Video codec"
|
||||
description="Preferred stream codec — the host falls back when its GPU can't encode it"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={CODECS.map((c) => ({ data: c, label: CODEC_LABELS[c] ?? c }))}
|
||||
selectedOption={s.codec ?? "auto"}
|
||||
onChange={(o) => patch({ codec: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Gamepad type"
|
||||
description="Which virtual controller the host creates for your inputs"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Host compositor"
|
||||
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
|
||||
selectedOption={s.compositor}
|
||||
onChange={(o) => patch({ compositor: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
description="Send the Deck's microphone to the host's virtual mic"
|
||||
checked={s.mic_enabled}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+64
-59
@@ -8,16 +8,16 @@
|
||||
//
|
||||
// TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key —
|
||||
// see applyControllerConfig):
|
||||
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host /
|
||||
// pinned game (PF_HOST/PF_LAUNCH/PF_BROWSE), rewritten per launch, so one shortcut serves
|
||||
// every host. Driven by the QAM/pins/host-library actions. Hidden — an implementation detail.
|
||||
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host
|
||||
// reference and the card's profile (PF_REF/PF_PROFILE/PF_REQUEST_ACCESS), rewritten per
|
||||
// launch, so one shortcut serves every host. Hidden — an implementation detail.
|
||||
// • GAMEPAD UI — visible, stateless: fixed launch options = bare `--browse` (PF_BROWSE, no
|
||||
// host) → the client's console home (host picker + pairing + settings, gamepad-navigable).
|
||||
// This is the library-visible "Punktfunk" app the user opens directly.
|
||||
//
|
||||
// Both get the shipped artwork and the native-touch controller config.
|
||||
|
||||
import { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend";
|
||||
import { applyControllerConfig, runnerInfo, shortcutArt } from "./backend";
|
||||
|
||||
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
|
||||
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
||||
@@ -257,11 +257,12 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
}
|
||||
const startDir = info.runner.replace(/\/[^/]*$/, "");
|
||||
void ensureControllerConfig();
|
||||
// Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
|
||||
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
||||
// PF_CLIENT_BIN only when the backend resolved a NATIVE client — else the wrapper's flatpak
|
||||
// default stands and this shortcut is exactly what it always was.
|
||||
const clientBin = info.client_bin ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
|
||||
// PF_BROWSE → the wrapper runs the SESSION's `--browse --fullscreen` (console home), which is
|
||||
// the one branch this rework deliberately left alone. %command% expands to the shortcut exe
|
||||
// (/bin/sh); the wrapper rides behind as an arg. PF_CLIENT_BIN only when the backend resolved
|
||||
// a NATIVE client — else the wrapper's flatpak default stands and this shortcut is exactly
|
||||
// what it always was.
|
||||
const clientBin = safeClientBin(info.client_bin) ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
|
||||
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
|
||||
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
||||
@@ -319,77 +320,81 @@ export async function launchGamepadUi(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-launch extras beyond the host target (all optional — {} is the plain stream). */
|
||||
/** Per-launch extras beyond the host reference (all optional — {} is the plain stream). */
|
||||
export interface LaunchOpts {
|
||||
/** Library id to launch on connect (a pinned game) — rides PF_LAUNCH → `--launch`. */
|
||||
launchId?: string;
|
||||
/** Open the gamepad library launcher instead of streaming (PF_BROWSE → `--browse`). */
|
||||
browse?: boolean;
|
||||
/** Management-API port for the launcher's library fetch (PF_MGMT; 0/absent = default). */
|
||||
mgmt?: number;
|
||||
/** A pinned card: stream with this settings profile, one-off (PF_PROFILE → `--profile`). */
|
||||
profileId?: string;
|
||||
/**
|
||||
* Ask the host's operator to admit this Deck rather than typing a PIN (PF_REQUEST_ACCESS).
|
||||
* The connect PARKS until somebody approves it, and the launch runs SUPERVISED — see the
|
||||
* wrapper for why `--exec` is dropped on this path alone.
|
||||
*/
|
||||
requestAccess?: boolean;
|
||||
}
|
||||
|
||||
// Launch ids ride Steam launch options as an env-prefix token (`PF_LAUNCH=<id>`), so they
|
||||
// must be space/quote-free — Steam's tokenizer and the wrapper's env both break otherwise.
|
||||
// Real ids are `steam:<digits>` / `custom:<slug>`, so this rejects nothing in practice;
|
||||
// it's VALIDATION, never encoding (the host must match the opaque token verbatim).
|
||||
const UNSAFE_LAUNCH_ID = /["'\\$`\s]/;
|
||||
// Host refs and profile ids ride Steam launch options as env-prefix tokens (`PF_REF=<ref>`),
|
||||
// so they must be space/quote-free — Steam's tokenizer and the wrapper's env both break
|
||||
// otherwise. Real values are UUIDs or `addr:port`, so this rejects nothing in practice; it is
|
||||
// VALIDATION, never encoding (the client must receive the opaque token verbatim).
|
||||
const UNSAFE_TOKEN = /["'\\$`\s]/;
|
||||
export function isSafeLaunchId(id: string): boolean {
|
||||
return (
|
||||
id.length > 0 &&
|
||||
id.length <= 128 &&
|
||||
UNSAFE_LAUNCH_ID.exec(id) === null &&
|
||||
UNSAFE_TOKEN.exec(id) === null &&
|
||||
/^[\x21-\x7e]+$/.test(id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
|
||||
* library title, or into a host's gamepad library). Encodes the target into the STREAM
|
||||
* shortcut's launch options (so one hidden shortcut serves every host and every pinned game),
|
||||
* then RunGame.
|
||||
* Is a resolved native-client path safe to put in Steam's launch options? Same rule, separate
|
||||
* name because the failure is different: an unsafe id is a bug in our own data, an unsafe path
|
||||
* is just where the user installed the client — so the browse shortcut degrades to its flatpak
|
||||
* default rather than refusing to exist.
|
||||
*/
|
||||
export async function launchStream(
|
||||
host: string,
|
||||
port: number,
|
||||
opts: LaunchOpts = {},
|
||||
): Promise<void> {
|
||||
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
|
||||
// so it races with the shortcut setup (near-zero added latency); its outcome is needed below
|
||||
// (the connect budget), and RunGame follows the await either way, so nothing is slower for it.
|
||||
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
||||
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
||||
const waking = wake(host, port).catch(() => ({ ok: false }));
|
||||
const [{ appId, runner, clientBin }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
const env = [`PF_HOST=${target}`];
|
||||
function safeClientBin(bin: string | undefined): bin is string {
|
||||
return !!bin && isSafeLaunchId(bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream `ref` fullscreen in Gaming Mode, optionally with a pinned card's profile. Encodes the
|
||||
* target into the STREAM shortcut's launch options — one hidden shortcut serves every host —
|
||||
* then RunGame.
|
||||
*
|
||||
* No Wake-on-LAN here any more. The plugin used to fire a magic packet itself and then stretch
|
||||
* the connect budget to 75 s to cover the host's resume, which was a workaround for the era
|
||||
* before the CLI existed. `punktfunk launch` now runs the real wake-and-wait loop (packet at
|
||||
* t=0, re-sent every 6 s, presence polled every second) and only dials once the host answers —
|
||||
* strictly better, and it deletes a backend method, a frontend call and a shell branch.
|
||||
*/
|
||||
export async function launchStream(ref: string, opts: LaunchOpts = {}): Promise<void> {
|
||||
if (!isSafeLaunchId(ref)) {
|
||||
throw new Error(`unsupported host reference: ${ref}`);
|
||||
}
|
||||
if (opts.profileId && !isSafeLaunchId(opts.profileId)) {
|
||||
throw new Error(`unsupported profile id: ${opts.profileId}`);
|
||||
}
|
||||
const { appId, runner, clientBin } = await ensureStreamShortcut();
|
||||
const env = [`PF_REF=${ref}`];
|
||||
// Set only for a NATIVE client install; absent, the wrapper takes its flatpak default, so every
|
||||
// existing Deck install produces byte-identical launch options to before.
|
||||
if (clientBin) {
|
||||
// The one launch-option value that comes from the backend rather than a store id, and so
|
||||
// the one that could carry a space: a path like `/home/deck/my apps/punktfunk-client` would
|
||||
// split Steam's tokenizer and land its tail in front of %command% as a bogus env token.
|
||||
if (!isSafeLaunchId(clientBin)) {
|
||||
throw new Error(`client path can't ride Steam's launch options: ${clientBin}`);
|
||||
}
|
||||
env.push(`PF_CLIENT_BIN=${clientBin}`);
|
||||
}
|
||||
// A magic packet actually went out (a MAC was known), so the host may be mid-resume from
|
||||
// suspend — that takes far longer than the client's default 15 s connect budget. Stretch the
|
||||
// budget so the client's wake-tolerant dial keeps retrying across the resume; against an
|
||||
// already-awake host the connect still lands in under a second, so this costs nothing.
|
||||
if (woke.ok) {
|
||||
env.push("PF_CONNECT_TIMEOUT=75");
|
||||
if (opts.profileId) {
|
||||
env.push(`PF_PROFILE=${opts.profileId}`);
|
||||
}
|
||||
if (opts.browse) {
|
||||
env.push("PF_BROWSE=1");
|
||||
if (opts.mgmt) {
|
||||
env.push(`PF_MGMT=${Math.floor(opts.mgmt)}`);
|
||||
}
|
||||
} else if (opts.launchId) {
|
||||
if (!isSafeLaunchId(opts.launchId)) {
|
||||
// Enforced at pin time too (the picker disables Pin) — this is the backstop.
|
||||
throw new Error(`unsupported launch id: ${opts.launchId}`);
|
||||
}
|
||||
env.push(`PF_LAUNCH=${opts.launchId}`);
|
||||
if (opts.requestAccess) {
|
||||
env.push("PF_REQUEST_ACCESS=1");
|
||||
}
|
||||
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
||||
// script rides behind it as an argument and reads PF_* from the environment. The wake was
|
||||
// awaited above, so the magic packet is out before the connect attempt.
|
||||
// script rides behind it as an argument and reads PF_* from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// The trust sheet — the step between "I can see a host" and "I can stream it".
|
||||
//
|
||||
// Two ways in, in the order the GTK dialog and the console's pair screen offer them:
|
||||
//
|
||||
// • REQUEST ACCESS (default) — no PIN. Save the host with the fingerprint it ADVERTISED,
|
||||
// then launch. The host parks that connect until its operator approves this Deck in the
|
||||
// console or web UI, admits it, and the stream starts by itself. It is not a second
|
||||
// pairing ceremony; it is an ordinary identified connect with a stretched budget, which
|
||||
// is why it costs no ceremony surface here at all.
|
||||
// • USE A PIN INSTEAD — the existing gamepad-navigable keypad (pair.tsx).
|
||||
//
|
||||
// NO FINGERPRINT, NO REQUEST ACCESS. The parked connect pins the advertised fingerprint, and
|
||||
// that pin is the only thing standing between a 185 s wait and an impostor answering for the
|
||||
// host. A host typed in by address advertises nothing, so it gets the PIN path only — and is
|
||||
// told why, rather than being shown a button that could only fail. Under no circumstances does
|
||||
// this sheet trust-on-first-use its way past a missing fingerprint.
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { FC, useRef, useState } from "react";
|
||||
import { trustHost } from "./backend";
|
||||
import { HostView } from "./hooks";
|
||||
import { PairModal } from "./pair";
|
||||
|
||||
/** User-facing copy for a `trustHost` failure code. */
|
||||
function trustErrorBody(error: string | undefined, name: string): string {
|
||||
switch (error) {
|
||||
case "refused":
|
||||
return `${name} is already saved under a different identity. Forget it in the Punktfunk app before trusting it again.`;
|
||||
case "client-outdated":
|
||||
return "Update the Punktfunk client to use request access.";
|
||||
case "client-unavailable":
|
||||
return "Couldn’t reach the Punktfunk client — is it still installed?";
|
||||
default:
|
||||
return `Couldn’t save ${name}.`;
|
||||
}
|
||||
}
|
||||
|
||||
export const TrustSheet: FC<{
|
||||
host: HostView;
|
||||
closeModal?: () => void;
|
||||
/** Stream this host, having just been let in. */
|
||||
onStream: (opts: { requestAccess?: boolean }) => void;
|
||||
/** Re-read the host list — the record changed underneath the panel. */
|
||||
onChanged: () => void;
|
||||
}> = ({ host, closeModal, onStream, onChanged }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ⚠ This sheet is a `showModal` PORTAL: it captures its callbacks ONCE and never re-renders
|
||||
// from panel state. Anything it needs to act on later must be read through a ref, not out of
|
||||
// a captured value — reading a captured array is exactly what made pinning a second game
|
||||
// compute from a stale base and clobber the first.
|
||||
const props = useRef({ host, onStream, onChanged });
|
||||
props.current = { host, onStream, onChanged };
|
||||
|
||||
// Request access pins what the host ADVERTISES. The record's own pin is a different thing:
|
||||
// a host that already has one streams without ever opening this sheet.
|
||||
const hasIdentity = host.advertisedFp !== "";
|
||||
// A host advertising `pair=optional` admits anyone who pins its identity — there is no
|
||||
// operator decision to wait for, and asking for one would be a wait that never ends and a
|
||||
// record claiming somebody approved this Deck when nobody did. `paired` means the PIN
|
||||
// ceremony or a real approval; the desktop client records exactly this case as *trusted*.
|
||||
const needsApproval = host.pairPolicy !== "optional";
|
||||
const canRequestAccess = hasIdentity && needsApproval;
|
||||
const canTrustDirectly = hasIdentity && !needsApproval;
|
||||
|
||||
/**
|
||||
* Pin the advertised identity, then stream.
|
||||
*
|
||||
* `approval` is what differs between the two doors, and it is not cosmetic: it decides whether
|
||||
* the launch waits ~185 s for an operator AND whether the record ends up marked paired.
|
||||
*/
|
||||
const letIn = async (approval: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const { host: h, onStream: stream, onChanged: changed } = props.current;
|
||||
try {
|
||||
// Step 1: save it with the ADVERTISED fingerprint, pinned but unpaired ("trusted").
|
||||
// Idempotent, so a retry after a declined approval is free.
|
||||
const r = await trustHost(h.addr, h.port, h.advertisedFp, h.name);
|
||||
if (!r.ok) {
|
||||
setError(trustErrorBody(r.error, h.name));
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
changed();
|
||||
// Step 2: the launch. Under approval it PARKS — and the session's plain connecting screen
|
||||
// looks identical whether it is parked or hanging, so say what is about to happen BEFORE
|
||||
// it starts. That toast is a patch over that, and the real fix belongs in the session.
|
||||
if (approval) {
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: `Approve this Deck in ${h.name}’s console — the stream starts by itself`,
|
||||
duration: 10_000,
|
||||
});
|
||||
}
|
||||
stream({ requestAccess: approval });
|
||||
closeModal?.();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const usePin = () => {
|
||||
// Hand off to the keypad. Closing first keeps one modal on screen at a time, which is what
|
||||
// the gamepad focus model expects.
|
||||
const { host: h, onStream: stream, onChanged: changed } = props.current;
|
||||
closeModal?.();
|
||||
showModal(
|
||||
<PairModal
|
||||
host={h}
|
||||
onPaired={() => {
|
||||
changed();
|
||||
stream({});
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
|
||||
Connect to {host.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
|
||||
{!hasIdentity
|
||||
? "No advertised identity for this host — pair with a PIN instead."
|
||||
: canTrustDirectly
|
||||
? `${host.name} accepts new devices. Connecting pins its identity so later streams are silent.`
|
||||
: `${host.name} needs to let this device in before it can stream.`}
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
|
||||
)}
|
||||
|
||||
<Focusable style={{ display: "flex", flexDirection: "column", gap: "0.5em" }}>
|
||||
{canRequestAccess && (
|
||||
<DialogButton disabled={busy} onClick={() => void letIn(true)}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Request access"}
|
||||
</DialogButton>
|
||||
)}
|
||||
{canTrustDirectly && (
|
||||
<DialogButton disabled={busy} onClick={() => void letIn(false)}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Connect"}
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton disabled={busy} onClick={usePin}>
|
||||
Use a PIN instead…
|
||||
</DialogButton>
|
||||
<DialogButton disabled={busy} onClick={() => closeModal?.()}>
|
||||
Cancel
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
|
||||
{canRequestAccess && (
|
||||
<div style={{ opacity: 0.6, fontSize: "0.85em", marginTop: "0.8em" }}>
|
||||
Request access asks {host.name}’s operator to approve this Deck in its console or web
|
||||
UI. No PIN to type — the stream starts as soon as they do.
|
||||
</div>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
// Shared UI primitives for the fullscreen page + modals. The one rule that keeps every row
|
||||
// looking consistent: a Field's action(s) always sit right-aligned, with real space between
|
||||
// them and the label text — never hugging it.
|
||||
//
|
||||
// Decky lays a Field out as `[ label .......... children ]`. When the children container is
|
||||
// grown (`childrenContainerWidth="max"`, which we want so multi-button clusters have room), a
|
||||
// bare `fit-content` button LEFT-aligns inside that grown container and ends up pressed against
|
||||
// the label with the space wasted to its right. Wrapping the action(s) in `RowActions` pushes
|
||||
// them to the right edge and evenly spaces multiples — the same treatment every row now gets.
|
||||
import { Focusable } from "@decky/ui";
|
||||
import { CSSProperties, FC, ReactNode } from "react";
|
||||
|
||||
export const RowActions: FC<{ children: ReactNode }> = ({ children }) => (
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5em",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Focusable>
|
||||
);
|
||||
|
||||
// A single action button sized to its content (not the gamepad-UI default of 100% width), with
|
||||
// a floor so short labels ("Pair", "Remove") don't render as tiny nubs and every row's button
|
||||
// reads at the same weight.
|
||||
export const actionButton: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "7em",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
// Square icon-only button (details ⓘ, header back arrow). Needs an explicit height or the zero
|
||||
// padding collapses it to the icon's line height.
|
||||
export const iconButton: CSSProperties = {
|
||||
width: "40px",
|
||||
minWidth: "40px",
|
||||
height: "40px",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
@@ -156,6 +156,34 @@ mod index {
|
||||
pub fn gamepad(s: &Settings) -> u32 {
|
||||
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn system_buttons(s: &Settings) -> u32 {
|
||||
SYSTEM_BUTTONS
|
||||
.iter()
|
||||
.position(|&v| v == s.system_buttons)
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn guide_gesture(s: &Settings) -> u32 {
|
||||
GUIDE_GESTURES
|
||||
.iter()
|
||||
.position(|&v| v == s.guide_gesture)
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn present_priority(s: &Settings) -> u32 {
|
||||
// Unknown values (a newer client's intent) read as the default, exactly as
|
||||
// `PresentPriority::resolve` treats them.
|
||||
PRESENT_PRIORITIES
|
||||
.iter()
|
||||
.position(|&p| p == s.present_priority)
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn smooth_buffer(s: &Settings) -> u32 {
|
||||
// The index IS the stored value: 0 = Automatic, 1..3 = frames.
|
||||
u32::from(s.smooth_buffer).min(SMOOTH_BUFFER_LABELS.len() as u32 - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a
|
||||
@@ -625,12 +653,33 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
|
||||
if touched.has("gamepad") {
|
||||
o.gamepad = Some(values.gamepad.clone());
|
||||
}
|
||||
if touched.has("gamepad_forwarding") {
|
||||
o.gamepad_forwarding = Some(values.gamepad_forwarding);
|
||||
}
|
||||
if touched.has("system_buttons") {
|
||||
o.system_buttons = Some(values.system_buttons.clone());
|
||||
}
|
||||
if touched.has("guide_gesture") {
|
||||
o.guide_gesture = Some(values.guide_gesture.clone());
|
||||
}
|
||||
if touched.has("stats_verbosity") {
|
||||
o.stats_verbosity = Some(values.stats_verbosity());
|
||||
}
|
||||
if touched.has("fullscreen_on_stream") {
|
||||
o.fullscreen_on_stream = Some(values.fullscreen_on_stream);
|
||||
}
|
||||
if touched.has("present_priority") {
|
||||
o.present_priority = Some(values.present_priority.clone());
|
||||
}
|
||||
if touched.has("smooth_buffer") {
|
||||
o.smooth_buffer = Some(values.smooth_buffer);
|
||||
}
|
||||
if touched.has("vsync") {
|
||||
o.vsync = Some(values.vsync);
|
||||
}
|
||||
if touched.has("allow_vrr") {
|
||||
o.allow_vrr = Some(values.allow_vrr);
|
||||
}
|
||||
// Resets are not handled here: they clear the field and re-seed their row the moment the
|
||||
// user asks, so by the time this runs the catalog already reflects them and the row is no
|
||||
// longer marked touched.
|
||||
@@ -658,6 +707,15 @@ const GAMEPADS: &[&str] = &[
|
||||
"dualshock4",
|
||||
"steamdeck",
|
||||
];
|
||||
/// System-button routing values (persisted under the cross-client `system_buttons` key):
|
||||
/// where the guide (Xbox/PS/Steam) and quick-access presses land while streaming. Auto =
|
||||
/// the host, except under Gaming Mode where the local Steam UI reacts to the same press.
|
||||
const SYSTEM_BUTTONS: &[&str] = &["auto", "forward", "local"];
|
||||
const SYSTEM_BUTTON_LABELS: &[&str] = &["Automatic", "Send to host", "This device"];
|
||||
/// Hold-Select guide gesture values (the cross-client `guide_gesture` key). Auto arms it
|
||||
/// only where the raw guide press can't reach the host (Gaming Mode here).
|
||||
const GUIDE_GESTURES: &[&str] = &["auto", "on", "off"];
|
||||
const GUIDE_GESTURE_LABELS: &[&str] = &["Automatic", "On", "Off"];
|
||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
|
||||
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
|
||||
@@ -684,6 +742,20 @@ const TOUCH_MODE_CAPTIONS: &[&str] = &[
|
||||
"The cursor jumps to your finger — a tap clicks there",
|
||||
"Real multi-touch reaches the host — for touch-native apps",
|
||||
];
|
||||
/// Presentation-intent values (persisted under the `present_priority` key the Apple and
|
||||
/// Android clients share) + labels + dynamic captions. Captions stay ONE line, like the
|
||||
/// touch/mouse rows.
|
||||
const PRESENT_PRIORITIES: &[&str] = &["latency", "smooth"];
|
||||
const PRESENT_PRIORITY_LABELS: &[&str] = &["Lowest latency", "Smoothness"];
|
||||
const PRESENT_PRIORITY_CAPTIONS: &[&str] = &[
|
||||
"Each frame shows the moment the display can take it",
|
||||
"Buffers a little to even out network hiccups",
|
||||
];
|
||||
/// Smoothness buffer depth, in frames — the index IS the stored `smooth_buffer` value
|
||||
/// (0 = Automatic, which resolves to 2). No millisecond hints: the cost is one refresh
|
||||
/// per frame, and the session's refresh isn't known here when the mode is Native.
|
||||
const SMOOTH_BUFFER_LABELS: &[&str] = &["Automatic", "1 frame", "2 frames", "3 frames"];
|
||||
|
||||
/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as
|
||||
/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream.
|
||||
const MOUSE_MODES: &[&str] = &["capture", "desktop"];
|
||||
@@ -1213,6 +1285,50 @@ pub fn show_scoped(
|
||||
row
|
||||
});
|
||||
|
||||
// ---- Display: Presentation ----
|
||||
// The intent pair the Apple and Android clients already carry. The buffer row only
|
||||
// means anything under Smoothness, so it hides itself the rest of the time rather
|
||||
// than sitting there inert.
|
||||
let present_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Prioritize",
|
||||
PRESENT_PRIORITY_CAPTIONS[0],
|
||||
PRESENT_PRIORITY_LABELS,
|
||||
);
|
||||
let buffer_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Smoothness buffer",
|
||||
"Each frame held absorbs one refresh of hiccup and adds one of delay",
|
||||
SMOOTH_BUFFER_LABELS,
|
||||
);
|
||||
{
|
||||
let w = present_row.widget().clone();
|
||||
let buffer = buffer_row.widget().clone();
|
||||
present_row.connect_changed(move |i| {
|
||||
let i = (i as usize).min(PRESENT_PRIORITY_CAPTIONS.len() - 1);
|
||||
set_row_subtitle(&w, PRESENT_PRIORITY_CAPTIONS[i]);
|
||||
buffer.set_visible(PRESENT_PRIORITIES[i] == "smooth");
|
||||
});
|
||||
}
|
||||
let vsync_row = adw::SwitchRow::builder()
|
||||
.title("V-Sync")
|
||||
.subtitle(
|
||||
"Tear-free. Turning it off removes the wait for the screen's refresh — the \
|
||||
lowest possible delay, at the cost of visible tearing. Not every driver \
|
||||
offers it; the stats overlay names the mode actually in use",
|
||||
)
|
||||
.build();
|
||||
let vrr_row = adw::SwitchRow::builder()
|
||||
.title("Follow variable refresh rate")
|
||||
.subtitle(
|
||||
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with the \
|
||||
stream instead of on a fixed cadence. Applies to fullscreen sessions; \
|
||||
harmless on a fixed-refresh screen",
|
||||
)
|
||||
.build();
|
||||
|
||||
// ---- Display: Host output ----
|
||||
let compositor_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
@@ -1376,6 +1492,17 @@ pub fn show_scoped(
|
||||
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
|
||||
// so it survives restarts — and disconnects: an offline pinned pad keeps its entry here
|
||||
// instead of silently snapping back to Automatic.
|
||||
// Off = this device's controllers are not sent at all, because they reach the host
|
||||
// another way (USB passthrough such as VirtualHere, or a pad plugged into the host).
|
||||
// It also stops the session OPENING the pad, which is what frees the device for a
|
||||
// passthrough tool to bind — so the two rows below have nothing to act on while it is
|
||||
// off, and are desensitised to say so.
|
||||
let pad_forward_row = adw::SwitchRow::builder()
|
||||
.title("Forward controllers")
|
||||
.subtitle(
|
||||
"Send this device's controllers to the host — off if it already has them another way",
|
||||
)
|
||||
.build();
|
||||
let pads = gamepads.pads();
|
||||
let saved_pin = settings.borrow().forward_pad.clone();
|
||||
let mut pad_names = vec!["Automatic (all controllers)".to_string()];
|
||||
@@ -1444,6 +1571,41 @@ pub fn show_scoped(
|
||||
"Steam Deck",
|
||||
],
|
||||
);
|
||||
// Where the guide (Xbox/PS/Steam) + quick-access presses land, and the hold-Select
|
||||
// gesture that keeps the host's guide reachable when they stay local. Desktop rarely
|
||||
// needs either off Automatic — they exist here because profiles are authored on the
|
||||
// desktop and applied everywhere, Gaming Mode included.
|
||||
let sysbtn_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Steam / guide button",
|
||||
"Automatic sends it to the host, except where this device reacts to it too",
|
||||
SYSTEM_BUTTON_LABELS,
|
||||
);
|
||||
let gesture_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Hold Select for guide",
|
||||
"Hold Select alone for the host's guide button — a tap still goes through",
|
||||
GUIDE_GESTURE_LABELS,
|
||||
);
|
||||
// The pad rows only mean something while something is being forwarded (the same
|
||||
// relationship mic → echo cancellation draws just above, initial state included: the
|
||||
// seed's `set_active` fires this only when it CHANGES the switch).
|
||||
{
|
||||
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
|
||||
let (sb, gg) = (sysbtn_row.widget().clone(), gesture_row.widget().clone());
|
||||
f.set_sensitive(seed.gamepad_forwarding);
|
||||
t.set_sensitive(seed.gamepad_forwarding);
|
||||
sb.set_sensitive(seed.gamepad_forwarding);
|
||||
gg.set_sensitive(seed.gamepad_forwarding);
|
||||
pad_forward_row.connect_active_notify(move |r| {
|
||||
f.set_sensitive(r.is_active());
|
||||
t.set_sensitive(r.is_active());
|
||||
sb.set_sensitive(r.is_active());
|
||||
gg.set_sensitive(r.is_active());
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Seed from the effective settings for this scope ----
|
||||
{
|
||||
@@ -1454,7 +1616,10 @@ pub fn show_scoped(
|
||||
hz_row.set_selected(index::refresh(s));
|
||||
scale_row.set_selected(index::render_scale(s));
|
||||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||
pad_forward_row.set_active(s.gamepad_forwarding);
|
||||
pad_row.set_selected(index::gamepad(s));
|
||||
sysbtn_row.set_selected(index::system_buttons(s));
|
||||
gesture_row.set_selected(index::guide_gesture(s));
|
||||
let touch_i = index::touch(s);
|
||||
touch_row.set_selected(touch_i);
|
||||
// set_selected never fires the changed hook, so seed the dynamic caption directly.
|
||||
@@ -1479,6 +1644,19 @@ pub fn show_scoped(
|
||||
let codec_i = index::codec(s);
|
||||
codec_row.set_selected(codec_i);
|
||||
set_row_subtitle(codec_row.widget(), codec_caption(codec_i));
|
||||
let present_i = index::present_priority(s);
|
||||
present_row.set_selected(present_i);
|
||||
set_row_subtitle(
|
||||
present_row.widget(),
|
||||
PRESENT_PRIORITY_CAPTIONS[present_i as usize],
|
||||
);
|
||||
buffer_row.set_selected(index::smooth_buffer(s));
|
||||
// `set_selected` never fires the changed hook, so mirror its visibility rule here.
|
||||
buffer_row
|
||||
.widget()
|
||||
.set_visible(PRESENT_PRIORITIES[present_i as usize] == "smooth");
|
||||
vsync_row.set_active(s.vsync);
|
||||
vrr_row.set_active(s.allow_vrr);
|
||||
}
|
||||
|
||||
// ---- Override markers, per-row reset, and the touch that creates an override ----
|
||||
@@ -1671,6 +1849,38 @@ pub fn show_scoped(
|
||||
index::surround
|
||||
);
|
||||
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
|
||||
choice!(
|
||||
sysbtn_row,
|
||||
"system_buttons",
|
||||
o.system_buttons.is_some(),
|
||||
index::system_buttons
|
||||
);
|
||||
choice!(
|
||||
gesture_row,
|
||||
"guide_gesture",
|
||||
o.guide_gesture.is_some(),
|
||||
index::guide_gesture
|
||||
);
|
||||
toggle!(
|
||||
pad_forward_row,
|
||||
"gamepad_forwarding",
|
||||
o.gamepad_forwarding.is_some(),
|
||||
gamepad_forwarding
|
||||
);
|
||||
choice!(
|
||||
present_row,
|
||||
"present_priority",
|
||||
o.present_priority.is_some(),
|
||||
index::present_priority
|
||||
);
|
||||
choice!(
|
||||
buffer_row,
|
||||
"smooth_buffer",
|
||||
o.smooth_buffer.is_some(),
|
||||
index::smooth_buffer
|
||||
);
|
||||
toggle!(vsync_row, "vsync", o.vsync.is_some(), vsync);
|
||||
toggle!(vrr_row, "allow_vrr", o.allow_vrr.is_some(), allow_vrr);
|
||||
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled);
|
||||
toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444);
|
||||
toggle!(
|
||||
@@ -1775,6 +1985,11 @@ pub fn show_scoped(
|
||||
if let (Some(r), false) = (&gpu_row, profile_mode) {
|
||||
quality_group.add(r.widget());
|
||||
}
|
||||
let presentation_group = group("Presentation", "");
|
||||
presentation_group.add(present_row.widget());
|
||||
presentation_group.add(buffer_row.widget());
|
||||
presentation_group.add(&vsync_row);
|
||||
presentation_group.add(&vrr_row);
|
||||
// The one form-level note (deliberately not repeated on every row).
|
||||
let output_group = group(
|
||||
"Host output",
|
||||
@@ -1783,6 +1998,7 @@ pub fn show_scoped(
|
||||
output_group.add(compositor_row.widget());
|
||||
display.add(&resolution_group);
|
||||
display.add(&quality_group);
|
||||
display.add(&presentation_group);
|
||||
display.add(&output_group);
|
||||
|
||||
let input = page("Input", "input-keyboard-symbolic");
|
||||
@@ -1843,10 +2059,16 @@ pub fn show_scoped(
|
||||
controllers_group.add(&row);
|
||||
}
|
||||
}
|
||||
// Profileable, so it shows in both scopes — unlike the pin below it, which is about
|
||||
// which of THIS device's pads goes first: a "Work" profile can decline to forward
|
||||
// controllers to a host that a "Game" profile forwards them to.
|
||||
controllers_group.add(&pad_forward_row);
|
||||
if !profile_mode {
|
||||
controllers_group.add(forward_row.widget());
|
||||
}
|
||||
controllers_group.add(pad_row.widget());
|
||||
controllers_group.add(sysbtn_row.widget());
|
||||
controllers_group.add(gesture_row.widget());
|
||||
controllers.add(&controllers_group);
|
||||
|
||||
// Cap every caption in one pass, after the rows exist: a per-row call would be sixteen
|
||||
@@ -1886,6 +2108,12 @@ pub fn show_scoped(
|
||||
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
|
||||
s.gamepad = GAMEPADS[pad_sel].to_string();
|
||||
}
|
||||
s.system_buttons = SYSTEM_BUTTONS
|
||||
[(sysbtn_row.selected() as usize).min(SYSTEM_BUTTONS.len() - 1)]
|
||||
.to_string();
|
||||
s.guide_gesture = GUIDE_GESTURES
|
||||
[(gesture_row.selected() as usize).min(GUIDE_GESTURES.len() - 1)]
|
||||
.to_string();
|
||||
s.touch_mode =
|
||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||
s.mouse_mode =
|
||||
@@ -1915,6 +2143,7 @@ pub fn show_scoped(
|
||||
s.auto_wake = wake_row.is_active();
|
||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||
s.invert_scroll = invert_row.is_active();
|
||||
s.gamepad_forwarding = pad_forward_row.is_active();
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
s.echo_cancel = echo_row.is_active();
|
||||
s.hdr_enabled = hdr_row.is_active();
|
||||
@@ -1925,6 +2154,14 @@ pub fn show_scoped(
|
||||
_ => 2,
|
||||
};
|
||||
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
|
||||
s.present_priority = PRESENT_PRIORITIES
|
||||
[(present_row.selected() as usize).min(PRESENT_PRIORITIES.len() - 1)]
|
||||
.to_string();
|
||||
// The index IS the value (0 = Automatic).
|
||||
s.smooth_buffer =
|
||||
(buffer_row.selected() as u8).min(SMOOTH_BUFFER_LABELS.len() as u8 - 1);
|
||||
s.vsync = vsync_row.is_active();
|
||||
s.allow_vrr = vrr_row.is_active();
|
||||
s.library_enabled = library_row.is_active();
|
||||
};
|
||||
|
||||
|
||||
@@ -558,6 +558,10 @@ async fn session(args: Args) -> Result<()> {
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Like STREAMED_AU above: the shared-core reassembler pins geometry per-frame, so
|
||||
// the probe accepts a mid-session shard change (and jumbo growth) up to the
|
||||
// receive ceiling — and it's exactly the tool to measure both.
|
||||
max_shard_payload: punktfunk_core::config::max_shard_payload() as u16,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
|
||||
@@ -61,6 +61,7 @@ default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10
|
||||
policy.
|
||||
|
||||
Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software`, `PUNKTFUNK_PRESENT_MODE=
|
||||
mailbox|immediate` (default FIFO), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
|
||||
mailbox|fifo|immediate|fifo_relaxed` (default MAILBOX, FIFO where the surface offers no
|
||||
MAILBOX — AMD on Windows), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
|
||||
`PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike
|
||||
demotion to software on healthy hardware).
|
||||
|
||||
@@ -79,20 +79,22 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
can_wake: false,
|
||||
last_used: k.and_then(|h| h.last_used),
|
||||
os: k.map(|h| h.os.clone()).unwrap_or_default(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
};
|
||||
let label = row.name.clone();
|
||||
if k.is_none() {
|
||||
seed = Some(row.clone());
|
||||
}
|
||||
if row.paired {
|
||||
(ConsoleEntry::Library(row), Some(label))
|
||||
(ConsoleEntry::Library(Box::new(row)), Some(label))
|
||||
} else {
|
||||
(ConsoleEntry::Home, Some(label))
|
||||
}
|
||||
}
|
||||
None if fake => {
|
||||
let row = fake_host_row();
|
||||
(ConsoleEntry::Library(row), None)
|
||||
(ConsoleEntry::Library(Box::new(row)), None)
|
||||
}
|
||||
None => (ConsoleEntry::Home, None),
|
||||
};
|
||||
@@ -169,6 +171,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
mouse_mode: settings_at_start.mouse_mode(),
|
||||
invert_scroll: settings_at_start.invert_scroll,
|
||||
inhibit_shortcuts: settings_at_start.inhibit_shortcuts,
|
||||
// Presentation-tier like the rows above: latched at console start, a per-host
|
||||
// profile cannot move it in this mode (the documented P4 gap).
|
||||
present_priority: settings_at_start.present_priority(),
|
||||
vsync: settings_at_start.vsync,
|
||||
allow_vrr: settings_at_start.allow_vrr,
|
||||
json_status,
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
@@ -202,6 +209,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
launch,
|
||||
title,
|
||||
request_access,
|
||||
profile,
|
||||
} => {
|
||||
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
||||
// Connect (and request-access) pin the host's advertised fingerprint;
|
||||
@@ -216,9 +224,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
// have changed the defaults since the last stream, and the host may carry
|
||||
// a profile binding. Console (and therefore Decky, which spawns this
|
||||
// binary) honors bindings with no console-side work — the resolver is the
|
||||
// same one `--connect` goes through. No one-off here: picking a profile is
|
||||
// a desktop-shell affordance in v1, pinned cards are the console's.
|
||||
let (settings, profile) = trust::effective_settings(&addr, port, None);
|
||||
// same one `--connect` goes through. A pinned card's connect arrives as a
|
||||
// one-off profile id; the resolver prefers it over the binding, and a
|
||||
// dangling id falls back to the defaults without blocking the connect.
|
||||
let (settings, profile) =
|
||||
trust::effective_settings(&addr, port, profile.as_deref());
|
||||
let mut params = session_params(
|
||||
&settings,
|
||||
profile.map(|p| p.name),
|
||||
@@ -298,6 +308,8 @@ fn fake_host_row() -> HostRow {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: "linux/arch/steamos".into(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +513,38 @@ impl ServiceState {
|
||||
ConsoleCmd::Probe => {
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60);
|
||||
}
|
||||
ConsoleCmd::SetPin {
|
||||
key,
|
||||
profile_id,
|
||||
pin,
|
||||
} => {
|
||||
// Presentation only (design §5.2a): order = card order, appended at the
|
||||
// end; never touches `profile_id` (the default binding). Idempotent, so
|
||||
// a repeated press inside one refresh window can't double-pin.
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let idx = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| !h.fp_hex.is_empty() && h.fp_hex == key)
|
||||
.or_else(|| {
|
||||
let (addr, port) = key.rsplit_once(':')?;
|
||||
known.index_by_addr(addr, port.parse().ok()?)
|
||||
});
|
||||
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
|
||||
tracing::warn!(%key, "pin toggle for an unknown host — ignoring");
|
||||
return;
|
||||
};
|
||||
if pin && !h.pinned_profiles.contains(&profile_id) {
|
||||
h.pinned_profiles.push(profile_id);
|
||||
} else if !pin {
|
||||
h.pinned_profiles.retain(|id| *id != profile_id);
|
||||
}
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
||||
}
|
||||
// `run` refreshes the rows right after this drain, so the carousel and
|
||||
// the pin screen reflect the new card within the same service pass.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,12 +583,21 @@ impl ServiceState {
|
||||
})
|
||||
}
|
||||
|
||||
/// The console home's rows: saved hosts (most recent first), then
|
||||
/// discovered-but-unsaved ones, then a still-uncovered `--browse` seed.
|
||||
/// The console home's rows: saved hosts (most recent first) — each followed by its
|
||||
/// pinned profile cards (design §5.2a) — then discovered-but-unsaved ones, then a
|
||||
/// still-uncovered `--browse` seed.
|
||||
fn rows(&self) -> Vec<HostRow> {
|
||||
let known = trust::KnownHosts::load();
|
||||
let catalog = pf_client_core::profiles::ProfilesFile::load();
|
||||
let probed = self.probed.lock().unwrap();
|
||||
let mut rows: Vec<HostRow> = known
|
||||
let chip = |p: &pf_client_core::profiles::StreamProfile| pf_console_ui::ProfileChip {
|
||||
id: p.id.clone(),
|
||||
name: p.name.clone(),
|
||||
accent: p.accent.clone(),
|
||||
};
|
||||
// Primary rows paired with their pinned cards, so the sort below can order hosts
|
||||
// while every host's cards stay glued behind its primary tile.
|
||||
let mut saved: Vec<(HostRow, Vec<HostRow>)> = known
|
||||
.hosts
|
||||
.iter()
|
||||
.map(|h| {
|
||||
@@ -558,8 +611,8 @@ impl ServiceState {
|
||||
|| (d.addr == h.addr && d.port == h.port)
|
||||
});
|
||||
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
|
||||
HostRow {
|
||||
key,
|
||||
let row = HostRow {
|
||||
key: key.clone(),
|
||||
name: host_display_name(&h.name, &h.addr),
|
||||
addr: h.addr.clone(),
|
||||
port: h.port,
|
||||
@@ -576,10 +629,34 @@ impl ServiceState {
|
||||
.filter(|d| !d.os.is_empty())
|
||||
.map(|d| d.os.clone())
|
||||
.unwrap_or_else(|| h.os.clone()),
|
||||
}
|
||||
pin: None,
|
||||
bound_profile: h
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.and_then(|id| catalog.find_by_id(id))
|
||||
.map(chip),
|
||||
};
|
||||
// A pinned card shares the primary tile's live state; its key rides the
|
||||
// profile id behind a NUL (impossible in a fingerprint or `addr:port`),
|
||||
// so cursor-follow and the wake path address the card itself.
|
||||
let pins = h
|
||||
.resolved_pins(&catalog)
|
||||
.into_iter()
|
||||
.map(|p| HostRow {
|
||||
key: format!("{key}\0{}", p.id),
|
||||
pin: Some(chip(p)),
|
||||
bound_profile: None,
|
||||
..row.clone()
|
||||
})
|
||||
.collect();
|
||||
(row, pins)
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
|
||||
saved.sort_by(|(a, _), (b, _)| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
|
||||
let mut rows: Vec<HostRow> = saved
|
||||
.into_iter()
|
||||
.flat_map(|(row, pins)| std::iter::once(row).chain(pins))
|
||||
.collect();
|
||||
|
||||
let mut extra: Vec<HostRow> = self
|
||||
.discovered
|
||||
@@ -607,6 +684,8 @@ impl ServiceState {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: d.os.clone(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
})
|
||||
.collect();
|
||||
extra.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
+104
-3
@@ -18,6 +18,78 @@
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
|
||||
/// The session control socket: a line-per-connection unix socket other same-user
|
||||
/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which
|
||||
/// press the HOST's system buttons (the Decky panel's "Steam menu / Quick access on the
|
||||
/// host" buttons; see `GamepadService::tap_guide`). Plain text, no JSON: `<verb>\n` in,
|
||||
/// `ok\n` / `err\n` back.
|
||||
///
|
||||
/// The path is `$XDG_RUNTIME_DIR/punktfunk-session-ctl.sock` — inside the flatpak app
|
||||
/// runtime dir (`…/app/$FLATPAK_ID/`) when sandboxed, the ONE runtime path a flatpak and
|
||||
/// the host see identically, which is what lets the Decky backend (outside the sandbox)
|
||||
/// reach a flatpak-run session.
|
||||
#[cfg(all(unix, any(target_os = "linux", windows)))]
|
||||
mod ctl_socket {
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn path() -> Option<PathBuf> {
|
||||
let mut p = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
|
||||
if let Ok(id) = std::env::var("FLATPAK_ID") {
|
||||
p.push("app");
|
||||
p.push(id);
|
||||
}
|
||||
Some(p.join("punktfunk-session-ctl.sock"))
|
||||
}
|
||||
|
||||
/// Bind + serve on a background thread, once per process (later calls no-op). Any
|
||||
/// failure just logs at debug — the socket is a convenience surface, never worth
|
||||
/// failing a stream over.
|
||||
pub(crate) fn spawn(gamepad: GamepadService) {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(move || {
|
||||
let Some(path) = path() else { return };
|
||||
// A previous session's socket file refuses the bind — it's ours to replace.
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = match UnixListener::bind(&path) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, path = %path.display(), "session ctl socket unavailable");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pf-session-ctl".into())
|
||||
.spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut s) = stream else { continue };
|
||||
let mut line = String::new();
|
||||
if BufReader::new(&s).read_line(&mut line).is_err() {
|
||||
continue;
|
||||
}
|
||||
let ok = match line.trim() {
|
||||
"guide" => {
|
||||
gamepad.tap_guide();
|
||||
true
|
||||
}
|
||||
"qam" => {
|
||||
gamepad.tap_qam();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let _ = s.write_all(if ok { b"ok\n" } else { b"err\n" });
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
tracing::debug!(error = %e, "session ctl thread failed to start");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod session_main {
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
@@ -44,14 +116,20 @@ mod session_main {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// Running under Gaming Mode (a Deck, or any gamescope session): the environment
|
||||
/// where the local Steam UI owns the physical Steam/QAM buttons — the system-button
|
||||
/// "auto" policy keys off this.
|
||||
pub(crate) fn gaming_mode() -> bool {
|
||||
std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a
|
||||
/// manual launch under Gaming Mode does the right thing too. (Browse-mode only —
|
||||
/// gated with `mod browse`, its one caller.)
|
||||
#[cfg(feature = "ui")]
|
||||
pub(crate) fn fullscreen_mode() -> bool {
|
||||
arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
arg_flag("--fullscreen") || gaming_mode()
|
||||
}
|
||||
|
||||
/// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning
|
||||
@@ -188,6 +266,26 @@ mod session_main {
|
||||
if !settings.forward_pad.is_empty() {
|
||||
gamepad.set_pinned(Some(settings.forward_pad.clone()));
|
||||
}
|
||||
// Whether to forward controllers AT ALL (off = the pad reaches the host by some other
|
||||
// route — VirtualHere and friends). Set unconditionally, not only when off: browse mode
|
||||
// reuses one service across launches, so a stream that follows one with it off must put
|
||||
// it back. It goes on before the attach below, so a non-forwarding session never opens
|
||||
// — never grabs — the device.
|
||||
gamepad.set_forwarding(settings.gamepad_forwarding);
|
||||
// System-button routing: whether raw guide/QAM presses ride the wire, and whether
|
||||
// hold-Select arms as the alternate guide route. Auto keys off Gaming Mode — the
|
||||
// local Steam UI reacts to the same physical buttons there no matter what, so
|
||||
// forwarding raw opens BOTH overlays, the local one on top of the stream. Set
|
||||
// unconditionally for the same browse-mode-reuse reason as the line above.
|
||||
let game_mode = gaming_mode();
|
||||
gamepad.set_system_buttons(
|
||||
settings.system_buttons_forward(game_mode),
|
||||
settings.guide_gesture_enabled(game_mode),
|
||||
);
|
||||
// The control socket (guide/QAM injection — the Decky panel's host buttons).
|
||||
// Spawned at first params-build so it exists for --connect AND console launches.
|
||||
#[cfg(unix)]
|
||||
crate::ctl_socket::spawn(gamepad.clone());
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
@@ -617,6 +715,9 @@ mod session_main {
|
||||
mouse_mode: settings.mouse_mode(),
|
||||
invert_scroll: settings.invert_scroll,
|
||||
inhibit_shortcuts: settings.inhibit_shortcuts,
|
||||
present_priority: settings.present_priority(),
|
||||
vsync: settings.vsync,
|
||||
allow_vrr: settings.allow_vrr,
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
|
||||
@@ -623,8 +623,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
actions.push(
|
||||
icon_btn("Settings", Symbol::Setting)
|
||||
.on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Settings)
|
||||
let (c, ss) = (ctx.clone(), set_screen.clone());
|
||||
move || {
|
||||
// Re-base the settings snapshot on the file before the page
|
||||
// renders — this process is not its only writer (see
|
||||
// settings::refresh_snapshot).
|
||||
super::settings::refresh_snapshot(&c);
|
||||
ss.call(Screen::Settings)
|
||||
}
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! Settings).
|
||||
|
||||
use super::style::*;
|
||||
use super::Screen;
|
||||
use super::{AppCtx, Screen};
|
||||
use std::sync::Arc;
|
||||
use windows_reactor::*;
|
||||
|
||||
/// punktfunk's own license (MIT OR Apache-2.0).
|
||||
@@ -15,10 +16,15 @@ const APP_LICENSE: &str = concat!(
|
||||
/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/).
|
||||
const THIRD_PARTY_NOTICES: &str = include_str!("../../../../THIRD-PARTY-NOTICES.txt");
|
||||
|
||||
pub(crate) fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
pub(crate) fn licenses_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
let back_btn = button("Back").accent().icon(Symbol::Back).on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Settings)
|
||||
let (c, ss) = (ctx.clone(), set_screen.clone());
|
||||
move || {
|
||||
// Back RE-ENTERS the settings page — re-base its snapshot on the file, same
|
||||
// as the hosts page's Settings button (see settings::refresh_snapshot).
|
||||
super::settings::refresh_snapshot(&c);
|
||||
ss.call(Screen::Settings)
|
||||
}
|
||||
});
|
||||
|
||||
let app_card = card(
|
||||
|
||||
@@ -172,6 +172,10 @@ pub(crate) struct Shared {
|
||||
|
||||
pub struct AppCtx {
|
||||
pub(crate) identity: (String, String),
|
||||
/// The settings snapshot the UI renders from. Loaded once at startup, and RE-BASED on
|
||||
/// the file when the settings page is (re)entered (`settings::refresh_snapshot`) and
|
||||
/// inside every `commit` — this process is not the file's only writer (session resize,
|
||||
/// console UI, Decky), so a plain process-lifetime snapshot goes stale on screen.
|
||||
pub(crate) settings: Mutex<Settings>,
|
||||
pub(crate) gamepad: GamepadService,
|
||||
pub(crate) shared: Arc<Shared>,
|
||||
@@ -688,7 +692,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
&set_settings_rev,
|
||||
nav_progress,
|
||||
),
|
||||
Screen::Licenses => licenses::licenses_page(&set_screen),
|
||||
Screen::Licenses => licenses::licenses_page(ctx, &set_screen),
|
||||
Screen::Help => help::help_page(&set_screen),
|
||||
Screen::Pair => component(pair::pair_page, svc),
|
||||
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
|
||||
|
||||
@@ -79,6 +79,17 @@ const GAMEPADS: &[(&str, &str)] = &[
|
||||
// user could not ask the host for the Deck-shaped pad (trackpads, back grips).
|
||||
("steamdeck", "Steam Deck"),
|
||||
];
|
||||
/// System-button routing: `(stored value, display label)` — where the guide (Xbox/PS)
|
||||
/// and quick-access presses land while streaming. The cross-client `system_buttons` key;
|
||||
/// Automatic forwards on desktop and stays local under Gaming Mode.
|
||||
const SYSTEM_BUTTONS: &[(&str, &str)] = &[
|
||||
("auto", "Automatic"),
|
||||
("forward", "Send to host"),
|
||||
("local", "This device"),
|
||||
];
|
||||
/// The hold-Select guide gesture: `(stored value, display label)` — the cross-client
|
||||
/// `guide_gesture` key. Automatic arms it only where the raw press can't reach the host.
|
||||
const GUIDE_GESTURES: &[(&str, &str)] = &[("auto", "Automatic"), ("on", "On"), ("off", "Off")];
|
||||
/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder
|
||||
/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window.
|
||||
const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
|
||||
@@ -101,6 +112,19 @@ const MOUSE_MODES: &[(&str, &str)] = &[
|
||||
("capture", "Capture (games)"),
|
||||
("desktop", "Desktop (absolute)"),
|
||||
];
|
||||
/// Presentation intent: `(stored value, display label)` — the `present_priority` key the
|
||||
/// Apple and Android clients share, so one profile means the same thing everywhere.
|
||||
const PRESENT_PRIORITIES: &[(&str, &str)] =
|
||||
&[("latency", "Lowest latency"), ("smooth", "Smoothness")];
|
||||
/// Smoothness buffer depth in frames: `(stored value, display label)`. `0` = Automatic,
|
||||
/// which resolves to 2 (`PresentPriority::resolve`). No millisecond hints — the cost is
|
||||
/// one refresh per frame, and the refresh isn't known here when the mode is Native.
|
||||
const SMOOTH_BUFFERS: &[(u8, &str)] = &[
|
||||
(0, "Automatic"),
|
||||
(1, "1 frame"),
|
||||
(2, "2 frames"),
|
||||
(3, "3 frames"),
|
||||
];
|
||||
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
||||
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
||||
const COMPOSITORS: &[(&str, &str)] = &[
|
||||
@@ -411,7 +435,16 @@ fn commit(
|
||||
return;
|
||||
}
|
||||
let mut catalog = ProfilesFile::load();
|
||||
let base = ctx.settings.lock().unwrap().clone();
|
||||
// The same rebase as the global arm above: `base` is what `absorb`'s before/after
|
||||
// effective settings derive from, and the snapshot is not the file — another process
|
||||
// (session resize, console UI, Decky) may have moved a global under us. The historical
|
||||
// rebase fix ("settings saves stop reverting each other") covered the whole-file
|
||||
// writers but missed this arm.
|
||||
let base = {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
*s = Settings::load();
|
||||
s.clone()
|
||||
};
|
||||
let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) else {
|
||||
return; // deleted from under us; the next render falls back to the defaults scope
|
||||
};
|
||||
@@ -425,6 +458,17 @@ fn commit(
|
||||
rev.1.call(rev.0 + 1);
|
||||
}
|
||||
|
||||
/// Re-base the process-lifetime settings snapshot on the file — called from the navigation
|
||||
/// handlers that (re)enter this page, NOT per render pass. `ctx.settings` is loaded once at
|
||||
/// process start and this process is not the file's only writer (a spawned session persists
|
||||
/// its match-window size, the console UI and Decky save too — profiles.rs documents the
|
||||
/// family), so without this the page opens showing values another process already replaced,
|
||||
/// which then visibly "jump" the moment a row is touched and `commit`'s rebase pulls the
|
||||
/// file in. The field report this fixes: a codec setting that "changed by itself".
|
||||
pub(crate) fn refresh_snapshot(ctx: &Arc<AppCtx>) {
|
||||
*ctx.settings.lock().unwrap() = Settings::load();
|
||||
}
|
||||
|
||||
/// Which tier-P rows the profile in scope overrides. Plain bools rather than a lookup so the
|
||||
/// call sites read as `over.codec` — the row and its flag stay visibly paired.
|
||||
#[derive(Default)]
|
||||
@@ -445,8 +489,15 @@ struct OverrideFlags {
|
||||
invert_scroll: bool,
|
||||
inhibit_shortcuts: bool,
|
||||
gamepad: bool,
|
||||
gamepad_forwarding: bool,
|
||||
system_buttons: bool,
|
||||
guide_gesture: bool,
|
||||
stats_verbosity: bool,
|
||||
fullscreen_on_stream: bool,
|
||||
present_priority: bool,
|
||||
smooth_buffer: bool,
|
||||
vsync: bool,
|
||||
allow_vrr: bool,
|
||||
}
|
||||
|
||||
impl OverrideFlags {
|
||||
@@ -473,8 +524,15 @@ impl OverrideFlags {
|
||||
invert_scroll: o.invert_scroll.is_some(),
|
||||
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
|
||||
gamepad: o.gamepad.is_some(),
|
||||
gamepad_forwarding: o.gamepad_forwarding.is_some(),
|
||||
system_buttons: o.system_buttons.is_some(),
|
||||
guide_gesture: o.guide_gesture.is_some(),
|
||||
stats_verbosity: o.stats_verbosity.is_some(),
|
||||
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
|
||||
present_priority: o.present_priority.is_some(),
|
||||
smooth_buffer: o.smooth_buffer.is_some(),
|
||||
vsync: o.vsync.is_some(),
|
||||
allow_vrr: o.allow_vrr.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +909,32 @@ pub(crate) fn settings_page(
|
||||
let chroma_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.enable_444, |s, on| {
|
||||
s.enable_444 = on
|
||||
});
|
||||
// Presentation intent (design/desktop-presentation-rebuild.md). The buffer row is
|
||||
// rendered only under Smoothness — `commit` bumps the revision, so flipping the
|
||||
// intent re-renders the section and the row appears/disappears with it.
|
||||
let (present_names, present_i) = presets(PRESENT_PRIORITIES, |v| *v == s.present_priority);
|
||||
let present_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
present_names,
|
||||
present_i,
|
||||
|s, i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string(),
|
||||
);
|
||||
let smoothing = s.present_priority == "smooth";
|
||||
let (buffer_names, buffer_i) = presets(SMOOTH_BUFFERS, |v| *v == s.smooth_buffer);
|
||||
let buffer_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
buffer_names,
|
||||
buffer_i,
|
||||
|s, i| s.smooth_buffer = SMOOTH_BUFFERS[i].0,
|
||||
);
|
||||
let vsync_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.vsync, |s, on| s.vsync = on);
|
||||
let vrr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.allow_vrr, |s, on| {
|
||||
s.allow_vrr = on
|
||||
});
|
||||
|
||||
// --- Input -----------------------------------------------------------------------------
|
||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
|
||||
@@ -898,12 +982,38 @@ pub(crate) fn settings_page(
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
let pad_forward_toggle =
|
||||
setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| {
|
||||
s.gamepad_forwarding = on
|
||||
});
|
||||
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
|
||||
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
|
||||
});
|
||||
let pad_combo = setting_combo(ctx, scope, (rev, set_rev), pad_names, pad_i, |s, i| {
|
||||
s.gamepad = GAMEPADS[i].0.to_string();
|
||||
});
|
||||
let (sysbtn_names, sysbtn_i) = presets(SYSTEM_BUTTONS, |v| *v == s.system_buttons);
|
||||
let sysbtn_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
sysbtn_names,
|
||||
sysbtn_i,
|
||||
|s, i| {
|
||||
s.system_buttons = SYSTEM_BUTTONS[i].0.to_string();
|
||||
},
|
||||
);
|
||||
let (gesture_names, gesture_i) = presets(GUIDE_GESTURES, |v| *v == s.guide_gesture);
|
||||
let gesture_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
gesture_names,
|
||||
gesture_i,
|
||||
|s, i| {
|
||||
s.guide_gesture = GUIDE_GESTURES[i].0.to_string();
|
||||
},
|
||||
);
|
||||
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
|
||||
let touch_combo = setting_combo(ctx, scope, (rev, set_rev), touch_names, touch_i, |s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
@@ -972,6 +1082,21 @@ pub(crate) fn settings_page(
|
||||
let ss = set_screen.clone();
|
||||
button("Third-party licenses").on_click(move || ss.call(Screen::Licenses))
|
||||
};
|
||||
// The client log's home — the file every "check the client log" message means, which until
|
||||
// this row had no way in from the UI at all. The folder rather than the file so the rotated
|
||||
// `.old` generation is in reach too.
|
||||
//
|
||||
// `real_dir` (not the literal %LOCALAPPDATA% path) because Explorer lives outside our MSIX
|
||||
// container: handed a path the package redirection keeps from ever existing, it silently
|
||||
// opens the user's Documents folder instead of failing, which is precisely what this button
|
||||
// shipped doing. The `is_dir` guard keeps that fallback unreachable — if the resolve ever
|
||||
// comes back wrong, the click does nothing rather than landing somewhere misleading.
|
||||
// Best-effort otherwise, like the log itself: a failed spawn stays silent.
|
||||
let logs_button = button("Open log folder").on_click(|| {
|
||||
if let Some(dir) = crate::logfile::real_dir().filter(|d| d.is_dir()) {
|
||||
let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn();
|
||||
}
|
||||
});
|
||||
let library_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.library_enabled, |s, on| {
|
||||
s.library_enabled = on
|
||||
});
|
||||
@@ -1065,8 +1190,9 @@ pub(crate) fn settings_page(
|
||||
"HDR10, when the host has HDR content and this display supports it. \
|
||||
HEVC only; otherwise the stream stays SDR.",
|
||||
),
|
||||
// Wording shared with the GTK client (its chroma_row) — same setting,
|
||||
// same constraints.
|
||||
// First sentence shared with the GTK client (its chroma_row); the
|
||||
// constraint sentence names the real gate (host: PyroWave || NVENC) —
|
||||
// "where the host can encode it" cost field users the discovery time.
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
@@ -1075,7 +1201,8 @@ pub(crate) fn settings_page(
|
||||
over.enable_444,
|
||||
chroma_toggle,
|
||||
"Full-colour video: crisp small text and thin lines, at more \
|
||||
bandwidth. HEVC only, and only where the host can encode it.",
|
||||
bandwidth. Requires an NVIDIA host (NVENC) or the PyroWave \
|
||||
codec \u{2014} other encoders stream 4:2:0.",
|
||||
),
|
||||
],
|
||||
None,
|
||||
@@ -1105,6 +1232,60 @@ pub(crate) fn settings_page(
|
||||
},
|
||||
None,
|
||||
));
|
||||
out.extend(group(
|
||||
Some("Presentation"),
|
||||
{
|
||||
let mut fields = vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"present_priority",
|
||||
"Prioritize",
|
||||
over.present_priority,
|
||||
present_combo,
|
||||
"Lowest latency shows each frame the moment the display can take \
|
||||
it \u{2014} a network hiccup becomes an occasional repeated or \
|
||||
skipped frame. Smoothness buffers a little to even those out.",
|
||||
)];
|
||||
if smoothing {
|
||||
fields.push(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"smooth_buffer",
|
||||
"Smoothness buffer",
|
||||
over.smooth_buffer,
|
||||
buffer_combo,
|
||||
"Frames held back before showing. Each one absorbs about a \
|
||||
refresh of network hiccup and adds a refresh of delay. \
|
||||
Automatic holds two.",
|
||||
));
|
||||
}
|
||||
fields.push(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"vsync",
|
||||
"V-Sync",
|
||||
over.vsync,
|
||||
vsync_toggle,
|
||||
"Tear-free. Turning it off removes the wait for the screen\u{2019}s \
|
||||
refresh \u{2014} the lowest possible delay, at the cost of visible \
|
||||
tearing. Not every driver offers it; the stats overlay names the \
|
||||
mode actually in use.",
|
||||
));
|
||||
fields.push(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"allow_vrr",
|
||||
"Follow variable refresh rate",
|
||||
over.allow_vrr,
|
||||
vrr_toggle,
|
||||
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with \
|
||||
the stream instead of on a fixed cadence. Applies to fullscreen \
|
||||
sessions; harmless on a fixed-refresh screen.",
|
||||
));
|
||||
fields
|
||||
},
|
||||
None,
|
||||
));
|
||||
out.extend(group(
|
||||
Some("Host output"),
|
||||
vec![described_overridable(
|
||||
@@ -1223,6 +1404,23 @@ pub(crate) fn settings_page(
|
||||
"Plug in or pair a controller and it appears here.",
|
||||
)
|
||||
}),
|
||||
// Whether ANY controller is forwarded — profileable, so it renders in
|
||||
// both scopes (a "Work" profile can decline what "Game" forwards),
|
||||
// unlike the device-fact picker below it.
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"gamepad_forwarding",
|
||||
"Forward controllers",
|
||||
over.gamepad_forwarding,
|
||||
pad_forward_toggle,
|
||||
"Sends controllers connected to this PC to the host. Turn it off when \
|
||||
your controller already reaches the host another way \u{2014} USB \
|
||||
passthrough such as VirtualHere, or a pad plugged into the host \
|
||||
itself \u{2014} so games don't see two of them. Off, this PC never \
|
||||
opens the controller at all, which is what leaves it free for a \
|
||||
passthrough tool to claim.",
|
||||
)),
|
||||
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
|
||||
// forwards every controller as its own player. Same picker, different rule.
|
||||
// Which physical pad this device forwards is a device fact (tier G), so it
|
||||
@@ -1246,6 +1444,30 @@ pub(crate) fn settings_page(
|
||||
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
|
||||
motion.",
|
||||
)),
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"system_buttons",
|
||||
"Steam / guide button",
|
||||
over.system_buttons,
|
||||
sysbtn_combo,
|
||||
"Where the guide (Xbox/PS) and quick-access presses go while \
|
||||
streaming. Automatic sends them to the host \u{2014} except on \
|
||||
devices whose own overlay reacts to the same press (Gaming Mode), \
|
||||
where they stay local and the gesture below reaches the host.",
|
||||
)),
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"guide_gesture",
|
||||
"Hold Select for guide",
|
||||
over.guide_gesture,
|
||||
gesture_combo,
|
||||
"Hold Select on its own to press the host's guide button \u{2014} keep \
|
||||
holding for a Gaming-Mode host's quick-access menu. A Select tap \
|
||||
still goes through, slightly delayed. Automatic arms it only where \
|
||||
the real button can't reach the host.",
|
||||
)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -1325,7 +1547,16 @@ pub(crate) fn settings_page(
|
||||
"About",
|
||||
group(
|
||||
None,
|
||||
vec![about_identity.into(), licenses_button.into()],
|
||||
vec![
|
||||
about_identity.into(),
|
||||
described_labeled(
|
||||
"Diagnostics",
|
||||
logs_button,
|
||||
"The client log (client.log, plus the session\u{2019}s whole \
|
||||
receive/decode/present trail) \u{2014} attach it to a bug report.",
|
||||
),
|
||||
licenses_button.into(),
|
||||
],
|
||||
None,
|
||||
),
|
||||
),
|
||||
@@ -1727,5 +1958,26 @@ mod tests {
|
||||
let f3 = OverrideFlags::of(Some(&p3));
|
||||
assert!(f3.echo_cancel);
|
||||
assert!(!f3.mic_enabled);
|
||||
|
||||
// The presentation pair, likewise independent: pinning the intent doesn't claim
|
||||
// the buffer (a "Smoothness, whatever the global buffer is" profile is valid).
|
||||
let mut p4 = StreamProfile::new("t4".to_string());
|
||||
p4.overrides = SettingsOverlay {
|
||||
present_priority: Some("smooth".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let f4 = OverrideFlags::of(Some(&p4));
|
||||
assert!(f4.present_priority);
|
||||
assert!(!f4.smooth_buffer);
|
||||
|
||||
// V-Sync and VRR are independent of each other and of the intent pair.
|
||||
let mut p5 = StreamProfile::new("t5".to_string());
|
||||
p5.overrides = SettingsOverlay {
|
||||
vsync: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
let f5 = OverrideFlags::of(Some(&p5));
|
||||
assert!(f5.vsync);
|
||||
assert!(!f5.allow_vrr && !f5.present_priority);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
|
||||
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
|
||||
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
|
||||
//!
|
||||
//! Two paths, deliberately: [`log_dir`] is what we open files through, [`real_dir`] is where
|
||||
//! they actually land. Under MSIX those differ, and only the second one is fit to show a user
|
||||
//! or hand to Explorer.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufRead, Write};
|
||||
@@ -21,13 +25,74 @@ const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
|
||||
|
||||
/// The log directory we WRITE through: `%LOCALAPPDATA%\punktfunk\logs`.
|
||||
///
|
||||
/// Correct to open files under, but NOT necessarily where the bytes land — see [`real_dir`].
|
||||
/// Anything shown to a user or handed to another process wants that one instead.
|
||||
fn log_dir() -> Option<PathBuf> {
|
||||
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
|
||||
}
|
||||
|
||||
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
|
||||
/// The log directory as it exists ON DISK — Settings ▸ About's "Open log folder" opens this in
|
||||
/// Explorer, and [`path`] names it in the startup line and the failed-spawn banner.
|
||||
///
|
||||
/// The shipping client is a full-trust MSIX package, and Windows redirects a packaged app's
|
||||
/// `%LOCALAPPDATA%` writes into its private `…\Packages\<family>\LocalCache\Local\…`. We create
|
||||
/// and append through that redirection without ever seeing it, so [`log_dir`] is the right path
|
||||
/// to WRITE to yet names a directory that never exists on disk. Explorer runs OUTSIDE the
|
||||
/// container: it resolves the literal path, finds nothing, and silently falls back to the user's
|
||||
/// Documents folder — which is exactly what "Open log folder" did in every packaged install, and
|
||||
/// what the two "check <path>" messages pointed at. An unpackaged dev run creates the literal
|
||||
/// directory for real, which is why this only ever showed up in the field.
|
||||
///
|
||||
/// Canonicalizing the directory we just created resolves through the redirection on a packaged
|
||||
/// run and changes nothing on an unpackaged one, so there is no package identity to detect.
|
||||
pub(crate) fn real_dir() -> Option<PathBuf> {
|
||||
let dir = log_dir()?;
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
Some(std::fs::canonicalize(&dir).map_or(dir, strip_verbatim))
|
||||
}
|
||||
|
||||
/// Undo the `\\?\` that [`std::fs::canonicalize`] always prefixes. Explorer refuses a verbatim
|
||||
/// path — it would take the very same silent Documents fallback [`real_dir`] exists to avoid —
|
||||
/// and it is noise in a line a user is meant to read and act on.
|
||||
fn strip_verbatim(p: PathBuf) -> PathBuf {
|
||||
use std::path::{Component, Prefix};
|
||||
|
||||
// Scoped so the borrow ends before the `return p` below can move it.
|
||||
let head = match p.components().next() {
|
||||
Some(Component::Prefix(pre)) => match pre.kind() {
|
||||
// `\\?\C:\…` → `C:\…`
|
||||
Prefix::VerbatimDisk(drive) => Some(PathBuf::from(format!(r"{}:\", drive as char))),
|
||||
// `\\?\UNC\server\share\…` → `\\server\share\…` (a roaming profile on a share).
|
||||
// Built through `OsString`, which appends verbatim — `PathBuf::push` would apply
|
||||
// separator logic to the bare `\\` and mangle it.
|
||||
Prefix::VerbatimUNC(server, share) => {
|
||||
let mut unc = std::ffi::OsString::from(r"\\");
|
||||
unc.push(server);
|
||||
unc.push(r"\");
|
||||
unc.push(share);
|
||||
Some(PathBuf::from(unc))
|
||||
}
|
||||
// Already a plain path — nothing to undo.
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let Some(mut out) = head else { return p };
|
||||
// `skip(1)` drops the prefix; the `RootDir` that follows it is already in `head`.
|
||||
out.extend(
|
||||
p.components()
|
||||
.skip(1)
|
||||
.filter(|c| !matches!(c, Component::RootDir)),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// The log file's path, for the "logs land here" startup line and the failed-spawn banner.
|
||||
/// Resolved like [`real_dir`] — a path a user is told to check has to be the one on disk.
|
||||
pub(crate) fn path() -> Option<PathBuf> {
|
||||
Some(log_dir()?.join("client.log"))
|
||||
Some(real_dir()?.join("client.log"))
|
||||
}
|
||||
|
||||
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
|
||||
@@ -96,3 +161,67 @@ pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The shape `canonicalize` actually returns for a local profile. Explorer treats a `\\?\`
|
||||
/// path as unresolvable and opens Documents instead, so the prefix has to come off.
|
||||
#[test]
|
||||
fn verbatim_disk_prefix_comes_off() {
|
||||
let p = PathBuf::from(r"\\?\C:\Users\ada\AppData\Local\punktfunk\logs");
|
||||
assert_eq!(
|
||||
strip_verbatim(p),
|
||||
PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs")
|
||||
);
|
||||
}
|
||||
|
||||
/// The MSIX-redirected form is what the fix is for: same treatment, longer path.
|
||||
#[test]
|
||||
fn verbatim_disk_prefix_comes_off_for_the_package_local_cache() {
|
||||
let p = PathBuf::from(
|
||||
r"\\?\C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs",
|
||||
);
|
||||
assert_eq!(
|
||||
strip_verbatim(p),
|
||||
PathBuf::from(
|
||||
r"C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// A roaming profile on a share canonicalizes to `\\?\UNC\…`; the plain UNC form is what
|
||||
/// Explorer takes. `\\server\share` must survive intact — dropping either half, or letting
|
||||
/// `PathBuf::push`'s separator logic at the bare `\\`, yields a path that opens nothing.
|
||||
#[test]
|
||||
fn verbatim_unc_prefix_becomes_a_plain_unc_path() {
|
||||
let p = PathBuf::from(r"\\?\UNC\fileserv\profiles\ada\AppData\Local\punktfunk\logs");
|
||||
assert_eq!(
|
||||
strip_verbatim(p),
|
||||
PathBuf::from(r"\\fileserv\profiles\ada\AppData\Local\punktfunk\logs")
|
||||
);
|
||||
}
|
||||
|
||||
/// An unpackaged dev run resolves to a path that was never verbatim — leave it alone.
|
||||
#[test]
|
||||
fn plain_path_is_untouched() {
|
||||
let p = PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs");
|
||||
assert_eq!(strip_verbatim(p.clone()), p);
|
||||
}
|
||||
|
||||
/// Whatever the run, the resolved directory is one Explorer can open: it exists, and it
|
||||
/// carries no verbatim prefix. This is the button's actual precondition.
|
||||
#[test]
|
||||
fn real_dir_is_an_openable_directory() {
|
||||
let Some(dir) = real_dir() else {
|
||||
return; // no LOCALAPPDATA (not a normal user session) — nothing to assert
|
||||
};
|
||||
assert!(dir.is_dir(), "{} is not a directory", dir.display());
|
||||
assert!(
|
||||
!dir.to_string_lossy().starts_with(r"\\?\"),
|
||||
"{} kept its verbatim prefix",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,14 @@ fn parse_line(line: &str) -> Option<ChildLine> {
|
||||
/// connect that silently drops back to the host list.
|
||||
pub(crate) fn silent_exit_banner(code: i32) -> Option<String> {
|
||||
(code != 0 && code != -1).then(|| {
|
||||
format!("The session didn't start (punktfunk-session exited with code {code}). Check the client log.")
|
||||
// Name the log's actual location — "check the client log" without a path is a
|
||||
// scavenger hunt (Settings ▸ About's "Open log folder" reaches it too).
|
||||
let log = crate::logfile::path()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| "the client log".into());
|
||||
format!(
|
||||
"The session didn't start (punktfunk-session exited with code {code}). Check {log}."
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -612,7 +612,10 @@ pub fn open_portal_monitor(
|
||||
/// 10-bit PQ/BT.2020 formats instead of the SDR set — pass it only when the output was actually
|
||||
/// brought up HDR (a gamescope spawned with `--hdr-enabled` off our `pipewire-hdr` build); the
|
||||
/// host resolves that in `capture::capturer_supports_hdr_for` **before** the Welcome, because a
|
||||
/// session that negotiated PQ cannot fall back to SDR afterwards.
|
||||
/// session that negotiated PQ cannot fall back to SDR afterwards. `cursor_id0_hides` declares the
|
||||
/// producer's cursor-meta contract — pass it for outputs whose compositor rewrites
|
||||
/// `SPA_META_Cursor` on every buffer (KWin), where an `id == 0` meta is an authoritative
|
||||
/// "pointer hidden" the composited/forwarded cursor must honor.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open_virtual_output(
|
||||
@@ -625,6 +628,7 @@ pub fn open_virtual_output(
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::from_virtual_output(
|
||||
remote_fd,
|
||||
@@ -636,6 +640,7 @@ pub fn open_virtual_output(
|
||||
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
|
||||
policy,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ struct CaptureOpts {
|
||||
/// the doomed birth mode. `false` everywhere else (Mutter SIZES the monitor from negotiation and
|
||||
/// gamescope fixates its own — gating those would starve legitimate first frames).
|
||||
expect_exact_dims: bool,
|
||||
/// The producer rewrites `SPA_META_Cursor` on EVERY buffer, so an `id == 0` meta is an
|
||||
/// authoritative "pointer hidden / off this output" the blend must honor (KWin). `false` for
|
||||
/// the stale-meta producers (Mutter recycles buffers without rewriting the region) — see
|
||||
/// [`pw_cursor::CursorState::id0_hides`](pw_cursor) for the full contract.
|
||||
cursor_id0_hides: bool,
|
||||
}
|
||||
|
||||
/// The shared state the PipeWire thread PUBLISHES and the capturer READS — one struct instead of
|
||||
@@ -301,6 +306,10 @@ impl PortalCapturer {
|
||||
want_444: false,
|
||||
want_hdr,
|
||||
expect_exact_dims: false,
|
||||
// The portal-monitor path today is Mutter (the GNOME HDR mirror) — the stale-meta
|
||||
// id-0 contract. A KDE portal capture would rewrite per buffer, but nothing routes
|
||||
// one through here yet; the virtual-output path below carries the real flag.
|
||||
cursor_id0_hides: false,
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
@@ -316,7 +325,8 @@ impl PortalCapturer {
|
||||
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
|
||||
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
|
||||
/// `want_hdr` runs the 10-bit PQ/BT.2020 offer instead of the SDR set — see
|
||||
/// [`crate::open_virtual_output`] for who is allowed to pass it.
|
||||
/// [`crate::open_virtual_output`] for who is allowed to pass it. `cursor_id0_hides` declares
|
||||
/// the producer's cursor-meta contract ([`CaptureOpts::cursor_id0_hides`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_virtual_output(
|
||||
remote_fd: Option<OwnedFd>,
|
||||
@@ -328,6 +338,7 @@ impl PortalCapturer {
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<PortalCapturer> {
|
||||
tracing::info!(
|
||||
node_id,
|
||||
@@ -335,6 +346,7 @@ impl PortalCapturer {
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
"connecting PipeWire to virtual output"
|
||||
);
|
||||
// Most virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise
|
||||
@@ -350,6 +362,7 @@ impl PortalCapturer {
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
|
||||
@@ -811,6 +811,7 @@ pub fn pipewire_thread(
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
..
|
||||
} = opts;
|
||||
crate::pwinit::ensure_init();
|
||||
@@ -985,7 +986,7 @@ pub fn pipewire_thread(
|
||||
yuv444: want_444,
|
||||
linear_nv12_failed: false,
|
||||
dbg_log_n: 0,
|
||||
cursor: CursorState::default(),
|
||||
cursor: CursorState::new(cursor_id0_hides),
|
||||
expect_dims: if expect_exact_dims {
|
||||
preferred.map(|(w, h, _)| (w, h))
|
||||
} else {
|
||||
|
||||
@@ -39,9 +39,23 @@ pub(super) struct CursorState {
|
||||
/// negotiated). Per-stream deliberately — a host serves many sessions per process, and a
|
||||
/// process-wide latch made the second session's triage read as "no meta".
|
||||
seen_meta: bool,
|
||||
/// This stream's producer rewrites the cursor meta on EVERY buffer, so an `id == 0` meta is
|
||||
/// an authoritative "pointer hidden / off this output" rather than a stale recycled region.
|
||||
/// True for KWin virtual outputs; false for the stale-meta producers (Mutter) — see
|
||||
/// [`note_cursor_id`].
|
||||
id0_hides: bool,
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
/// The per-stream state, declaring which `id == 0` contract the producer follows
|
||||
/// ([`Self::id0_hides`]).
|
||||
pub(super) fn new(id0_hides: bool) -> CursorState {
|
||||
CursorState {
|
||||
id0_hides,
|
||||
..CursorState::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
|
||||
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
|
||||
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
|
||||
@@ -79,6 +93,31 @@ pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one parsed `spa_meta_cursor.id` to the visibility state; returns whether the rest of the
|
||||
/// meta region (position, bitmap) is worth parsing.
|
||||
///
|
||||
/// Two producer contracts meet on `id == 0`. **KWin** rewrites the cursor meta on EVERY enqueued
|
||||
/// buffer, and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream —
|
||||
/// which covers a globally hidden cursor AND a client null-cursor surface (empty cursor geometry
|
||||
/// intersects nothing). There id 0 is the authoritative hide, and honoring it is what lets a game
|
||||
/// or Big Picture hide the pointer mid-stream ([`CursorState::id0_hides`], set for KWin virtual
|
||||
/// outputs; without it the composited arrow outlived every hide — the 0.22.0 field report).
|
||||
/// **Mutter** only rewrites a buffer's meta region when the cursor changed, so recycled buffers
|
||||
/// between damage frames carry a stale id-0 meta — treating that as hidden flickered the cursor
|
||||
/// off between hovers (on-glass round 5). There the last-known state holds, and a pointer that
|
||||
/// really left/hid simply stops producing updates (the M3 hidden hint has no Mutter signal —
|
||||
/// Windows has its own CURSOR_SUPPRESSED source).
|
||||
fn note_cursor_id(cursor: &mut CursorState, id: u32) -> bool {
|
||||
if id == 0 {
|
||||
if cursor.id0_hides {
|
||||
cursor.visible = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
cursor.visible = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
|
||||
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
|
||||
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
|
||||
@@ -121,16 +160,9 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
|
||||
(*cur).bitmap_offset,
|
||||
)
|
||||
};
|
||||
if id == 0 {
|
||||
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
|
||||
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
|
||||
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
|
||||
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
|
||||
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
|
||||
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
|
||||
if !note_cursor_id(cursor, id) {
|
||||
return;
|
||||
}
|
||||
cursor.visible = true;
|
||||
cursor.x = pos_x - hot_x;
|
||||
cursor.y = pos_y - hot_y;
|
||||
cursor.hot_x = hot_x;
|
||||
@@ -367,9 +399,44 @@ mod tests {
|
||||
hot_x: 0,
|
||||
hot_y: 0,
|
||||
seen_meta: true,
|
||||
id0_hides: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- note_cursor_id: the two producer id-0 contracts --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn id_zero_hides_only_on_a_rewriting_producer() {
|
||||
// KWin contract (`id0_hides`): id 0 is written fresh on every buffer, so it IS the hide —
|
||||
// a game or Big Picture hiding the pointer must reach the stream.
|
||||
let mut kwin = cursor(10, 10, 8, 8, (255, 255, 255), 255);
|
||||
kwin.id0_hides = true;
|
||||
assert!(!note_cursor_id(&mut kwin, 0), "id 0 parses no further");
|
||||
let o = kwin.overlay().expect("bitmap stays cached across a hide");
|
||||
assert!(!o.visible, "KWin id 0 must hide the overlay");
|
||||
// The pointer coming back re-shows the SAME cached bitmap.
|
||||
assert!(note_cursor_id(&mut kwin, 1));
|
||||
assert!(kwin.overlay().expect("still cached").visible);
|
||||
|
||||
// Mutter contract: recycled buffers carry stale id-0 metas — the last-known state holds
|
||||
// (honoring them flickered the cursor off between hovers, on-glass round 5).
|
||||
let mut mutter = cursor(10, 10, 8, 8, (255, 255, 255), 255);
|
||||
assert!(!note_cursor_id(&mut mutter, 0));
|
||||
assert!(
|
||||
mutter.overlay().expect("cached").visible,
|
||||
"a stale-meta producer's id 0 must NOT hide"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_zero_before_any_bitmap_yields_no_overlay() {
|
||||
// A KWin stream whose pointer was never on the output: hides arrive before any bitmap —
|
||||
// `overlay()` must stay `None` (nothing to blend), not a phantom empty cursor.
|
||||
let mut c = CursorState::new(true);
|
||||
assert!(!note_cursor_id(&mut c, 0));
|
||||
assert!(c.overlay().is_none());
|
||||
}
|
||||
|
||||
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1670,6 +1670,22 @@ impl IddPushCapturer {
|
||||
// the running correlated/total tally — lives on `StallWatch` (sweep Phase 5.4). It was
|
||||
// ~65 lines of log prose inside `try_consume`, which is the hot loop, and its two
|
||||
// counters were capturer fields that nothing else touched.
|
||||
// One ETW read serves both evidence fields: the prose summary spans the gap plus
|
||||
// the same 300 ms lead-in the report's OS-event correlation uses (the disturbance
|
||||
// that CAUSED the hole lands just before it), while the discriminator counts span
|
||||
// the GAP ONLY — no lead-in: presents from the healthy flow right before the hole
|
||||
// would falsely acquit the content (the stall-ending frame's own present lands at
|
||||
// the window edge and stays well under the acquit bar). Both halves must come from
|
||||
// the same ring snapshot under the same clock anchor, or the prose and the verdict
|
||||
// can disagree about the same hole.
|
||||
let (etw, etw_counts) = self
|
||||
.etw
|
||||
.as_ref()
|
||||
.and_then(|w| {
|
||||
now.checked_sub(stall.gap)
|
||||
.map(|from| w.window_report(from, now, Duration::from_millis(300)))
|
||||
})
|
||||
.unzip();
|
||||
let evidence = StallEvidence {
|
||||
// A publisher re-attach restarts `offered_total` near zero; a ring recreate resets
|
||||
// the stall watch before that can matter, but guard the delta anyway (a restarted
|
||||
@@ -1682,24 +1698,14 @@ impl IddPushCapturer {
|
||||
}
|
||||
}),
|
||||
max_heartbeat_age_ms: self.max_hb_age_us / 1_000,
|
||||
// The probe + ETW reads span the same window the report's OS-event correlation
|
||||
// uses (the gap plus a lead-in for the disturbance that CAUSED it).
|
||||
// The probe read spans the same window the report's OS-event correlation uses
|
||||
// (the gap plus a lead-in for the disturbance that CAUSED it).
|
||||
probes: now
|
||||
.checked_sub(stall.gap + Duration::from_millis(300))
|
||||
.zip(self.probes.as_deref())
|
||||
.map(|(from, p)| p.window(from, now)),
|
||||
etw: self.etw.as_ref().and_then(|w| {
|
||||
now.checked_sub(stall.gap + Duration::from_millis(300))
|
||||
.map(|from| w.summary(from, now))
|
||||
}),
|
||||
// The discriminator counts span the GAP ONLY — no lead-in: presents from the
|
||||
// healthy flow right before the hole would falsely acquit the content. The
|
||||
// stall-ending frame's own present lands at the window edge and stays well
|
||||
// under the acquit bar.
|
||||
etw_counts: self.etw.as_ref().and_then(|w| {
|
||||
now.checked_sub(stall.gap)
|
||||
.map(|from| w.window_counts(from, now))
|
||||
}),
|
||||
etw,
|
||||
etw_counts,
|
||||
};
|
||||
self.stall_watch.report(&stall, now, &evidence);
|
||||
}
|
||||
@@ -2453,6 +2459,18 @@ mod tests {
|
||||
),
|
||||
StallClass::ContentSilence
|
||||
);
|
||||
// A LIVE witness (history true = it demonstrably worked just before the hole) reading
|
||||
// an exact zero is the strongest content conviction — the zero is a measurement, not
|
||||
// an absence.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
|
||||
Some(&counts(0, 0))
|
||||
),
|
||||
StallClass::ContentSilence
|
||||
);
|
||||
// The present witness does NOT overrule the driver's own verdicts or the harder
|
||||
// classes — it only refines compose-silence.
|
||||
assert_eq!(
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! A second provider rides the same session: `Microsoft-Windows-DXGI` (user-mode), filtered to
|
||||
//! `Present`/`PresentMultiplaneOverlay` starts (ids 42/55) — one event per swapchain present,
|
||||
//! stamped with the PRESENTING process id. Together they are the compose-silence discriminator
|
||||
//! ([`EtwWatch::window_counts`]): DXGI presents flowing while `BltQueueAddEntry` gaps = the OS
|
||||
//! ([`EtwWatch::window_report`]): DXGI presents flowing while `BltQueueAddEntry` gaps = the OS
|
||||
//! display path dropped composed frames (the real display-path bug); BOTH silent = the content
|
||||
//! stopped presenting (benign pause — menus/loading/game hitch). The predecessor witnesses are
|
||||
//! retired for cause: DxgKrnl id 184 `Present` never fires on the modern redirected path, and
|
||||
@@ -48,7 +48,8 @@ use windows::Win32::System::Diagnostics::Etw::{
|
||||
EVENT_CONTROL_CODE_ENABLE_PROVIDER, EVENT_FILTER_DESCRIPTOR, EVENT_FILTER_TYPE_EVENT_ID,
|
||||
EVENT_RECORD, EVENT_TRACE_CONTROL_STOP, EVENT_TRACE_LOGFILEW, EVENT_TRACE_PROPERTIES,
|
||||
EVENT_TRACE_REAL_TIME_MODE, PROCESSTRACE_HANDLE, PROCESS_TRACE_MODE_EVENT_RECORD,
|
||||
PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION, WNODE_FLAG_TRACED_GUID,
|
||||
PROCESS_TRACE_MODE_RAW_TIMESTAMP, PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION,
|
||||
WNODE_FLAG_TRACED_GUID,
|
||||
};
|
||||
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
|
||||
use windows::Win32::System::Threading::{
|
||||
@@ -122,8 +123,13 @@ fn qpc_freq() -> i64 {
|
||||
})
|
||||
}
|
||||
|
||||
/// The consumer's per-event callback — record id + QPC timestamp (the session's `ClientContext`
|
||||
/// is 1, so `TimeStamp` IS a QPC value) and return; runs on the consumer thread.
|
||||
/// The consumer's per-event callback — record id + timestamp + pid into the ring and return;
|
||||
/// runs on the consumer thread. `TimeStamp` is a raw QPC value only because BOTH halves of the
|
||||
/// clock contract hold: `ClientContext = 1` makes QPC the session clock, and the consumer is
|
||||
/// opened with `PROCESS_TRACE_MODE_RAW_TIMESTAMP`, which is what stops ProcessTrace converting
|
||||
/// every event's timestamp to FILETIME (100 ns units since 1601) on delivery. Without the flag
|
||||
/// the conversion happens REGARDLESS of the session clock, and every `ts <= to_q` comparison
|
||||
/// downstream is against the wrong clock — never true, a witness that silently reads empty.
|
||||
unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) {
|
||||
if record.is_null() {
|
||||
return;
|
||||
@@ -150,10 +156,10 @@ pub(super) struct EtwWatch {
|
||||
}
|
||||
|
||||
// SAFETY: both fields are plain kernel handle VALUES (u64 wrappers) owned by this watch; every
|
||||
// operation on them (summary reads the static ring; Drop stops/closes) is thread-safe by the ETW
|
||||
// API contract, and the singleton hands out only `Arc<EtwWatch>`.
|
||||
// operation on them (window_report reads the static ring; Drop stops/closes) is thread-safe by
|
||||
// the ETW API contract, and the singleton hands out only `Arc<EtwWatch>`.
|
||||
unsafe impl Send for EtwWatch {}
|
||||
// SAFETY: as above — `&EtwWatch` exposes only `summary` (static-ring reads).
|
||||
// SAFETY: as above — `&EtwWatch` exposes only `window_report` (static-ring reads).
|
||||
unsafe impl Sync for EtwWatch {}
|
||||
|
||||
static WATCH: Mutex<Weak<EtwWatch>> = Mutex::new(Weak::new());
|
||||
@@ -201,8 +207,10 @@ impl EtwWatch {
|
||||
let mut session = CONTROLTRACE_HANDLE::default();
|
||||
// SAFETY: `buf` is a live, zeroed allocation of base + name bytes; every write below is a
|
||||
// field of the properties struct at its head; `LoggerNameOffset = base` points at the
|
||||
// appended name space (ETW copies the name there itself). ClientContext 1 = QPC clock —
|
||||
// what makes event timestamps comparable to our probe windows.
|
||||
// appended name space (ETW copies the name there itself). ClientContext 1 selects QPC as
|
||||
// the SESSION clock — necessary but not sufficient for QPC comparisons: ProcessTrace
|
||||
// still converts every event's timestamp to FILETIME on delivery unless the consumer is
|
||||
// opened with PROCESS_TRACE_MODE_RAW_TIMESTAMP (set below).
|
||||
let rc = unsafe {
|
||||
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
|
||||
(*props).Wnode.BufferSize = buf.len() as u32;
|
||||
@@ -224,6 +232,11 @@ impl EtwWatch {
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// A fresh session gets a fresh ring: the static [`RING`] outlives any `EtwWatch`, so
|
||||
// whatever is in it belongs to a DEAD session — leaking it forward would let a previous
|
||||
// session's presents pose as this session's witness history. Race-free here: the
|
||||
// consumer thread that repopulates it is spawned below.
|
||||
RING.lock().unwrap().clear();
|
||||
|
||||
// Enable DxgKrnl with a kernel-side event-id filter — the whole point: the provider's
|
||||
// vblank/DPC keywords never reach us. Fatal on failure (the DDI families + queue
|
||||
@@ -240,7 +253,7 @@ impl EtwWatch {
|
||||
return None;
|
||||
}
|
||||
// The DXGI (user-mode) present witness rides the same session. Degraded-not-fatal: a
|
||||
// refusal only costs the per-process present counts — `window_counts` then reports
|
||||
// refusal only costs the per-process present counts — `window_report` then reports
|
||||
// no present history and classification stays honest (Unattributed, never a guess).
|
||||
if !enable_provider(session, &DXGI, &DXGI_FILTER_IDS) {
|
||||
tracing::debug!(
|
||||
@@ -252,8 +265,12 @@ impl EtwWatch {
|
||||
LoggerName: PWSTR(name.as_ptr() as *mut _),
|
||||
..Default::default()
|
||||
};
|
||||
log.Anonymous1.ProcessTraceMode =
|
||||
PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD;
|
||||
// RAW_TIMESTAMP is load-bearing: it stops ProcessTrace converting `EVENT_HEADER.TimeStamp`
|
||||
// to FILETIME on delivery, so events arrive stamped in the session clock (QPC, per the
|
||||
// ClientContext above) — the only clock the window edges are computed in.
|
||||
log.Anonymous1.ProcessTraceMode = PROCESS_TRACE_MODE_REAL_TIME
|
||||
| PROCESS_TRACE_MODE_EVENT_RECORD
|
||||
| PROCESS_TRACE_MODE_RAW_TIMESTAMP;
|
||||
log.Anonymous2.EventRecordCallback = Some(on_event);
|
||||
// SAFETY: `log` is a fully-initialized local; `name` outlives the call (OpenTrace copies
|
||||
// what it needs before returning).
|
||||
@@ -303,14 +320,34 @@ impl EtwWatch {
|
||||
Some(Self { session, consumer })
|
||||
}
|
||||
|
||||
/// Summarize the DDI activity inside `[from, to]` — the correlation line a stall report
|
||||
/// carries. Brackets that merely SPAN the window count too (a freeze-long `SetPowerState`
|
||||
/// has both edges outside the hole it caused). `"none"` when the window is clean.
|
||||
pub(super) fn summary(&self, from: Instant, to: Instant) -> String {
|
||||
// Instant → QPC: anchor both clocks now and offset backwards.
|
||||
/// One stall window's ETW evidence, both halves from a SINGLE ring snapshot under a SINGLE
|
||||
/// `(Instant::now(), qpc_now())` anchor: the DDI/present prose summary a stall report
|
||||
/// carries, and the structured discriminator counts the classifier folds in. The summary
|
||||
/// covers `[hole_from - lead_in, hole_to]` — the disturbance that CAUSED a hole lands just
|
||||
/// before DWM stops delivering, so the prose needs the lead-in. The counts cover
|
||||
/// `[hole_from, hole_to]` ONLY — presents from the healthy flow inside the lead-in would
|
||||
/// falsely acquit the content. Two separate reads (two locks, two anchors, syscalls in
|
||||
/// between) would let events arriving between them make the prose and the verdict disagree
|
||||
/// about the same hole — hence one method returning both.
|
||||
///
|
||||
/// Brackets that merely SPAN the summary window count too (a freeze-long `SetPowerState`
|
||||
/// has both edges outside the hole it caused). The summary reads `"none"` when the window
|
||||
/// is clean.
|
||||
pub(super) fn window_report(
|
||||
&self,
|
||||
hole_from: Instant,
|
||||
hole_to: Instant,
|
||||
lead_in: Duration,
|
||||
) -> (String, EtwWindowCounts) {
|
||||
// Instant → QPC: anchor both clocks once and offset backwards; every window edge below
|
||||
// derives from this one anchor.
|
||||
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(hole_to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(hole_from), freq);
|
||||
let summary_from_q = from_q - duration_qpc(lead_in, freq);
|
||||
// One snapshot, then the lock drops: everything below — including the OpenProcess
|
||||
// syscalls behind `process_name` — runs off the copy, so the consumer callback never
|
||||
// queues behind a stall report.
|
||||
let events: Vec<(i64, u16, u32)> = {
|
||||
let ring = RING.lock().unwrap();
|
||||
ring.iter()
|
||||
@@ -318,6 +355,7 @@ impl EtwWatch {
|
||||
.copied()
|
||||
.collect()
|
||||
};
|
||||
let counts = count_window(&events, from_q, to_q, duration_qpc(LOOKBACK, freq));
|
||||
let ms = |dq: i64| dq.max(0) * 1_000 / freq;
|
||||
let mut parts = Vec::new();
|
||||
for (start_id, stop_id, label) in [
|
||||
@@ -335,7 +373,7 @@ impl EtwWatch {
|
||||
} else if id == stop_id {
|
||||
if let Some(s) = open.take() {
|
||||
// The bracket [s, ts] counts when it intersects the window.
|
||||
if s <= to_q && ts >= from_q {
|
||||
if s <= to_q && ts >= summary_from_q {
|
||||
count += 1;
|
||||
max_ms = max_ms.max(ms(ts - s));
|
||||
}
|
||||
@@ -362,43 +400,34 @@ impl EtwWatch {
|
||||
] {
|
||||
let count = events
|
||||
.iter()
|
||||
.filter(|(ts, i, _)| *i == id && *ts >= from_q && *ts <= to_q)
|
||||
.filter(|(ts, i, _)| *i == id && *ts >= summary_from_q && *ts <= to_q)
|
||||
.count();
|
||||
if count > 0 {
|
||||
parts.push(format!("{label}×{count}"));
|
||||
}
|
||||
}
|
||||
// Present + queue accounting (DXGI 42/55 + BltQueueAddEntry/Complete): total presents
|
||||
// inside the window plus the top presenters, NAMED — the line that splits a
|
||||
// inside the summary window plus the top presenters, NAMED — the line that splits a
|
||||
// compose-silence hole into "the content stopped presenting" (no presents anywhere)
|
||||
// versus "presents flowed and the display path dropped them" (presents at rate while
|
||||
// the queue starves). "Present×0" is printed explicitly when the stream has history
|
||||
// but the window is empty — silence is a finding, not an absence.
|
||||
// the queue starves). "Present×0" is printed explicitly when the witness was LIVE
|
||||
// before the hole ([`LOOKBACK`]) but the window is empty — silence is a finding, not
|
||||
// an absence; a dead witness's window prints nothing rather than a fake zero.
|
||||
let mut per_pid: Vec<(u32, u32)> = Vec::new();
|
||||
let mut have_present_history = false;
|
||||
let (mut adds, mut completes) = (0u32, 0u32);
|
||||
let mut have_queue_history = false;
|
||||
for &(ts, id, pid) in &events {
|
||||
if ts < summary_from_q || ts > to_q {
|
||||
continue;
|
||||
}
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
have_present_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
match per_pid.iter_mut().find(|(p, _)| *p == pid) {
|
||||
Some((_, c)) => *c += 1,
|
||||
None => per_pid.push((pid, 1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID | BLT_COMPLETE_ID => {
|
||||
have_queue_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
if id == BLT_ADD_ID {
|
||||
adds += 1;
|
||||
} else {
|
||||
completes += 1;
|
||||
}
|
||||
match per_pid.iter_mut().find(|(p, _)| *p == pid) {
|
||||
Some((_, c)) => *c += 1,
|
||||
None => per_pid.push((pid, 1)),
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID => adds += 1,
|
||||
BLT_COMPLETE_ID => completes += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -415,61 +444,80 @@ impl EtwWatch {
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
parts.push(format!("Present×{total}({top})"));
|
||||
} else if have_present_history {
|
||||
} else if counts.present_history {
|
||||
parts.push("Present×0".to_string());
|
||||
}
|
||||
if have_queue_history {
|
||||
if counts.queue_history || adds > 0 || completes > 0 {
|
||||
parts.push(format!("blt-queue add×{adds} complete×{completes}"));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
let summary = if parts.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// The structured discriminator read for `[from, to]` (the stall classifier's evidence):
|
||||
/// how many swapchain presents (DXGI 42/55, any process) and how many virtual-display
|
||||
/// queue entries (`BltQueueAddEntry`) landed in the window, plus whether each stream has
|
||||
/// EVER produced an event (distinguishing a true zero from a witness that is not working —
|
||||
/// e.g. the DXGI enable was refused, or an OS build renumbered the BltQueue events).
|
||||
pub(super) fn window_counts(&self, from: Instant, to: Instant) -> EtwWindowCounts {
|
||||
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
||||
let ring = RING.lock().unwrap();
|
||||
let mut out = EtwWindowCounts::default();
|
||||
for &(ts, id, _) in ring.iter() {
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
out.present_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
out.presents += 1;
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID => {
|
||||
out.queue_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
out.queue_adds += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
(summary, counts)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`EtwWatch::window_counts`]'s read: the compose-silence discriminator's structured evidence.
|
||||
/// Witness-liveness lookback: the [`EtwWindowCounts`] history flags are true only when the
|
||||
/// stream produced at least one event inside the `LOOKBACK` window ENDING at the hole's start.
|
||||
/// "Ever produced an event" would be wrong in both directions: an event that arrived only AFTER
|
||||
/// the hole (the resume burst, the stall-ending frame) proves nothing about whether the witness
|
||||
/// was working DURING it, and a provider that died mid-session (or whose events aged out of the
|
||||
/// ring) would keep flying a stale known-working flag forever. Demonstrated life immediately
|
||||
/// BEFORE the hole is the claim the classifier actually needs; 5 s is far longer than any
|
||||
/// pre-stall active-flow gate, so a genuinely working witness cannot blink false across a
|
||||
/// frame-time lull.
|
||||
const LOOKBACK: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The discriminator's windowing math, factored pure (plain i64 QPC-tick arithmetic, no ETW,
|
||||
/// no clock reads) so the ring→counts contract is unit-testable without a session: presents
|
||||
/// (DXGI 42/55, any process) and queue entries (`BltQueueAddEntry`) inside `[from_q, to_q]`,
|
||||
/// witness liveness from `[from_q - lookback_q, from_q]` (see [`LOOKBACK`]). A
|
||||
/// `BltQueueCompleteIndirectPresent` proves the queue witness works exactly as an add does —
|
||||
/// both ride the same provider enable — so either satisfies `queue_history`.
|
||||
fn count_window(
|
||||
events: &[(i64, u16, u32)],
|
||||
from_q: i64,
|
||||
to_q: i64,
|
||||
lookback_q: i64,
|
||||
) -> EtwWindowCounts {
|
||||
let mut out = EtwWindowCounts::default();
|
||||
for &(ts, id, _) in events {
|
||||
let in_window = ts >= from_q && ts <= to_q;
|
||||
let in_lookback = ts >= from_q.saturating_sub(lookback_q) && ts <= from_q;
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
out.present_history |= in_lookback;
|
||||
if in_window {
|
||||
out.presents += 1;
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID => {
|
||||
out.queue_history |= in_lookback;
|
||||
if in_window {
|
||||
out.queue_adds += 1;
|
||||
}
|
||||
}
|
||||
BLT_COMPLETE_ID => out.queue_history |= in_lookback,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`EtwWatch::window_report`]'s structured half: the compose-silence discriminator's evidence.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct EtwWindowCounts {
|
||||
/// Swapchain presents (any process — the game AND dwm both count) inside the window.
|
||||
pub(super) presents: u32,
|
||||
/// `BltQueueAddEntry` events (frames entering the virtual display's kernel queue) inside it.
|
||||
pub(super) queue_adds: u32,
|
||||
/// The present stream has produced at least one event EVER (witness known-working).
|
||||
/// The present stream demonstrated liveness inside [`LOOKBACK`] BEFORE the hole opened — a
|
||||
/// working witness whose in-window zero is a reading, not a dead one whose zero is noise.
|
||||
pub(super) present_history: bool,
|
||||
/// The queue stream has produced at least one event EVER (witness known-working).
|
||||
/// Queue-stream liveness inside [`LOOKBACK`] before the hole (`BltQueueAddEntry` or
|
||||
/// `BltQueueCompleteIndirectPresent` — either proves the witness works).
|
||||
pub(super) queue_history: bool,
|
||||
}
|
||||
|
||||
@@ -561,3 +609,72 @@ impl Drop for EtwWatch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The module only compiles on Windows (lib.rs gates `mod windows`), so plain `cfg(test)` here
|
||||
// already means "Windows tests" — and [`count_window`] itself is pure tick math, no session.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// [`count_window`]'s contract: counts come from the hole window `[from, to]`; liveness
|
||||
/// comes ONLY from the lookback window ending at the hole's start. An event after the hole
|
||||
/// (the resume burst) or older than the lookback (a dead provider's leftovers) must not fly
|
||||
/// the known-working flag — those are exactly the shapes that used to convict every
|
||||
/// compose-silence hole as content.
|
||||
#[test]
|
||||
fn count_window_liveness_and_windowing() {
|
||||
// Hole [1000, 2000], lookback 500 → liveness window [500, 1000]. Plain ticks.
|
||||
let (from, to, lb) = (1_000i64, 2_000i64, 500i64);
|
||||
let ev = |ts: i64, id: u16| (ts, id, 42u32);
|
||||
|
||||
// The healthy shape: liveness demonstrated before the hole, activity inside it.
|
||||
let events = [
|
||||
ev(600, DXGI_PRESENT_ID), // lookback → present witness live
|
||||
ev(700, BLT_COMPLETE_ID), // lookback → queue witness live (completes count)
|
||||
ev(1_100, DXGI_PRESENT_ID), // in-window present
|
||||
ev(1_200, DXGI_PRESENT_MPO_ID), // in-window present (MPO path)
|
||||
ev(1_300, BLT_ADD_ID), // in-window queue add
|
||||
ev(1_400, 430), // non-witness id: never counted here
|
||||
];
|
||||
assert_eq!(
|
||||
count_window(&events, from, to, lb),
|
||||
EtwWindowCounts {
|
||||
presents: 2,
|
||||
queue_adds: 1,
|
||||
present_history: true,
|
||||
queue_history: true,
|
||||
}
|
||||
);
|
||||
|
||||
// In-window events count but do NOT confer liveness — the witness must have worked
|
||||
// BEFORE the hole for its zeros elsewhere to mean anything.
|
||||
let window_only = [ev(1_500, DXGI_PRESENT_ID), ev(1_600, BLT_ADD_ID)];
|
||||
let c = count_window(&window_only, from, to, lb);
|
||||
assert_eq!((c.presents, c.queue_adds), (1, 1));
|
||||
assert!(!c.present_history && !c.queue_history);
|
||||
|
||||
// An event only AFTER the hole proves nothing about the witness during it.
|
||||
let after_only = [ev(2_100, DXGI_PRESENT_ID), ev(2_200, BLT_ADD_ID)];
|
||||
assert_eq!(
|
||||
count_window(&after_only, from, to, lb),
|
||||
EtwWindowCounts::default()
|
||||
);
|
||||
|
||||
// Events that aged past the lookback (a previous session's leftovers) don't either.
|
||||
let stale = [ev(499, DXGI_PRESENT_ID), ev(1, BLT_ADD_ID)];
|
||||
assert_eq!(
|
||||
count_window(&stale, from, to, lb),
|
||||
EtwWindowCounts::default()
|
||||
);
|
||||
|
||||
// Both lookback edges are inclusive; the hole-start event is both liveness and count.
|
||||
let edges = [ev(500, DXGI_PRESENT_ID), ev(1_000, BLT_ADD_ID)];
|
||||
let c = count_window(&edges, from, to, lb);
|
||||
assert!(c.present_history && c.queue_history);
|
||||
assert_eq!((c.presents, c.queue_adds), (0, 1));
|
||||
|
||||
// A lookback reaching below tick 0 saturates instead of wrapping.
|
||||
let c = count_window(&[ev(0, DXGI_PRESENT_ID)], 3, to, i64::MAX);
|
||||
assert!(c.present_history);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ pub(super) struct StallEvidence {
|
||||
/// The DxgKrnl DDI activity inside the window (Phase A.3 ETW summary); `None` when the
|
||||
/// session is unavailable (non-admin dev run).
|
||||
pub(super) etw: Option<String>,
|
||||
/// The structured present-vs-queue counts for the window ([`EtwWatch::window_counts`]) —
|
||||
/// The structured present-vs-queue counts for the window ([`EtwWatch::window_report`]) —
|
||||
/// the compose-silence discriminator: presents flowing while the queue starves = the OS
|
||||
/// display path dropped composed frames; both silent = the content stopped presenting.
|
||||
/// `None` when the ETW session is unavailable.
|
||||
|
||||
@@ -168,9 +168,18 @@ struct PlayerData {
|
||||
/// Drained chunk Vecs go back here for the decode side to refill (allocation pool).
|
||||
recycle: SyncSender<Vec<f32>>,
|
||||
ring: VecDeque<f32>,
|
||||
primed: bool,
|
||||
/// Shared ms-denominated de-jitter policy: prime depth, drift correction, de-prime
|
||||
/// hysteresis. Replaces the old `3 × quantum` target, which meant 15 ms at a 5 ms graph
|
||||
/// quantum and a silent 64 ms at a 20 ms one, and the `if ring.is_empty()` re-prime, where
|
||||
/// one transient drain manufactured a whole target's worth of fresh silence.
|
||||
policy: punktfunk_core::audio::JitterPolicy,
|
||||
/// Interleaved channel count this stream was opened with (2/6/8).
|
||||
channels: usize,
|
||||
/// Diagnostics (WP0.3), logged ~every 10 s: the audio plane used to be entirely silent in a
|
||||
/// client log, so a latency or dropout report had nothing to go on.
|
||||
underruns: u64,
|
||||
sheds: u64,
|
||||
callbacks: u64,
|
||||
}
|
||||
|
||||
fn pw_thread(
|
||||
@@ -223,8 +232,14 @@ fn pw_thread(
|
||||
rx: pcm_rx,
|
||||
recycle: recycle_tx,
|
||||
ring: VecDeque::new(),
|
||||
primed: false,
|
||||
policy: punktfunk_core::audio::JitterPolicy::new(
|
||||
punktfunk_core::audio::JitterTuning::PIPEWIRE,
|
||||
channels as u8,
|
||||
),
|
||||
channels,
|
||||
underruns: 0,
|
||||
sheds: 0,
|
||||
callbacks: 0,
|
||||
};
|
||||
|
||||
let _listener = stream
|
||||
@@ -252,23 +267,29 @@ fn pw_thread(
|
||||
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
|
||||
let want = want_frames * ud.channels;
|
||||
|
||||
// Adaptive jitter buffer (same shape as the host's virtual mic): prime to
|
||||
// ~3 quanta, cap at ~1 quantum of slack beyond that, re-prime after a
|
||||
// genuine drain.
|
||||
let target = (3 * want).clamp(720 * ud.channels, 9600 * ud.channels);
|
||||
while ud.ring.len() > target.max(want) + want {
|
||||
ud.ring.pop_front();
|
||||
}
|
||||
if !ud.primed && ud.ring.len() >= target {
|
||||
ud.primed = true;
|
||||
// Shared de-jitter policy: prime depth in MILLISECONDS, smooth drift correction
|
||||
// (a crossfaded 5 ms shed) so latency returns to target instead of ratcheting,
|
||||
// and a hard cap as the backstop.
|
||||
let step = ud.policy.step(ud.ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
ud.sheds += 1;
|
||||
punktfunk_core::audio::crossfade_drop(
|
||||
&mut ud.ring,
|
||||
step.drop_front,
|
||||
step.crossfade,
|
||||
);
|
||||
}
|
||||
|
||||
let mut ran_short = false;
|
||||
let n_frames = if let Some(slice) = data.data() {
|
||||
for k in 0..want {
|
||||
let s = if ud.primed {
|
||||
ud.ring.pop_front().unwrap_or(0.0)
|
||||
} else {
|
||||
let s = if step.silence {
|
||||
0.0
|
||||
} else {
|
||||
ud.ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
})
|
||||
};
|
||||
let off = k * 4;
|
||||
slice[off..off + 4].copy_from_slice(&s.to_le_bytes());
|
||||
@@ -277,8 +298,21 @@ fn pw_thread(
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if ud.ring.is_empty() {
|
||||
ud.primed = false;
|
||||
// No-op while un-primed (the policy ignores it), so a deliberate priming silence
|
||||
// is never miscounted as an underrun.
|
||||
ud.policy.note_read(ran_short);
|
||||
ud.underruns += u64::from(ran_short);
|
||||
ud.callbacks += 1;
|
||||
// ~10 s at a 5 ms quantum; the exact cadence does not matter, only that the
|
||||
// plane stops being invisible.
|
||||
if ud.callbacks % 2_000 == 0 {
|
||||
tracing::debug!(
|
||||
buffer_ms = ud.policy.avg_depth_ms(),
|
||||
target_ms = ud.policy.target_ms(),
|
||||
underruns = ud.underruns,
|
||||
drift_sheds = ud.sheds,
|
||||
"audio playback"
|
||||
);
|
||||
}
|
||||
let chunk = data.chunk_mut();
|
||||
*chunk.offset_mut() = 0;
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
//!
|
||||
//! The WASAPI twin of `audio.rs` (PipeWire) — same public surface (`AudioPlayer::spawn`/
|
||||
//! `take_buffer`/`push`, `MicStreamer::spawn`), swapped in by lib.rs's `#[path]` so the
|
||||
//! session pump compiles against one `crate::audio` on both OSes. Adapted from
|
||||
//! `clients/windows/src/audio.rs` (which remains the WinUI shell's own copy until its
|
||||
//! built-in streaming path is deleted).
|
||||
//! session pump compiles against one `crate::audio` on both OSes. It began as a copy of the
|
||||
//! WinUI shell's own audio path; that shell's built-in streaming path has since been deleted,
|
||||
//! so this is now the only WASAPI client ring.
|
||||
//!
|
||||
//! Playback mirrors the host's virtual-mic producer's adaptive jitter buffer: the session
|
||||
//! pump pushes 5 ms Opus-decoded chunks on the network clock; the WASAPI render thread
|
||||
//! pulls whole event-driven quanta on the device clock. Prime to ~3 quanta before
|
||||
//! producing, cap the ring so latency stays bounded, re-prime after a real drain.
|
||||
//! Playback: the session pump pushes 5 ms Opus-decoded chunks on the network clock; the WASAPI
|
||||
//! render thread pulls whole event-driven quanta on the device clock. The depth policy between
|
||||
//! them is the SHARED `punktfunk_core::audio::JitterPolicy` (`JitterTuning::WASAPI`) — target in
|
||||
//! milliseconds, crossfaded drift correction, de-prime hysteresis — so all four clients behave
|
||||
//! the same way and none of them can ratchet latency upward.
|
||||
//!
|
||||
//! WASAPI objects are COM-apartment-bound and not `Send`, so they live on a dedicated
|
||||
//! thread (the same discipline as the host's `wasapi_cap`); only the channels + stop flag
|
||||
@@ -250,10 +251,20 @@ fn render_thread(
|
||||
audio_client.start_stream().context("start render stream")?;
|
||||
let _ = ready.send(Ok(()));
|
||||
|
||||
// Adaptive jitter buffer, in f32-byte units (same shape as the host's virtual mic).
|
||||
let mut ring: VecDeque<u8> = VecDeque::new();
|
||||
let mut primed = false;
|
||||
// De-jitter ring, in interleaved f32 SAMPLES (it used to be raw bytes, which made the
|
||||
// depth arithmetic byte-vs-sample and kept it from sharing the policy and the crossfade
|
||||
// helper with the other three clients).
|
||||
let mut ring: VecDeque<f32> = VecDeque::new();
|
||||
// Shared ms-denominated policy: prime depth, crossfaded drift correction so latency
|
||||
// returns to target instead of ratcheting, and de-prime hysteresis — the last replacing
|
||||
// the old `if ring.is_empty()`, where a single transient drain manufactured a whole
|
||||
// target's worth of fresh silence.
|
||||
let mut policy = punktfunk_core::audio::JitterPolicy::new(
|
||||
punktfunk_core::audio::JitterTuning::WASAPI,
|
||||
channels,
|
||||
);
|
||||
let mut out = Vec::new(); // per-quantum scratch, reused across iterations
|
||||
let (mut underruns, mut sheds, mut callbacks) = (0u64, 0u64, 0u64);
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
@@ -262,9 +273,7 @@ fn render_thread(
|
||||
// Drain everything the pump has queued into the ring, returning each drained
|
||||
// Vec to the pool (a full/closed pool drops it).
|
||||
while let Ok(mut chunk) = pcm_rx.try_recv() {
|
||||
for s in chunk.iter() {
|
||||
ring.extend(s.to_le_bytes());
|
||||
}
|
||||
ring.extend(chunk.iter().copied());
|
||||
chunk.clear();
|
||||
let _ = recycle_tx.try_send(chunk);
|
||||
}
|
||||
@@ -274,28 +283,40 @@ fn render_thread(
|
||||
if avail_frames == 0 {
|
||||
continue;
|
||||
}
|
||||
let want_bytes = avail_frames * block_align;
|
||||
let want = avail_frames * channels as usize;
|
||||
|
||||
// Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain.
|
||||
let target = (3 * want_bytes).clamp(720 * block_align, 9600 * block_align);
|
||||
let cap = target.max(want_bytes) + want_bytes;
|
||||
if ring.len() > cap {
|
||||
ring.drain(..ring.len() - cap);
|
||||
}
|
||||
if !primed && ring.len() >= target {
|
||||
primed = true;
|
||||
let step = policy.step(ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
sheds += 1;
|
||||
punktfunk_core::audio::crossfade_drop(&mut ring, step.drop_front, step.crossfade);
|
||||
}
|
||||
|
||||
out.clear();
|
||||
out.resize(want_bytes, 0);
|
||||
if primed {
|
||||
let n = ring.len().min(want_bytes);
|
||||
for (dst, b) in out.iter_mut().zip(ring.drain(..n)) {
|
||||
*dst = b;
|
||||
out.resize(avail_frames * block_align, 0);
|
||||
let mut ran_short = false;
|
||||
if !step.silence {
|
||||
// `out` is exactly `want` f32s wide (avail_frames × channels × 4 bytes).
|
||||
for dst in out.chunks_exact_mut(4) {
|
||||
let s = ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
});
|
||||
dst.copy_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
}
|
||||
if ring.is_empty() {
|
||||
primed = false;
|
||||
// No-op while un-primed (the policy ignores it), so a deliberate priming silence is
|
||||
// never miscounted as an underrun.
|
||||
policy.note_read(ran_short);
|
||||
underruns += u64::from(ran_short);
|
||||
callbacks += 1;
|
||||
if callbacks % 1_000 == 0 {
|
||||
tracing::debug!(
|
||||
buffer_ms = policy.avg_depth_ms(),
|
||||
target_ms = policy.target_ms(),
|
||||
underruns,
|
||||
drift_sheds = sheds,
|
||||
"audio playback"
|
||||
);
|
||||
}
|
||||
render_client
|
||||
.write_to_device(avail_frames, &out, None)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! cards and flip a saved host's online pip when its advert disappears.
|
||||
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiscoveredHost {
|
||||
@@ -31,6 +33,19 @@ pub struct DiscoveredHost {
|
||||
pub os: String,
|
||||
}
|
||||
|
||||
impl DiscoveredHost {
|
||||
/// The host's advertised stable id (mDNS TXT `id`), or `""` when it doesn't advertise one.
|
||||
/// [`DiscoveredHost::key`] falls back to the mDNS fullname in that case, so the two being
|
||||
/// equal is exactly the "no id" signal — read it through here rather than re-deriving it.
|
||||
pub fn advertised_id(&self) -> &str {
|
||||
if self.key == self.fullname {
|
||||
""
|
||||
} else {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One discovery update for the UI's advert map.
|
||||
pub enum DiscoveryEvent {
|
||||
/// A host advert appeared or refreshed (new address, pairing flipped, …).
|
||||
@@ -39,8 +54,8 @@ pub enum DiscoveryEvent {
|
||||
Removed { fullname: String },
|
||||
}
|
||||
|
||||
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
||||
/// dropped (the send fails) or the daemon dies.
|
||||
/// Browse continuously. The worker exits when the returned receiver is dropped, or when the
|
||||
/// daemon dies — checked on a tick, so it stops even on a LAN where no advert ever arrives.
|
||||
pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
std::thread::Builder::new()
|
||||
@@ -60,7 +75,24 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
while let Ok(event) = receiver.recv() {
|
||||
// Polled rather than blocked on: the worker has to notice that its consumer went
|
||||
// away even when NOTHING is arriving, which is the normal state of a LAN with no
|
||||
// hosts on it. A plain `recv()` parks forever there, and the ignored-event arm below
|
||||
// never touches `tx` — so a bounded consumer like `discover_for` would leak this
|
||||
// thread and its daemon (another thread, and a socket bound to :5353) on every call.
|
||||
loop {
|
||||
// Checked at the TOP so it also covers the arms below that `continue` without
|
||||
// ever touching `tx` — the ignored event kinds, and an advert with no IPv4
|
||||
// address. Those are the paths that would otherwise keep this thread alive with
|
||||
// nobody to send to.
|
||||
if tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(event) => event,
|
||||
Err(_) if receiver.is_disconnected() => break,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let update = match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
let props = info.get_properties();
|
||||
@@ -117,3 +149,154 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
.expect("spawn mdns thread");
|
||||
rx
|
||||
}
|
||||
|
||||
/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the
|
||||
/// fold — which is where dedupe and removal actually live — is testable without a network.
|
||||
type Adverts = BTreeMap<String, DiscoveredHost>;
|
||||
|
||||
/// Apply one event to the map. A refreshed advert WINS over the one already there (it carries
|
||||
/// the newer address — a host that changed DHCP lease re-announces), and a removal drops
|
||||
/// whichever entry that mDNS fullname produced, whatever it was keyed under.
|
||||
fn fold(adverts: &mut Adverts, event: DiscoveryEvent) {
|
||||
match event {
|
||||
DiscoveryEvent::Resolved(host) => {
|
||||
adverts.insert(host.key.clone(), host);
|
||||
}
|
||||
DiscoveryEvent::Removed { fullname } => {
|
||||
adverts.retain(|_, h| h.fullname != fullname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse for `timeout`, then return what answered — deduped by `key`, address-sorted.
|
||||
///
|
||||
/// Blocking; intended for one-shot consumers (the CLI's `discover` verb, a plugin backend that
|
||||
/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door:
|
||||
/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened.
|
||||
pub fn discover_for(timeout: Duration) -> Vec<DiscoveredHost> {
|
||||
let rx = browse();
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut adverts = Adverts::new();
|
||||
while Instant::now() < deadline {
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
fold(&mut adverts, event);
|
||||
}
|
||||
// A short tick rather than a blocking recv with a deadline: `async_channel`'s blocking
|
||||
// receive has no timeout, and the whole point of this call is that it is bounded.
|
||||
std::thread::sleep(Duration::from_millis(50).min(timeout));
|
||||
}
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
fold(&mut adverts, event);
|
||||
}
|
||||
// Dropping the receiver is what stops the worker — it polls for that, so this holds even
|
||||
// when nothing is advertising. Without it a one-shot consumer would leak a browse per call.
|
||||
drop(rx);
|
||||
sorted(adverts)
|
||||
}
|
||||
|
||||
/// The map as the list a caller gets: sorted by address, then port. IPv4 is compared
|
||||
/// NUMERICALLY (a lexical sort puts `.10` before `.9`, which reads as scrambled in a host list).
|
||||
fn sorted(adverts: Adverts) -> Vec<DiscoveredHost> {
|
||||
let mut hosts: Vec<DiscoveredHost> = adverts.into_values().collect();
|
||||
hosts.sort_by_key(|h| {
|
||||
(
|
||||
h.addr.parse::<std::net::Ipv4Addr>().ok().map(u32::from),
|
||||
h.addr.clone(),
|
||||
h.port,
|
||||
)
|
||||
});
|
||||
hosts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn host(key: &str, fullname: &str, addr: &str) -> DiscoveredHost {
|
||||
DiscoveredHost {
|
||||
key: key.into(),
|
||||
fullname: fullname.into(),
|
||||
name: fullname.split('.').next().unwrap_or("?").into(),
|
||||
addr: addr.into(),
|
||||
port: 9777,
|
||||
fp_hex: "aa".into(),
|
||||
pair: "required".into(),
|
||||
mgmt_port: Some(47990),
|
||||
mac: vec![],
|
||||
os: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two adverts for the same host collapse to one row, and the LATER one wins — that is how
|
||||
/// a host that moved to a new address stops being listed at the stale one.
|
||||
#[test]
|
||||
fn refreshed_advert_supersedes_the_earlier_one() {
|
||||
let mut adverts = Adverts::new();
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
|
||||
);
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.20")),
|
||||
);
|
||||
let out = sorted(adverts);
|
||||
assert_eq!(out.len(), 1, "same key must not render twice");
|
||||
assert_eq!(out[0].addr, "192.168.1.20", "the newer address wins");
|
||||
}
|
||||
|
||||
/// A host that goes away during the browse window is not in the answer.
|
||||
#[test]
|
||||
fn removal_drops_the_advert_it_names() {
|
||||
let mut adverts = Adverts::new();
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
|
||||
);
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-2", "tv._punktfunk._udp.local.", "192.168.1.10")),
|
||||
);
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Removed {
|
||||
fullname: "desk._punktfunk._udp.local.".into(),
|
||||
},
|
||||
);
|
||||
let out = sorted(adverts);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].key, "id-2");
|
||||
}
|
||||
|
||||
/// A host with no `id` TXT is keyed by its fullname — and must not then report that
|
||||
/// fullname as an id, which would send a caller launching against a nonexistent reference.
|
||||
#[test]
|
||||
fn advertised_id_is_empty_without_the_txt() {
|
||||
let named = host("id-1", "desk._punktfunk._udp.local.", "10.0.0.1");
|
||||
assert_eq!(named.advertised_id(), "id-1");
|
||||
let anonymous = host(
|
||||
"desk._punktfunk._udp.local.",
|
||||
"desk._punktfunk._udp.local.",
|
||||
"10.0.0.1",
|
||||
);
|
||||
assert_eq!(anonymous.advertised_id(), "");
|
||||
}
|
||||
|
||||
/// Addresses sort the way a person reads them, not the way strings compare.
|
||||
#[test]
|
||||
fn addresses_sort_numerically() {
|
||||
let mut adverts = Adverts::new();
|
||||
for (i, addr) in ["192.168.1.20", "192.168.1.9", "192.168.1.100"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host(&format!("id-{i}"), &format!("h{i}."), addr)),
|
||||
);
|
||||
}
|
||||
let out = sorted(adverts);
|
||||
let addrs: Vec<&str> = out.iter().map(|h| h.addr.as_str()).collect();
|
||||
assert_eq!(addrs, ["192.168.1.9", "192.168.1.20", "192.168.1.100"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,23 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
|
||||
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
||||
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// Hold Select/Back ALONE at least this long to send the HOST the guide button — the
|
||||
/// [`SelectGesture`], armed by [`Settings::guide_gesture`]. The synthetic guide stays down
|
||||
/// for as long as Select is held, so a long hold IS the host's long-press (the QAM on a
|
||||
/// Gaming-Mode host). Exists because on some platforms the physical guide press can never
|
||||
/// reach the host cleanly: the local shell reserves it (iOS's Game Overlay, tvOS) or
|
||||
/// reacts to it in parallel (Gaming Mode's Steam UI — see [`Settings::system_buttons`]).
|
||||
///
|
||||
/// [`Settings::guide_gesture`]: crate::trust::Settings::guide_gesture
|
||||
/// [`Settings::system_buttons`]: crate::trust::Settings::system_buttons
|
||||
const GUIDE_HOLD: Duration = Duration::from_millis(350);
|
||||
|
||||
/// A held-back Select TAP is delivered as a press with its release scheduled this far
|
||||
/// behind — never back-to-back: per-transition sends are folded into seq'd `GamepadState`
|
||||
/// snapshots by the core input task, and a down+up inside one fold window can coalesce
|
||||
/// into no press at all.
|
||||
const TAP_PRESS: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Steam Deck actuator-decay keepalive cadence, declared to the core's rumble policy engine as an
|
||||
/// [`ActuatorQuirks`] at slot open. The Deck's built-in actuator decays inside SDL's ~2 s internal
|
||||
/// rumble resend (`SDL_RUMBLE_RESEND_MS`) and SDL short-circuits an identical `set_rumble` value
|
||||
@@ -285,6 +302,21 @@ fn set_valve_hidapi(enabled: bool) {
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v);
|
||||
}
|
||||
|
||||
/// Disable the Valve HIDAPI drivers **before SDL exists** — call this alongside the other
|
||||
/// pre-`SDL_Init` hints, not after a subsystem is up.
|
||||
///
|
||||
/// The damage these drivers do happens at *enumeration*, which is part of initialising the
|
||||
/// joystick/gamepad subsystem. Setting the hint afterwards does detach the driver, but only after
|
||||
/// it has already sent the Deck its `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` — so the
|
||||
/// built-in trackpad-mouse dies system-wide and stays dead until the firmware watchdog restores
|
||||
/// lizard mode seconds later. The threaded worker ([`run`]) has always done this in the right
|
||||
/// order; the caller-pumped path could not, because by the time it receives a
|
||||
/// [`sdl3::GamepadSubsystem`] the enumeration has already happened. Hence a separate entry point
|
||||
/// its callers can put in the right place.
|
||||
pub fn preinit_disable_valve_hidapi() {
|
||||
set_valve_hidapi(false);
|
||||
}
|
||||
|
||||
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
|
||||
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
use sdl3::gamepad::GamepadType as T;
|
||||
@@ -336,6 +368,9 @@ enum Ctl {
|
||||
Detach,
|
||||
Pin(Option<String>),
|
||||
KindOverride(GamepadPref),
|
||||
Forwarding(bool),
|
||||
SystemButtons { forward_raw: bool, gesture: bool },
|
||||
TapButton(u32),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
}
|
||||
@@ -392,9 +427,12 @@ impl GamepadService {
|
||||
/// and calls [`GamepadPump::tick`] once per loop iteration (the threaded worker's
|
||||
/// per-wakeup work: ctl drain, chord-hold check, menu repeat, feedback).
|
||||
///
|
||||
/// Like the threaded worker, this disables the Valve HIDAPI drivers up front (their
|
||||
/// mere enumeration kills the Deck's trackpad-mouse system-wide); they are enabled
|
||||
/// for the duration of an attached session only.
|
||||
/// The Valve HIDAPI drivers are held off here too, but this is **too late to be the only
|
||||
/// place it happens**: the `subsystem` argument means enumeration is already done, and that
|
||||
/// is when the Deck driver kills the trackpad-mouse. The caller must also call
|
||||
/// [`preinit_disable_valve_hidapi`] with its other pre-`SDL_Init` hints. This call still
|
||||
/// earns its place — it re-asserts "off" for a process that ran a session earlier — but on
|
||||
/// its own it only detaches a driver that has already done the damage.
|
||||
pub fn pumped(subsystem: sdl3::GamepadSubsystem) -> (GamepadService, GamepadPump) {
|
||||
set_valve_hidapi(false);
|
||||
let pads = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -482,6 +520,59 @@ impl GamepadService {
|
||||
let _ = self.ctl.send(Ctl::KindOverride(pref));
|
||||
}
|
||||
|
||||
/// Forward this device's controllers to the host at all ([`Settings::gamepad_forwarding`],
|
||||
/// default on). Off is for a couch whose pad reaches the host another way — a USB
|
||||
/// passthrough tool like VirtualHere, or a controller plugged into the host itself —
|
||||
/// where forwarding as well would give the host two pads for one pair of hands.
|
||||
///
|
||||
/// Off holds no slot open, so nothing is sent AND nothing is *grabbed*: no arrival, no
|
||||
/// virtual pad host-side, and the hidraw node stays free for the passthrough tool to
|
||||
/// bind (SDL's HIDAPI drivers take it at open — a held device cannot be bound away).
|
||||
/// It follows that the escape chord, which only listens on forwarded pads, is not
|
||||
/// available while off; the keyboard chord and the client's own UI still end a session.
|
||||
///
|
||||
/// Menu navigation is untouched: the launcher still opens the active pad to drive its
|
||||
/// UI, and a session — which supersedes menu mode whether it forwards or not — releases
|
||||
/// it again, so the pad is free for the whole time a stream is up.
|
||||
///
|
||||
/// [`Settings::gamepad_forwarding`]: crate::trust::Settings::gamepad_forwarding
|
||||
pub fn set_forwarding(&self, on: bool) {
|
||||
let _ = self.ctl.send(Ctl::Forwarding(on));
|
||||
}
|
||||
|
||||
/// The session's system-button policy, resolved from
|
||||
/// [`Settings::system_buttons_forward`] × [`Settings::guide_gesture_enabled`]:
|
||||
/// `forward_raw` gates the physical guide/QAM presses onto the wire (off = they stay
|
||||
/// with the local shell — the Gaming-Mode default, where Steam reacts to them no
|
||||
/// matter what and forwarding opens BOTH overlays); `gesture` arms the hold-Select
|
||||
/// guide gesture ([`GUIDE_HOLD`]), the alternate route that keeps the host's guide —
|
||||
/// and, held longer, a Gaming-Mode host's QAM — reachable from a controller.
|
||||
///
|
||||
/// [`Settings::system_buttons_forward`]: crate::trust::Settings::system_buttons_forward
|
||||
/// [`Settings::guide_gesture_enabled`]: crate::trust::Settings::guide_gesture_enabled
|
||||
pub fn set_system_buttons(&self, forward_raw: bool, gesture: bool) {
|
||||
let _ = self.ctl.send(Ctl::SystemButtons {
|
||||
forward_raw,
|
||||
gesture,
|
||||
});
|
||||
}
|
||||
|
||||
/// One-shot synthetic tap of the HOST's guide button ([`Ctl::TapButton`]): down now,
|
||||
/// up [`TAP_PRESS`] later, on the first forwarded slot's wire index (pad 0 when none
|
||||
/// is open). The session control socket's "press the host's Steam/guide button" verb
|
||||
/// — the Decky panel's UI route to the host overlay. No-op while no session is
|
||||
/// attached.
|
||||
pub fn tap_guide(&self) {
|
||||
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_GUIDE));
|
||||
}
|
||||
|
||||
/// Like [`Self::tap_guide`] for the quick-access button (`MISC1` — the Deck `…`).
|
||||
/// Opens the QAM on a Gaming-Mode host whose virtual pad is Deck-shaped; other
|
||||
/// virtual pads map it to their own misc button (or drop it) — harmless.
|
||||
pub fn tap_qam(&self) {
|
||||
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||
}
|
||||
@@ -531,10 +622,43 @@ impl GamepadPump {
|
||||
/// chord-hold and haptics inside the threaded worker's tolerances).
|
||||
pub fn tick(&mut self) {
|
||||
let _ = self.worker.drain_ctl(&self.ctl_rx);
|
||||
self.worker.gesture_poll();
|
||||
self.worker.maybe_fire_disconnect();
|
||||
self.worker.menu_poll();
|
||||
self.worker.render_feedback();
|
||||
}
|
||||
|
||||
/// Close every forwarded slot — flush its held wire state, tell the host to remove the pad,
|
||||
/// and physically silence it. Call once on the way out of the caller's event loop.
|
||||
///
|
||||
/// [`GamepadService::detach`] only *posts* `Ctl::Detach`; the close — the flush, the host-side
|
||||
/// `GamepadRemove`, and the explicit `set_rumble(0, 0)` backstop in `close_slot_at` — happens
|
||||
/// when the pump next drains it. An exit path that detached and then left the loop without
|
||||
/// another [`tick`](Self::tick) therefore skipped all of it, and nothing else would: the slots
|
||||
/// hold no `Drop` that silences them. A pad left mid-buzz stayed buzzing.
|
||||
///
|
||||
/// This closes the slots directly rather than draining the queued `Ctl::Detach` that would
|
||||
/// have done it. Same physical outcome by a shorter path, and deliberately so: this also runs
|
||||
/// from `Drop`, and `drain_ctl` reaches `Mutex::lock().unwrap()`, which on a poisoned lock
|
||||
/// would panic — during an unwind that aborts the process. Closing a slot touches no lock.
|
||||
///
|
||||
/// Idempotent, and safe with nothing attached.
|
||||
pub fn shutdown(&mut self) {
|
||||
self.worker.close_all_slots();
|
||||
}
|
||||
}
|
||||
|
||||
/// The silence backstop of last resort. A caller's loop can also leave by `?` on a fatal overlay
|
||||
/// or present error — several paths do — and those would skip an explicit
|
||||
/// [`shutdown`](GamepadPump::shutdown) entirely, leaving a forwarded pad buzzing on the way out.
|
||||
///
|
||||
/// Callers should still call `shutdown` at their normal exit rather than lean on this: the pad
|
||||
/// wants to go quiet *before* a long teardown (session join, `vkDeviceWaitIdle`), not after it.
|
||||
/// Doing both is free — `shutdown` is idempotent.
|
||||
impl Drop for GamepadPump {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest wire pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)) not already held
|
||||
@@ -677,6 +801,9 @@ struct Slot {
|
||||
/// close lift a click held across detach/unplug.
|
||||
held_clicks: [bool; 2],
|
||||
last_accel: [i16; 3],
|
||||
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
|
||||
/// `guide_gesture` policy is on.
|
||||
gesture: SelectGesture,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
@@ -692,6 +819,7 @@ impl Slot {
|
||||
surface_last: [(0, 0, false); 2],
|
||||
held_clicks: [false; 2],
|
||||
last_accel: [0; 3],
|
||||
gesture: SelectGesture::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,6 +830,98 @@ impl Slot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-slot hold-Select→guide state machine (see [`GUIDE_HOLD`]). Pure — fed transitions
|
||||
/// and polled with a clock, it emits the wire sends due as `(button bit, down)` pairs —
|
||||
/// so the timing rules are testable without SDL or a live session.
|
||||
///
|
||||
/// The rules:
|
||||
/// - Select pressed ALONE is held back (pending). Any other button already down means
|
||||
/// Select is part of a combo — the escape chord ends in it — and passes through.
|
||||
/// - A button pressed WHILE Select is pending makes it a real Select after all; its
|
||||
/// deferred down goes out first, preserving chronology.
|
||||
/// - Pending past [`GUIDE_HOLD`] becomes a synthetic guide, down until Select releases.
|
||||
/// - Released before the threshold, it's a TAP: press delivered on release, the release
|
||||
/// itself [`TAP_PRESS`] behind it (back-to-back transitions can fold into nothing).
|
||||
#[derive(Default)]
|
||||
struct SelectGesture {
|
||||
/// Select is down and held back — tap-or-guide undecided.
|
||||
pending_since: Option<Instant>,
|
||||
/// The held-back Select became a synthetic guide; its release lifts the guide.
|
||||
as_guide: bool,
|
||||
/// A delivered tap's release is owed at this time.
|
||||
release_due: Option<Instant>,
|
||||
}
|
||||
|
||||
impl SelectGesture {
|
||||
/// Select went down (`alone` = no other button held on this slot). Returns true when
|
||||
/// the press is held back; false lets the caller forward it as a normal button.
|
||||
fn on_select_down(&mut self, now: Instant, alone: bool, out: &mut Vec<(u32, bool)>) -> bool {
|
||||
// A previous tap's scheduled release still owed: lift it before the new press.
|
||||
if self.release_due.take().is_some() {
|
||||
out.push((wire::BTN_BACK, false));
|
||||
}
|
||||
if alone {
|
||||
self.pending_since = Some(now);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Another button went down on this slot: a pending Select is a real Select after
|
||||
/// all — its deferred down goes out before the caller sends the new button's.
|
||||
fn on_other_down(&mut self, out: &mut Vec<(u32, bool)>) {
|
||||
if self.pending_since.take().is_some() {
|
||||
out.push((wire::BTN_BACK, true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Select released. Returns true when the gesture owned this release (the caller
|
||||
/// skips the normal button-up send).
|
||||
fn on_select_up(&mut self, now: Instant, out: &mut Vec<(u32, bool)>) -> bool {
|
||||
if self.as_guide {
|
||||
self.as_guide = false;
|
||||
out.push((wire::BTN_GUIDE, false));
|
||||
return true;
|
||||
}
|
||||
if self.pending_since.take().is_some() {
|
||||
// A tap: deliver the held-back press now, its release TAP_PRESS behind.
|
||||
out.push((wire::BTN_BACK, true));
|
||||
self.release_due = Some(now + TAP_PRESS);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Clock-driven work: the hold threshold and the owed tap release.
|
||||
fn poll(&mut self, now: Instant, out: &mut Vec<(u32, bool)>) {
|
||||
if let Some(since) = self.pending_since {
|
||||
if now.duration_since(since) >= GUIDE_HOLD {
|
||||
self.pending_since = None;
|
||||
self.as_guide = true;
|
||||
out.push((wire::BTN_GUIDE, true));
|
||||
}
|
||||
}
|
||||
if let Some(due) = self.release_due {
|
||||
if now >= due {
|
||||
self.release_due = None;
|
||||
out.push((wire::BTN_BACK, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Slot close / gesture disarm: nothing may stay down (or owed) on the wire.
|
||||
fn flush(&mut self, out: &mut Vec<(u32, bool)>) {
|
||||
self.pending_since = None;
|
||||
if self.as_guide {
|
||||
self.as_guide = false;
|
||||
out.push((wire::BTN_GUIDE, false));
|
||||
}
|
||||
if self.release_due.take().is_some() {
|
||||
out.push((wire::BTN_BACK, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Worker {
|
||||
subsystem: sdl3::GamepadSubsystem,
|
||||
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
|
||||
@@ -721,10 +941,22 @@ struct Worker {
|
||||
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
|
||||
/// (an explicit single-player choice); Automatic forwards every real controller.
|
||||
pinned: Option<String>,
|
||||
/// Forward controllers to an attached session at all ([`GamepadService::set_forwarding`]).
|
||||
/// Off makes [`Self::forwarded_ids`] empty, so a session opens no slot — the whole point
|
||||
/// being that the hardware stays ungrabbed for a USB passthrough tool.
|
||||
forwarding: bool,
|
||||
/// The user's explicit "controller type" setting ([`GamepadService::set_kind_override`]);
|
||||
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
|
||||
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
|
||||
kind_override: GamepadPref,
|
||||
/// Forward raw guide/QAM presses ([`GamepadService::set_system_buttons`]); off keeps
|
||||
/// them with the local shell.
|
||||
system_forward: bool,
|
||||
/// The hold-Select guide gesture is armed ([`GamepadService::set_system_buttons`]).
|
||||
guide_gesture: bool,
|
||||
/// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the
|
||||
/// down went out on receipt, the up goes out from the poll once `due` passes.
|
||||
synthetic_ups: Vec<(u8, u32, Instant)>,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
@@ -815,6 +1047,11 @@ impl Worker {
|
||||
/// back to the single most-recent pad when only a Steam-virtual pad is present (the Deck
|
||||
/// game-mode case — otherwise its gyro/paddles/input would have nowhere to land).
|
||||
fn forwarded_ids(&self) -> Vec<u32> {
|
||||
// Forwarding off: nothing is forwarded, so nothing is opened either — the device stays
|
||||
// free for whatever route the user's controller actually takes to the host.
|
||||
if !self.forwarding {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(key) = &self.pinned {
|
||||
if let Some(id) = self
|
||||
.order
|
||||
@@ -973,6 +1210,7 @@ impl Worker {
|
||||
// unplug) must not depend on what SDL does to a rumbling device at close. Errors are
|
||||
// expected for an already-unplugged pad.
|
||||
let _ = self.slots[i].pad.set_rumble(0, 0, 100);
|
||||
Self::reset_slot_feedback(&mut self.slots[i]);
|
||||
if let Some(c) = self.attached.clone() {
|
||||
Self::flush_slot(&c, &mut self.slots[i]);
|
||||
// Signal the host to tear down this pad's virtual device (native hot-unplug). Sent
|
||||
@@ -988,6 +1226,35 @@ impl Worker {
|
||||
);
|
||||
}
|
||||
|
||||
/// Hand the physical controller back in a neutral state before its handle closes.
|
||||
///
|
||||
/// Rumble stops on its own the moment nothing renews it, but the rich planes do not: an
|
||||
/// adaptive-trigger effect and a lightbar colour are LATCHED in the pad's firmware and survive
|
||||
/// the stream, the app, and being unplugged. Ending a session on a weapon's trigger resistance
|
||||
/// left the physical trigger stiff on the desktop afterwards, with nothing to clear it but
|
||||
/// another game. Apple's client already resets on teardown; this is the desktop half.
|
||||
///
|
||||
/// Best-effort throughout: the pad may already be gone (that is one of the ways we get here).
|
||||
fn reset_slot_feedback(slot: &mut Slot) {
|
||||
if matches!(
|
||||
slot.pref,
|
||||
GamepadPref::DualSense | GamepadPref::DualSenseEdge
|
||||
) {
|
||||
// An all-zero trigger block is mode 0x00 — no effect — which is what releases the
|
||||
// trigger. Both sides, then the lightbar dark and the player indicator clear.
|
||||
for which in [0u8, 1] {
|
||||
let _ = slot
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::trigger_packet(which, &[0u8; 11]));
|
||||
}
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(0, 0, 0));
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(0));
|
||||
} else {
|
||||
// Anything else with an LED goes dark through SDL, which owns the per-device details.
|
||||
let _ = slot.pad.set_led(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn close_all_slots(&mut self) {
|
||||
while !self.slots.is_empty() {
|
||||
self.close_slot_at(0);
|
||||
@@ -1021,6 +1288,14 @@ impl Worker {
|
||||
/// Emits wire events only (no SDL device calls), so it is safe against an already-removed pad.
|
||||
fn flush_slot(c: &NativeClient, slot: &mut Slot) {
|
||||
let pad = slot.index;
|
||||
// Gesture first: a synthetic guide is NOT in `held_buttons`, so the drain below
|
||||
// would never lift it — and a still-pending Select was never sent, so dropping
|
||||
// it beats delivering a ghost press into the close.
|
||||
let mut due = Vec::new();
|
||||
slot.gesture.flush(&mut due);
|
||||
for (b, down) in due {
|
||||
send(c, InputKind::GamepadButton, b, down as i32, pad);
|
||||
}
|
||||
for b in slot.held_buttons.drain(..) {
|
||||
send(c, InputKind::GamepadButton, b, 0, pad);
|
||||
}
|
||||
@@ -1098,6 +1373,36 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clock-driven [`SelectGesture`] work — the hold threshold and owed tap releases —
|
||||
/// polled like the chord hold, so timings carry at most one wakeup (~10 ms attached)
|
||||
/// of jitter.
|
||||
fn gesture_poll(&mut self) {
|
||||
let Some(c) = self.attached.clone() else {
|
||||
self.synthetic_ups.clear();
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
// Owed releases of synthetic taps (the control socket's guide/QAM verbs).
|
||||
self.synthetic_ups.retain(|&(pad, bit, due)| {
|
||||
if now >= due {
|
||||
send(&c, InputKind::GamepadButton, bit, 0, pad);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
if !self.guide_gesture {
|
||||
return;
|
||||
}
|
||||
for slot in &mut self.slots {
|
||||
let mut due = Vec::new();
|
||||
slot.gesture.poll(now, &mut due);
|
||||
for (b, down) in due {
|
||||
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the disconnect signal once the escape chord has been continuously held past
|
||||
/// [`DISCONNECT_HOLD`]. Polled from the main loop so the hold completes without new events.
|
||||
fn maybe_fire_disconnect(&mut self) {
|
||||
@@ -1243,10 +1548,16 @@ impl Worker {
|
||||
Ok(Ctl::Attach(c)) => {
|
||||
self.attached = Some(c);
|
||||
self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
|
||||
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
|
||||
// enabling them re-enumerates a Deck's built-in pad with paddles/
|
||||
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
|
||||
set_valve_hidapi(true);
|
||||
|
||||
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
|
||||
// enabling them re-enumerates a Deck's built-in pad with paddles/
|
||||
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
|
||||
// Not with forwarding off: this session opens no slot, and the drivers'
|
||||
// mere enumeration both kills the Deck's trackpad-mouse and is the
|
||||
// opposite of leaving the hardware alone for a passthrough tool.
|
||||
if self.forwarding {
|
||||
set_valve_hidapi(true);
|
||||
}
|
||||
self.sync_open();
|
||||
}
|
||||
Ok(Ctl::Detach) => {
|
||||
@@ -1269,6 +1580,66 @@ impl Worker {
|
||||
self.refresh_active();
|
||||
}
|
||||
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
|
||||
Ok(Ctl::SystemButtons {
|
||||
forward_raw,
|
||||
gesture,
|
||||
}) => {
|
||||
self.system_forward = forward_raw;
|
||||
if self.guide_gesture == gesture {
|
||||
continue;
|
||||
}
|
||||
self.guide_gesture = gesture;
|
||||
// A mid-session flip may strand gesture state — a synthetic guide
|
||||
// still down, an owed tap release — lift it now (no-op on the way on:
|
||||
// an unarmed gesture was never fed).
|
||||
if let Some(c) = self.attached.clone() {
|
||||
for slot in &mut self.slots {
|
||||
let mut due = Vec::new();
|
||||
slot.gesture.flush(&mut due);
|
||||
for (b, down) in due {
|
||||
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Ctl::TapButton(bit)) => {
|
||||
// Synthetic system-button tap (the session control socket): down on
|
||||
// the first forwarded slot's index — pad 0 when none is open (a
|
||||
// forwarding-off session; best-effort there, the wire pad may not
|
||||
// exist host-side). The up is owed via `synthetic_ups`, TAP_PRESS
|
||||
// later, so the pair can't fold into nothing.
|
||||
if let Some(c) = self.attached.clone() {
|
||||
let pad = self.slots.first().map_or(0, |s| s.index);
|
||||
send(&c, InputKind::GamepadButton, bit, 1, pad);
|
||||
self.synthetic_ups
|
||||
.push((pad, bit, Instant::now() + TAP_PRESS));
|
||||
}
|
||||
}
|
||||
Ok(Ctl::Forwarding(on)) => {
|
||||
if self.forwarding == on {
|
||||
continue;
|
||||
}
|
||||
self.forwarding = on;
|
||||
self.reset_chord(); // no forwarded pad can be mid-chord across the flip
|
||||
|
||||
// Applied live rather than at attach only, so a mid-session flip (an
|
||||
// in-stream settings screen) takes effect on the pad in your hands.
|
||||
//
|
||||
// The Valve HIDAPI drivers are an in-session-only thing (see
|
||||
// set_valve_hidapi), and forwarding off is — for their purpose — not in
|
||||
// session. Order matters and differs by direction: ON must enable them
|
||||
// BEFORE `sync_open`, or a Deck's built-in pad opens under its old
|
||||
// identity; OFF must disable them AFTER, so no slot outlives the driver
|
||||
// that opened it.
|
||||
let attached = self.attached.is_some();
|
||||
if on && attached {
|
||||
set_valve_hidapi(true);
|
||||
}
|
||||
self.sync_open();
|
||||
if !on && attached {
|
||||
set_valve_hidapi(false);
|
||||
}
|
||||
}
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
@@ -1344,8 +1715,32 @@ impl Worker {
|
||||
return;
|
||||
}
|
||||
if let Some(bit) = button_bit(button) {
|
||||
// Raw system buttons stay with the local shell when passthrough is
|
||||
// off (the Gaming-Mode default): Steam already opened ITS overlay
|
||||
// for this press; the host's is reached via the hold-Select gesture
|
||||
// (and the Decky panel) instead.
|
||||
if !self.system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) {
|
||||
return;
|
||||
}
|
||||
let mut due = Vec::new();
|
||||
let held_back = if !self.guide_gesture {
|
||||
false
|
||||
} else if bit == wire::BTN_BACK {
|
||||
let alone = slot.held_buttons.is_empty();
|
||||
slot.gesture.on_select_down(Instant::now(), alone, &mut due)
|
||||
} else {
|
||||
slot.gesture.on_other_down(&mut due);
|
||||
false
|
||||
};
|
||||
for (b, down) in due {
|
||||
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
|
||||
}
|
||||
// Held-back or not, the chord bookkeeping sees the physical press —
|
||||
// the escape chord must not care that the gesture exists.
|
||||
slot.held_buttons.push(bit);
|
||||
send(&c, InputKind::GamepadButton, bit, 1, slot.index);
|
||||
if !held_back {
|
||||
send(&c, InputKind::GamepadButton, bit, 1, slot.index);
|
||||
}
|
||||
self.maybe_fire_escape();
|
||||
}
|
||||
}
|
||||
@@ -1361,8 +1756,20 @@ impl Worker {
|
||||
return;
|
||||
}
|
||||
if let Some(bit) = button_bit(button) {
|
||||
if !self.system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) {
|
||||
return;
|
||||
}
|
||||
slot.held_buttons.retain(|&b| b != bit);
|
||||
send(&c, InputKind::GamepadButton, bit, 0, slot.index);
|
||||
let mut due = Vec::new();
|
||||
let owned = self.guide_gesture
|
||||
&& bit == wire::BTN_BACK
|
||||
&& slot.gesture.on_select_up(Instant::now(), &mut due);
|
||||
for (b, down) in due {
|
||||
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
|
||||
}
|
||||
if !owned {
|
||||
send(&c, InputKind::GamepadButton, bit, 0, slot.index);
|
||||
}
|
||||
self.rearm_escape();
|
||||
}
|
||||
}
|
||||
@@ -1565,6 +1972,11 @@ impl Worker {
|
||||
HidOutput::PlayerLeds { bits, .. } if is_ds => {
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(bits));
|
||||
}
|
||||
// Every other pad with player LEDs gets them through SDL, which owns the
|
||||
// per-device pattern. This used to fall through and do nothing at all.
|
||||
HidOutput::PlayerLeds { bits, .. } => {
|
||||
let _ = set_player_leds(&slot.pad, bits);
|
||||
}
|
||||
HidOutput::Trigger {
|
||||
which, ref effect, ..
|
||||
} if is_ds => {
|
||||
@@ -1572,12 +1984,43 @@ impl Worker {
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
||||
}
|
||||
_ => {}
|
||||
// Deliberately unhandled, listed rather than left to a bare `_` so a new
|
||||
// variant cannot join them silently: adaptive triggers exist only on a
|
||||
// DualSense, and the trackpad-haptic / raw-passthrough planes are DS-specific
|
||||
// and carried by `send_effect` above when the pad is one.
|
||||
HidOutput::Trigger { .. }
|
||||
| HidOutput::TrackpadHaptic { .. }
|
||||
| HidOutput::HidRaw { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The SDL player index for the wire's positional player-LED `bits`, or `None` for "no player".
|
||||
///
|
||||
/// The wire carries a bitmask — one bit per LED, low 5 — while SDL wants a player *index* and owns
|
||||
/// the per-device pattern. The count bridges them: every convention that reaches this wire spells
|
||||
/// "player N" as N lit LEDs, both the DualSense patterns (`0x04`, `0x0A`, `0x15`, `0x1B`, `0x1F`)
|
||||
/// and the Switch/XInput run of low bits (`0x01`, `0x03`, `0x07`, `0x0F`). SDL's index is 0-based,
|
||||
/// so player 1 is index 0; no lit LED means *no* player rather than player 0.
|
||||
///
|
||||
/// Split out from [`set_player_leds`] so the mapping is testable — an `sdl3::Gamepad` needs a real
|
||||
/// device, so nothing that takes one can be.
|
||||
fn player_index_from_bits(bits: u8) -> Option<u16> {
|
||||
match (bits & 0x1F).count_ones() {
|
||||
0 => None,
|
||||
n => Some((n - 1) as u16),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive a non-DualSense pad's player LEDs from the wire's positional `bits`.
|
||||
fn set_player_leds(pad: &sdl3::gamepad::Gamepad, bits: u8) -> Result<(), sdl3::Error> {
|
||||
match player_index_from_bits(bits) {
|
||||
None => pad.unset_player_index(),
|
||||
Some(i) => pad.set_player_index(i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire pad index a [`HidOutput`] is addressed to (every variant carries `pad`).
|
||||
fn hidout_pad(h: &HidOutput) -> u8 {
|
||||
match h {
|
||||
@@ -1608,7 +2051,11 @@ impl Worker {
|
||||
menu_open: None,
|
||||
order: Vec::new(),
|
||||
pinned: None,
|
||||
forwarding: true,
|
||||
kind_override: GamepadPref::Auto,
|
||||
system_forward: true,
|
||||
guide_gesture: false,
|
||||
synthetic_ups: Vec::new(),
|
||||
attached: None,
|
||||
escape_tx,
|
||||
disconnect_tx,
|
||||
@@ -1680,6 +2127,7 @@ fn run(
|
||||
|
||||
// Escalate a held escape chord to a disconnect (polled — the hold completes with no
|
||||
// new button events; the chord itself is only detected while a session is attached).
|
||||
w.gesture_poll();
|
||||
w.maybe_fire_disconnect();
|
||||
|
||||
w.menu_poll();
|
||||
@@ -1687,6 +2135,115 @@ fn run(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod select_gesture_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tap_delivers_press_then_scheduled_release() {
|
||||
let mut g = SelectGesture::default();
|
||||
let t = Instant::now();
|
||||
let mut out = Vec::new();
|
||||
assert!(g.on_select_down(t, true, &mut out), "not held back");
|
||||
assert!(out.is_empty(), "a held-back press sends nothing yet");
|
||||
// Released inside the threshold: the press goes out on release…
|
||||
let up = t + Duration::from_millis(120);
|
||||
assert!(g.on_select_up(up, &mut out));
|
||||
assert_eq!(out, vec![(wire::BTN_BACK, true)]);
|
||||
out.clear();
|
||||
// …and the release only TAP_PRESS behind it, so the pair can't fold away.
|
||||
g.poll(up + TAP_PRESS - Duration::from_millis(1), &mut out);
|
||||
assert!(out.is_empty(), "release went out early");
|
||||
g.poll(up + TAP_PRESS, &mut out);
|
||||
assert_eq!(out, vec![(wire::BTN_BACK, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hold_becomes_guide_down_until_release() {
|
||||
let mut g = SelectGesture::default();
|
||||
let t = Instant::now();
|
||||
let mut out = Vec::new();
|
||||
assert!(g.on_select_down(t, true, &mut out));
|
||||
g.poll(t + GUIDE_HOLD - Duration::from_millis(1), &mut out);
|
||||
assert!(out.is_empty(), "guide fired inside the threshold");
|
||||
g.poll(t + GUIDE_HOLD, &mut out);
|
||||
assert_eq!(out, vec![(wire::BTN_GUIDE, true)]);
|
||||
out.clear();
|
||||
// Held on: nothing more (the host times its own long-press = QAM).
|
||||
g.poll(t + GUIDE_HOLD * 4, &mut out);
|
||||
assert!(out.is_empty());
|
||||
// Release lifts the guide, never a Select.
|
||||
assert!(g.on_select_up(t + GUIDE_HOLD * 5, &mut out));
|
||||
assert_eq!(out, vec![(wire::BTN_GUIDE, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_button_makes_pending_select_real() {
|
||||
let mut g = SelectGesture::default();
|
||||
let t = Instant::now();
|
||||
let mut out = Vec::new();
|
||||
assert!(g.on_select_down(t, true, &mut out));
|
||||
// A joins inside the window: the deferred Select down goes out first (the
|
||||
// caller then sends A's own down — chronology preserved).
|
||||
g.on_other_down(&mut out);
|
||||
assert_eq!(out, vec![(wire::BTN_BACK, true)]);
|
||||
out.clear();
|
||||
// The release is a normal button-up now — the gesture doesn't own it.
|
||||
assert!(!g.on_select_up(t + Duration::from_millis(200), &mut out));
|
||||
assert!(out.is_empty());
|
||||
// And no stale guide fires later.
|
||||
g.poll(t + GUIDE_HOLD * 2, &mut out);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_inside_a_combo_passes_through() {
|
||||
let mut g = SelectGesture::default();
|
||||
let mut out = Vec::new();
|
||||
// L1+R1+Start already down (the escape chord ends in Select): not held back.
|
||||
assert!(!g.on_select_down(Instant::now(), false, &mut out));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_repress_lifts_owed_release_first() {
|
||||
let mut g = SelectGesture::default();
|
||||
let t = Instant::now();
|
||||
let mut out = Vec::new();
|
||||
assert!(g.on_select_down(t, true, &mut out));
|
||||
assert!(g.on_select_up(t + Duration::from_millis(80), &mut out));
|
||||
out.clear();
|
||||
// Re-pressed before the owed release fired: the up goes out before the new
|
||||
// press is held back — the host never sees two downs in a row.
|
||||
assert!(g.on_select_down(t + Duration::from_millis(100), true, &mut out));
|
||||
assert_eq!(out, vec![(wire::BTN_BACK, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_lifts_synthetic_guide_and_owed_release() {
|
||||
let mut g = SelectGesture::default();
|
||||
let t = Instant::now();
|
||||
let mut out = Vec::new();
|
||||
// Transformed hold: flush lifts the guide.
|
||||
assert!(g.on_select_down(t, true, &mut out));
|
||||
g.poll(t + GUIDE_HOLD, &mut out);
|
||||
out.clear();
|
||||
g.flush(&mut out);
|
||||
assert_eq!(out, vec![(wire::BTN_GUIDE, false)]);
|
||||
out.clear();
|
||||
// Owed tap release: flush emits it. A pending (never-sent) Select just drops.
|
||||
assert!(g.on_select_down(t, true, &mut out));
|
||||
assert!(g.on_select_up(t + Duration::from_millis(80), &mut out));
|
||||
out.clear();
|
||||
g.flush(&mut out);
|
||||
assert_eq!(out, vec![(wire::BTN_BACK, false)]);
|
||||
out.clear();
|
||||
assert!(g.on_select_down(t, true, &mut out));
|
||||
g.flush(&mut out);
|
||||
assert!(out.is_empty(), "a never-sent pending Select ghosted a send");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod menu_nav_tests {
|
||||
use super::*;
|
||||
@@ -1946,3 +2503,86 @@ mod slot_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reset_packet_tests {
|
||||
use super::*;
|
||||
|
||||
/// The exact bytes a teardown sends to hand a DualSense back neutral. The *timing* of this
|
||||
/// (slot close) needs a live SDL handle and stays untestable, so pin the payloads: a wrong
|
||||
/// enable flag or a non-zero mode byte would silently leave the effect latched, which is the
|
||||
/// bug this reset exists to prevent.
|
||||
#[test]
|
||||
fn reset_packets_release_the_triggers_and_darken_the_lights() {
|
||||
// Trigger release: mode 0x00 with no parameters, on the side's own enable bit.
|
||||
let l = Ds5Feedback::trigger_packet(0, &[0u8; 11]);
|
||||
assert_eq!(l[0], 0x08, "left-trigger enable bit");
|
||||
assert!(
|
||||
l[Ds5Feedback::LEFT_TRIGGER..Ds5Feedback::LEFT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0),
|
||||
"an all-zero block is mode 0x00 = no effect"
|
||||
);
|
||||
let r = Ds5Feedback::trigger_packet(1, &[0u8; 11]);
|
||||
assert_eq!(r[0], 0x04, "right-trigger enable bit");
|
||||
assert!(
|
||||
r[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
|
||||
// Lightbar off: enable bit set, RGB all zero. The enable bit matters — without it the pad
|
||||
// ignores the payload and keeps the game's last colour.
|
||||
let bar = Ds5Feedback::lightbar_packet(0, 0, 0);
|
||||
assert_eq!(bar[1], 0x04, "lightbar enable bit");
|
||||
assert_eq!(
|
||||
&bar[Ds5Feedback::LED_RGB..Ds5Feedback::LED_RGB + 3],
|
||||
&[0, 0, 0]
|
||||
);
|
||||
|
||||
// Player indicator cleared.
|
||||
let pl = Ds5Feedback::player_packet(0);
|
||||
assert_eq!(pl[1], 0x10, "player-LED enable bit");
|
||||
assert_eq!(pl[Ds5Feedback::PAD_LIGHTS], 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod player_led_tests {
|
||||
use super::*;
|
||||
|
||||
/// Both conventions that reach this wire spell "player N" as N lit LEDs, so the count is the
|
||||
/// player number regardless of WHICH bits a given pad lights. Pinned because the mapping is
|
||||
/// otherwise only obvious once you have seen both patterns side by side.
|
||||
#[test]
|
||||
fn player_index_counts_lit_leds_for_both_conventions() {
|
||||
// DualSense / hid-playstation patterns — non-contiguous, symmetric about the centre LED.
|
||||
assert_eq!(player_index_from_bits(0x04), Some(0)); // player 1
|
||||
assert_eq!(player_index_from_bits(0x0A), Some(1)); // player 2
|
||||
assert_eq!(player_index_from_bits(0x15), Some(2)); // player 3
|
||||
assert_eq!(player_index_from_bits(0x1B), Some(3)); // player 4
|
||||
assert_eq!(player_index_from_bits(0x1F), Some(4)); // player 5
|
||||
|
||||
// Switch/XInput style — a contiguous run of low bits, the same count each time.
|
||||
assert_eq!(player_index_from_bits(0x01), Some(0));
|
||||
assert_eq!(player_index_from_bits(0x03), Some(1));
|
||||
assert_eq!(player_index_from_bits(0x07), Some(2));
|
||||
assert_eq!(player_index_from_bits(0x0F), Some(3));
|
||||
}
|
||||
|
||||
/// No lit LED is "no player", NOT player 0 — the difference between LEDs off and player 1 lit.
|
||||
#[test]
|
||||
fn no_lit_led_is_no_player() {
|
||||
assert_eq!(player_index_from_bits(0x00), None);
|
||||
// Only the low 5 bits are player LEDs; junk above them must not invent a player.
|
||||
assert_eq!(player_index_from_bits(0xE0), None);
|
||||
}
|
||||
|
||||
/// The mask is applied before counting, so out-of-range bits cannot inflate the index past
|
||||
/// the 5 real LEDs.
|
||||
#[test]
|
||||
fn high_bits_are_masked_off_before_counting() {
|
||||
assert_eq!(player_index_from_bits(0xFF), Some(4)); // 0x1F worth of LEDs, not 8
|
||||
assert_eq!(player_index_from_bits(0xE4), Some(0)); // 0x04 with junk on top
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,6 +982,10 @@ mod tests {
|
||||
height: 1440,
|
||||
bitrate_kbps: 55000,
|
||||
codec: "av1".into(),
|
||||
present_priority: "smooth".into(),
|
||||
smooth_buffer: 2,
|
||||
vsync: false,
|
||||
allow_vrr: false,
|
||||
..Default::default()
|
||||
},
|
||||
clipboard: true,
|
||||
|
||||
@@ -74,9 +74,27 @@ pub struct SettingsOverlay {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gamepad: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gamepad_forwarding: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_buttons: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guide_gesture: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stats_verbosity: Option<StatsVerbosity>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fullscreen_on_stream: Option<bool>,
|
||||
/// The presentation cluster — the keys the Apple client already writes into this
|
||||
/// same catalog shape (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`;
|
||||
/// Android carries the first two). First-class here so a profile authored on any
|
||||
/// client applies on all of them instead of riding `extra` unapplied.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub present_priority: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub smooth_buffer: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vsync: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_vrr: Option<bool>,
|
||||
/// Overlay keys a newer client wrote and this one doesn't model — carried through a
|
||||
/// load→save round-trip untouched.
|
||||
#[serde(flatten)]
|
||||
@@ -142,6 +160,15 @@ impl SettingsOverlay {
|
||||
if let Some(v) = &self.gamepad {
|
||||
s.gamepad = v.clone();
|
||||
}
|
||||
if let Some(v) = self.gamepad_forwarding {
|
||||
s.gamepad_forwarding = v;
|
||||
}
|
||||
if let Some(v) = &self.system_buttons {
|
||||
s.system_buttons = v.clone();
|
||||
}
|
||||
if let Some(v) = &self.guide_gesture {
|
||||
s.guide_gesture = v.clone();
|
||||
}
|
||||
if let Some(v) = self.stats_verbosity {
|
||||
// Through the setter so the legacy `show_stats` bool stays coherent for
|
||||
// pre-tier binaries reading the same settings file.
|
||||
@@ -150,6 +177,18 @@ impl SettingsOverlay {
|
||||
if let Some(v) = self.fullscreen_on_stream {
|
||||
s.fullscreen_on_stream = v;
|
||||
}
|
||||
if let Some(v) = &self.present_priority {
|
||||
s.present_priority = v.clone();
|
||||
}
|
||||
if let Some(v) = self.smooth_buffer {
|
||||
s.smooth_buffer = v;
|
||||
}
|
||||
if let Some(v) = self.vsync {
|
||||
s.vsync = v;
|
||||
}
|
||||
if let Some(v) = self.allow_vrr {
|
||||
s.allow_vrr = v;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
@@ -220,12 +259,33 @@ impl SettingsOverlay {
|
||||
if after.gamepad != before.gamepad {
|
||||
self.gamepad = Some(after.gamepad.clone());
|
||||
}
|
||||
if after.gamepad_forwarding != before.gamepad_forwarding {
|
||||
self.gamepad_forwarding = Some(after.gamepad_forwarding);
|
||||
}
|
||||
if after.system_buttons != before.system_buttons {
|
||||
self.system_buttons = Some(after.system_buttons.clone());
|
||||
}
|
||||
if after.guide_gesture != before.guide_gesture {
|
||||
self.guide_gesture = Some(after.guide_gesture.clone());
|
||||
}
|
||||
if after.stats_verbosity() != before.stats_verbosity() {
|
||||
self.stats_verbosity = Some(after.stats_verbosity());
|
||||
}
|
||||
if after.fullscreen_on_stream != before.fullscreen_on_stream {
|
||||
self.fullscreen_on_stream = Some(after.fullscreen_on_stream);
|
||||
}
|
||||
if after.present_priority != before.present_priority {
|
||||
self.present_priority = Some(after.present_priority.clone());
|
||||
}
|
||||
if after.smooth_buffer != before.smooth_buffer {
|
||||
self.smooth_buffer = Some(after.smooth_buffer);
|
||||
}
|
||||
if after.vsync != before.vsync {
|
||||
self.vsync = Some(after.vsync);
|
||||
}
|
||||
if after.allow_vrr != before.allow_vrr {
|
||||
self.allow_vrr = Some(after.allow_vrr);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one override by its overlay field name, putting the row back to inheriting. The
|
||||
@@ -257,8 +317,15 @@ impl SettingsOverlay {
|
||||
"invert_scroll" => self.invert_scroll = None,
|
||||
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
|
||||
"gamepad" => self.gamepad = None,
|
||||
"gamepad_forwarding" => self.gamepad_forwarding = None,
|
||||
"system_buttons" => self.system_buttons = None,
|
||||
"guide_gesture" => self.guide_gesture = None,
|
||||
"stats_verbosity" => self.stats_verbosity = None,
|
||||
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
|
||||
"present_priority" => self.present_priority = None,
|
||||
"smooth_buffer" => self.smooth_buffer = None,
|
||||
"vsync" => self.vsync = None,
|
||||
"allow_vrr" => self.allow_vrr = None,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
@@ -433,6 +500,10 @@ mod tests {
|
||||
assert_eq!((out.width, out.height), (1920, 1080));
|
||||
assert_eq!(out.bitrate_kbps, 20000);
|
||||
assert_eq!(out.codec, "hevc");
|
||||
assert!(
|
||||
out.gamepad_forwarding,
|
||||
"default on, and an empty overlay leaves it alone"
|
||||
);
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let overlay = SettingsOverlay {
|
||||
@@ -452,9 +523,16 @@ mod tests {
|
||||
invert_scroll: Some(true),
|
||||
inhibit_shortcuts: Some(false),
|
||||
gamepad: Some("dualsense".into()),
|
||||
gamepad_forwarding: Some(false),
|
||||
system_buttons: Some("local".into()),
|
||||
guide_gesture: Some("on".into()),
|
||||
match_window: Some(true),
|
||||
fullscreen_on_stream: Some(false),
|
||||
stats_verbosity: Some(StatsVerbosity::Detailed),
|
||||
present_priority: Some("smooth".into()),
|
||||
smooth_buffer: Some(3),
|
||||
vsync: Some(false),
|
||||
allow_vrr: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!overlay.is_empty());
|
||||
@@ -473,9 +551,16 @@ mod tests {
|
||||
assert!(out.invert_scroll);
|
||||
assert!(!out.inhibit_shortcuts);
|
||||
assert_eq!(out.gamepad, "dualsense");
|
||||
assert!(!out.gamepad_forwarding);
|
||||
assert_eq!(out.system_buttons, "local");
|
||||
assert_eq!(out.guide_gesture, "on");
|
||||
assert!(out.match_window);
|
||||
assert!(!out.fullscreen_on_stream);
|
||||
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
|
||||
assert_eq!(out.present_priority, "smooth");
|
||||
assert_eq!(out.smooth_buffer, 3);
|
||||
assert!(!out.vsync);
|
||||
assert!(!out.allow_vrr);
|
||||
// The tier goes through the setter, so the legacy bool a pre-tier binary reads
|
||||
// stays coherent with it.
|
||||
assert!(out.show_stats);
|
||||
@@ -573,6 +658,59 @@ mod tests {
|
||||
assert!(o.is_empty());
|
||||
}
|
||||
|
||||
/// The presentation cluster is first-class, not `extra` passengers: it applies,
|
||||
/// absorbs, clears, and serialises under the exact keys the Apple client already
|
||||
/// writes (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`) — one catalog
|
||||
/// has to round-trip through every platform, and a mismatched key would be carried
|
||||
/// but never applied.
|
||||
#[test]
|
||||
fn presentation_cluster_is_first_class() {
|
||||
let base = Settings::default();
|
||||
let mut o = SettingsOverlay::default();
|
||||
let before = o.apply(&base);
|
||||
let mut after = before.clone();
|
||||
after.present_priority = "smooth".into();
|
||||
o.absorb(&before, &after);
|
||||
let before = o.apply(&base);
|
||||
let mut after = before.clone();
|
||||
after.smooth_buffer = 1;
|
||||
o.absorb(&before, &after);
|
||||
assert_eq!(o.present_priority.as_deref(), Some("smooth"));
|
||||
assert_eq!(o.smooth_buffer, Some(1));
|
||||
assert!(
|
||||
o.extra.is_empty(),
|
||||
"modelled fields must never land in the passthrough"
|
||||
);
|
||||
let out = o.apply(&base);
|
||||
assert_eq!(
|
||||
out.present_priority(),
|
||||
crate::trust::PresentPriority::Smooth { buffer: 1 }
|
||||
);
|
||||
|
||||
// Serialised under the shared keys, and read back from a foreign client's file.
|
||||
let text = serde_json::to_string(&o).unwrap();
|
||||
assert!(text.contains("\"present_priority\":\"smooth\""), "{text}");
|
||||
assert!(text.contains("\"smooth_buffer\":1"), "{text}");
|
||||
let from_apple: SettingsOverlay = serde_json::from_str(
|
||||
r#"{"present_priority":"latency","smooth_buffer":2,"vsync":true,"allow_vrr":false}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(from_apple.present_priority.as_deref(), Some("latency"));
|
||||
assert_eq!(from_apple.smooth_buffer, Some(2));
|
||||
assert_eq!(from_apple.vsync, Some(true));
|
||||
assert_eq!(from_apple.allow_vrr, Some(false));
|
||||
assert!(from_apple.extra.is_empty());
|
||||
|
||||
assert!(o.clear("present_priority"));
|
||||
assert!(o.clear("smooth_buffer"));
|
||||
assert_eq!(o.present_priority, None);
|
||||
assert!(o.is_empty());
|
||||
let mut vrr = from_apple;
|
||||
assert!(vrr.clear("vsync"));
|
||||
assert!(vrr.clear("allow_vrr"));
|
||||
assert_eq!((vrr.vsync, vrr.allow_vrr), (None, None));
|
||||
}
|
||||
|
||||
/// `clear` is the explicit way back to inheriting, including the resolution tri-state.
|
||||
#[test]
|
||||
fn clear_drops_one_override() {
|
||||
@@ -591,6 +729,29 @@ mod tests {
|
||||
assert!(!o.clear("no_such_field"));
|
||||
}
|
||||
|
||||
/// Controller forwarding defaults ON, so its interesting override is the FALSE one — and a
|
||||
/// `false` that `apply` dropped would silently forward a pad the profile said not to.
|
||||
/// `absorb` must record it, `clear` must undo it, and the serialized name both carry is the
|
||||
/// one every client's reset button sends.
|
||||
#[test]
|
||||
fn gamepad_forwarding_overrides_off_and_resets_back() {
|
||||
let base = Settings::default();
|
||||
assert!(base.gamepad_forwarding, "the shipped default");
|
||||
|
||||
let mut o = SettingsOverlay::default();
|
||||
let mut after = base.clone();
|
||||
after.gamepad_forwarding = false;
|
||||
o.absorb(&base, &after);
|
||||
assert_eq!(o.gamepad_forwarding, Some(false));
|
||||
assert!(!o.apply(&base).gamepad_forwarding);
|
||||
|
||||
assert!(o.clear("gamepad_forwarding"));
|
||||
assert_eq!(o.gamepad_forwarding, None);
|
||||
assert!(o.is_empty());
|
||||
// Back to inheriting: the global's live value, not a remembered false.
|
||||
assert!(o.apply(&base).gamepad_forwarding);
|
||||
}
|
||||
|
||||
/// Stats verbosity Off must survive `apply` — it is a legitimate override, and going
|
||||
/// through `set_stats_verbosity` keeps `show_stats` in sync in that direction too.
|
||||
#[test]
|
||||
|
||||
@@ -424,11 +424,31 @@ fn pump(
|
||||
// Build the decoder for the codec the host resolved (never assume HEVC), honoring the
|
||||
// Settings backend preference (auto/vaapi/software).
|
||||
let codec_id = crate::video::ffmpeg_codec_id(connector.codec);
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
// The WIRE codec is the negotiated truth; the FFmpeg id is meaningful only where
|
||||
// FFmpeg decodes it. `ffmpeg_codec_id`'s fallthrough maps every unknown wire bit —
|
||||
// PyroWave included — to HEVC, so logging it unconditionally claimed
|
||||
// `codec_id=HEVC` for wavelet sessions that never touch FFmpeg at all.
|
||||
let codec = match connector.codec {
|
||||
punktfunk_core::quic::CODEC_H264 => "H264",
|
||||
punktfunk_core::quic::CODEC_HEVC => "HEVC",
|
||||
punktfunk_core::quic::CODEC_AV1 => "AV1",
|
||||
punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave",
|
||||
_ => "unknown",
|
||||
};
|
||||
if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE {
|
||||
tracing::info!(
|
||||
codec,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
codec,
|
||||
?codec_id,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
}
|
||||
// A negotiated PyroWave session decodes on the presenter's device, no FFmpeg —
|
||||
// reachable only through the explicit preference above (resolve_codec never
|
||||
// auto-picks the bit), so failing loudly here is failing an opted-in experiment.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user