Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| caa7a1c735 | |||
| 13dc7fc49f | |||
| 57ae00a9c8 | |||
| 882a3d57f6 | |||
| fa28fa19a0 | |||
| 42595b5558 | |||
| 4de543c146 | |||
| 42d1c74663 | |||
| 136f6e8f0e | |||
| 00acf5e44e | |||
| 38078fe7ee | |||
| 69609945a3 | |||
| 8470419433 | |||
| 449a67ce8d | |||
| 09a5957c6d | |||
| c7630ff5dc | |||
| 2c7ded0f3c | |||
| b7048446c4 | |||
| 3039626b87 | |||
| 3f33ed30ae | |||
| 7e31020c1c | |||
| fe54aff658 | |||
| b46aa15afb | |||
| 058630f542 | |||
| e9c1f4083a | |||
| 20f0d2802f | |||
| 6f8fb15c9b | |||
| 89455032a0 | |||
| 0da9d8ec10 | |||
| fbf3fea0c8 | |||
| c52ae119e1 | |||
| 5d7aabe8f0 | |||
| f204a89cef | |||
| 24fa018c70 | |||
| 51a6ca7e02 | |||
| b9fde03f1e | |||
| efb1ba26d7 | |||
| 1320e3dc66 | |||
| 1be83575b6 | |||
| 4d1d20f832 | |||
| 6e875fea44 | |||
| 4f3cd24036 | |||
| af13f0b749 | |||
| d285d4a0b2 | |||
| 04f370999c | |||
| 2c937855b3 | |||
| 8005b11faf | |||
| 01fcb01019 | |||
| 95a08e99c3 | |||
| a3e1ea2b44 | |||
| 6686fcdded | |||
| 31c382fde0 | |||
| d707ee4d4e | |||
| e8196b33b8 | |||
| fd699b3e2c | |||
| 79dd8f58e3 | |||
| be879c946a | |||
| f3646d4e7c | |||
| 396c3453f5 | |||
| 6921e147dd |
@@ -0,0 +1,9 @@
|
||||
# Shown on the "new issue" chooser so security reports go to the private channel, not a public issue.
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 🔒 Report a security vulnerability
|
||||
url: https://git.unom.io/unom/punktfunk/src/branch/main/SECURITY.md
|
||||
about: >-
|
||||
Found a security issue? Please report it privately by email to security@punktfunk.com — do not
|
||||
open a public issue, so other users aren't exposed before a fix ships. See SECURITY.md for the
|
||||
full policy.
|
||||
@@ -78,9 +78,10 @@ jobs:
|
||||
- name: Version + channel
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
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
|
||||
*) VN="0.5.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
esac
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -36,16 +36,17 @@ jobs:
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
|
||||
# A main push -> 0.5.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||
# below the eventual 0.5.0 tag, it climbs monotonically by run number, and the canary base
|
||||
# stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves
|
||||
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
||||
# A main push -> <next-minor>~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||
# below the eventual tag, it climbs monotonically by run number, and the canary base is
|
||||
# derived one minor AHEAD of the latest stable tag (scripts/ci/pf-version.sh) so a
|
||||
# stable->canary box re-point still moves forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
||||
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||
*) V="0.5.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
@@ -126,6 +127,14 @@ jobs:
|
||||
run: |
|
||||
for DEB in dist/*.deb; do
|
||||
echo "uploading $DEB"
|
||||
# A re-tagged release re-fires this workflow and the apt registry 409s on duplicate
|
||||
# package versions — delete any prior copy of this exact name/version/arch first
|
||||
# (404 on the first publish is fine).
|
||||
NAME=$(dpkg-deb -f "$DEB" Package)
|
||||
VER=$(dpkg-deb -f "$DEB" Version)
|
||||
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
||||
# PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login.
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||
|
||||
@@ -63,7 +63,8 @@ jobs:
|
||||
pnpm run build # rollup -> clients/decky/dist/index.js
|
||||
|
||||
- name: Version + channel + stamp
|
||||
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.<run>
|
||||
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> <next-minor>.<run>
|
||||
# (base one minor ahead of the latest stable tag via scripts/ci/pf-version.sh)
|
||||
# (`canary/` alias). Decky reads a plugin's INSTALLED version from package.json (NOT
|
||||
# plugin.json), and the plugin's own update check (clients/decky/main.py check_update)
|
||||
# compares against it — so the build version is STAMPED into package.json here (mirrored
|
||||
@@ -72,9 +73,12 @@ jobs:
|
||||
# (ci10 < ci9), which would break update detection; the run number is monotonic.
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_MAJOR/PF_MINOR (base one minor ahead of latest stable)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;;
|
||||
*) V="0.3.${GITHUB_RUN_NUMBER}"; ALIAS=canary ;;
|
||||
# Canary MUST be a plain monotonic numeric semver (see the note above): <major>.<minor>.<run>,
|
||||
# where major.minor track one minor ahead of the latest stable and the run number climbs.
|
||||
*) V="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"; ALIAS=canary ;;
|
||||
esac
|
||||
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
@@ -122,8 +126,13 @@ jobs:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
||||
# 1) Immutable, versioned URL + its update manifest (the manifest's `artifact` points
|
||||
# here, so the published sha256 keeps matching what Decky later downloads).
|
||||
# 1) Versioned URL + its update manifest (the manifest's `artifact` points here, so the
|
||||
# published sha256 keeps matching what Decky later downloads). A re-tagged release
|
||||
# re-fires this workflow and the registry 409s on duplicate uploads — delete any
|
||||
# prior copy of this version first (404 on the first publish is fine).
|
||||
for f in punktfunk.zip manifest.json; do
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$VERSION/$f" || true
|
||||
done
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
||||
"$BASE/$VERSION/punktfunk.zip"
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \
|
||||
|
||||
@@ -73,15 +73,17 @@ jobs:
|
||||
|
||||
- name: Version + channel
|
||||
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
|
||||
# 0.5.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo
|
||||
# <next-minor>-ciN.g<sha> on the `canary` branch (base one minor ahead of the latest stable
|
||||
# tag via scripts/ci/pf-version.sh). The two branches live side-by-side in one repo
|
||||
# (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
|
||||
# on a stable box never jumps to a canary build. The generic-registry version string allows
|
||||
# letters/dots/hyphens.
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
|
||||
*) V="0.5.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
||||
*) V="${PF_BASE}-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
|
||||
@@ -106,6 +108,40 @@ jobs:
|
||||
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
|
||||
-o packaging/flatpak/cargo-sources.json
|
||||
|
||||
- name: Seed the local OSTree repo from the live server (keep BOTH channels in the summary)
|
||||
# Each CI run builds only ONE branch (canary on main, stable on a tag). The deploy step's
|
||||
# `flatpak build-update-repo` regenerates the repo SUMMARY from whatever refs are in the
|
||||
# LOCAL repo, and the rsync publishes it (without --delete). A fresh single-branch local
|
||||
# repo therefore produces a single-branch summary that CLOBBERS the other channel on the
|
||||
# server — the exact bug that made `app/io.unom.Punktfunk/x86_64/stable` unresolvable
|
||||
# ("No such ref") after a canary main-push overwrote the post-release summary, even though
|
||||
# the stable commit's objects were still on disk. Fix: mirror the published repo DOWN first,
|
||||
# so the local repo carries every existing branch; the build below then only ADDS this run's
|
||||
# commit and the regenerated+signed summary keeps both channels. No-op on a fresh repo (first
|
||||
# publish) or when the deploy secrets aren't set (the build still produces a valid bundle).
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_SSH_KEY:-}" ]; then
|
||||
echo "::warning::DEPLOY_* not set — no seed; building a fresh single-branch repo."
|
||||
exit 0
|
||||
fi
|
||||
install -d -m700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
mkdir -p "$PWD/repo"
|
||||
# Pull the currently-published repo (all channels' objects + refs) into the repo the build
|
||||
# will extend. No --delete: the local repo starts empty, so this only ADDS. A missing
|
||||
# server repo (very first publish) is fine — we continue with a fresh repo.
|
||||
rsync -az --info=stats1 -e "$SSH" "$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/" \
|
||||
|| echo "::warning::no published repo to seed (first publish?) — continuing fresh"
|
||||
echo "seeded refs:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
|
||||
|
||||
- name: Build the flatpak (install deps from Flathub, offline build)
|
||||
run: |
|
||||
# --install-deps-from=flathub pulls everything the manifest declares: the GNOME 50
|
||||
@@ -133,7 +169,10 @@ jobs:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
||||
# 1) Immutable, versioned URL.
|
||||
# 1) Versioned URL. A re-tagged release re-fires this workflow and the registry 409s on
|
||||
# duplicate uploads — delete any prior copy first (404 on the first publish is fine).
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"$BASE/$VERSION/$BUNDLE" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
||||
"$BASE/$VERSION/$BUNDLE"
|
||||
echo "published $BASE/$VERSION/$BUNDLE"
|
||||
@@ -174,6 +213,10 @@ jobs:
|
||||
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME"
|
||||
flatpak build-update-repo --generate-static-deltas \
|
||||
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" "$PWD/repo"
|
||||
# The regenerated summary advertises exactly these refs — must include EVERY channel that
|
||||
# has ever published (the seed step ensures the other channel's commit is present). If this
|
||||
# ever shows only one branch on a repo that had two, the seed didn't run — investigate.
|
||||
echo "published summary advertises:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
|
||||
# 2) Build the install descriptors (GPGKey = the committed public key, base64).
|
||||
GPGKEY="$(base64 -w0 packaging/flatpak/unom-flatpak.gpg)"
|
||||
rm -rf site && mkdir -p site
|
||||
@@ -185,9 +228,12 @@ jobs:
|
||||
Comment=unom Flatpak applications
|
||||
GPGKey=$GPGKEY
|
||||
EOF
|
||||
# Two refs, one per channel — both regenerated every run and rsync'd without --delete, so
|
||||
# the server always offers both (the stable ref only resolves once a release has built the
|
||||
# `stable` branch). A box installs ONE; `flatpak update` then tracks that channel's branch.
|
||||
# Two refs, one per channel. Both descriptor files are regenerated every run and rsync'd
|
||||
# without --delete; the repo SUMMARY carries both branches because the build was seeded
|
||||
# from the live repo above (so build-update-repo below re-signs a summary listing every
|
||||
# published channel, not just this run's). The stable ref resolves for good once any
|
||||
# release has built the `stable` branch. A box installs ONE; `flatpak update` then tracks
|
||||
# that channel's branch.
|
||||
write_ref() { # <filename> <branch> <title>
|
||||
cat > "site/$1" <<EOF
|
||||
[Flatpak Ref]
|
||||
|
||||
@@ -99,13 +99,14 @@ jobs:
|
||||
|
||||
- name: Version from tag
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
||||
*) V="0.5.0" ;; # canary marketing version; the build number disambiguates
|
||||
*) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
||||
echo "version $V build $GITHUB_RUN_NUMBER"
|
||||
echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
|
||||
|
||||
- name: Rust toolchain (mac + iOS + tvOS slices)
|
||||
run: |
|
||||
|
||||
@@ -68,16 +68,17 @@ jobs:
|
||||
restore-keys: cargo-home-
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.5.0-0.ciN.g<sha>
|
||||
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.5.0-1 yet
|
||||
# climbs by run number. The canary base stays one minor ahead of the latest stable so a
|
||||
# stable->canary box re-point still moves forward. The spec %build stamps
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> <next-minor>-0.ciN.g<sha>
|
||||
# in the `<base>-canary` group, whose "0." release sorts below the eventual <next-minor>-1 yet
|
||||
# climbs by run number. The canary base is derived one minor ahead of the latest stable tag
|
||||
# (scripts/ci/pf-version.sh) so a stable->canary box re-point still moves forward. The spec %build stamps
|
||||
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
|
||||
*) V="0.5.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
||||
*) V="$PF_BASE"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
||||
esac
|
||||
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
|
||||
@@ -103,6 +104,14 @@ jobs:
|
||||
for rpm in dist/*.rpm; do
|
||||
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
|
||||
echo "uploading $rpm"
|
||||
# A re-tagged release re-fires this workflow and the rpm registry 409s on duplicate
|
||||
# package versions — delete any prior copy of this exact name/version-release/arch
|
||||
# first (404 on the first publish is fine).
|
||||
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
|
||||
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
|
||||
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
||||
done
|
||||
|
||||
@@ -131,11 +131,21 @@ jobs:
|
||||
# dispatched provisioning workflow landing on a different one. Path is relative to the job
|
||||
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
|
||||
run: ../../../scripts/ci/ensure-windows-toolchain.ps1
|
||||
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay)
|
||||
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay + gamepad drivers)
|
||||
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
|
||||
# pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve
|
||||
# against IddCxStub end-to-end (M1 step 2 gate).
|
||||
# pf-vdisplay (the real IddCx driver) + pf-umdf-util (the safe UMDF primitive layer) + the two
|
||||
# gamepad drivers. pf-vdisplay linking proves the IddCx call sites resolve against IddCxStub
|
||||
# end-to-end (M1 step 2 gate); the gamepad drivers prove pf-umdf-util's WDF dispatch links.
|
||||
run: cargo build -v
|
||||
- name: cargo clippy the shipped drivers (-D warnings — enforces the unsafe-audit gates)
|
||||
# The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited
|
||||
# unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a
|
||||
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
||||
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
||||
# toolchain-only probe crate and is excluded.)
|
||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||
- name: cargo fmt --check the safe-layer + gamepad drivers
|
||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
|
||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||
run: |
|
||||
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||
|
||||
@@ -16,15 +16,17 @@
|
||||
# Versioning (free-form; not MSIX's 4-part rule) — single project version:
|
||||
# vX.Y.Z tag -> X.Y.Z (THE release; published + stable `latest/` alias + attached to the
|
||||
# unified Gitea Release).
|
||||
# main push / dispatch -> 0.3.<run_number> (canary; `canary/` alias; climbs by run number).
|
||||
# main push / dispatch -> <next-minor>.<run_number> (canary; `canary/` alias; base one minor
|
||||
# ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs).
|
||||
#
|
||||
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
|
||||
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
||||
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
||||
#
|
||||
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
||||
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export
|
||||
# .def with llvm-dlltool (no GPU/SDK at build time).
|
||||
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
|
||||
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
|
||||
# AMD/Intel-only boxes before main).
|
||||
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
|
||||
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
||||
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
||||
@@ -37,6 +39,7 @@ on:
|
||||
paths:
|
||||
- 'crates/punktfunk-host/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/punktfunk-tray/**'
|
||||
- 'packaging/windows/**'
|
||||
- 'scripts/windows/**'
|
||||
- 'web/**'
|
||||
@@ -100,30 +103,33 @@ jobs:
|
||||
if (-not $env:VBCABLE_DIR) {
|
||||
"VBCABLE_DIR=C:\Users\Public\vbcable" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
$env:GITHUB_REF_NAME -replace '^v', ''
|
||||
} else {
|
||||
"0.3.$($env:GITHUB_RUN_NUMBER)"
|
||||
# Canary: <major>.<minor>.<run> — major.minor track one minor ahead of stable, run climbs monotonically.
|
||||
"$($pf.PF_MAJOR).$($pf.PF_MINOR).$($env:GITHUB_RUN_NUMBER)"
|
||||
}
|
||||
"HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "host version $v"
|
||||
|
||||
- name: Generate NVENC import lib
|
||||
shell: pwsh
|
||||
run: |
|
||||
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
|
||||
"PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build (release, nvenc + amf-qsv)
|
||||
shell: pwsh
|
||||
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
|
||||
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
|
||||
|
||||
- name: Clippy (host, Windows)
|
||||
- name: Build (release, status tray)
|
||||
shell: pwsh
|
||||
# The per-user notification-area companion the installer bundles (punktfunk-tray.exe).
|
||||
run: cargo build --release -p punktfunk-tray
|
||||
|
||||
- name: Clippy (host + tray, Windows)
|
||||
shell: pwsh
|
||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
||||
run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings
|
||||
run: |
|
||||
cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
shell: pwsh
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
|
||||
# Published to the generic registry + the stable `latest/` alias + attached to the
|
||||
# unified Gitea Release alongside every other platform's artifact.
|
||||
# main push / dispatch -> 0.3.<run_number>.0 (canary; climbs monotonically by run number).
|
||||
# main push / dispatch -> <next-minor>.<run_number>.0 (canary; base is one minor ahead of the
|
||||
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
|
||||
# Published to the generic registry + the `canary/` alias.
|
||||
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||
#
|
||||
@@ -78,11 +79,13 @@ jobs:
|
||||
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
rustup target add ${{ matrix.target }}
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
|
||||
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
|
||||
} else {
|
||||
@('0', '3', $env:GITHUB_RUN_NUMBER)
|
||||
# Canary: <major>.<minor>.<run>.0 — major.minor track one minor ahead of stable, run climbs monotonically.
|
||||
@($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER)
|
||||
}
|
||||
while ($parts.Count -lt 4) { $parts += '0' }
|
||||
$v = ($parts[0..3] -join '.')
|
||||
|
||||
@@ -31,3 +31,6 @@ xcuserdata/
|
||||
# Python bytecode (e.g. clients/android/ci tooling)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Claude Code project instructions — local to each dev box, not part of the repo.
|
||||
CLAUDE.md
|
||||
|
||||
@@ -1,516 +0,0 @@
|
||||
# CLAUDE.md — punktfunk
|
||||
|
||||
Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core
|
||||
(`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design:
|
||||
[`design/implementation-plan.md`](design/implementation-plan.md). Status table: `README.md`.
|
||||
|
||||
## Where the work stands
|
||||
|
||||
- **Core (`punktfunk-core` + C ABI): complete and hardened.** FEC recovery, loopback-under-loss,
|
||||
proptests, C ABI harness all green; 13 adversarial-review findings fixed +
|
||||
regression-tested (`a913042`).
|
||||
- **GameStream host: working end-to-end with a stock Moonlight client.** Validated live
|
||||
on this box: pairing (persists across restarts), serverinfo/applist (app catalog from
|
||||
`~/.config/punktfunk/apps.json` → each entry picks a compositor + nested command), RTSP, ENet
|
||||
control, audio, and video at the **client's native resolution and refresh** — the host
|
||||
creates a per-session virtual output via per-compositor `VirtualDisplay` backends:
|
||||
**KWin** (`zkde_screencast stream_virtual_output`, needs KWin ≥ 6.5.6 headless; >60 Hz via
|
||||
custom modes), **gamescope** (spawned headless at WxH@Hz, its PipeWire node captured, needs
|
||||
gamescope ≥ 3.16.22 — older deadlocks on PipeWire ≥ 1.6), **Mutter** (D-Bus
|
||||
`RecordVirtual` virtual monitor; validated live on headless GNOME Shell 50, zero-copy),
|
||||
**Sway/wlroots** (`swaymsg create_output` + custom mode, xdpw portal capture with a
|
||||
managed chooser config; validated live on sway 1.11, zero-copy).
|
||||
Performance work landed and measured: GPU **zero-copy** on all paths (tiled dmabuf →
|
||||
EGL/GL → CUDA; LINEAR dmabuf → **Vulkan bridge** → CUDA → NVENC), auto 2-way NVENC
|
||||
split-encode above ~1 Gpix/s (5K@240), infinite GOP + RFI keyframes (killed the periodic
|
||||
freeze), encode|send thread split with `sendmmsg` batching. Stable 240 fps at 5120×1440.
|
||||
Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS
|
||||
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
|
||||
back-channel; validated live — pad created/destroyed with the session). Management REST API +
|
||||
checked-in OpenAPI doc (`mgmt.rs`). **Web-console performance capture** (`stats_recorder.rs`,
|
||||
design: [`design/stats-capture-plan.md`](design/stats-capture-plan.md)): the operator arms stats
|
||||
recording from the web console, plays, stops, and reviews the run as graphs (per-stage latency
|
||||
breakdown · fps new/repeat · goodput · loss/FEC). A shared `Arc<StatsRecorder>` ring (the hot-path
|
||||
gate is a runtime `AtomicBool`, replacing the startup-only `PUNKTFUNK_PERF`) is fed by **both** the
|
||||
native `virtual_stream` and the GameStream encode loop at their existing ~2 s/~1 s aggregation
|
||||
boundary, and finished captures are saved as on-disk recordings
|
||||
(`~/.config/punktfunk/captures/*.json`) browsable/exportable from the console's **Performance** page
|
||||
(recharts). Endpoints `/api/v1/stats/*` (bearer-only). *Implemented; not yet on-glass validated.*
|
||||
**Web-console log view** (`log_capture.rs`): a `tracing` layer tees DEBUG-and-up (independent of
|
||||
`RUST_LOG`) into a 4096-entry in-memory ring, served cursor-paged at `GET /api/v1/logs`
|
||||
(bearer-only) → the console's **Logs** page (follow/pause · level filter · search). The Windows
|
||||
gamepad drivers now stamp attach/heartbeat marks into their shm sections and the host's
|
||||
`DriverAttach` watcher turns silence into a one-shot diagnosis WARN (driver-store check + CM
|
||||
devnode problem code) — failure-mode table: [`design/gamepad-driver-health.md`](design/gamepad-driver-health.md).
|
||||
The Android client gained Settings → **Connected controllers** (device list + VID/PID + resolved
|
||||
pad type + live input test) for the client end of the same chain. *Log view + driver health:
|
||||
Linux-tested; Windows/Android sides CI/device-validation pending.*
|
||||
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
|
||||
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
|
||||
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
||||
(inexpressible in GameStream), host creates the native virtual output at the client's
|
||||
requested mode. `punktfunk1-host` is a **persistent listener** (sessions back to back;
|
||||
`--max-sessions`). QUIC datagrams carry the side planes, demuxed by first byte: input
|
||||
0xC8 (incl. **gamepads** — incremental events accumulated into the uinput xpad), **Opus
|
||||
audio** 0xC9 (48 kHz stereo, 5 ms, host→client), **rumble** 0xCA (host→client). **Trust:**
|
||||
host serves its persistent identity (`~/.config/punktfunk/cert.pem`, shared with GameStream
|
||||
pairing) and logs the SHA-256 fingerprint; clients pin it, established by a **SPAKE2 PIN pairing
|
||||
ceremony** (host arms pairing and displays a 4-digit PIN; a PAKE binds both cert fingerprints so an
|
||||
attacker gets one online guess, no offline dictionary attack) — PIN pairing is the default for new
|
||||
hosts. **TOFU on first connect** (`endpoint::client_pinned`) stays as an explicit host opt-in
|
||||
(`punktfunk1-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs;
|
||||
clients only offer the TOFU "Trust" path for a host that advertised `pair=optional`, route every
|
||||
other new host straight to the PIN ceremony, and on a pinned-fingerprint change force re-pairing
|
||||
(no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores
|
||||
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
|
||||
default; `--allow-tofu`/`--open` accept unpaired clients).
|
||||
**LAN auto-discovery**: both `serve` and `punktfunk1-host` advertise the native service over
|
||||
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
|
||||
pin)/`pair`(required|optional)/`id`; `punktfunk-probe --discover` lists hosts, Apple clients
|
||||
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
|
||||
**Mid-stream mode renegotiation**: `Reconfigure` on the still-open control stream — the
|
||||
host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on
|
||||
(validated live: one .h265 with 720p and 1080p segments). Measured on-box at 720p120: 1680/1680 frames, **p50 0.83 ms**
|
||||
capture→…→reassembled; audio measured live (~200 pkts/s). A **wall-clock skew handshake**
|
||||
(`ClockProbe`/`ClockEcho`, 8 NTP rounds after `Start`, `clock_offset_ns`) aligns the client to the
|
||||
host clock, so that latency is now valid **cross-machine** (`skew_corrected=true`) — measured GNOME
|
||||
box → dev box over the LAN: **p50 1.30 ms** (the −1.57 ms inter-box clock offset removed).
|
||||
`punktfunk-probe` is the
|
||||
working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad).
|
||||
The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect`
|
||||
(pin/TOFU) + `next_au`/`next_audio`/`next_rumble`/`next_hidout`/`send_input`/
|
||||
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
|
||||
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
|
||||
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
|
||||
env > uinput Xbox 360. Backends: **Xbox 360** (uinput on Linux / the pf-xusb UMDF driver on
|
||||
Windows), **Xbox One/Series** (the same
|
||||
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
|
||||
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
|
||||
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
|
||||
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
|
||||
LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows
|
||||
(UMDF minidriver)** backend — `inject/windows/dualsense_windows.rs` + `inject/windows/dualshock4_windows.rs`, one
|
||||
driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4
|
||||
reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays
|
||||
Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller
|
||||
(DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad
|
||||
dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver
|
||||
(`packaging/windows/drivers/pf-xusb/`, `inject/windows/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB`
|
||||
and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState`
|
||||
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
|
||||
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI
|
||||
(`packaging/windows/drivers/`) and installed by the Inno Setup installer via
|
||||
`punktfunk-host.exe driver install --gamepad`.
|
||||
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
|
||||
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
|
||||
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
|
||||
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
|
||||
the remaining piece.)
|
||||
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
|
||||
behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx
|
||||
**pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`;
|
||||
DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`), GPU encode (NVENC
|
||||
`--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers
|
||||
(`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire
|
||||
convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK;
|
||||
the Windows client derives it from the scancode, NOT the layout-resolved `vkCode`) — the Windows
|
||||
injector resolves them via a fixed table mirroring the Linux `vk_to_evdev` (never through a
|
||||
keyboard layout: the SYSTEM service thread's layout re-reads positions as characters — the
|
||||
German y↔z / ö→ü scramble), while GameStream/Moonlight VKs are layout-semantic
|
||||
(`KEY_FLAG_SEMANTIC_VK`, resolved under the foreground app's layout, Sunshine's model). Linux
|
||||
renders positions under the session compositor's layout (libei) or the virtual keyboard's
|
||||
uploaded keymap (Sway/wlroots — honors `XKB_DEFAULT_LAYOUT` et al., default US); the Android
|
||||
client reads `KeyEvent.scanCode` first so a user-selected physical-keyboard layout can't
|
||||
re-map keycodes semantically. Ships as a **signed
|
||||
Inno Setup installer** that registers a `LocalSystem` SCM service launching into the interactive
|
||||
session for secure-desktop (UAC/lock-screen) capture (`windows/service.rs`), bundles the
|
||||
pf-vdisplay driver + the FFmpeg DLLs (+ VB-CABLE for the virtual mic), and is published by
|
||||
`windows-host.yml`. **Encoder is GPU-aware** (`encode.rs` `open_video` + `windows_resolved_backend`):
|
||||
`PUNKTFUNK_ENCODER=auto` (the host.env default) reads the **selected render adapter's** vendor →
|
||||
**NVENC** (NVIDIA, direct SDK, `encode/windows/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec
|
||||
(`encode/windows/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`,
|
||||
system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a
|
||||
system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is
|
||||
probed per-GPU on AMF/QSV (`windows_codec_support` → `serverinfo`, AV1 gated; cached per selected
|
||||
GPU). **Multi-GPU is first-class** (`gpu.rs`): GPU inventory + a persisted auto/manual preference
|
||||
(`<config>/gpu-settings.json`, stored by stable PCI identity — LUIDs are per-boot) exposed over
|
||||
`GET /api/v1/gpus` + `PUT /api/v1/gpus/preference` and a web-console GPU card (Host page: list,
|
||||
Automatic/Prefer, "In use · backend" badge). One selection — precedence **console preference >
|
||||
`PUNKTFUNK_RENDER_ADAPTER` > max VRAM**, graceful fallback when the preferred GPU is absent —
|
||||
feeds `win_adapter::resolve_render_adapter_luid` (capture ring + IddCx render pin), the encoder
|
||||
vendor auto-detect (previously DXGI adapter 0 — wrong on hybrid boxes like NVIDIA dGPU + Intel
|
||||
Arc iGPU), and the NVENC 4:4:4 probe; a preference change applies to the next session. On Linux a
|
||||
matched manual preference picks the VAAPI render node / NVENC-vs-VAAPI auto choice (auto mode
|
||||
unchanged). *Implemented + unit-tested; not yet on-glass validated on the hybrid box.* **HDR (10-bit)**: WGC
|
||||
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
|
||||
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
|
||||
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
|
||||
host only** (the Linux host stays 8-bit, blocked upstream). **Vulkan-game HDR over the virtual
|
||||
display**: NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an IddCx
|
||||
indirect display (so Vulkan games — Doom: The Dark Ages, id Tech, etc. — say "device does not support
|
||||
HDR"), even though the ICD happily *accepts + presents* a forced HDR swapchain there. A tiny always-on
|
||||
Vulkan **implicit layer** (`packaging/windows/pf-vkhdr-layer/`, `VK_LAYER_PUNKTFUNK_hdr_inject`)
|
||||
injects the `HDR10_ST2084`/scRGB surface formats into `vkGetPhysicalDeviceSurfaceFormats[2]KHR`,
|
||||
self-gated on the display's actual advanced-color state (no-op on SDR / real monitors); bundled +
|
||||
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
|
||||
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
|
||||
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
|
||||
|
||||
## What's left
|
||||
|
||||
1. **Native clients — decode + present: macOS stage 1 done, first light achieved
|
||||
(2026-06-10).** PunktfunkKit compiles and is tested on macOS (AnnexB → VideoToolbox →
|
||||
`AVSampleBufferDisplayLayer`, GCMouse/GCKeyboard capture, `PunktfunkClient` app shell);
|
||||
validated live Mac ↔ this box at 720p60 — vkcube on glass, input injected via gamescope
|
||||
EIS. The app speaks the full ABI v2 trust surface: Keychain-persisted client identity
|
||||
presented on every connect, SPAKE2 PIN pairing UI (host-card context menu + the trust
|
||||
prompt's "Pair with PIN instead…"), TOFU fingerprint prompt. **Gamepads (2026-06-11):**
|
||||
controller discovery + selection in Settings (`GamepadManager` — exactly one pad
|
||||
forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical
|
||||
controller, user-overridable), capture incl. DualSense touchpad/motion
|
||||
(`GamepadCapture`/`GamepadWire`), feedback rendering (rumble → CoreHaptics; lightbar /
|
||||
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
|
||||
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
|
||||
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
|
||||
motion sign/scale derived, not yet live-verified. **Gamepad UI (iOS/iPadOS + macOS,
|
||||
2026-07-02 rework):** a connected pad swaps the home for a console-style launcher
|
||||
(`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add
|
||||
Host tile (A connect · Y library · X settings · B back), a controller-navigable
|
||||
settings screen (vertical `GamepadMenuList`, left/right steps values), an add-host
|
||||
flow with an on-screen controller keyboard (`GamepadKeyboard` — no touch needed
|
||||
anywhere), and the coverflow library, all over an animated aurora backdrop
|
||||
(`GamepadScreenBackground`, TimelineView-driven drifting blobs — pure SwiftUI ON
|
||||
PURPOSE: a .metal lib only reliably bundles in one of the two build systems (SPM vs
|
||||
xcodeproj synced folders) these sources compile under). Input is the polled
|
||||
`GamepadMenuInput` (handlers don't fire outside a stream; on (re)start it SNAPSHOTS
|
||||
held buttons so a handoff press never double-fires), haptics dual-channel (device +
|
||||
`MenuHaptics` on the pad). macOS: same screens, settings/add-host as sheets (no
|
||||
fullScreenCover), NSScreen-based mode lists, scroll indicators `.never` (macOS
|
||||
"always show scroll bars" overrides `.hidden`); launcher/settings/add-host/keyboard
|
||||
render-verified live on this Mac via `PUNKTFUNK_FORCE_GAMEPAD_UI=1` (dev hook, forces
|
||||
the mode without a pad). Controller-in-hand on-glass validation still pending on all
|
||||
platforms. Tests: `swift test` in
|
||||
`clients/apple` (unit + real-codec round trip),
|
||||
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
|
||||
includes the pairing ceremony + `--require-pairing` gate),
|
||||
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
||||
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter is now the DEFAULT**
|
||||
(stage-1 is the Metal-unavailable / DEBUG fallback): explicit `VTDecompressionSession` decode →
|
||||
`CAMetalLayer`, presented from the hosting view's **main-runloop `CADisplayLink`** (`renderTick` pops
|
||||
the newest ready frame per vsync; macOS `displaySyncEnabled = false` is the real fullscreen-judder fix,
|
||||
~11 ms p50). *(An off-main `CAMetalDisplayLink` and an off-main blocking-render present thread were
|
||||
both tried and reverted — both measured slower on macOS and iPad.)* **HDR fixed**
|
||||
(`design/apple-stage2-presenter.md`): the "too bright" bug was a missing reference-white anchor — the
|
||||
fix keeps the PQ-passthrough shader and adds `CAEDRMetadata.hdr10(…, opticalOutputScale: 203)` +
|
||||
`wantsExtendedDynamicRangeContent` on the layer (on all platforms; the old `#if os(macOS)` guard left
|
||||
iOS/tvOS EDR half-engaged), routing the 0xCE mastering metadata to the layer (via `setHdrMeta`) instead
|
||||
of a never-composited source buffer. **Mid-session SDR↔HDR** is handled: `render` reconciles the layer
|
||||
per-frame from the decoded `frame.isHDR` (per-mode pixel format `bgra8`/`rgba16Float`), so a game
|
||||
entering HDR mid-stream just reconfigures (last 0xCE grade cached + re-applied; pump drains 0xCE
|
||||
unconditionally). **4:4:4 added**: decode format is a 2×2 `(chroma, HDR)` matrix
|
||||
(`420v/x420/444v/x444`, all biplanar so the shaders are unchanged), advertised (`VIDEO_CAP_444`) only
|
||||
behind a **hardware-required `VTDecompressionSession` probe** (`Stage444Probe`, validated on M3) with a
|
||||
Settings opt-out + a bounded pump backstop for an undecodable 4:4:4 session. *HDR brightness + 4:4:4
|
||||
still need on-glass validation (Windows-HDR / `PUNKTFUNK_444` host).* Next: glass-to-glass numbers via
|
||||
`tools/latency-probe`.
|
||||
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
|
||||
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
||||
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
||||
PIN dialogs (identity shared with client-rs), FFmpeg software HEVC decode (LOW_DELAY,
|
||||
slice threads) → `GtkGraphicsOffload`-wrapped picture, PipeWire playback (mic-player
|
||||
jitter ring inverted), SDL3 gamepad capture + rumble/lightbar feedback, keyboard via
|
||||
exact inverse of the host VK table, absolute mouse + 120-unit scroll. Validated live
|
||||
against `serve` on this box: 1080p60, steady 60 fps, capture→decoded p50
|
||||
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
|
||||
stage 1.5 (2026-06-12 evening)**: capture state machine (click-to-capture,
|
||||
Ctrl+Alt+Shift+Q / focus-loss release, held-state flush), app-lifetime SDL gamepad
|
||||
service (pad pin UI, auto type from the physical pad, DualSense touchpad/motion 0xCC +
|
||||
raw-DS5-effects trigger/player-LED replay — needs a physical pad to live-verify), mic
|
||||
uplink (validated live), per-host speed test, compositor pref, native-display mode
|
||||
default, saved-hosts list, .deb + RPM-subpackage CI (deb.yml/rpm.yml). **VAAPI decode
|
||||
→ DRM-PRIME dmabuf → `GdkDmabufTexture`** (BT.709 color state; Tier-1 zero-copy on
|
||||
Intel/AMD, `PUNKTFUNK_DECODER=software|vaapi` override) with a proven fallback ladder —
|
||||
no VAAPI device (NVIDIA) or mid-session VAAPI error → software decode. **First AMD test
|
||||
(Steam Deck) hit a green-screen bug, fixed:** FFmpeg's VAAPI export uses
|
||||
`SEPARATE_LAYERS`, so NV12 arrives as two single-plane layers (R8 luma + GR88 chroma,
|
||||
one shared fd); the mapper took `layers[0]` only → GTK got a luma-only R8 texture, chroma
|
||||
read as 0 → green field / red whites. Fix derives the combined fourcc from the decoder
|
||||
`sw_format` (→ `DRM_FORMAT_NV12`) and flattens all planes across all layers (mpv's
|
||||
pattern); a first-frame descriptor dump logs the real layout. Awaiting Steam Deck
|
||||
reconfirm. Next: the stage-2 raw-Wayland
|
||||
presenter (wp_presentation feedback, tearing-control, Vulkan Video on NVIDIA) —
|
||||
**wgpu/winit rejected** (no dmabuf import / presentation feedback / shortcuts-inhibit).
|
||||
**Windows stage 1 done 2026-06-15** (`clients/windows`, binary
|
||||
`punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like
|
||||
framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The
|
||||
video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain**, presented from a
|
||||
**dedicated render thread** (`render.rs`, 2026-07-02 rewrite — presenting never touches or is
|
||||
stalled by the XAML thread): frame-latency-waitable swapchain + `SetMaximumFrameLatency(1)`
|
||||
(≤1 queued present, newest-wins drain after the wait, so a stream faster than the display drops
|
||||
backlog before any GPU work), **HiDPI-correct** (pixel-sized buffers + `SetMatrixTransform`
|
||||
96/DPI — DIP-sized buffers were blurry at 125/150 %), Contain-fit letterbox, WARP fallback.
|
||||
**FFmpeg decode with a D3D11VA hardware path on all vendors** (`gpu.rs` shares one D3D11 device
|
||||
between decoder + presenter, adapter picked by console pref `PUNKTFUNK_ADAPTER` > the window's
|
||||
monitor's adapter > default; `PUNKTFUNK_D3D_DEBUG=1` adds the debug layer): the decode pool is
|
||||
**decoder-only bind, sized/aligned by libavcodec itself** (get_format returns `AV_PIX_FMT_D3D11`
|
||||
and lets `hw_device_ctx` drive — three hand-built-frames-context strikes are why: NVIDIA rejects
|
||||
`DECODER|SHADER_RESOURCE` arrays, `BindFlags=0` fails texture creation, and Intel rejects
|
||||
non-128-aligned HEVC surfaces at the first `SubmitDecoderBuffers`), a DXVA **profile probe**
|
||||
before the hwdevice commits software-vs-hardware up front (no burned first IDR), and the
|
||||
presenter copies the decoded slice with ONE display-size-boxed `CopySubresourceRegion` (a planar
|
||||
slice is a single subresource in D3D11 — the old two-copy D3D12-style code silently no-opped =
|
||||
the black screen) into its sampleable NV12/P010 texture → per-plane SRVs + YUV→RGB shaders
|
||||
(NV12/BT.709, P010/BT.2020-PQ). **Software CPU decode is the fallback** (auto-selected,
|
||||
`DecoderPref` override, mid-session demotion + keyframe re-request) and now feeds the SAME
|
||||
shaders (swscale → NV12/P010 planes → two dynamic plane textures) so hw/sw colour math is
|
||||
identical. **HDR10**: the client advertises 10-bit/HDR (Settings toggle, gated on an HDR
|
||||
display), detects PQ in-band (`transfer == SMPTE2084`), and flips the swapchain to
|
||||
`R10G10B10A2` + ST.2084 with HDR10 metadata (0xCE mastering metadata plumbed). **WASAPI** render
|
||||
+ mic capture, **SDL3** gamepads (rumble/lightbar/DualSense), `mdns-sd` discovery, and the full
|
||||
trust surface — all **in-app**: a polished WinUI shell (host tiles w/ monogram + status pills,
|
||||
`InfoBar` errors/hints, `ToggleSwitch` settings, status-chip stream HUD showing GPU/CPU decode +
|
||||
HDR), host list (live mDNS + saved + manual), settings (resolution/refresh/decoder/bitrate/HDR/
|
||||
mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch re-pair. **Live-validated 2026-07-02
|
||||
on the hybrid laptop (Intel Arc Pro iGPU + RTX 3500 Ada) against the local Windows host**:
|
||||
D3D11VA hardware decode 60 fps on BOTH vendors (headless, `PUNKTFUNK_ADAPTER`-forced; NVIDIA
|
||||
0.2 ms decode, Intel 0.2 ms), software path, and the GUI on glass (real decoded desktop pixels,
|
||||
GPU-decode HUD chip, ~18 ms capture→decoded p50 over loopback — dominated by the host's 60 Hz
|
||||
virtual-display capture cadence). HDR-on-glass still pending. **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor
|
||||
exposes no raw key/pointer events; native Windows VK + absolute mouse (client-rect Contain-fit) +
|
||||
wheel, Ctrl+Alt+Shift+Q capture toggle. `--headless`/`--discover` keep CLI paths. Builds + clippy
|
||||
+ fmt green on **`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`** — the latter
|
||||
**cross-compiled off the one x64 runner** (no ARM64 runner; the x64 MSVC toolset's ARM64 cross
|
||||
compiler + a per-arch `FFMPEG_DIR` ARM64 tree, SDL3/libopus build-from-source cross-compile
|
||||
cleanly), and both ship as signed MSIX (`windows-msix.yml` matrix → `..._x64.msix`/`..._arm64.msix`,
|
||||
verified: ARM64 binaries + manifest arch). **windows-reactor is unpublished** (git
|
||||
dep pinned to commit `a4f7b2cb`, bumped 2026-07-02 from `b4129fcc` for `on_pointer_entered`/
|
||||
`on_pointer_exited` hover events — mechanical renames only: `SymbolGlyph`→`Symbol`,
|
||||
`placeholder`→`placeholder_text`, TextBox `on_changed`→`on_text_changed`, ToggleSwitch
|
||||
`on_changed`→`on_toggled`, `on_menu_item_clicked`→`on_item_clicked`, SwapChainPanel
|
||||
`on_ready`→`on_mounted`; `windows` pinned to the SAME commit so `IDXGISwapChain1` unifies with
|
||||
`set_swap_chain`). New-model runtime staging: reactor has NO build.rs anymore — the app's own
|
||||
`build.rs` calls `windows_reactor_setup::as_framework_dependent()` (same-rev build-dep, stages
|
||||
the bootstrap DLL + resources.pri that pack-msix expects) and `main` calls
|
||||
`windows_reactor::bootstrap()` before `App` (packaged MSIX: a no-op, the manifest's
|
||||
`Microsoft.WindowsAppRuntime.2` dependency resolves the runtime). `CARGO_WORKSPACE_DIR` is no
|
||||
longer required (harmless where still set). Gotcha: `CARGO_HOME` must be an ASCII path
|
||||
— the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. **Parity/cleanup
|
||||
batch (2026-07-02)**: `app.rs` split into per-screen `app/` modules (mod=root/router · hosts ·
|
||||
connect · pair · speed · settings · licenses · stream · style; thread-driven state lives in ROOT
|
||||
`use_async_state` and flows down as props — a child's own async-state write does NOT re-render it);
|
||||
"Native display" now resolves the real monitor mode at connect (`MonitorFromWindow` →
|
||||
`EnumDisplaySettingsW`, was hardcoded 1080p60); per-host **speed test** (saved-host card button +
|
||||
`--headless --speed-test`, probe burst → recommended ≈70 % bitrate applied in one tap; bitrate
|
||||
setting is now a free-form NumberBox); **forget host** (ContentDialog confirm →
|
||||
`KnownHosts::remove_by_fp`); settings gained forwarded-controller picker + gamepad type + host
|
||||
compositor + capture-system-shortcuts — the previously-dead `Settings.compositor`/
|
||||
`inhibit_shortcuts` are now honored (off = Alt+Tab/Alt+Esc/Ctrl+Esc/Win act locally);
|
||||
**click-to-recapture** after a Ctrl+Alt+Shift+Q release with the HUD hint tracking capture state;
|
||||
input hook caches lock geometry (no per-move `GetClientRect`), audio jitter-ring trims via
|
||||
`drain`. Validated on the bare-metal RTX box: `--discover` (3 live LAN hosts), synthetic-host
|
||||
loopback E2E (TOFU connect → clock skew → HEVC negotiate → shared-D3D11 + D3D11VA init → WASAPI →
|
||||
session end; synthetic payload isn't decodable so decode output stays unvalidated), speed-test
|
||||
E2E. The WinUI window itself CANNOT be launched from SSH (session-0 → WinAppSDK 0x80070005,
|
||||
pre-existing; needs the console session, e.g. PsExec -i 1). **UX batch (2026-07-02 evening,
|
||||
UIA-smoke-tested on the hybrid laptop)**: host tiles get the WinUI pointer-over fill
|
||||
(`on_pointer_entered`/`exited` → root hover state → `ControlFillSecondary`), Settings is a stock
|
||||
**NavigationView** sidebar (Windows-Settings pattern: Display/Video/Input/Audio/About panes,
|
||||
built-in back arrow, section in root state; the section card is **keyed by section** — an
|
||||
in-place diff across sections re-sets a reused ComboBox's items, clearing WinUI's selection,
|
||||
but skips `selected_index` when the values compare equal → blank selection; the key forces a
|
||||
remount — and the content column rides its own section-switch slide-up tween), new
|
||||
**"Show the stats overlay (HUD)"** toggle
|
||||
(`Settings::show_hud`, applies mid-stream via the 400 ms HUD re-render), the Add-host modal
|
||||
slides up + fades in (root margin/opacity tween, same pattern as screen navigation), and a
|
||||
self-initiated disconnect (Ctrl+Alt+Shift+D → `Ended(None)`) returns to the host list silently
|
||||
instead of raising the error banner.
|
||||
Next: **HDR on-glass validation** (Windows host with `PUNKTFUNK_10BIT` → the HDR laptop
|
||||
display), then RAWINPUT relative-mouse pointer-lock.
|
||||
**Android stage 1 done** (`clients/android`, Kotlin app + `native/` Rust JNI core linking
|
||||
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
|
||||
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
|
||||
Opus/AAudio audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
|
||||
(`feedback.rs`), **native `mdns-sd` mDNS discovery** (`discovery.rs`, polled over JNI — the same
|
||||
browse the Linux/Windows clients use, replacing the flaky per-OEM `NsdManager`; Kotlin keeps only
|
||||
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
|
||||
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
|
||||
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
|
||||
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish.
|
||||
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
||||
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
||||
at high res).
|
||||
3. **punktfunk/1 protocol growth.** **Done:** unified host (`serve --gamestream` runs GameStream + the
|
||||
punktfunk/1 QUIC host in one process; bare `serve` is the secure native-only default — GameStream is
|
||||
opt-in, trusted-LAN only, security-review #5/#9) with native pairing driven over the mgmt API /
|
||||
web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
|
||||
**Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
|
||||
`pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts
|
||||
unpaired clients); clients render TOFU only for a `pair=optional` host and force re-pairing on a
|
||||
fingerprint change. **Done:** concurrent sessions — the accept loop spawns each session
|
||||
(`--max-concurrent`, default 4, an NVENC bound), each with its own virtual output + encoder, sharing
|
||||
the host-lifetime input/audio/mic services (shared-desktop multi-view on kwin/mutter/wlroots).
|
||||
**Done:** delegated pairing approval (§8b-1) — an unpaired device shows up as a pending request in
|
||||
the web console, one click approves + pins it. Next (see roadmap): gamescope multi-user isolation
|
||||
(per-session input/audio = independent desktops); §8b-2 peer-push approval from a paired device's
|
||||
own app.
|
||||
4. **GameStream host polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
|
||||
-highbitdepth 1` already encodes Main10 from 8-bit input on this box),
|
||||
reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented
|
||||
and unit/live-capture tested — both still need a live Moonlight confirmation (select
|
||||
AV1 in a stock client; a real 5.1/7.1 listen incl. FEC under loss).
|
||||
|
||||
Box one-time setup is complete: udev rule + `input` group (gamepads validated live),
|
||||
gamescope 3.16.22 installed system-wide (no PATH override), gnome-shell installed (Mutter
|
||||
backend validated live). All three compositor backends are live-validated.
|
||||
|
||||
## Build / test / run
|
||||
|
||||
```sh
|
||||
cargo build --workspace # green on Linux and macOS
|
||||
cargo test --workspace # unit + loopback + proptest + C ABI harness (~100 tests)
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo fmt --all --check
|
||||
|
||||
cargo run -p loss-harness # FEC loss-resilience sweep (no network needed)
|
||||
bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip proof
|
||||
```
|
||||
|
||||
Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h`
|
||||
(cbindgen from `punktfunk-core/src/abi.rs`) and `api/openapi.json` (regenerate with
|
||||
`cargo run -p punktfunk-host -- openapi > api/openapi.json`; spec lives in `mgmt.rs`).
|
||||
|
||||
CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the
|
||||
workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site
|
||||
build+typecheck; `docker.yml` builds+pushes the web/docs/rust-ci images (host and native
|
||||
clients are deliberately NOT containerized); `apple.yml` builds the xcframework and runs
|
||||
`swift build`/`swift test` on the `macos-arm64` host-mode runner (home-mac-mini-1,
|
||||
provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows:
|
||||
`deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml`
|
||||
(Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG +
|
||||
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner
|
||||
(`home-windows-runner-1`, vmid 210, `windows-amd64:host` label). The runner is reproducible and
|
||||
**owned by `unom/infra`**, not this repo, since it's shared across unom Windows projects going
|
||||
forward: `unom/infra`'s `windows-runner/` Packer template bakes a generic Windows 11 template (OS
|
||||
install + OpenSSH Server + VS Build Tools/NASM/CMake/LLVM + the act_runner/Node/rustup base, no
|
||||
registration) on Proxmox once; `proxmox/windows-runner/` (Terraform, `bpg/proxmox`) full-clones it
|
||||
(agent-based IP discovery, no pre-provisioned DHCP reservation needed) and registers the instance
|
||||
over SSH remote-exec — the same bake-once/clone-fast split `proxmox/unom-1` uses for the Linux CI
|
||||
host, just without a native Windows cloud-init (registration goes over `remote-exec`/SSH instead of
|
||||
`initialization{}`; WinRM was tried first but is deprecated in OpenTofu, so this moved to SSH via
|
||||
Windows' in-box OpenSSH Server). punktfunk layers its own extras on top of that generic runner:
|
||||
`scripts/ci/provision-windows-wdk.ps1` (WDK + cargo-wdk for the UMDF drivers) and
|
||||
`scripts/ci/provision-windows-punktfunk-extras.ps1` (FFmpeg x64/ARM64 trees, Inno Setup, the
|
||||
`aarch64-pc-windows-msvc` rustup target) — both idempotent, and both run automatically at the start
|
||||
of every Windows CI job via the shared `scripts/ci/ensure-windows-toolchain.ps1` step (a fast no-op
|
||||
once already provisioned), rather than a separate manually-dispatched provisioning workflow — that
|
||||
avoided a real footgun once there could be more than one `windows-amd64` runner: a manually
|
||||
dispatched provisioning workflow has no way to target a *specific* runner instance, so it could
|
||||
land on an already-provisioned box instead of the one that actually needed it.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated)
|
||||
crates/punktfunk-host/
|
||||
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
|
||||
vdisplay/linux/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
|
||||
vdisplay/windows/{pf_vdisplay,manager,identity}.rs all-Rust IddCx virtual display (pf-vdisplay)
|
||||
linux/zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
||||
inject/linux/{libei,wlr,gamepad,dualsense,dualshock4,steam_*}.rs Linux input (uinput xpad · UHID pads · virtual Deck)
|
||||
inject/windows/{sendinput,gamepad_windows,dualsense_windows,dualshock4_windows}.rs Windows input (UMDF shared-mem pads)
|
||||
encode/linux/{mod,vaapi}.rs · encode/windows/{nvenc,ffmpeg_win}.rs · encode/sw.rs per-GPU encoders (NVENC/CUDA · VAAPI · AMF/QSV) + GPU-less openh264
|
||||
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
|
||||
windows/{service,install,interactive}.rs SCM service + in-binary driver/web install
|
||||
capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs
|
||||
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
||||
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
||||
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
|
||||
clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController)
|
||||
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
|
||||
clients/decky/ Steam Deck Decky plugin
|
||||
packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI)
|
||||
web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs)
|
||||
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
|
||||
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
||||
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
|
||||
include/punktfunk_core.h generated C header
|
||||
```
|
||||
|
||||
## Design invariants — do not regress
|
||||
|
||||
- **One core, linked everywhere.** Protocol/FEC/crypto live only in `punktfunk-core`, behind a
|
||||
stable, versioned C ABI. `tokio`/`quinn` exist only behind the `quic` feature (control
|
||||
plane); **no async on the per-frame path** — native threads only.
|
||||
- **Native client resolution, no scaling.** A session gets a virtual output at exactly the
|
||||
client's WxH@Hz via the `VirtualDisplay` trait (`create(mode) → VirtualOutput { node_id,
|
||||
remote_fd, preferred_mode, keepalive }`, RAII teardown). There is no cross-compositor
|
||||
protocol for this — each compositor keeps its own backend.
|
||||
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block, Moonlight-compatible) and GF(2¹⁶)
|
||||
Leopard (≤65535 shards/block) — punktfunk/1 negotiates the latter, removing the ~1 Gbps
|
||||
ceiling.
|
||||
- **Core security hardening stays intact**: reassembler bounds attacker-controlled fields
|
||||
before allocating (`ReassemblerLimits`); AES-GCM per-direction nonce salts + seq-as-AAD;
|
||||
ABI `struct_size` checks. Regression tests exist — keep them green.
|
||||
- **PipeWire consumer discipline**: our capture streams set `node.dont-reconnect` and tear
|
||||
down promptly on negotiation timeout — one wedged link head-blocks the daemon's shared
|
||||
work queue system-wide.
|
||||
|
||||
## Running on this box
|
||||
|
||||
Headless QEMU VM (Ubuntu 26.04, kernel 7.0), passthrough RTX 5070 Ti (driver 595 **open**
|
||||
module — a kernel update silently drops it; reinstall `nvidia-driver-595-open`), no KMS
|
||||
scanout → KWin `--drm` impossible; everything renders offscreen via `renderD128`.
|
||||
|
||||
```sh
|
||||
# compositor session (shell 1, or the systemd unit in scripts/): full headless Plasma.
|
||||
# The script sets XDG_MENU_PREFIX=plasma- & co. — without it plasmashell runs but the
|
||||
# launcher menu is EMPTY (no apps, no System Settings).
|
||||
bash scripts/headless/run-headless-kde.sh 1920x1080
|
||||
|
||||
# host (shell 2): bare `serve` is native-only (secure default); add --gamestream for Moonlight compat.
|
||||
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_VIDEO_SOURCE=virtual \
|
||||
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve --gamestream
|
||||
|
||||
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
|
||||
# across sessions — bound it with --max-sessions):
|
||||
cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 10 --max-sessions 1
|
||||
cargo run -rp punktfunk-probe -- --mode 1280x720x120 --out /tmp/a.h265 --input-test # + --pin HEX
|
||||
```
|
||||
|
||||
Pinned crate facts: `ashpd` 0.13 + `pipewire` 0.9 (must match ashpd's) + `ffmpeg-next` 8.x
|
||||
(`ffmpeg-sys-next` auto-detects the system FFmpeg, so it builds against **FFmpeg 7.x/libavcodec 61
|
||||
or 8.x/libavcodec 62** — validated live on Ubuntu 26.04 (8) and Bazzite F43 (7.1); the zero-copy
|
||||
FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKTFUNK_VIDEO_SOURCE=virtual|portal`,
|
||||
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1|0` (Linux default: ON for
|
||||
VAAPI/AMD/Intel with a one-shot CPU downgrade if the dmabuf offer never negotiates, OFF/opt-in for
|
||||
NVENC), `PUNKTFUNK_VAAPI_LOW_POWER=1|0` (pin the VAAPI entrypoint; auto = full-feature then VDEnc
|
||||
fallback for modern Intel), `PUNKTFUNK_NV12=0` (opt OUT of the default GPU RGB→NV12 convert on the
|
||||
NVIDIA tiled zero-copy path), `PUNKTFUNK_INTRA_REFRESH=1` (opt-in NVENC intra-refresh loss recovery),
|
||||
`PUNKTFUNK_PIN_CLOCKS=1` (opt-in NVML GPU clock floor, root-gated), `PUNKTFUNK_GAMESCOPE_APP=...`,
|
||||
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
|
||||
test — injects N% wire-packet loss on BOTH the GameStream and native video paths, no netem needed), `PUNKTFUNK_FEC_PCT=N`, `PUNKTFUNK_DSCP=1` (opt-in DSCP/SO_PRIORITY media QoS on the data +
|
||||
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy),
|
||||
`PUNKTFUNK_444=1` (full-chroma HEVC 4:4:4, see below).
|
||||
|
||||
**HEVC 4:4:4 (full chroma, Range Extensions)**: opt-in via `PUNKTFUNK_444`, negotiated like 10-bit —
|
||||
the host emits 4:4:4 only when the client advertised `VIDEO_CAP_444` (wire bit `0x04` + ABI
|
||||
`PUNKTFUNK_VIDEO_CAP_444`), the codec is HEVC, the session is single-process, **and** a GPU probe
|
||||
(`encode::can_encode_444`, run before the Welcome) confirms support — else it resolves to 4:2:0 and
|
||||
`Welcome::chroma_format` reflects the real value (honest downgrade; the client reads it via
|
||||
`punktfunk_connection_chroma_format`). **punktfunk/1-native only** — GameStream/Moonlight stays 4:2:0
|
||||
(stock clients can't decode 4:4:4). **NVENC is the implemented path**: Linux `hevc_nvenc` feeds a
|
||||
swscale'd `yuv444p` (RGB-in is always 4:2:0 — verified on the RTX 5070 Ti — so the session forces CPU
|
||||
RGB capture for 4:4:4); Windows NVENC keeps ARGB input + FREXT profile + `chromaFormatIDC=3` and the
|
||||
DDA capturer delivers RGB. VAAPI / AMF / QSV **decline** (probe returns false — no validated 4:4:4
|
||||
hardware in the lab; they'd produce 4:2:0). Software (openh264) is 4:2:0-only. Test with
|
||||
`PUNKTFUNK_CLIENT_444=1 punktfunk-probe --out x.h265` then `ffprobe x.h265` (expect `pix_fmt yuv444p`).
|
||||
*Linux NVENC mechanism validated on the RTX 5070 Ti (ffmpeg CLI); Windows NVENC + 10-bit-4:4:4 not yet
|
||||
on-glass validated.*
|
||||
|
||||
## Conventions
|
||||
|
||||
- Rust 2021, `rustfmt` + `clippy -D warnings` clean before commit.
|
||||
- Match the surrounding code's comment density and naming.
|
||||
- Commit messages end with the Co-Authored-By trailer (see `git log`).
|
||||
- `pkill` caution on this box: match exact comm names (`pkill -x gamescope-wl`,
|
||||
`pkill -x punktfunk-host`) — `pkill -f` self-matches the invoking shell.
|
||||
Generated
+180
-8
@@ -228,6 +228,67 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
@@ -239,6 +300,30 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -434,6 +519,19 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -2002,9 +2100,26 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ksni"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da9eeb3f510b6148ae68f963af2c1fbb0de4d9e4e05f82813cfb319837c3ad2b"
|
||||
dependencies = [
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"futures-channel",
|
||||
"futures-lite",
|
||||
"futures-util",
|
||||
"pastey",
|
||||
"serde",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2136,7 +2251,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2561,6 +2676,12 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
@@ -2599,6 +2720,17 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pipewire"
|
||||
version = "0.9.2"
|
||||
@@ -2654,6 +2786,20 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -2729,7 +2875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -2743,7 +2889,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2765,7 +2911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2788,7 +2934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -2818,7 +2964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -2839,6 +2985,7 @@ dependencies = [
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"log",
|
||||
"mdns-sd",
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
@@ -2863,6 +3010,7 @@ dependencies = [
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-log",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"usbip-sim",
|
||||
@@ -2879,13 +3027,14 @@ dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"windows-service",
|
||||
"winreg",
|
||||
"winresource",
|
||||
"x509-parser",
|
||||
"xkbcommon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -2897,6 +3046,23 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
"libc",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ureq",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"windows-service",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
@@ -5219,8 +5385,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ members = [
|
||||
"crates/punktfunk-core",
|
||||
"crates/punktfunk-host",
|
||||
"crates/punktfunk-host/vendor/usbip-sim",
|
||||
"crates/punktfunk-tray",
|
||||
"crates/pf-driver-proto",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
@@ -16,7 +17,7 @@ members = [
|
||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -15,6 +15,9 @@ your local network.
|
||||
💬 **Community: [Discord](https://discord.gg/kaPNvzMuGU)** — chat, support, and **Android beta
|
||||
access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
|
||||
|
||||
🔒 **Security:** found a vulnerability? Report it privately to **security@punktfunk.com** — see
|
||||
[SECURITY.md](SECURITY.md). Please don't open a public issue.
|
||||
|
||||
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
||||
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
||||
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
||||
@@ -49,19 +52,19 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
||||
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
||||
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
||||
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
||||
| **Windows host** (x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||
| **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
|
||||
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
|
||||
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | 🟡 Stage 1 complete, ships as signed MSIX (x64 + ARM64); D3D11VA decode + HDR present pending on-glass validation |
|
||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: D3D11VA hardware decode on all GPU vendors (NVIDIA + Intel validated on glass) with software fallback, WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
|
||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
|
||||
|
||||
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
|
||||
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
|
||||
and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin,
|
||||
gamescope, Mutter, and Sway/wlroots backends), encoded with GPU **zero-copy** (dmabuf → CUDA/Vulkan →
|
||||
NVENC) up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a
|
||||
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→reassembled at 720p120), with
|
||||
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→received at 720p120), with
|
||||
mid-stream mode renegotiation and a wall-clock skew handshake so latency stays valid across machines.
|
||||
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
|
||||
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
|
||||
@@ -82,7 +85,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
|
||||
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Windows** (x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||
@@ -135,10 +138,9 @@ clients/
|
||||
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
|
||||
probe/ headless reference / measurement client for punktfunk/1
|
||||
decky/ Steam Deck Decky plugin
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
|
||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||
design/ design notes & deep-dive plans (index: design/README.md)
|
||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||
tools/ latency-probe · loss-harness (measurement)
|
||||
```
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
# Security Policy
|
||||
|
||||
punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
|
||||
machine, so we take security reports seriously and appreciate responsible disclosure.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please report security issues privately by email to security@punktfunk.com.**
|
||||
|
||||
Do **not** open a public issue, pull request, or chat/forum post for a suspected vulnerability — that
|
||||
exposes other users before a fix exists.
|
||||
|
||||
### What to include
|
||||
|
||||
The more of this you can give us, the faster we can act:
|
||||
|
||||
- The component and version (e.g. `punktfunk-host 0.6.0`, Windows or Linux, which client).
|
||||
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
|
||||
admin, a paired client, …).
|
||||
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
|
||||
- Any suggested fix or mitigation (optional).
|
||||
|
||||
## What to expect
|
||||
|
||||
We're a small team, so timelines are best-effort, but we commit to:
|
||||
|
||||
- **Acknowledge** your report within **3 business days**.
|
||||
- Give an **initial assessment** (severity + whether we can reproduce) within about **7 days**.
|
||||
- Keep you updated, and tell you when a fix ships.
|
||||
- **Credit** you in the advisory / release notes when the fix is public — unless you'd rather stay
|
||||
anonymous.
|
||||
|
||||
We practice **coordinated disclosure**: please give us reasonable time to release a fix before
|
||||
publishing details. We aim to resolve valid issues within **90 days** and will agree a disclosure
|
||||
date with you.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope — the code in this repository:
|
||||
|
||||
- The host (`punktfunk-host`), its Windows drivers, and the protocol/crypto core (`punktfunk-core`).
|
||||
- The native clients (Apple, Linux, Windows, Android), the web management console, and the management
|
||||
API.
|
||||
|
||||
Known limits — documented behavior, not vulnerabilities (see
|
||||
https://docs.punktfunk.unom.io/docs/security):
|
||||
|
||||
- **Admin/SYSTEM already on the host = out of scope.** An attacker who is already administrator or
|
||||
SYSTEM on the host owns the machine regardless of punktfunk.
|
||||
- **The virtual display is a real monitor** — any process already in the interactive desktop session
|
||||
can capture it via the normal OS screen-capture APIs, exactly as it could a physical monitor.
|
||||
- **GameStream/Moonlight compatibility** (`--gamestream`) uses legacy encryption and is documented as
|
||||
opt-in, trusted-LAN-only.
|
||||
- **Public-internet exposure is unsupported** — issues that only arise from exposing the host to the
|
||||
WAN are expected; keep the host on a trusted LAN or a VPN.
|
||||
|
||||
If you're unsure whether something is in scope, report it anyway — we'd rather hear about it.
|
||||
|
||||
## Safe harbor
|
||||
|
||||
We consider good-faith security research that follows this policy to be authorized, and we won't
|
||||
pursue legal action against researchers who:
|
||||
|
||||
- make a good-faith effort to avoid privacy violations, data loss, and service disruption,
|
||||
- only test systems they own or have explicit permission to test,
|
||||
- give us reasonable time to remediate before public disclosure,
|
||||
- don't exfiltrate more data than needed to demonstrate the issue.
|
||||
|
||||
Thank you for helping keep punktfunk and its users safe.
|
||||
+96
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.5.0"
|
||||
"version": "0.6.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -578,6 +578,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/local/summary": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"host"
|
||||
],
|
||||
"summary": "Local status summary for the tray icon",
|
||||
"description": "Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device\nnames). Unauthenticated, but served to loopback peers only.",
|
||||
"operationId": "getLocalSummary",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Non-sensitive local host status (loopback peers only)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LocalSummary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Non-loopback peer",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/logs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -2083,6 +2118,66 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LocalSummary": {
|
||||
"type": "object",
|
||||
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
|
||||
"required": [
|
||||
"version",
|
||||
"video_streaming",
|
||||
"audio_streaming",
|
||||
"paired_clients",
|
||||
"native_paired_clients",
|
||||
"pin_pending",
|
||||
"pending_approvals"
|
||||
],
|
||||
"properties": {
|
||||
"audio_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while the audio stream thread is running."
|
||||
},
|
||||
"native_paired_clients": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Number of paired native (punktfunk/1) devices.",
|
||||
"minimum": 0
|
||||
},
|
||||
"paired_clients": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Number of pinned (paired) GameStream client certificates.",
|
||||
"minimum": 0
|
||||
},
|
||||
"pending_approvals": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Native pairing knocks awaiting the operator's approval (count only).",
|
||||
"minimum": 0
|
||||
},
|
||||
"pin_pending": {
|
||||
"type": "boolean",
|
||||
"description": "True while a GameStream pairing handshake is parked waiting for the user's PIN."
|
||||
},
|
||||
"session": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionInfo",
|
||||
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Host version (mirrors `/health`)."
|
||||
},
|
||||
"video_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while the video stream thread is running."
|
||||
}
|
||||
}
|
||||
},
|
||||
"LogEntry": {
|
||||
"type": "object",
|
||||
"description": "One captured log event.",
|
||||
|
||||
@@ -27,8 +27,15 @@
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
|
||||
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
|
||||
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
|
||||
eligible for the vendor's game performance profile — the aggressive CPU/GPU governor and
|
||||
scheduler treatment games get — which, together with the ADPF hints in the native decode
|
||||
path, is what keeps clocks up for low, consistent decode latency. Also groups it correctly
|
||||
under Games in battery/data usage. Advisory: devices without Game Mode ignore it. -->
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:appCategory="game"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -33,13 +33,19 @@ data class Settings(
|
||||
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
|
||||
val statsHudEnabled: Boolean = true,
|
||||
/**
|
||||
* Touch input model. `true` (default) = trackpad: the cursor stays put on touch-down and moves
|
||||
* by the finger's relative delta (swipe to nudge, lift and re-swipe to walk it across), tap to
|
||||
* click where it is. `false` = direct pointing: the cursor jumps to the finger (the old behaviour).
|
||||
* Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
|
||||
* the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
|
||||
* lift and re-swipe to walk it across), tap to click where it is. [TouchMode.POINTER]: the
|
||||
* cursor jumps to the finger (direct pointing). [TouchMode.TOUCH]: real multi-touch
|
||||
* passthrough — every finger reaches the host as a touchscreen contact, for apps/games that
|
||||
* understand touch. Mirrors the Apple client's TouchInputMode.
|
||||
*/
|
||||
val trackpadMode: Boolean = true,
|
||||
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
||||
)
|
||||
|
||||
/** [Settings.touchMode] values; persisted by name. */
|
||||
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
|
||||
|
||||
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
||||
class SettingsStore(context: Context) {
|
||||
private val prefs =
|
||||
@@ -57,7 +63,10 @@ class SettingsStore(context: Context) {
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
statsHudEnabled = prefs.getBoolean(K_HUD, true),
|
||||
trackpadMode = prefs.getBoolean(K_TRACKPAD, true),
|
||||
touchMode = prefs.getString(K_TOUCH_MODE, null)
|
||||
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -73,7 +82,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||
.putBoolean(K_TRACKPAD, s.trackpadMode)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -89,6 +98,9 @@ class SettingsStore(context: Context) {
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
|
||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
}
|
||||
}
|
||||
@@ -195,6 +207,13 @@ val COMPOSITOR_OPTIONS = listOf(
|
||||
"gamescope",
|
||||
)
|
||||
|
||||
/** (mode, label) for the touch-input model. */
|
||||
val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TRACKPAD to "Trackpad",
|
||||
TouchMode.POINTER to "Direct pointer",
|
||||
TouchMode.TOUCH to "Touch passthrough",
|
||||
)
|
||||
|
||||
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
|
||||
val GAMEPAD_OPTIONS = listOf(
|
||||
"Automatic",
|
||||
|
||||
@@ -165,13 +165,21 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Pointer") {
|
||||
ToggleRow(
|
||||
title = "Trackpad mode",
|
||||
subtitle = "Relative cursor like a laptop touchpad — swipe to nudge, tap to click. " +
|
||||
"Off = the cursor jumps to your finger.",
|
||||
checked = s.trackpadMode,
|
||||
onCheckedChange = { on -> update(s.copy(trackpadMode = on)) },
|
||||
SettingsGroup("Touch input") {
|
||||
SettingDropdown(
|
||||
label = "Touch input",
|
||||
options = TOUCH_MODE_OPTIONS,
|
||||
selected = s.touchMode,
|
||||
onSelect = { mode -> update(s.copy(touchMode = mode)) },
|
||||
)
|
||||
Text(
|
||||
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger " +
|
||||
"tap right-clicks, two fingers scroll, tap-then-drag holds the button. " +
|
||||
"Direct pointer: the cursor jumps to your finger. Touch passthrough: real " +
|
||||
"multi-touch reaches the host, for apps that understand touch.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,16 @@ import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The live stats overlay — mirrors the Apple client's HUD. Reads the 14-double layout from
|
||||
* The live stats overlay — the unified HUD (`design/stats-unification.md`, Android v1: headline is
|
||||
* `capture→decoded`, tiled by `host+network` + `decode`). Reads the 18-double layout from
|
||||
* [NativeBridge.nativeVideoStats]:
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc]`. The trailing four (present on a current native lib) describe the
|
||||
* negotiated video feed and render as a codec/depth/colour/chroma line; older layouts just omit it.
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms]`. Indexes 10–13
|
||||
* (present on a current native lib) describe the negotiated video feed and render as a
|
||||
* codec/depth/colour/chroma line; 14/15 render as the stage equation — split into
|
||||
* `host + network + decode` when the Phase-2 terms at 16/17 are nonzero (a current host sends
|
||||
* per-AU 0xCF timings; an old host leaves them 0 and the combined `host+network` term stands);
|
||||
* older layouts just omit those lines.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
@@ -29,7 +34,7 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
val hz = s[8].toInt()
|
||||
val latValid = s[4] != 0.0
|
||||
val skew = s[5] != 0.0
|
||||
val dropped = s[9].toLong()
|
||||
val lost = s[9].toLong()
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||
@@ -50,17 +55,33 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
if (latValid) {
|
||||
val tag = if (skew) "" else " (same-host)"
|
||||
val tag = if (skew) "" else " (same-host clock)"
|
||||
Text(
|
||||
"capture→client ${"%.1f".format(s[2])}/${"%.1f".format(s[3])} ms p50/p95$tag",
|
||||
"end-to-end ${"%.1f".format(s[2])} ms p50 · ${"%.1f".format(s[3])} p95 · capture→decoded$tag",
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
if (s.size >= 16) {
|
||||
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
|
||||
// reported its share this window; otherwise the combined term (old host / no
|
||||
// matched 0xCF timing).
|
||||
val equation = if (s.size >= 18 && s[16] > 0) {
|
||||
"= host ${"%.1f".format(s[16])} + network ${"%.1f".format(s[17])} + decode ${"%.1f".format(s[15])}"
|
||||
} else {
|
||||
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
|
||||
}
|
||||
Text(
|
||||
equation,
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (dropped > 0) {
|
||||
if (lost > 0) {
|
||||
Text(
|
||||
"dropped $dropped",
|
||||
"lost $lost",
|
||||
color = Color(0xFFFFB0B0),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
|
||||
@@ -57,7 +57,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
|
||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||
val trackpad = initialSettings.trackpadMode
|
||||
val touchMode = initialSettings.touchMode
|
||||
LaunchedEffect(handle, showStats) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
|
||||
if (showStats) {
|
||||
@@ -148,11 +148,18 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
if (showStats) {
|
||||
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
||||
}
|
||||
// Touch → mouse (trackpad vs. direct pointing + the shared gesture vocabulary — see
|
||||
// streamTouchInput in TouchInput.kt).
|
||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||
Box(
|
||||
Modifier.fillMaxSize().pointerInput(handle, trackpad) {
|
||||
streamTouchInput(handle, trackpad, onToggleStats = { showStats = !showStats })
|
||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||
when (touchMode) {
|
||||
TouchMode.TOUCH -> streamTouchPassthrough(handle)
|
||||
else -> streamTouchInput(
|
||||
handle,
|
||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||
onToggleStats = { showStats = !showStats },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.ui.input.pointer.PointerId
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.hypot
|
||||
@@ -38,6 +42,54 @@ private const val ACCEL_MAX = 3.0f
|
||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||
* windows); three-finger tap = [onToggleStats] (the stats HUD).
|
||||
*/
|
||||
/**
|
||||
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||
* contact (down/move/up with a stable per-finger id), with NO gesture interpretation — taps,
|
||||
* drags and multi-finger input mean whatever the remote app decides. Coordinates are overlay
|
||||
* pixels with the overlay size as the surface, exactly like the absolute-mouse path (the host
|
||||
* normalizes and maps into the output). On teardown (stream leaves composition) every still-held
|
||||
* contact is lifted so nothing stays stuck on the host.
|
||||
*/
|
||||
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
|
||||
val ids = mutableMapOf<PointerId, Int>()
|
||||
fun alloc(p: PointerId): Int {
|
||||
var id = 0
|
||||
while (ids.containsValue(id)) id++
|
||||
ids[p] = id
|
||||
return id
|
||||
}
|
||||
try {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val sw = size.width
|
||||
val sh = size.height
|
||||
if (sw <= 0 || sh <= 0) continue
|
||||
for (c in ev.changes) {
|
||||
val x = c.position.x.roundToInt().coerceIn(0, sw - 1)
|
||||
val y = c.position.y.roundToInt().coerceIn(0, sh - 1)
|
||||
when {
|
||||
c.changedToDownIgnoreConsumed() ->
|
||||
NativeBridge.nativeSendTouch(handle, alloc(c.id), 0, x, y, sw, sh)
|
||||
c.changedToUpIgnoreConsumed() ->
|
||||
ids.remove(c.id)?.let {
|
||||
NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, sw, sh)
|
||||
}
|
||||
c.positionChanged() ->
|
||||
ids[c.id]?.let {
|
||||
NativeBridge.nativeSendTouch(handle, it, 1, x, y, sw, sh)
|
||||
}
|
||||
}
|
||||
c.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Lift anything still down (composition/session teardown mid-touch).
|
||||
ids.values.forEach { NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, 1, 1) }
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun PointerInputScope.streamTouchInput(
|
||||
handle: Long,
|
||||
trackpad: Boolean,
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.BrandDark
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.SettingsScreen
|
||||
import io.unom.punktfunk.StatsOverlay
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
|
||||
gamepad = 2,
|
||||
micEnabled = true,
|
||||
statsHudEnabled = true,
|
||||
trackpadMode = true,
|
||||
touchMode = TouchMode.TRACKPAD,
|
||||
),
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
|
||||
@@ -37,13 +37,30 @@ def call(method, url, token=None, data=None, content_type=None, want_json=True):
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300) as r:
|
||||
body = r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
|
||||
return json.loads(body) if (want_json and body) else body
|
||||
# Transient-fault retries: googleapis.com occasionally drops the TLS session ("EOF
|
||||
# occurred in violation of protocol" — failed two release uploads on 2026-07-02) or
|
||||
# answers 5xx. Retry those with backoff; 4xx raises immediately (a real API error).
|
||||
# The edits API is transactional until commit, so re-sending any of these is safe.
|
||||
last = None
|
||||
for attempt in range(4):
|
||||
if attempt:
|
||||
delay = 3**attempt
|
||||
print(f"transient Play API failure ({last}); retry {attempt}/3 in {delay}s")
|
||||
time.sleep(delay)
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300) as r:
|
||||
body = r.read()
|
||||
return json.loads(body) if (want_json and body) else body
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code >= 500:
|
||||
last = f"HTTP {e.code}"
|
||||
continue
|
||||
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
|
||||
except urllib.error.URLError as e:
|
||||
last = str(getattr(e, "reason", e))
|
||||
continue
|
||||
sys.exit(f"ERROR: {method} {url} still failing after retries: {last}")
|
||||
|
||||
|
||||
def load_sa():
|
||||
|
||||
@@ -105,12 +105,17 @@ object NativeBridge {
|
||||
|
||||
/**
|
||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||
* Returns 14 doubles:
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped,
|
||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`
|
||||
* (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — bit depth
|
||||
* 8/10, CICP primaries/transfer, and the HEVC chroma_format_idc 1=4:2:0 / 3=4:4:4). Poll ~1 Hz;
|
||||
* each call resets the measurement window.
|
||||
* Returns 18 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
* netP50Ms]`
|
||||
* (the two flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
||||
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
||||
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
||||
* `host+network` = capture→received, `decode` = received→decoded; 16/17 split the
|
||||
* `host+network` term via the host's per-AU 0xCF timings — `host` = the host's capture→sent,
|
||||
* `network` = the remainder — both 0.0 when no timing matched this window, i.e. an old host).
|
||||
* Poll ~1 Hz; each call resets the measurement window.
|
||||
*/
|
||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
||||
|
||||
@@ -159,6 +164,22 @@ object NativeBridge {
|
||||
/** One scroll step. axis: 0=vertical 1=horizontal. delta: signed, 120-scaled, +=up/right. */
|
||||
external fun nativeSendScroll(handle: Long, axis: Int, delta: Int)
|
||||
|
||||
/**
|
||||
* One REAL touchscreen transition (the touch-passthrough input mode). [kind]: 0=down 1=move
|
||||
* 2=up. [id] distinguishes fingers and is reusable after up; coordinates are pixels on the
|
||||
* client's touch surface — the host rescales against [surfaceWidth]×[surfaceHeight] and
|
||||
* injects a real touch contact. On up only [id] matters.
|
||||
*/
|
||||
external fun nativeSendTouch(
|
||||
handle: Long,
|
||||
id: Int,
|
||||
kind: Int,
|
||||
x: Int,
|
||||
y: Int,
|
||||
surfaceWidth: Int,
|
||||
surfaceHeight: Int,
|
||||
)
|
||||
|
||||
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
||||
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Android Adaptive Performance Framework (ADPF) — CPU performance hints for the decode thread.
|
||||
//!
|
||||
//! ADPF lets a latency-critical app tell the platform "these threads run a repeating workload with
|
||||
//! this per-cycle deadline, and here's how long they *actually* took." The kernel's CPU governor
|
||||
//! (on Qualcomm Snapdragon in particular — its ADPF backend is among the most responsive) then keeps
|
||||
//! those threads on the fast cores at high clocks instead of migrating them to a little core or
|
||||
//! down-clocking between frames. For a stream client the win is on the in-process hot path we
|
||||
//! control — the `pf-decode` feed/drain/present loop — *not* the hardware codec itself (that decodes
|
||||
//! in the mediacodec service, a separate process we can't hint); keeping our loop from being
|
||||
//! scheduled late directly trims the jitter between "AU received" and "buffer released to the
|
||||
//! Surface." It complements the codec-side `operating-rate`/`priority` hints, which push the codec's
|
||||
//! own clocks.
|
||||
//!
|
||||
//! The `APerformanceHint_*` API arrived in NDK **API level 33**. minSdk is 31, so we CANNOT link the
|
||||
//! symbols directly: a `libpunktfunk_android.so` carrying an unresolved
|
||||
//! `APerformanceHint_createSession` import fails to load on API 31/32 devices
|
||||
//! (`System.loadLibrary` throws) even if the code path is never taken. Instead we resolve the
|
||||
//! entry points from `libandroid.so` with `dlsym` at runtime — absent on < 33 ⇒
|
||||
//! [`HintSession::create`] returns `None` and the decode loop simply runs without hints.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::os::raw::c_int;
|
||||
|
||||
// `APerformanceHint_*` function-pointer types. The manager/session handles are opaque, so we treat
|
||||
// them as `*mut c_void`.
|
||||
type GetManagerFn = unsafe extern "C" fn() -> *mut c_void;
|
||||
type CreateSessionFn = unsafe extern "C" fn(*mut c_void, *const i32, usize, i64) -> *mut c_void;
|
||||
type ReportFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
|
||||
type UpdateTargetFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
|
||||
type CloseFn = unsafe extern "C" fn(*mut c_void);
|
||||
|
||||
/// The entry points we use, resolved once from `libandroid.so`, plus the process-wide manager.
|
||||
struct Api {
|
||||
create_session: CreateSessionFn,
|
||||
report: ReportFn,
|
||||
update_target: UpdateTargetFn,
|
||||
close: CloseFn,
|
||||
manager: *mut c_void,
|
||||
}
|
||||
|
||||
/// Resolve the ADPF entry points + the process manager, or `None` on API < 33 (symbols absent) or if
|
||||
/// the manager is unavailable.
|
||||
fn resolve_api() -> Option<Api> {
|
||||
// SAFETY: `dlopen` of an always-present system library with a NUL-terminated name; it returns
|
||||
// null on failure (checked below). `libandroid.so` is already mapped into every app process, so
|
||||
// this only bumps its refcount — we intentionally never `dlclose` (process-lifetime handle).
|
||||
let lib = unsafe { libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW) };
|
||||
if lib.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `dlsym` on the valid handle above with NUL-terminated symbol names; each returns null
|
||||
// when the symbol is absent (device API < 33), which we check before transmuting the non-null
|
||||
// pointer to its fn-pointer type (layout-compatible; a resolved symbol is a valid code address).
|
||||
unsafe {
|
||||
let get_manager = libc::dlsym(lib, c"APerformanceHint_getManager".as_ptr());
|
||||
let create_session = libc::dlsym(lib, c"APerformanceHint_createSession".as_ptr());
|
||||
let report = libc::dlsym(lib, c"APerformanceHint_reportActualWorkDuration".as_ptr());
|
||||
let update_target = libc::dlsym(lib, c"APerformanceHint_updateTargetWorkDuration".as_ptr());
|
||||
let close = libc::dlsym(lib, c"APerformanceHint_closeSession".as_ptr());
|
||||
if get_manager.is_null()
|
||||
|| create_session.is_null()
|
||||
|| report.is_null()
|
||||
|| update_target.is_null()
|
||||
|| close.is_null()
|
||||
{
|
||||
return None; // device API < 33 — no ADPF
|
||||
}
|
||||
let get_manager = std::mem::transmute::<*mut c_void, GetManagerFn>(get_manager);
|
||||
let manager = get_manager();
|
||||
if manager.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(Api {
|
||||
create_session: std::mem::transmute::<*mut c_void, CreateSessionFn>(create_session),
|
||||
report: std::mem::transmute::<*mut c_void, ReportFn>(report),
|
||||
update_target: std::mem::transmute::<*mut c_void, UpdateTargetFn>(update_target),
|
||||
close: std::mem::transmute::<*mut c_void, CloseFn>(close),
|
||||
manager,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A live ADPF hint session bound to a set of thread ids. Dropping it closes the session. Holds raw
|
||||
/// handles, so it is `!Send`/`!Sync` — created and used only on the `pf-decode` thread.
|
||||
pub struct HintSession {
|
||||
api: Api,
|
||||
session: *mut c_void,
|
||||
}
|
||||
|
||||
impl HintSession {
|
||||
/// Open a session hinting `tids` with an initial per-frame target of `target_ns` nanoseconds.
|
||||
/// `None` when ADPF is unavailable (device API < 33) or the platform declines — the caller then
|
||||
/// runs unhinted (a no-op, not an error).
|
||||
pub fn create(target_ns: i64, tids: &[i32]) -> Option<Self> {
|
||||
if target_ns <= 0 || tids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let api = resolve_api()?;
|
||||
// SAFETY: `api.manager` is the live process manager returned above; `tids` is a valid slice
|
||||
// of `len` i32s that `createSession` copies; it returns null on failure (checked).
|
||||
let session =
|
||||
unsafe { (api.create_session)(api.manager, tids.as_ptr(), tids.len(), target_ns) };
|
||||
if session.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(Self { api, session })
|
||||
}
|
||||
|
||||
/// Report the wall-clock time the hinted thread spent producing the last displayed frame. When
|
||||
/// it exceeds the session target the governor boosts the cores running the thread; when it
|
||||
/// stays under, clocks may relax. No-op on a non-positive duration (the API rejects it).
|
||||
pub fn report_actual(&self, actual_ns: i64) {
|
||||
if actual_ns <= 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `self.session` is a live session for `self`'s lifetime.
|
||||
unsafe { (self.api.report)(self.session, actual_ns) };
|
||||
}
|
||||
|
||||
/// Update the per-frame target (e.g. after a mid-session refresh-rate change). Unused today —
|
||||
/// the decode thread restarts on renegotiation — but kept for that path.
|
||||
#[allow(dead_code)]
|
||||
pub fn update_target(&self, target_ns: i64) {
|
||||
if target_ns <= 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `self.session` is a live session for `self`'s lifetime.
|
||||
unsafe { (self.api.update_target)(self.session, target_ns) };
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HintSession {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.session` was created by `createSession` and is closed exactly once, here.
|
||||
unsafe { (self.api.close)(self.session) };
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,10 @@ fn decode_loop(
|
||||
counters: Arc<Counters>,
|
||||
channels: usize,
|
||||
) {
|
||||
// Fold this Opus→AAudio thread into the client's hot-thread set so the ADPF session the decode
|
||||
// thread opens also keeps audio decode on a fast core (registered before the video pump's first
|
||||
// frame arrives, so it's captured when that session is created). No-op below API 33.
|
||||
client.register_hot_thread();
|
||||
// Interleaved f32 samples per millisecond at this layout — the ring's 5 ms reserve check below.
|
||||
let ms = (SAMPLE_RATE as usize / 1000) * channels;
|
||||
// Opus decode scratch: worst-case 120 ms frame (5760 samples/ch) × channels.
|
||||
|
||||
@@ -9,16 +9,27 @@
|
||||
use ndk::data_space::DataSpace;
|
||||
use ndk::media::media_codec::{
|
||||
DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection,
|
||||
OutputBuffer,
|
||||
};
|
||||
use ndk::media::media_format::MediaFormat;
|
||||
use ndk::native_window::{FrameRateCompatibility, NativeWindow};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::error::PunktfunkError;
|
||||
use punktfunk_core::session::Frame;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Cap on the pts→received-timestamp map below: MediaCodec holds only a handful of frames in
|
||||
/// flight, so anything beyond this is stale (codec flushed / HUD toggled) and gets evicted.
|
||||
const IN_FLIGHT_CAP: usize = 64;
|
||||
|
||||
/// Cap on received AUs awaiting their 0xCF host timing (Phase 2 host/network split): the timing
|
||||
/// datagram trails its AU by at most the wire, so a match lands within a frame or two — anything
|
||||
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
|
||||
const PENDING_SPLIT_CAP: usize = 256;
|
||||
|
||||
/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes.
|
||||
pub fn run(
|
||||
client: Arc<NativeClient>,
|
||||
@@ -61,7 +72,14 @@ pub fn run(
|
||||
// realtime priority + the target frame rate, so vendor decoders (e.g. Qualcomm) run at full
|
||||
// clocks instead of a power-saving cadence that adds dequeue latency.
|
||||
format.set_i32("priority", 0); // 0 = realtime
|
||||
format.set_i32("operating-rate", mode.refresh_hz as i32);
|
||||
// Operating rate = the codec's clock hint. Setting it to the display rate merely asks the
|
||||
// decoder to *sustain* that cadence — a Qualcomm decoder can meet 60/120 fps at a power-saving
|
||||
// clock that adds a millisecond-plus of decode latency per frame. Setting it to the AOSP
|
||||
// "unbounded" sentinel (Short.MAX) instead asks the decoder to run each frame at max clocks and
|
||||
// finish ASAP, minimising per-frame decode latency — the right trade for a real-time stream
|
||||
// (costs power/heat; the dial to lower if a device thermally throttles over a long session).
|
||||
// Ignored where unsupported.
|
||||
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
||||
|
||||
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
|
||||
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
|
||||
@@ -104,6 +122,25 @@ pub fn run(
|
||||
);
|
||||
}
|
||||
|
||||
// ADPF: hint the platform that the whole video pipeline — this pf-decode feed/drain/present
|
||||
// loop, the core's data-plane pump (UDP receive + FEC reassembly), and the audio thread — runs a
|
||||
// per-frame real-time workload, so the CPU governor keeps those threads on fast cores at high
|
||||
// clocks instead of down-clocking between frames or parking them on a little core. Snapdragon's
|
||||
// ADPF backend responds well to this. We register this thread now but create the session lazily
|
||||
// on the first presented frame: by then the pump + audio threads have registered their ids too,
|
||||
// and ADPF `createSession` rejects a set with any not-yet-live/dead tid. No-op below API 33.
|
||||
let frame_period_ns = if mode.refresh_hz > 0 {
|
||||
1_000_000_000i64 / mode.refresh_hz as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
client.register_hot_thread(); // this decode thread → the pipeline's hot-thread set
|
||||
let mut hint: Option<crate::adpf::HintSession> = None;
|
||||
let mut hint_tried = false;
|
||||
// Accumulates the loop's productive (feed+drain) time between displayed frames; reported to ADPF
|
||||
// once per rendered frame against the frame-period target.
|
||||
let mut work_accum_ns: i64 = 0;
|
||||
|
||||
let mut fed: u64 = 0;
|
||||
let mut rendered: u64 = 0;
|
||||
let mut discarded: u64 = 0;
|
||||
@@ -115,9 +152,19 @@ pub fn run(
|
||||
// climbs.
|
||||
let mut last_dropped = client.frames_dropped();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
// Capture→client-receipt latency uses the negotiated host-minus-client clock offset (0 if the
|
||||
// host didn't answer the skew handshake — then the HUD flags it "same-host").
|
||||
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
||||
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
||||
// HUD flags it "(same-host clock)").
|
||||
let clock_offset = client.clock_offset_ns;
|
||||
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
|
||||
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
||||
// to its receipt for the `decode` stage. Only fed while the HUD is visible.
|
||||
let mut in_flight: VecDeque<(u64, i128)> = VecDeque::new();
|
||||
// Phase-2 host/network split (design/stats-unification.md): received AUs awaiting their 0xCF
|
||||
// host timing, as (pts_ns, capture→received µs). The timings are drained non-blockingly right
|
||||
// where receipts are recorded and matched by pts; `network = hostnet − host` (saturating).
|
||||
// Only fed while the HUD is visible; an old host never sends a 0xCF, so entries just age out.
|
||||
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
|
||||
// The dataspace we've signalled on the Surface so far (None = default/SDR). Set reactively once
|
||||
// the decoder reports an HDR stream (see `drain`); avoids re-applying every format event.
|
||||
let mut applied_ds: Option<DataSpace> = None;
|
||||
@@ -138,15 +185,41 @@ pub fn run(
|
||||
&p[..p.len().min(6)]
|
||||
);
|
||||
}
|
||||
// HUD stat: capture→client-receipt latency = client_now + (host−client) −
|
||||
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
||||
// capture_pts. Gated on the HUD being visible — `enabled` first so the hidden
|
||||
// steady state skips the wall-clock read and the lock entirely.
|
||||
// steady state skips the wall-clock read and the lock entirely. The receipt
|
||||
// stamp is also parked in `in_flight` (keyed by the pts the codec will echo on
|
||||
// the output buffer) for the decoded-point pairing in `drain`.
|
||||
if stats.enabled() {
|
||||
let lat_ns =
|
||||
now_realtime_ns() + clock_offset as i128 - frame.pts_ns as i128;
|
||||
let received_ns = now_realtime_ns();
|
||||
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
stats.note(frame.data.len(), lat_us, clock_offset != 0);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
||||
if in_flight.len() > IN_FLIGHT_CAP {
|
||||
in_flight.pop_front(); // stale — codec never echoed it back
|
||||
}
|
||||
// Phase-2 split: park this AU's capture→received sample, then match any
|
||||
// 0xCF host timings that have arrived — host = the host's own
|
||||
// capture→sent, network = our capture→received minus it (per-frame
|
||||
// tiling; saturating in case of clock jitter).
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front(); // 0xCF lost / old host — evict
|
||||
}
|
||||
}
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pending = Some(frame);
|
||||
}
|
||||
@@ -154,6 +227,9 @@ pub fn run(
|
||||
Err(_) => break, // session closed
|
||||
}
|
||||
}
|
||||
// Time the productive work (feed + drain) only — the `next_frame` poll wait above is idle
|
||||
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
|
||||
let work_t0 = Instant::now();
|
||||
if let Some(frame) = pending.take() {
|
||||
if feed(&codec, &frame.data, frame.pts_ns / 1000) {
|
||||
fed += 1;
|
||||
@@ -173,10 +249,48 @@ pub fn run(
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
let (r, d) = drain(&codec, &window, &mut applied_ds, wait);
|
||||
let (r, d) = drain(
|
||||
&codec,
|
||||
&window,
|
||||
&mut applied_ds,
|
||||
wait,
|
||||
&stats,
|
||||
&mut in_flight,
|
||||
clock_offset,
|
||||
);
|
||||
rendered += r;
|
||||
discarded += d;
|
||||
|
||||
// ADPF: attribute this iteration's feed+drain time to the frame being produced, and report
|
||||
// the accumulated per-frame work once one is actually presented (r > 0). Under back-pressure
|
||||
// the short output-dequeue wait is included in the tally — for a latency-first client,
|
||||
// biasing the governor toward "boost" is the desired behaviour. Cheap when `hint` is None
|
||||
// (one `Instant` diff, no report).
|
||||
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||
if r > 0 {
|
||||
if !hint_tried {
|
||||
// First presented frame: the pump + audio threads have registered their ids by now.
|
||||
// Build one ADPF session over the whole pipeline's thread set (empty below API 33,
|
||||
// or where the platform declines → `None`, and the loop runs unhinted).
|
||||
hint_tried = true;
|
||||
let tids = client.hot_thread_ids();
|
||||
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
|
||||
log::info!(
|
||||
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
||||
if hint.is_some() {
|
||||
"active"
|
||||
} else {
|
||||
"unavailable"
|
||||
},
|
||||
tids.len(),
|
||||
);
|
||||
}
|
||||
if let Some(h) = &hint {
|
||||
h.report_actual(work_accum_ns);
|
||||
}
|
||||
work_accum_ns = 0;
|
||||
}
|
||||
|
||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
||||
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
|
||||
// reference-missing delta frames that follow and renders them without error, so keying off
|
||||
@@ -271,11 +385,19 @@ fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) -> bool {
|
||||
/// the caller's input is blocked so the loop waits on decoder progress instead of busy-spinning.
|
||||
/// Returns `(rendered, discarded)`. Also reacts to `OutputFormatChanged` (which can interleave
|
||||
/// between buffers — handled without losing the held buffer) to signal HDR on the Surface.
|
||||
///
|
||||
/// Each dequeued buffer is also the HUD's `decoded` measurement point (rendered or not — the frame
|
||||
/// finished decoding either way): end-to-end = decoded + clock_offset − capture pts, and the
|
||||
/// `decode` stage pairs the buffer's echoed presentationTimeUs back to the receipt stamp in
|
||||
/// `in_flight` (single-clock local difference, no skew involved).
|
||||
fn drain(
|
||||
codec: &MediaCodec,
|
||||
window: &NativeWindow,
|
||||
applied_ds: &mut Option<DataSpace>,
|
||||
first_wait: Duration,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
) -> (u64, u64) {
|
||||
let mut held = None; // newest ready buffer so far, presented after the loop
|
||||
let mut discarded: u64 = 0;
|
||||
@@ -284,6 +406,9 @@ fn drain(
|
||||
match codec.dequeue_output_buffer(wait) {
|
||||
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
||||
wait = Duration::ZERO; // only the first dequeue may block
|
||||
if stats.enabled() {
|
||||
note_decoded(stats, in_flight, clock_offset, &buf);
|
||||
}
|
||||
if let Some(stale) = held.replace(buf) {
|
||||
// A newer frame is ready — drop the held one without rendering.
|
||||
if let Err(e) = codec.release_output_buffer(stale, false) {
|
||||
@@ -333,6 +458,40 @@ fn drain(
|
||||
(rendered, discarded)
|
||||
}
|
||||
|
||||
/// HUD `decoded` point for one dequeued output buffer: build the end-to-end (capture→decoded,
|
||||
/// skew-corrected, clamped to (0, 10 s)) and `decode` (received→decoded, single-clock local, ≥ 0)
|
||||
/// samples and hand them to [`crate::stats::VideoStats::note_decoded`]. The codec echoes the input
|
||||
/// `presentationTimeUs` on the output buffer, which keys the receipt stamp in `in_flight`; entries
|
||||
/// older than the echoed pts are evicted (decode order == input order here — low-latency, no
|
||||
/// B-frames — so anything before it was dropped inside the codec or stamped before a flush).
|
||||
fn note_decoded(
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
buf: &OutputBuffer<'_>,
|
||||
) {
|
||||
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
||||
let decoded_ns = now_realtime_ns();
|
||||
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
||||
let mut received_ns = None;
|
||||
while let Some(&(p, r)) = in_flight.front() {
|
||||
if p > pts_us {
|
||||
break; // future frame — leave it for its own output buffer
|
||||
}
|
||||
in_flight.pop_front();
|
||||
if p == pts_us {
|
||||
received_ns = Some(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// pts_us is the truncated frame.pts_ns/1000 we queued, so ×1000 re-approximates capture time
|
||||
// to < 1 µs — negligible against the ms-scale figures shown.
|
||||
let e2e_ns = decoded_ns + clock_offset as i128 - pts_us as i128 * 1000;
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
let decode_us = received_ns.map(|r| ((decoded_ns - r).max(0) / 1000) as u64);
|
||||
stats.note_decoded(e2e_us, decode_us);
|
||||
}
|
||||
|
||||
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
||||
/// integer values are the Android MediaFormat colour constants the NDK shares: COLOR_TRANSFER
|
||||
/// ST2084 = 6 (PQ/HDR10), HLG = 7; COLOR_RANGE FULL = 1, LIMITED = 2 (the host encodes limited).
|
||||
|
||||
@@ -25,6 +25,8 @@ use jni::objects::JObject;
|
||||
use jni::sys::jint;
|
||||
use jni::JNIEnv;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod adpf;
|
||||
#[cfg(target_os = "android")]
|
||||
mod audio;
|
||||
#[cfg(target_os = "android")]
|
||||
|
||||
@@ -93,6 +93,34 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
|
||||
send_event(handle, InputKind::MouseScroll, axis as u32, delta, 0, 0);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendTouch(handle, id, kind, x, y, surfaceWidth, surfaceHeight)` — one REAL
|
||||
/// touchscreen transition (`kind`: 0=down 1=move 2=up), for the touch-passthrough input mode. `id`
|
||||
/// distinguishes fingers (reusable after up); coordinates are pixels on the client's touch
|
||||
/// surface, whose size rides in `flags` so the host can rescale into the output (identical
|
||||
/// packing to MouseMoveAbs). On up only the id matters. The host injects a real touch contact
|
||||
/// (libei touchscreen / wlroots / SendInput).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
id: jint,
|
||||
kind: jint,
|
||||
x: jint,
|
||||
y: jint,
|
||||
surface_width: jint,
|
||||
surface_height: jint,
|
||||
) {
|
||||
let kind = match kind {
|
||||
0 => InputKind::TouchDown,
|
||||
1 => InputKind::TouchMove,
|
||||
_ => InputKind::TouchUp,
|
||||
};
|
||||
let w = (surface_width.max(0) as u32) & 0xffff;
|
||||
let h = (surface_height.max(0) as u32) & 0xffff;
|
||||
send_event(handle, kind, id as u32, x, y, (w << 16) | h);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
|
||||
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
|
||||
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
|
||||
|
||||
@@ -72,14 +72,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
|
||||
/// Returns 14 doubles
|
||||
/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`
|
||||
/// (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — see below), or
|
||||
/// `null` when no decode thread is running. Poll ~1 Hz from the UI; each call resets the measurement
|
||||
/// window. Not android-gated — pure `jni` + connector reads, so it links on the host build too
|
||||
/// (Kotlin only ever calls it on device).
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 18 doubles
|
||||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
/// netP50Ms]`
|
||||
/// (the two flags are 1.0/0.0; indexes 0–15 match the previous 16-double layout — 0–13 the original
|
||||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
|
||||
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
|
||||
/// window, i.e. an old host), or `null` when no decode thread is running. Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
env: JNIEnv,
|
||||
@@ -98,11 +103,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
let snap = h.stats.drain();
|
||||
let mode = h.client.mode();
|
||||
let color = h.client.color;
|
||||
let buf: [f64; 14] = [
|
||||
let buf: [f64; 18] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.lat_p50_ms,
|
||||
snap.lat_p95_ms,
|
||||
snap.e2e_p50_ms,
|
||||
snap.e2e_p95_ms,
|
||||
if snap.lat_valid { 1.0 } else { 0.0 },
|
||||
if snap.skew_corrected { 1.0 } else { 0.0 },
|
||||
mode.width as f64,
|
||||
@@ -117,6 +122,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
color.primaries as f64,
|
||||
color.transfer as f64,
|
||||
h.client.chroma_format as f64,
|
||||
// Stage p50s tiling the end-to-end headline (appended to keep 0–13 index-compatible).
|
||||
snap.hostnet_p50_ms,
|
||||
snap.decode_p50_ms,
|
||||
// Phase-2 host/network split of the `host+network` stage (0xCF host timings): 0.0
|
||||
// when no timing matched this window (old host) — the HUD keeps the combined term.
|
||||
snap.host_p50_ms,
|
||||
snap.net_p50_ms,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
//! Live decode stats for the on-stream HUD (mirrors the Apple client's stats overlay): FPS,
|
||||
//! receive throughput, and capture→client-receipt latency (p50/p95). The decode thread is the sole
|
||||
//! writer (`note` per access unit); the JNI accessor `nativeVideoStats` drains a snapshot ~1 Hz and
|
||||
//! resets the window. Sampling is gated on the HUD actually being visible (`set_enabled`, driven by
|
||||
//! `nativeSetVideoStatsEnabled`) so the hidden steady state costs one relaxed atomic load per frame.
|
||||
//! Live decode stats for the on-stream HUD, following the unified stats spec
|
||||
//! (`design/stats-unification.md`): FPS, receive throughput, and the Android v1 stage split —
|
||||
//! headline `end-to-end` = capture→decoded (p50/p95) tiled by `host+network` = capture→received
|
||||
//! and `decode` = received→decoded (stage p50s). When the host emits per-AU 0xCF host timings, the
|
||||
//! `host+network` term further splits into `host` + `network` (Phase 2, `note_host_split`); an old
|
||||
//! host emits none and the combined term stands. The decode thread is the sole writer
|
||||
//! (`note_received` per access unit at receipt, `note_decoded` per decoder output buffer); the JNI
|
||||
//! accessor `nativeVideoStats` drains a snapshot ~1 Hz and resets the window. Sampling is gated on
|
||||
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
|
||||
//! hidden steady state costs one relaxed atomic load per frame.
|
||||
//! Pure `std` so it compiles on the host build too (the decode thread is android-only, but
|
||||
//! `SessionHandle` holds the shared handle unconditionally).
|
||||
|
||||
@@ -13,9 +18,9 @@ use std::time::Instant;
|
||||
/// Rolling per-window accumulator. Rates are computed over the actual elapsed wall-time at drain
|
||||
/// (robust to poll jitter), so a poll that lands at 0.9 s or 1.1 s still reports the right FPS.
|
||||
pub struct VideoStats {
|
||||
/// HUD gate: `note` runs on the per-frame decode path, so while the overlay is hidden it (and
|
||||
/// the caller's latency computation — see `enabled`) early-outs on this flag alone. Off until
|
||||
/// Kotlin shows the HUD.
|
||||
/// HUD gate: the samplers run on the per-frame decode path, so while the overlay is hidden
|
||||
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
|
||||
/// Off until Kotlin shows the HUD.
|
||||
enabled: AtomicBool,
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
@@ -24,23 +29,52 @@ struct Inner {
|
||||
window_start: Instant,
|
||||
frames: u64,
|
||||
bytes: u64,
|
||||
/// capture→client-receipt latency samples for this window, in microseconds.
|
||||
lat_us: Vec<u64>,
|
||||
/// `end-to-end` = capture→decoded latency samples for this window, in microseconds
|
||||
/// (skew-corrected clock base).
|
||||
e2e_us: Vec<u64>,
|
||||
/// `host+network` stage = capture→received samples, in microseconds (skew-corrected).
|
||||
hostnet_us: Vec<u64>,
|
||||
/// Phase-2 split of `host+network` (design/stats-unification.md Phase 2), fed only when the
|
||||
/// host emits per-AU 0xCF timings: `host` = the host's own capture→sent duration, µs.
|
||||
host_us: Vec<u64>,
|
||||
/// The matching `network` term, µs: capture→received minus the host's capture→sent
|
||||
/// (wire + reassembly). Always pushed in lockstep with `host_us`.
|
||||
net_us: Vec<u64>,
|
||||
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
|
||||
decode_us: Vec<u64>,
|
||||
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
|
||||
skew_corrected: bool,
|
||||
}
|
||||
|
||||
/// A drained, computed view of one window. `lat_valid` is false when no in-range latency sample
|
||||
/// landed (then p50/p95 are 0 and the HUD hides the latency line, exactly like the Apple client).
|
||||
/// A drained, computed view of one window. `lat_valid` is false when no in-range end-to-end sample
|
||||
/// landed (then the latency figures are 0 and the HUD hides the latency lines, exactly like the
|
||||
/// Apple client).
|
||||
pub struct Snapshot {
|
||||
pub fps: f64,
|
||||
pub mbps: f64,
|
||||
pub lat_p50_ms: f64,
|
||||
pub lat_p95_ms: f64,
|
||||
/// Headline `end-to-end` (capture→decoded) percentiles, ms.
|
||||
pub e2e_p50_ms: f64,
|
||||
pub e2e_p95_ms: f64,
|
||||
/// Stage p50s (ms): `host+network` (capture→received) and `decode` (received→decoded).
|
||||
pub hostnet_p50_ms: f64,
|
||||
pub decode_p50_ms: f64,
|
||||
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
|
||||
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
|
||||
pub host_p50_ms: f64,
|
||||
pub net_p50_ms: f64,
|
||||
pub lat_valid: bool,
|
||||
pub skew_corrected: bool,
|
||||
}
|
||||
|
||||
/// Percentile over a sorted-in-place µs sample vec, in ms. 0.0 when empty.
|
||||
fn pctl_ms(sorted_us: &[u64], p: f64) -> f64 {
|
||||
if sorted_us.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let n = sorted_us.len();
|
||||
sorted_us[((n as f64 * p) as usize).min(n - 1)] as f64 / 1000.0
|
||||
}
|
||||
|
||||
impl VideoStats {
|
||||
pub fn new() -> VideoStats {
|
||||
VideoStats {
|
||||
@@ -49,14 +83,18 @@ impl VideoStats {
|
||||
window_start: Instant::now(),
|
||||
frames: 0,
|
||||
bytes: 0,
|
||||
lat_us: Vec::with_capacity(256),
|
||||
e2e_us: Vec::with_capacity(256),
|
||||
hostnet_us: Vec::with_capacity(256),
|
||||
host_us: Vec::with_capacity(256),
|
||||
net_us: Vec::with_capacity(256),
|
||||
decode_us: Vec::with_capacity(256),
|
||||
skew_corrected: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the HUD wants samples. The decode thread checks this BEFORE building a latency
|
||||
/// sample, so the per-frame wall-clock read is skipped too while hidden.
|
||||
/// sample, so the per-frame wall-clock reads are skipped too while hidden.
|
||||
// Read only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn enabled(&self) -> bool {
|
||||
@@ -75,18 +113,23 @@ impl VideoStats {
|
||||
g.window_start = Instant::now();
|
||||
g.frames = 0;
|
||||
g.bytes = 0;
|
||||
g.lat_us.clear();
|
||||
g.e2e_us.clear();
|
||||
g.hostnet_us.clear();
|
||||
g.host_us.clear();
|
||||
g.net_us.clear();
|
||||
g.decode_us.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one decoded access unit: its wire size and (if in range) its capture→client latency.
|
||||
/// Record one received access unit: its wire size and (if in range) its capture→received
|
||||
/// `host+network` stage sample. Receipt is the fps/goodput counting point per the spec.
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note(&self, bytes: usize, lat_us: Option<u64>, skew_corrected: bool) {
|
||||
pub fn note_received(&self, bytes: usize, hostnet_us: Option<u64>, skew_corrected: bool) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock (the caller already skipped the clock read)
|
||||
}
|
||||
// Poison-proof: `note` runs per-frame on the decode thread, which has no catch_unwind —
|
||||
// Poison-proof: this runs per-frame on the decode thread, which has no catch_unwind —
|
||||
// a panic elsewhere must not turn every later lock into a second panic (the counters
|
||||
// stay consistent regardless).
|
||||
let mut g = self
|
||||
@@ -96,14 +139,56 @@ impl VideoStats {
|
||||
g.frames += 1;
|
||||
g.bytes += bytes as u64;
|
||||
g.skew_corrected = skew_corrected;
|
||||
if let Some(l) = lat_us {
|
||||
g.lat_us.push(l);
|
||||
if let Some(l) = hostnet_us {
|
||||
g.hostnet_us.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one matched host/network split sample (Phase 2): the host's reported capture→sent
|
||||
/// duration and our capture→received minus it, both µs — one pair per AU whose 0xCF host
|
||||
/// timing arrived and matched by pts. An old host emits none, leaving the vecs empty and the
|
||||
/// snapshot p50s at 0 (HUD keeps the combined `host+network` term).
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_host_split(&self, host_us: u64, net_us: u64) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock
|
||||
}
|
||||
// Poison-proof for the same reason as `note_received`.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.host_us.push(host_us);
|
||||
g.net_us.push(net_us);
|
||||
}
|
||||
|
||||
/// Record one decoded output frame: its capture→decoded `end-to-end` sample and its
|
||||
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
|
||||
/// this pts predates the HUD being shown).
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_decoded(&self, e2e_us: Option<u64>, decode_us: Option<u64>) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock (the caller already skipped the clock read)
|
||||
}
|
||||
// Poison-proof for the same reason as `note_received`.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(l) = e2e_us {
|
||||
g.e2e_us.push(l);
|
||||
}
|
||||
if let Some(l) = decode_us {
|
||||
g.decode_us.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
||||
pub fn drain(&self) -> Snapshot {
|
||||
// Poison-proof for the same reason as `note` — a poisoned window still drains fine.
|
||||
// Poison-proof for the same reason as `note_received` — a poisoned window still drains
|
||||
// fine.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -111,26 +196,31 @@ impl VideoStats {
|
||||
let elapsed = g.window_start.elapsed().as_secs_f64().max(1e-3);
|
||||
let fps = g.frames as f64 / elapsed;
|
||||
let mbps = g.bytes as f64 * 8.0 / 1_000_000.0 / elapsed;
|
||||
let (p50, p95, valid) = if g.lat_us.is_empty() {
|
||||
(0.0, 0.0, false)
|
||||
} else {
|
||||
g.lat_us.sort_unstable();
|
||||
let n = g.lat_us.len();
|
||||
let at = |p: f64| g.lat_us[((n as f64 * p) as usize).min(n - 1)] as f64 / 1000.0;
|
||||
(at(0.50), at(0.95), true)
|
||||
g.e2e_us.sort_unstable();
|
||||
g.hostnet_us.sort_unstable();
|
||||
g.host_us.sort_unstable();
|
||||
g.net_us.sort_unstable();
|
||||
g.decode_us.sort_unstable();
|
||||
let snap = Snapshot {
|
||||
fps,
|
||||
mbps,
|
||||
e2e_p50_ms: pctl_ms(&g.e2e_us, 0.50),
|
||||
e2e_p95_ms: pctl_ms(&g.e2e_us, 0.95),
|
||||
hostnet_p50_ms: pctl_ms(&g.hostnet_us, 0.50),
|
||||
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
|
||||
host_p50_ms: pctl_ms(&g.host_us, 0.50),
|
||||
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
||||
lat_valid: !g.e2e_us.is_empty(),
|
||||
skew_corrected: g.skew_corrected,
|
||||
};
|
||||
let skew = g.skew_corrected;
|
||||
g.window_start = Instant::now();
|
||||
g.frames = 0;
|
||||
g.bytes = 0;
|
||||
g.lat_us.clear();
|
||||
Snapshot {
|
||||
fps,
|
||||
mbps,
|
||||
lat_p50_ms: p50,
|
||||
lat_p95_ms: p95,
|
||||
lat_valid: valid,
|
||||
skew_corrected: skew,
|
||||
}
|
||||
g.e2e_us.clear();
|
||||
g.hostnet_us.clear();
|
||||
g.host_us.clear();
|
||||
g.net_us.clear();
|
||||
g.decode_us.clear();
|
||||
snap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,15 +326,21 @@ struct ContentView: View {
|
||||
onCaptureChange: { [weak model] captured in
|
||||
model?.mouseCaptured = captured
|
||||
},
|
||||
onFrame: { [meter = model.meter, latency = model.latency, offset = conn.clockOffsetNs] au in
|
||||
onFrame: { [meter = model.meter, latency = model.latency,
|
||||
split = model.latencySplit, offset = conn.clockOffsetNs] au in
|
||||
meter.note(byteCount: au.data.count)
|
||||
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
||||
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
||||
// host/network split — drained by the 1 s stats tick).
|
||||
split.recordReceipt(
|
||||
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
|
||||
},
|
||||
onSessionEnd: { [weak model] in
|
||||
Task { @MainActor in model?.sessionEnded() }
|
||||
},
|
||||
presentMeter: model.presentLatency,
|
||||
presentTailMeter: model.presentTail
|
||||
endToEndMeter: model.endToEnd,
|
||||
decodeMeter: model.decodeStage,
|
||||
displayMeter: model.displayStage
|
||||
)
|
||||
.overlay(alignment: placement.alignment) {
|
||||
if captureEnabled && hudEnabled {
|
||||
|
||||
@@ -170,7 +170,10 @@ private struct ShotHUD: View {
|
||||
Text("5120×1440@240 240 fps 812.4 Mb/s")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
Text("capture→client 1.3/2.1 ms p50/p95")
|
||||
Text("end-to-end 2.9 ms p50 · 3.8 p95 · capture→on-glass")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("= host+network 1.3 + decode 0.7 + display 0.9")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
#if os(macOS)
|
||||
|
||||
@@ -59,36 +59,62 @@ final class SessionModel: ObservableObject {
|
||||
@Published var fps = 0
|
||||
@Published var mbps = 0.0
|
||||
@Published var totalFrames = 0
|
||||
/// Capture→client-receipt latency (ms), skew-corrected across machines via the connect-time
|
||||
/// clock offset — p50/p95 for the HUD. `latencyValid` is false until the first sample drains
|
||||
/// (and whenever no host frames arrived in the last interval). `latencySkewCorrected` = the host
|
||||
/// The unified latency stages (design/stats-unification.md), ms per 1 s window. `host+network`
|
||||
/// = capture→received, skew-corrected across machines via the connect-time clock offset: the
|
||||
/// stage-2 HUD shows its p50 in the equation line; the stage-1 fallback shows p50/p95 as its
|
||||
/// `capture→received` headline. `hostNetworkValid` is false until the first sample drains (and
|
||||
/// whenever no host frames arrived in the last interval). `hostNetworkSkewCorrected` = the host
|
||||
/// answered the skew handshake (the number is cross-machine valid, not just same-host).
|
||||
@Published var latencyP50Ms = 0.0
|
||||
@Published var latencyP95Ms = 0.0
|
||||
@Published var latencyValid = false
|
||||
@Published var latencySkewCorrected = false
|
||||
/// Capture→present (glass-to-glass, modulo the host render→capture term) — only the stage-2
|
||||
/// presenter can stamp this (it owns decode + a CAMetalLayer/display-link present). Stays
|
||||
/// invalid under stage-1, where the layer presents internally with no per-frame callback.
|
||||
@Published var presentLatencyP50Ms = 0.0
|
||||
@Published var presentLatencyP95Ms = 0.0
|
||||
@Published var presentLatencyValid = false
|
||||
@Published var presentLatencySkewCorrected = false
|
||||
/// Decode-completion→present (the "present tail": ring wait + render + vsync) — the term the
|
||||
/// stage-2 presenter exists to shorten. Both instants are client-side, so no skew applies.
|
||||
@Published var presentTailP50Ms = 0.0
|
||||
@Published var presentTailP95Ms = 0.0
|
||||
@Published var presentTailValid = false
|
||||
@Published var hostNetworkP50Ms = 0.0
|
||||
@Published var hostNetworkP95Ms = 0.0
|
||||
@Published var hostNetworkValid = false
|
||||
@Published var hostNetworkSkewCorrected = false
|
||||
/// Phase 2 of the same stage: `host+network` split into its two terms via the host's per-AU
|
||||
/// 0xCF timing reports (host = capture→fully-sent as the host measured it, network = the
|
||||
/// remainder), matched to receipts by pts in `latencySplit`. `splitValid` is false whenever
|
||||
/// no timing matched in the window — an old host that never emits the plane, or heavy 0xCF
|
||||
/// loss — and the HUD then falls back to the combined `host+network` term.
|
||||
@Published var hostP50Ms = 0.0
|
||||
@Published var networkP50Ms = 0.0
|
||||
@Published var splitValid = false
|
||||
/// End-to-end = capture→on-glass, measured directly per frame (never summed from the stages) —
|
||||
/// the HUD headline. Only the stage-2 presenter can stamp it (it owns decode + a
|
||||
/// CAMetalLayer/display-link present); stays invalid under stage-1, where the layer presents
|
||||
/// internally with no per-frame callback.
|
||||
@Published var endToEndP50Ms = 0.0
|
||||
@Published var endToEndP95Ms = 0.0
|
||||
@Published var endToEndValid = false
|
||||
@Published var endToEndSkewCorrected = false
|
||||
/// The client-local stage terms of the HUD's equation line (single clock, no skew; p50 only):
|
||||
/// decode = received→decoded, display = decoded→on-glass (ring wait + render + vsync — the
|
||||
/// term the stage-2 presenter exists to shorten).
|
||||
@Published var decodeP50Ms = 0.0
|
||||
@Published var decodeValid = false
|
||||
@Published var displayP50Ms = 0.0
|
||||
@Published var displayValid = false
|
||||
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
|
||||
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
|
||||
@Published var lostFrames = 0
|
||||
@Published var lostPct = 0.0
|
||||
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
||||
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
||||
@Published var mouseCaptured = false
|
||||
|
||||
let meter = FrameMeter()
|
||||
/// Capture→received (the host+network stage), fed per AU at receipt by the stream view's
|
||||
/// onFrame — under both presenters.
|
||||
let latency = LatencyMeter()
|
||||
/// Fed by the stage-2 presenter's display link (capture→present). Passed to StreamView.
|
||||
let presentLatency = LatencyMeter()
|
||||
/// Fed by the same present stamp (decode-completion→present). Passed to StreamView.
|
||||
let presentTail = LatencyMeter()
|
||||
/// The host/network split of that same stage: onFrame also records (pts, interval) receipts
|
||||
/// here, and the 1 s stats tick drains the connection's 0xCF host timings into it — under
|
||||
/// both presenters (the receipt path is presenter-independent).
|
||||
let latencySplit = HostNetworkSplitter()
|
||||
/// The stage-2 meters, passed to StreamView: end-to-end (capture→on-glass, stamped at
|
||||
/// present), decode (received→decoded), display (decoded→on-glass).
|
||||
let endToEnd = LatencyMeter()
|
||||
let decodeStage = LatencyMeter()
|
||||
let displayStage = LatencyMeter()
|
||||
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
|
||||
private var lastFramesDropped: UInt64 = 0
|
||||
private var statsTimer: Timer?
|
||||
private var audio: SessionAudio?
|
||||
private var gamepadCapture: GamepadCapture?
|
||||
@@ -281,7 +307,13 @@ final class SessionModel: ObservableObject {
|
||||
phase = .idle
|
||||
fps = 0
|
||||
mbps = 0
|
||||
latencyValid = false
|
||||
hostNetworkValid = false
|
||||
splitValid = false
|
||||
endToEndValid = false
|
||||
decodeValid = false
|
||||
displayValid = false
|
||||
lostFrames = 0
|
||||
lostPct = 0
|
||||
mouseCaptured = false
|
||||
}
|
||||
|
||||
@@ -306,6 +338,7 @@ final class SessionModel: ObservableObject {
|
||||
audio.start(
|
||||
speakerUID: defaults.string(forKey: DefaultsKey.speakerUID) ?? "",
|
||||
micUID: defaults.string(forKey: DefaultsKey.micUID) ?? "",
|
||||
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
|
||||
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
|
||||
self.audio = audio
|
||||
// Gamepads: forward GamepadManager's active controller as pad 0 and render the
|
||||
@@ -321,6 +354,8 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func startStatsTimer() {
|
||||
lastFramesDropped = 0 // a fresh connection's cumulative drop counter starts at 0
|
||||
latencySplit.reset() // no stale receipts/samples from a previous session
|
||||
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
@@ -328,28 +363,60 @@ final class SessionModel: ObservableObject {
|
||||
self.fps = frames
|
||||
self.mbps = Double(bytes) * 8 / 1_000_000
|
||||
self.totalFrames = total
|
||||
// Per-window `lost` = the delta of the connector's cumulative reassembler-drop
|
||||
// counter (0 after close — treat a rewind as no loss rather than underflowing).
|
||||
let dropped = self.connection?.framesDropped() ?? 0
|
||||
let lost = dropped >= self.lastFramesDropped
|
||||
? Int(dropped - self.lastFramesDropped) : 0
|
||||
self.lastFramesDropped = dropped
|
||||
self.lostFrames = lost
|
||||
self.lostPct = lost > 0 ? Double(lost) / Double(frames + lost) * 100 : 0
|
||||
if let lat = self.latency.drain() {
|
||||
self.latencyP50Ms = lat.p50Ms
|
||||
self.latencyP95Ms = lat.p95Ms
|
||||
self.latencySkewCorrected = lat.skewCorrected
|
||||
self.latencyValid = true
|
||||
self.hostNetworkP50Ms = lat.p50Ms
|
||||
self.hostNetworkP95Ms = lat.p95Ms
|
||||
self.hostNetworkSkewCorrected = lat.skewCorrected
|
||||
self.hostNetworkValid = true
|
||||
} else {
|
||||
self.latencyValid = false
|
||||
self.hostNetworkValid = false
|
||||
}
|
||||
if let p = self.presentLatency.drain() {
|
||||
self.presentLatencyP50Ms = p.p50Ms
|
||||
self.presentLatencyP95Ms = p.p95Ms
|
||||
self.presentLatencySkewCorrected = p.skewCorrected
|
||||
self.presentLatencyValid = true
|
||||
} else {
|
||||
self.presentLatencyValid = false
|
||||
// Phase 2: drain the window's per-AU host timings (0xCF) into the splitter —
|
||||
// non-blocking, bounded (a 240 fps window is ~240 reports; the cap only guards
|
||||
// a pathological burst). `try?` flattens (SE-0230); a throw (.closed during
|
||||
// teardown) just ends the drain. An old host never emits any → splitValid stays
|
||||
// false and the HUD keeps the combined host+network term.
|
||||
if let conn = self.connection {
|
||||
var burst = 0
|
||||
while burst < 1024, let t = try? conn.nextHostTiming(timeoutMs: 0) {
|
||||
self.latencySplit.noteHostTiming(ptsNs: t.ptsNs, hostUs: t.hostUs)
|
||||
burst += 1
|
||||
}
|
||||
}
|
||||
if let t = self.presentTail.drain() {
|
||||
self.presentTailP50Ms = t.p50Ms
|
||||
self.presentTailP95Ms = t.p95Ms
|
||||
self.presentTailValid = true
|
||||
if let s = self.latencySplit.drain() {
|
||||
self.hostP50Ms = s.hostP50Ms
|
||||
self.networkP50Ms = s.networkP50Ms
|
||||
self.splitValid = true
|
||||
} else {
|
||||
self.presentTailValid = false
|
||||
self.splitValid = false
|
||||
}
|
||||
if let e = self.endToEnd.drain() {
|
||||
self.endToEndP50Ms = e.p50Ms
|
||||
self.endToEndP95Ms = e.p95Ms
|
||||
self.endToEndSkewCorrected = e.skewCorrected
|
||||
self.endToEndValid = true
|
||||
} else {
|
||||
self.endToEndValid = false
|
||||
}
|
||||
if let d = self.decodeStage.drain() {
|
||||
self.decodeP50Ms = d.p50Ms
|
||||
self.decodeValid = true
|
||||
} else {
|
||||
self.decodeValid = false
|
||||
}
|
||||
if let d = self.displayStage.drain() {
|
||||
self.displayP50Ms = d.p50Ms
|
||||
self.displayValid = true
|
||||
} else {
|
||||
self.displayValid = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// The streaming overlay HUD: mode + fps/throughput, the capture→client (and, under the stage-2
|
||||
// presenter, capture→present) latency lines, the platform input hint, and disconnect.
|
||||
// The streaming overlay HUD: mode + fps/throughput, the unified latency lines
|
||||
// (design/stats-unification.md — end-to-end headline + the stage equation under stage-2, the
|
||||
// capture→received headline under the stage-1 fallback), the loss counter, the platform input
|
||||
// hint, and disconnect.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
@@ -18,24 +20,46 @@ struct StreamHUDView: View {
|
||||
Text("\(connection.width)×\(connection.height)@\(connection.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
if model.latencyValid {
|
||||
// Capture→client-receipt (skew-corrected); excludes the layer's decode+present —
|
||||
// see LatencyMeter. "(same-host)" when the host didn't answer the skew handshake.
|
||||
Text("capture→client \(model.latencyP50Ms, specifier: "%.1f")/\(model.latencyP95Ms, specifier: "%.1f") ms p50/p95\(model.latencySkewCorrected ? "" : " (same-host)")")
|
||||
if model.endToEndValid {
|
||||
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
||||
// corrected) — "(same-host clock)" when the host didn't answer the skew handshake.
|
||||
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if model.presentLatencyValid {
|
||||
// Capture→present (glass-to-glass, modulo host render→capture) — stage-2 presenter
|
||||
// only; stage-1's layer presents internally with no per-frame stamp.
|
||||
Text("capture→present \(model.presentLatencyP50Ms, specifier: "%.1f")/\(model.presentLatencyP95Ms, specifier: "%.1f") ms p50/p95\(model.presentLatencySkewCorrected ? "" : " (same-host)")")
|
||||
// The equation: the stages tiling the headline interval (per-window p50s —
|
||||
// they only approximately sum to the directly-measured total). With a host
|
||||
// that reports per-AU timings (0xCF) the first term splits into host + network
|
||||
// (phase 2); an old host keeps the combined term.
|
||||
if model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||
if model.splitValid {
|
||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} else if model.hostNetworkValid {
|
||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||||
// per-frame stamp, so the honest headline ends at receipt. The host/network
|
||||
// split still applies there (receipt is presenter-independent) — it becomes the
|
||||
// only equation line; without it, host+network IS the whole measured interval.
|
||||
Text("capture→received \(model.hostNetworkP50Ms, specifier: "%.1f") ms p50 · \(model.hostNetworkP95Ms, specifier: "%.1f") p95\(model.hostNetworkSkewCorrected ? "" : " (same-host clock)")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
if model.splitValid {
|
||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if model.presentTailValid {
|
||||
// Decode→present (the client-local "present tail": ring wait + render + vsync) —
|
||||
// the term the stage-2 presenter shortens; no skew applies (one clock).
|
||||
Text("decode→present \(model.presentTailP50Ms, specifier: "%.1f")/\(model.presentTailP95Ms, specifier: "%.1f") ms p50/p95")
|
||||
if model.lostFrames > 0 {
|
||||
// Unrecoverable network drops this window; hidden while the link is clean.
|
||||
// String(format:) rather than specifier interpolation: the literal % would
|
||||
// otherwise land in the LocalizedStringKey's format string as a bogus conversion.
|
||||
Text(String(format: "lost %d (%.1f%%)", model.lostFrames, model.lostPct))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -255,6 +255,10 @@ struct ControllerTestView: View {
|
||||
Toggle("Light motor (right)", isOn: $lightOn)
|
||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
if let problem = tester.rumbleHealth {
|
||||
Label(problem, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.orange)
|
||||
}
|
||||
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
|
||||
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
||||
+ "can't reach its motors on macOS).")
|
||||
|
||||
@@ -7,63 +7,15 @@ import SwiftUI
|
||||
extension SettingsView {
|
||||
// MARK: - Sections (shared)
|
||||
|
||||
// NOTE: the Section content is deliberately split into the small named builders below — as one
|
||||
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
|
||||
// type-checker budget ("unable to type-check this expression in reasonable time"), which
|
||||
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
||||
@ViewBuilder var streamModeSection: some View {
|
||||
Section {
|
||||
#if os(iOS)
|
||||
// Touch-first: a rotating wheel of common resolutions (this device's own mode first) and
|
||||
// a segmented refresh-rate control — the same family as the Clock/Timer pickers. The host
|
||||
// renders a virtual output at exactly the chosen mode, so these are real pixel sizes. The
|
||||
// last wheel row, "Custom…", reveals width/height/refresh fields for an arbitrary mode.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Resolution")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Resolution", selection: resolutionSelection) {
|
||||
ForEach(resolutionChoices, id: \.tag) { choice in
|
||||
Text(choice.label).tag(choice.tag)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxHeight: 140)
|
||||
}
|
||||
if isCustomResolution {
|
||||
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
|
||||
HStack {
|
||||
TextField("Width", value: $width, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
Text("×")
|
||||
TextField("Height", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
// A row built from an HStack of TextFields otherwise insets its bottom separator to
|
||||
// the inner content, clipping the hairline under "Width"; pin it to the cell edge.
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
|
||||
LabeledContent("Refresh rate") {
|
||||
TextField("Hz", value: $hz, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if refreshChoices.count > 1 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Refresh rate")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Refresh rate", selection: $hz) {
|
||||
ForEach(refreshChoices, id: \.self) { rate in
|
||||
Text("\(rate) Hz").tag(rate)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
} else {
|
||||
// A device with a single supported rate (e.g. 60 Hz) has nothing to pick.
|
||||
LabeledContent("Refresh rate") {
|
||||
Text("\(hz) Hz").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
iosResolutionWheel
|
||||
iosRefreshRows
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
#elseif os(macOS)
|
||||
HStack {
|
||||
@@ -78,23 +30,7 @@ extension SettingsView {
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
if bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
Text("Bitrate")
|
||||
}
|
||||
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 76, alignment: .trailing)
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
bitrateRows
|
||||
#endif
|
||||
} header: {
|
||||
Text("Stream mode")
|
||||
@@ -109,6 +45,67 @@ extension SettingsView {
|
||||
#if os(iOS)
|
||||
// MARK: - Stream mode (iOS wheel)
|
||||
|
||||
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) — the
|
||||
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
|
||||
/// chosen mode, so these are real pixel sizes. The last wheel row, "Custom…", reveals
|
||||
/// width/height/refresh fields for an arbitrary mode (see `iosRefreshRows`).
|
||||
@ViewBuilder private var iosResolutionWheel: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Resolution")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Resolution", selection: resolutionSelection) {
|
||||
ForEach(resolutionChoices, id: \.tag) { choice in
|
||||
Text(choice.label).tag(choice.tag)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxHeight: 140)
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom W×H(+Hz) fields, a segmented refresh picker, or a static single-rate row.
|
||||
@ViewBuilder private var iosRefreshRows: some View {
|
||||
if isCustomResolution {
|
||||
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
|
||||
HStack {
|
||||
TextField("Width", value: $width, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
Text("×")
|
||||
TextField("Height", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
// A row built from an HStack of TextFields otherwise insets its bottom separator to
|
||||
// the inner content, clipping the hairline under "Width"; pin it to the cell edge.
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
|
||||
LabeledContent("Refresh rate") {
|
||||
TextField("Hz", value: $hz, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if refreshChoices.count > 1 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Refresh rate")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Refresh rate", selection: $hz) {
|
||||
ForEach(refreshChoices, id: \.self) { rate in
|
||||
Text("\(rate) Hz").tag(rate)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
} else {
|
||||
// A device with a single supported rate (e.g. 60 Hz) has nothing to pick.
|
||||
LabeledContent("Refresh rate") {
|
||||
Text("\(hz) Hz").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentinel wheel tag for the "Custom…" row. Real tags are "WxH" (digits + "x"), so this can't
|
||||
/// collide with a resolution.
|
||||
private static let customResolutionTag = "custom"
|
||||
@@ -156,6 +153,29 @@ extension SettingsView {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
||||
@ViewBuilder private var bitrateRows: some View {
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
if bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
Text("Bitrate")
|
||||
}
|
||||
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 76, alignment: .trailing)
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder var audioSection: some View {
|
||||
Section {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
@@ -188,6 +208,17 @@ extension SettingsView {
|
||||
}
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
|
||||
// pick it. Auto sums every channel (a lone hot mic still passes at full level).
|
||||
if micChannelCount > 1 {
|
||||
Picker("Microphone channel", selection: $micChannel) {
|
||||
Text("Auto (all channels)").tag(0)
|
||||
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||
Text("Channel \(ch)").tag(ch)
|
||||
}
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
}
|
||||
#endif
|
||||
} header: {
|
||||
Text("Audio")
|
||||
@@ -201,27 +232,45 @@ extension SettingsView {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// iPad-only pointer-capture toggle: lock the mouse/trackpad for relative movement (games) vs
|
||||
/// forward an absolute cursor position (desktop). Empty on iPhone (no hardware-pointer lock —
|
||||
/// the mouse path there is always the absolute fallback).
|
||||
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
|
||||
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
|
||||
@ViewBuilder var pointerSection: some View {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
Section {
|
||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||
} header: {
|
||||
Text("Pointer")
|
||||
} footer: {
|
||||
Text("With a mouse or trackpad connected, lock the pointer and send relative "
|
||||
+ "movement — the expected behavior for games (mouse-look). Turn this off for "
|
||||
+ "desktop use to keep the pointer free and send its absolute position instead. "
|
||||
+ "The lock needs the stream full-screen and frontmost; it falls back to the "
|
||||
+ "absolute pointer automatically (Stage Manager, Slide Over). Finger touch is "
|
||||
+ "unaffected. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
Section {
|
||||
Picker("Touch input", selection: $touchMode) {
|
||||
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
|
||||
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
|
||||
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
|
||||
}
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||
}
|
||||
} header: {
|
||||
Text("Touch & pointer")
|
||||
} footer: {
|
||||
Text(pointerFooterText)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Footer copy for `pointerSection`, built in plain `+=` statements. Deliberately NOT one big
|
||||
/// `+` chain (with a ternary) inside the ViewBuilder — that single expression blew Swift's
|
||||
/// type-checker budget and was what actually broke the iOS archive.
|
||||
private var pointerFooterText: String {
|
||||
var text = "Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
|
||||
text += "click, two-finger tap for a right click, two-finger drag to scroll, "
|
||||
text += "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
|
||||
text += "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
|
||||
text += "multi-touch reaches the host, for apps that understand touch. Applies from "
|
||||
text += "the next touch."
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
text += " Pointer capture locks a hardware mouse/trackpad for relative movement "
|
||||
text += "(mouse-look); off keeps the pointer free and sends absolute positions. "
|
||||
text += "The lock needs the stream full-screen and frontmost, and falls back "
|
||||
text += "automatically (Stage Manager, Slide Over)."
|
||||
}
|
||||
return text
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder var compositorSection: some View {
|
||||
@@ -272,10 +321,11 @@ extension SettingsView {
|
||||
Text("Video presenter · debug")
|
||||
} footer: {
|
||||
Text("Stage 2 (default) decodes explicitly and presents through Metal with a display "
|
||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD and "
|
||||
+ "self-recovers from decode stalls. Stage 1 feeds compressed video straight to the "
|
||||
+ "system display layer; it freezes on a lost HEVC reference frame, so it's a debug "
|
||||
+ "fallback only. Applies from the next session.")
|
||||
+ "link — it gives the HUD the end-to-end (capture→on-glass) headline with the "
|
||||
+ "host+network/decode/display stage equation and self-recovers from decode "
|
||||
+ "stalls. Stage 1 feeds compressed video straight to the system display layer; "
|
||||
+ "it freezes on a lost HEVC reference frame, so it's a debug fallback only. "
|
||||
+ "Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ struct SettingsView: View {
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||
// General on iPad (a two-column layout should never open with an empty detail).
|
||||
@@ -60,8 +61,12 @@ struct SettingsView: View {
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||||
@State var outputDevices: [AudioDevice] = []
|
||||
@State var inputDevices: [AudioDevice] = []
|
||||
// Input channels of the selected mic — drives the "Microphone channel" picker, which only
|
||||
// appears for a multi-channel interface (>1). 0 until the Audio tab loads it.
|
||||
@State var micChannelCount = 0
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
@@ -114,6 +119,12 @@ struct SettingsView: View {
|
||||
.onAppear {
|
||||
outputDevices = AudioDevices.outputs()
|
||||
inputDevices = AudioDevices.inputs()
|
||||
micChannelCount = AudioDevices.inputChannelCount(forUID: micUID)
|
||||
}
|
||||
.onChange(of: micUID) { _, newUID in
|
||||
// A different mic → different channel count; drop a now-out-of-range pin to Auto.
|
||||
micChannelCount = AudioDevices.inputChannelCount(forUID: newUID)
|
||||
if micChannel > micChannelCount { micChannel = 0 }
|
||||
}
|
||||
.tabItem { Label("Audio", systemImage: "speaker.wave.2") }
|
||||
|
||||
|
||||
@@ -33,6 +33,49 @@ public enum AudioDevices {
|
||||
}
|
||||
}
|
||||
|
||||
/// Input channel count of the mic the picker would use — the device with this UID, or the
|
||||
/// system default input when `uid` is empty. 0 when it can't be resolved. Drives the
|
||||
/// "Microphone channel" picker (only shown for multi-channel interfaces).
|
||||
public static func inputChannelCount(forUID uid: String) -> Int {
|
||||
let id = uid.isEmpty ? defaultInputDevice() : deviceID(forUID: uid)
|
||||
guard let id else { return 0 }
|
||||
return channelCount(id, scope: kAudioObjectPropertyScopeInput)
|
||||
}
|
||||
|
||||
private static func defaultInputDevice() -> AudioDeviceID? {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var dev = AudioDeviceID(0)
|
||||
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||
guard AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &dev) == noErr,
|
||||
dev != 0
|
||||
else { return nil }
|
||||
return dev
|
||||
}
|
||||
|
||||
/// Sum of channels across the device's streams in `scope` (its total input/output channels).
|
||||
private static func channelCount(
|
||||
_ id: AudioDeviceID, scope: AudioObjectPropertyScope
|
||||
) -> Int {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||
mScope: scope,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var size: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr, size > 0
|
||||
else { return 0 }
|
||||
let raw = UnsafeMutableRawPointer.allocate(
|
||||
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||
defer { raw.deallocate() }
|
||||
guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, raw) == noErr else { return 0 }
|
||||
let abl = UnsafeMutableAudioBufferListPointer(
|
||||
raw.assumingMemoryBound(to: AudioBufferList.self))
|
||||
return abl.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||
}
|
||||
|
||||
private static func all() -> [AudioDeviceID] {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
@@ -62,7 +105,8 @@ public enum AudioDevices {
|
||||
return AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr && size > 0
|
||||
}
|
||||
|
||||
private static func describe(_ id: AudioDeviceID) -> AudioDevice? {
|
||||
/// UID + human name for a live AudioDeviceID (nil if either property is unreadable).
|
||||
static func describe(_ id: AudioDeviceID) -> AudioDevice? {
|
||||
guard let uid = stringProperty(id, kAudioDevicePropertyDeviceUID),
|
||||
let name = stringProperty(id, kAudioObjectPropertyName)
|
||||
else { return nil }
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
// AVAudioSourceNode pulls from the ring (silence on underrun with re-priming, so a
|
||||
// network gap costs one dip, not permanent crackle).
|
||||
//
|
||||
// mic → host: a second AVAudioEngine taps the input device, resamples to 48 kHz
|
||||
// stereo, slices 20 ms chunks, Opus-encodes, and sendMic()s each packet — the host
|
||||
// feeds them into a virtual PipeWire source.
|
||||
// mic → host: a second AVAudioEngine taps the input device, folds it to one mono bus (the
|
||||
// chosen channel of a multi-channel interface, or a sum of all channels), resamples to 48 kHz
|
||||
// stereo, slices 20 ms chunks, Opus-encodes, and sendMic()s each packet — the host feeds them
|
||||
// into a virtual PipeWire source.
|
||||
//
|
||||
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
|
||||
// concrete device and follows default-device changes). Two engines, not one — a single
|
||||
@@ -68,10 +69,11 @@ public final class SessionAudio {
|
||||
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
|
||||
/// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not
|
||||
/// on return. The mic may start later still if the permission prompt is pending.
|
||||
public func start(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||
public func start(speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool) {
|
||||
#if os(macOS)
|
||||
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||
startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
|
||||
startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel, micEnabled: micEnabled)
|
||||
#else
|
||||
// Configure + activate the session OFF the main thread (it blocks on the audio server),
|
||||
// then start the engines back on the main thread once it's active — engine routing/format
|
||||
@@ -81,7 +83,9 @@ public final class SessionAudio {
|
||||
self.activateAudioSession(micEnabled: micEnabled)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
|
||||
self.startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -115,7 +119,9 @@ public final class SessionAudio {
|
||||
|
||||
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main
|
||||
/// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
|
||||
private func startEngines(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||
private func startEngines(
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool
|
||||
) {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
#if os(tvOS)
|
||||
// No app-accessible microphone input on tvOS — playback only.
|
||||
@@ -123,12 +129,12 @@ public final class SessionAudio {
|
||||
guard micEnabled else { return }
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
startCapture(micUID: micUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self, granted, !self.flag.isStopped else { return }
|
||||
self.startCapture(micUID: micUID)
|
||||
self.startCapture(micUID: micUID, micChannel: micChannel)
|
||||
}
|
||||
}
|
||||
default:
|
||||
@@ -280,7 +286,7 @@ public final class SessionAudio {
|
||||
// MARK: - Mic (mic → host)
|
||||
|
||||
#if !os(tvOS)
|
||||
private func startCapture(micUID: String) {
|
||||
private func startCapture(micUID: String, micChannel: Int) {
|
||||
let engine = AVAudioEngine()
|
||||
let input = engine.inputNode
|
||||
#if os(macOS)
|
||||
@@ -300,8 +306,63 @@ public final class SessionAudio {
|
||||
log.error("no usable input device — mic uplink disabled")
|
||||
return
|
||||
}
|
||||
guard let encoder = try? OpusEncoder(),
|
||||
let resampler = AVAudioConverter(from: inFormat, to: encoder.pcmFormat),
|
||||
|
||||
// Multi-channel-interface handling. A pro interface exposes N discrete inputs with the mic
|
||||
// on ONE of them, but AVAudioConverter's N→stereo downmix takes channels 0/1 — dead
|
||||
// silence when the mic sits higher up (the classic "host receives zeros"). So we fold the
|
||||
// input to a single mono bus OURSELVES and resample that. micChannel: 0 = Auto (sum every
|
||||
// channel — a lone hot mic passes at full level), n≥1 pins 1-based input channel n.
|
||||
let inChannels = Int(inFormat.channelCount)
|
||||
let pinnedChannel: Int? = {
|
||||
guard micChannel >= 1 else { return nil }
|
||||
let idx = micChannel - 1
|
||||
guard idx < inChannels else {
|
||||
log.warning(
|
||||
"mic channel \(micChannel) out of range (device has \(inChannels)) — mixing all")
|
||||
return nil
|
||||
}
|
||||
return idx
|
||||
}()
|
||||
let channelPlan = pinnedChannel.map { "channel \($0 + 1)/\(inChannels)" }
|
||||
?? (inChannels > 1 ? "mix \(inChannels)ch→mono" : "mono")
|
||||
|
||||
// Name the device we're ACTUALLY recording from + its format + how we fold it, once per
|
||||
// session. This single line localizes the whole class of "host receives silence" failures
|
||||
// that otherwise need a host-side tone injection to pin down: a UID that silently fell back
|
||||
// to the default, the wrong device being live, or the wrong channel picked.
|
||||
#if os(macOS)
|
||||
if let unit = input.audioUnit, let live = Self.currentDevice(of: unit),
|
||||
let dev = AudioDevices.describe(live) {
|
||||
if !micUID.isEmpty, dev.uid != micUID {
|
||||
log.warning("""
|
||||
mic selection not honored — requested \(micUID) but capturing from \
|
||||
\(dev.name) [\(dev.uid)]; the device's UID likely changed (replug) — \
|
||||
reselect it in Settings
|
||||
""")
|
||||
}
|
||||
log.info("""
|
||||
mic capture: \(dev.name) [\(dev.uid)] — \(Int(inFormat.sampleRate)) Hz, \
|
||||
\(inChannels) ch, \(channelPlan)
|
||||
""")
|
||||
} else {
|
||||
log.info("""
|
||||
mic capture: <device unavailable> — \(Int(inFormat.sampleRate)) Hz, \
|
||||
\(inChannels) ch, \(channelPlan)
|
||||
""")
|
||||
}
|
||||
#else
|
||||
log.info(
|
||||
"mic capture: \(Int(inFormat.sampleRate)) Hz, \(inChannels) ch, \(channelPlan)")
|
||||
#endif
|
||||
|
||||
// Encode a single mono bus (folded from `inFormat` in the tap): the resampler goes
|
||||
// mono@inputSR → the encoder's 48 kHz stereo, so it handles both the rate change and the
|
||||
// mono→stereo duplication, and the wrong-channel downmix never happens.
|
||||
guard let monoFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: inFormat.sampleRate,
|
||||
channels: 1, interleaved: false),
|
||||
let encoder = try? OpusEncoder(),
|
||||
let resampler = AVAudioConverter(from: monoFormat, to: encoder.pcmFormat),
|
||||
let chunk = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: OpusEncoder.framesPerPacket)
|
||||
else {
|
||||
@@ -317,11 +378,59 @@ public final class SessionAudio {
|
||||
let connection = connection
|
||||
let flag = flag
|
||||
|
||||
// Silence tripwire (tap-confined): a "recording" app can be handed pure digital zeros —
|
||||
// a zeroed input-volume slider, a stale TCC grant, a muted device, OR the wrong channel
|
||||
// picked — and everything downstream looks alive while the host gets silence. Track the
|
||||
// peak of the EXTRACTED mono bus over the first ~10 s (not the raw device — a mic present
|
||||
// on a channel we didn't grab must still read as silence) and emit exactly ONE verdict.
|
||||
// This is the log line whose absence made the last occurrence take a host-side tone.
|
||||
let silenceWindow = Int(inFormat.sampleRate * 10)
|
||||
let deviceLabel = micUID.isEmpty ? "default input" : micUID
|
||||
var framesInspected = 0
|
||||
var inputPeak: Float = 0
|
||||
var levelReported = false
|
||||
|
||||
input.installTap(onBus: 0, bufferSize: 2048, format: inFormat) { buffer, _ in
|
||||
if flag.isStopped { return }
|
||||
let frames = Int(buffer.frameLength)
|
||||
guard frames > 0, let src = buffer.floatChannelData,
|
||||
let mono = AVAudioPCMBuffer(
|
||||
pcmFormat: monoFormat, frameCapacity: buffer.frameLength),
|
||||
let dst = mono.floatChannelData?[0]
|
||||
else { return }
|
||||
mono.frameLength = buffer.frameLength
|
||||
|
||||
// Fold the multi-channel input down to the one mono bus we encode.
|
||||
Self.foldToMono(
|
||||
input: src, frames: frames, channels: Int(buffer.format.channelCount),
|
||||
interleaved: buffer.format.isInterleaved, pinned: pinnedChannel, out: dst)
|
||||
|
||||
if !levelReported {
|
||||
var localPeak: Float = 0
|
||||
for i in 0..<frames where abs(dst[i]) > localPeak { localPeak = abs(dst[i]) }
|
||||
if localPeak > inputPeak { inputPeak = localPeak }
|
||||
framesInspected += frames
|
||||
if framesInspected >= silenceWindow {
|
||||
levelReported = true
|
||||
if inputPeak == 0 {
|
||||
log.warning("""
|
||||
mic uplink has been pure digital SILENCE for 10 s (\(deviceLabel), \
|
||||
\(channelPlan)) — check the input level (System Settings → Sound → \
|
||||
Input), Privacy & Security → Microphone, and the Microphone channel in \
|
||||
Settings; the host is receiving zeros
|
||||
""")
|
||||
} else {
|
||||
let dbfs = 20 * log10(inputPeak)
|
||||
log.info("""
|
||||
mic uplink OK — peak \(String(format: "%.1f", dbfs)) dBFS over first \
|
||||
10 s (\(deviceLabel), \(channelPlan))
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ratio = 48_000 / inFormat.sampleRate
|
||||
let outCapacity = AVAudioFrameCount(
|
||||
(Double(buffer.frameLength) * ratio).rounded(.up) + 64)
|
||||
let outCapacity = AVAudioFrameCount((Double(frames) * ratio).rounded(.up) + 64)
|
||||
guard let staging = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: outCapacity)
|
||||
else { return }
|
||||
@@ -334,7 +443,7 @@ public final class SessionAudio {
|
||||
}
|
||||
fed = true
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
return mono
|
||||
}
|
||||
guard status != .error, let p = staging.floatChannelData?[0] else { return }
|
||||
fifo.append(contentsOf: UnsafeBufferPointer(
|
||||
@@ -378,6 +487,42 @@ public final class SessionAudio {
|
||||
stateLock.unlock()
|
||||
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
|
||||
}
|
||||
|
||||
/// Fold `channels` of input (`floatChannelData` layout: `interleaved` → one buffer strided by
|
||||
/// channel count; else one buffer per channel) down to a single mono bus in `out` (`frames`
|
||||
/// long). `pinned` (0-based, must be `< channels`) copies exactly that channel — the fix for a
|
||||
/// mic on one input of a multi-channel interface; `nil` sums every channel, clamped to
|
||||
/// [-1, 1], so a lone hot channel still passes at full level instead of the silent 0/1 the
|
||||
/// default N→stereo downmix would grab. Pure + `internal` for unit testing the index math.
|
||||
static func foldToMono(
|
||||
input: UnsafePointer<UnsafeMutablePointer<Float>>, frames: Int, channels: Int,
|
||||
interleaved: Bool, pinned: Int?, out: UnsafeMutablePointer<Float>
|
||||
) {
|
||||
if let ch = pinned, ch < channels {
|
||||
if interleaved {
|
||||
let d = input[0]
|
||||
for i in 0..<frames { out[i] = d[i * channels + ch] }
|
||||
} else {
|
||||
let d = input[ch]
|
||||
for i in 0..<frames { out[i] = d[i] }
|
||||
}
|
||||
} else if interleaved {
|
||||
let d = input[0]
|
||||
for i in 0..<frames {
|
||||
var s: Float = 0
|
||||
for c in 0..<channels { s += d[i * channels + c] }
|
||||
out[i] = max(-1, min(1, s))
|
||||
}
|
||||
} else {
|
||||
let d0 = input[0]
|
||||
for i in 0..<frames { out[i] = d0[i] }
|
||||
for c in 1..<channels {
|
||||
let d = input[c]
|
||||
for i in 0..<frames { out[i] += d[i] }
|
||||
}
|
||||
if channels > 1 { for i in 0..<frames { out[i] = max(-1, min(1, out[i])) } }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
@@ -387,5 +532,18 @@ public final class SessionAudio {
|
||||
unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0,
|
||||
&dev, UInt32(MemoryLayout<AudioDeviceID>.size)) == noErr
|
||||
}
|
||||
|
||||
/// Read back the AUHAL's live device — the definitive "what are we actually capturing
|
||||
/// from", which catches a selection that succeeded on paper but silently fell back to
|
||||
/// the system default (a stale/changed UID, a device that vanished between resolve and
|
||||
/// start). 0 / an error means we couldn't tell.
|
||||
private static func currentDevice(of unit: AudioUnit) -> AudioDeviceID? {
|
||||
var dev = AudioDeviceID(0)
|
||||
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||
let status = AudioUnitGetProperty(
|
||||
unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &dev, &size)
|
||||
guard status == noErr, dev != 0 else { return nil }
|
||||
return dev
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ public struct AccessUnit: Sendable {
|
||||
public let ptsNs: UInt64
|
||||
public let frameIndex: UInt32
|
||||
public let flags: UInt32
|
||||
/// Client `CLOCK_REALTIME` instant the AU was handed over by the core (post-FEC, decrypted)
|
||||
/// — the **received** measurement point of design/stats-unification.md. The decode stage is
|
||||
/// `decodedNs - receivedNs`, both client-local (no skew offset applies).
|
||||
public let receivedNs: Int64
|
||||
}
|
||||
|
||||
/// One Opus audio packet (48 kHz stereo, 5 ms frames) — decode with AVAudioConverter
|
||||
@@ -79,6 +83,9 @@ public final class PunktfunkConnection {
|
||||
/// Same role for the feedback drain thread (rumble + HID-output — two core planes,
|
||||
/// drained sequentially by one thread).
|
||||
private let feedbackLock = NSLock()
|
||||
/// Same role for the host-timing (0xCF) puller — its own plane in the core, drained
|
||||
/// non-blockingly by the app's 1 s stats tick (never contends with the blocking pullers).
|
||||
private let statsLock = NSLock()
|
||||
|
||||
/// Negotiated session mode (host-confirmed).
|
||||
public private(set) var width: UInt32 = 0
|
||||
@@ -419,9 +426,13 @@ public final class PunktfunkConnection {
|
||||
case statusOK:
|
||||
guard let base = frame.data, frame.len > 0 else { return nil }
|
||||
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
|
||||
var ts = timespec()
|
||||
clock_gettime(CLOCK_REALTIME, &ts)
|
||||
let receivedNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
return AccessUnit(
|
||||
data: data, ptsNs: frame.pts_ns,
|
||||
frameIndex: frame.frame_index, flags: frame.flags)
|
||||
frameIndex: frame.frame_index, flags: frame.flags,
|
||||
receivedNs: receivedNs)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
@@ -657,6 +668,40 @@ public final class PunktfunkConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// One per-AU host-timing report (0xCF): the host's capture→fully-sent duration for the
|
||||
/// access unit whose `AccessUnit.ptsNs` equals `ptsNs` exactly. The stats consumer derives
|
||||
/// `network = (receivedNs + clockOffsetNs − ptsNs) − hostUs` — the host/network split of the
|
||||
/// HUD's `host+network` stage (design/stats-unification.md Phase 2).
|
||||
public struct HostTiming: Sendable, Equatable {
|
||||
/// The AU's capture stamp (host capture clock — matches the AU's `ptsNs`).
|
||||
public let ptsNs: UInt64
|
||||
/// Host capture→sent duration, µs.
|
||||
public let hostUs: UInt32
|
||||
}
|
||||
|
||||
/// Pull the next per-AU host timing; nil on timeout, throws `.closed` once the session
|
||||
/// ended. Best-effort plane: an older host never emits any — keep showing the combined
|
||||
/// `host+network` stage then. Drain non-blockingly (`timeoutMs: 0`) from ONE stats
|
||||
/// consumer (its own core plane, safe alongside the other pullers).
|
||||
public func nextHostTiming(timeoutMs: UInt32 = 0) throws -> HostTiming? {
|
||||
statsLock.lock()
|
||||
defer { statsLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var out = PunktfunkHostTiming()
|
||||
let rc = punktfunk_connection_next_host_timing(h, &out, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
return HostTiming(ptsNs: out.pts_ns, hostUs: out.host_us)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
|
||||
/// silently dropped after close.
|
||||
public func send(_ event: PunktfunkInputEvent) {
|
||||
@@ -676,10 +721,12 @@ public final class PunktfunkConnection {
|
||||
pumpLock.lock() // pullers exit at their next poll boundary, releasing these
|
||||
audioLock.lock()
|
||||
feedbackLock.lock()
|
||||
statsLock.lock()
|
||||
abiLock.lock()
|
||||
let h = handle
|
||||
handle = nil
|
||||
abiLock.unlock()
|
||||
statsLock.unlock()
|
||||
feedbackLock.unlock()
|
||||
audioLock.unlock()
|
||||
pumpLock.unlock()
|
||||
|
||||
@@ -10,13 +10,20 @@ import GameController
|
||||
/// a passing test exercises the exact code a session runs.
|
||||
@MainActor
|
||||
public final class ControllerTester: ObservableObject {
|
||||
private let renderer = RumbleRenderer()
|
||||
// `.manual`: the panel's toggles hold a level until changed — no session wire refreshes
|
||||
// exist here to keep the renderer's staleness watchdog fed.
|
||||
private let renderer = RumbleRenderer(policy: .manual)
|
||||
private weak var controller: GCController?
|
||||
|
||||
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
||||
/// for the test panel to display so it's obvious which path a given pad takes.
|
||||
@Published public private(set) var rumbleBackend = "—"
|
||||
|
||||
/// Why rumble structurally cannot work right now (nil = healthy) — e.g. the device's
|
||||
/// haptics service refusing every connection, or a pad with no rumble engine. Shown by the
|
||||
/// test panel so silence diagnoses itself instead of reading as an app bug.
|
||||
@Published public private(set) var rumbleHealth: String?
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Aim the feedback at a controller (nil releases it). Idempotent — safe to call on every
|
||||
@@ -24,9 +31,14 @@ public final class ControllerTester: ObservableObject {
|
||||
public func target(_ c: GCController?) {
|
||||
guard c !== controller else { return }
|
||||
controller = c
|
||||
renderer.retarget(c) { [weak self] note in
|
||||
Task { @MainActor in self?.rumbleBackend = note }
|
||||
}
|
||||
renderer.retarget(
|
||||
c,
|
||||
onBackend: { [weak self] note in
|
||||
Task { @MainActor in self?.rumbleBackend = note }
|
||||
},
|
||||
onHealth: { [weak self] problem in
|
||||
Task { @MainActor in self?.rumbleHealth = problem }
|
||||
})
|
||||
}
|
||||
|
||||
/// Drive both motors at 0...1 amplitudes — low = left/heavy, high = right/light — mapped to
|
||||
|
||||
@@ -102,6 +102,13 @@ public final class GamepadCapture {
|
||||
tp?.primary.valueChangedHandler = nil
|
||||
tp?.secondary.valueChangedHandler = nil
|
||||
}
|
||||
// Hand the system gestures back to the OS before letting the old pad go — outside a
|
||||
// stream the share button's screenshot and the Home overlay are the user's, not ours.
|
||||
if let old = bound {
|
||||
for element in old.physicalInputProfile.elements.values {
|
||||
element.preferredSystemGestureState = .enabled
|
||||
}
|
||||
}
|
||||
if let motion = bound?.motion {
|
||||
motion.valueChangedHandler = nil
|
||||
// Power the sensors back down — left active they keep the pad streaming
|
||||
@@ -114,14 +121,21 @@ public final class GamepadCapture {
|
||||
ext.valueChangedHandler = { [weak self] g, _ in
|
||||
MainActor.assumeIsolated { self?.sync(g) }
|
||||
}
|
||||
// The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit). On
|
||||
// macOS the SYSTEM grabs it by default (opens Launchpad's Games folder), so it never reached
|
||||
// the app — `preferredSystemGestureState = .disabled` on the element is what hands it to us.
|
||||
// We drive `guide` 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.
|
||||
// Claim EVERY element's system gesture while this pad drives a stream. The OS attaches
|
||||
// gestures to several controller buttons — share/create → local screenshot/recording,
|
||||
// Home → Game Center overlay (iOS) / Launchpad's Games folder (macOS) — and with a
|
||||
// gesture attached the press is the system's, not the game's. During capture the remote
|
||||
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
|
||||
// the PS button must open the host's Steam overlay. Restored to .enabled on unbind.
|
||||
for element in c.physicalInputProfile.elements.values {
|
||||
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
|
||||
// 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.
|
||||
if let home = c.physicalInputProfile.buttons[GCInputButtonHome] {
|
||||
home.preferredSystemGestureState = .disabled
|
||||
home.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||
MainActor.assumeIsolated { self?.sendGuide(down: pressed) }
|
||||
}
|
||||
@@ -192,6 +206,11 @@ public final class GamepadCapture {
|
||||
if g.dpad.right.isPressed { b |= GamepadWire.dpadRight }
|
||||
if g.buttonMenu.isPressed { b |= GamepadWire.start }
|
||||
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
|
||||
// The share/create/capture element (Xbox Series share, a clone pad's screenshot button —
|
||||
// e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose
|
||||
// the create button BOTH as buttonOptions and as the share element this OR is harmless —
|
||||
// same wire bit.
|
||||
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back }
|
||||
if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
|
||||
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
|
||||
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
|
||||
|
||||
@@ -25,7 +25,7 @@ public final class GamepadFeedback {
|
||||
private let flag = StopFlag()
|
||||
private let drainDone = DispatchSemaphore(value: 0)
|
||||
private var drainStarted = false
|
||||
private let rumble = RumbleRenderer()
|
||||
private let rumble = RumbleRenderer(policy: .session)
|
||||
private var activeSub: AnyCancellable?
|
||||
|
||||
// Last applied feedback (main-actor) — replayed when the active controller changes.
|
||||
@@ -82,8 +82,21 @@ public final class GamepadFeedback {
|
||||
// poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR
|
||||
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
|
||||
// rumble/HID latency low while leaving the lock free between polls.
|
||||
if let r = try connection.nextRumble(timeoutMs: 0), r.pad == 0 {
|
||||
self?.rumble.apply(low: r.low, high: r.high)
|
||||
//
|
||||
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
|
||||
// level. The old one-datagram-per-cycle shape let a burst outpace the ~125 Hz
|
||||
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
|
||||
// and its drop-newest overflow could shed a stop while stale nonzero states
|
||||
// queued ahead of it — buzzing until the host's next 500 ms refresh.
|
||||
var newest: (low: UInt16, high: UInt16)?
|
||||
var rumbleBurst = 0
|
||||
while rumbleBurst < 64, !flag.isStopped,
|
||||
let r = try connection.nextRumble(timeoutMs: 0) {
|
||||
if r.pad == 0 { newest = (r.low, r.high) }
|
||||
rumbleBurst += 1
|
||||
}
|
||||
if let n = newest {
|
||||
self?.rumble.apply(low: n.low, high: n.high)
|
||||
}
|
||||
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||
|
||||
@@ -5,28 +5,145 @@ import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
|
||||
/// Rumble → CoreHaptics, isolated on one serial queue (CHHapticEngine is not main-bound,
|
||||
/// but it isn't a free-for-all either). Engines are created lazily on the first nonzero
|
||||
/// amplitude and torn down on retarget; players run only while their motor is on, so an
|
||||
/// idle controller costs no radio traffic. Failures (pads without haptics, engine resets)
|
||||
/// downgrade to silence — rumble is best-effort by design.
|
||||
///
|
||||
/// `@unchecked Sendable` is sound because every property (`controller`/`low`/`high`/`broken`) is
|
||||
/// read and written only inside `queue` closures — the serial queue is the synchronization.
|
||||
final class RumbleRenderer: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||
/// Tuning constants + the pure scheduling decisions of the rumble renderer, split out so the
|
||||
/// policy is unit-testable without a `CHHapticEngine` or a physical pad.
|
||||
enum RumbleTuning {
|
||||
/// Haptic segment length. **No event is ever infinite**: a player the renderer loses track
|
||||
/// of (a stop dropped inside CoreHaptics, an engine race) self-silences when its segment
|
||||
/// expires, so this is the hard ceiling on how long the actuator can diverge from the
|
||||
/// target state.
|
||||
static let segmentSeconds: TimeInterval = 4.0
|
||||
/// Re-arm the successor segment once the current one has less than this left. Generous
|
||||
/// against the ticker period so a steady rumble can never miss the boundary and gap.
|
||||
static let rearmHeadroom: TimeInterval = 1.0
|
||||
/// Renderer ticker period while anything is (or should be) audible. Silence runs no timer.
|
||||
static let tickSeconds: TimeInterval = 0.05
|
||||
/// Minimum spacing between player rebuilds for nonzero→nonzero level changes — a game
|
||||
/// ramping rumble per frame would otherwise stop/start players at 60+ Hz, which is exactly
|
||||
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
|
||||
/// zero is never throttled.
|
||||
static let minRebakeSeconds: TimeInterval = 0.025
|
||||
/// Session watchdog: silence the motors when no wire command arrived for this long. The
|
||||
/// host re-sends the current rumble state every 500 ms as its loss heal, so this trips only
|
||||
/// after 3 consecutive refreshes vanished — i.e. the channel or host died while audible.
|
||||
static let sessionStaleSeconds: TimeInterval = 1.6
|
||||
/// Levels closer than this (≈0.4 % of full scale) are the same level — an identical host
|
||||
/// refresh must never rebuild a player.
|
||||
static let levelEpsilon: Float = 1.0 / 256.0
|
||||
/// macOS DualSense raw-HID path: re-write an unchanged nonzero level this often so the
|
||||
/// pad's firmware never times the rumble out mid-effect (Bluetooth pads watchdog output
|
||||
/// reports), and a dropped report heals.
|
||||
static let hidKeepaliveSeconds: TimeInterval = 0.9
|
||||
|
||||
/// One actuator's started engine plus the player currently driving it (nil = idle). The
|
||||
/// player is rebuilt per level change — `drive` bakes the target intensity into a fresh
|
||||
/// continuous event rather than scaling a long-lived one with a dynamic parameter.
|
||||
/// `CHHapticEvent` sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
||||
/// defined frequency to move at all (an intensity-only event left them silent) while a
|
||||
/// classic Xbox ERM rotor ignores it. On split-handle pads the wire's two motors render at
|
||||
/// distinct frequencies mirroring the real hardware they emulate — low/left ≈ the heavy
|
||||
/// low-frequency rotor, high/right ≈ the light buzzer; a single combined actuator keeps the
|
||||
/// proven mid value.
|
||||
static let sharpnessLow: Float = 0.3
|
||||
static let sharpnessHigh: Float = 0.7
|
||||
static let sharpnessCombined: Float = 0.5
|
||||
|
||||
/// Wire amplitude (0...0xFFFF) → CoreHaptics intensity (0...1).
|
||||
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
|
||||
/// Wire amplitude → DualSense HID motor byte.
|
||||
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
|
||||
/// Single-actuator pads render whichever motor is stronger.
|
||||
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
|
||||
/// Are two baked levels the same (skip the rebuild)?
|
||||
static func sameLevel(_ a: Float, _ b: Float) -> Bool { abs(a - b) <= levelEpsilon }
|
||||
/// Time for a segment handoff to act (engine timeline).
|
||||
static func shouldRearm(endsAt: TimeInterval, now: TimeInterval) -> Bool {
|
||||
endsAt - now <= rearmHeadroom
|
||||
}
|
||||
/// When the successor segment starts: exactly as the current one expires — unless that
|
||||
/// already passed (the gap already happened; start now).
|
||||
static func handoffStart(endsAt: TimeInterval, now: TimeInterval) -> TimeInterval {
|
||||
max(endsAt, now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rumble → the active physical controller (CoreHaptics; a DualSense on macOS goes over raw HID
|
||||
/// instead, see `DualSenseHID`), built around one principle: **rumble is idempotent state on a
|
||||
/// lossy channel, and the actuator's divergence from that state must be bounded** — not
|
||||
/// best-effort. The previous renderer drove infinite-duration players torn down and rebuilt per
|
||||
/// wire update; one asynchronous `stop` dropped inside CoreHaptics left an unstoppable player
|
||||
/// buzzing with its handle discarded, which no later (0,0) could reach — the "walked into the
|
||||
/// menu and the rumble never stopped" bug.
|
||||
///
|
||||
/// The invariants that bound divergence now:
|
||||
/// 1. **No infinite events.** A motor plays finite `segmentSeconds` segments; while the level
|
||||
/// holds, the successor is scheduled ON the engine timeline to start exactly when the
|
||||
/// current segment expires (seamless — no stop/start race in steady state). A leaked player
|
||||
/// therefore self-silences in ≤ `segmentSeconds`.
|
||||
/// 2. **Idempotent targets.** An update equal to the current target (the host re-sends rumble
|
||||
/// state every 500 ms as its loss heal) is a liveness stamp, never a player rebuild.
|
||||
/// 3. **Zero is immediate, ramps are throttled.** (0,0) stops players the moment it lands;
|
||||
/// nonzero→nonzero changes rebuild at most every `minRebakeSeconds` per motor (the ticker
|
||||
/// lands the newest value once the window opens).
|
||||
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown — the
|
||||
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
|
||||
/// exponential backoff.
|
||||
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
|
||||
/// `sessionStaleSeconds` → force silence. A lost stop can outlive the host's 500 ms heal
|
||||
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
|
||||
/// (the settings test panel) instead holds a level until it is changed.
|
||||
///
|
||||
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
|
||||
/// failures (pads without haptics, engine resets) downgrade to silence — rumble is best-effort
|
||||
/// by design, but *staying silent* when told to stop is not.
|
||||
///
|
||||
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
||||
/// `queue` closures — the serial queue is the synchronization.
|
||||
final class RumbleRenderer: @unchecked Sendable {
|
||||
/// What an un-refreshed nonzero target means. A live session ties motor life to wire
|
||||
/// liveness (the host refreshes state every 500 ms); the controller test panel holds a
|
||||
/// slider level indefinitely.
|
||||
struct Policy {
|
||||
let staleAfter: TimeInterval?
|
||||
static let session = Policy(staleAfter: RumbleTuning.sessionStaleSeconds)
|
||||
static let manual = Policy(staleAfter: nil)
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||
private let policy: Policy
|
||||
|
||||
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
|
||||
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
|
||||
/// advertises `adv players: 0`, and as of iOS 27 beta 2 an advanced-player sequence load
|
||||
/// doesn't degrade gracefully there — the daemon faults decoding the XPC message and drops
|
||||
/// it (CoreHaptics -4811/4097, rumble dead). We only need `start(atTime:)`/`stop(atTime:)`,
|
||||
/// which the plain protocol has.
|
||||
private struct Segment {
|
||||
let player: CHHapticPatternPlayer
|
||||
let endsAt: TimeInterval
|
||||
}
|
||||
|
||||
/// One actuator's started engine and the segment(s) realizing `level` on it. `retiring` is
|
||||
/// the predecessor across a segment handoff — left to expire naturally (its successor
|
||||
/// starts the instant it ends), but the reference is held so a level change or stop can
|
||||
/// still force-stop it.
|
||||
private struct Motor {
|
||||
let engine: CHHapticEngine
|
||||
var player: CHHapticAdvancedPatternPlayer?
|
||||
let sharpness: Float
|
||||
var level: Float = 0
|
||||
var current: Segment?
|
||||
var retiring: Segment?
|
||||
var lastRebake = DispatchTime(uptimeNanoseconds: 0)
|
||||
}
|
||||
|
||||
private var controller: GCController?
|
||||
private var low: Motor?
|
||||
private var high: Motor?
|
||||
/// Wire-truth target (raw wire units) and when it was last confirmed by any command.
|
||||
private var target: (low: UInt16, high: UInt16) = (0, 0)
|
||||
private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
|
||||
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
|
||||
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
|
||||
/// so an idle controller costs no timer wakeups and no radio traffic.
|
||||
private var ticker: DispatchSourceTimer?
|
||||
|
||||
// `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad
|
||||
// on an OS that doesn't expose rumble through GameController, a Siri Remote) — nothing to retry
|
||||
// until the controller changes. A transient engine failure does NOT latch it; it tears down for
|
||||
@@ -39,86 +156,277 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
// break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble
|
||||
// update immediately rebuilds into the same dead connection, flooding the log and never
|
||||
// recovering. Delay the next setup() — growing 0.5→1→2→4 s on repeated failure — and clear it
|
||||
// the moment a player runs cleanly (or the controller changes).
|
||||
private var retryAfter = Date.distantPast
|
||||
// the moment a player is actually running (or the controller changes).
|
||||
private var retryAfter = DispatchTime(uptimeNanoseconds: 0)
|
||||
private var consecutiveFailures = 0
|
||||
|
||||
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
||||
/// defined frequency to move at all — an intensity-only event (no sharpness) left them
|
||||
/// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid
|
||||
/// value the known-working macOS DualSense rumble implementations use. (Used only on the
|
||||
/// CoreHaptics path — a DualSense on macOS is driven over raw HID instead, see below.)
|
||||
private static let sharpness: Float = 0.5
|
||||
/// Downgrade after split-handle engines fail: retry with ONE combined `.default` engine —
|
||||
/// the configuration virtually every iOS game (and this app's own menu haptics) uses — before
|
||||
/// treating the service as unreachable. A haptics daemon that mishandles per-handle
|
||||
/// localities for a particular pad can still serve the combined engine. One-way per
|
||||
/// controller; retarget resets it.
|
||||
private var preferCombined = false
|
||||
/// Health reporting for the debug test panel: a human-readable problem while rumble cannot
|
||||
/// work (nil = healthy). Without this, a wedged system haptics service (gamecontrollerd
|
||||
/// refusing every XPC connection — CoreHaptics -4811/4097, which no in-app retry can fix)
|
||||
/// reads as "the app's rumble is broken" when actually no app on the device can rumble.
|
||||
private var healthSink: ((String?) -> Void)?
|
||||
private var lastHealth: String?
|
||||
|
||||
#if os(macOS)
|
||||
/// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
|
||||
/// does not reach them on macOS — adaptive triggers/lightbar work, rumble is silent). nil for
|
||||
/// every other controller, which keeps the CoreHaptics path.
|
||||
private var dualSenseHID: DualSenseHID?
|
||||
private var lastHidWrite: (levels: (UInt8, UInt8), at: DispatchTime) =
|
||||
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||
#endif
|
||||
|
||||
init(policy: Policy = .session) {
|
||||
self.policy = policy
|
||||
}
|
||||
|
||||
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
||||
/// rumble backend now in use — for the debug controller-test panel.
|
||||
func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) {
|
||||
/// rumble backend now in use; `onHealth` with a problem description whenever rumble transitions
|
||||
/// between working and structurally failing (nil = healthy) — both for the debug test panel.
|
||||
func retarget(
|
||||
_ c: GCController?, onBackend: ((String) -> Void)? = nil,
|
||||
onHealth: ((String?) -> Void)? = nil
|
||||
) {
|
||||
queue.async {
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
self.controller = c
|
||||
self.broken = false
|
||||
self.preferCombined = false
|
||||
self.consecutiveFailures = 0
|
||||
self.retryAfter = .distantPast
|
||||
self.retryAfter = DispatchTime(uptimeNanoseconds: 0)
|
||||
if let onHealth { self.healthSink = onHealth }
|
||||
self.lastHealth = nil
|
||||
self.healthSink?(nil)
|
||||
_ = self.openHIDIfDualSense(c)
|
||||
onBackend?(self.backendNote(for: c))
|
||||
// The target survives the swap: render replays the current level onto the new pad
|
||||
// right away (a mid-rumble controller change keeps rumbling, like moving a real pad
|
||||
// between hands mid-effect).
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the wire-truth target. Called with every 0xCA state the host sends — level changes
|
||||
/// AND the 500 ms refreshes; refreshes stamp liveness for the watchdog and are otherwise
|
||||
/// free (invariant 2).
|
||||
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
||||
queue.async {
|
||||
self.lastCommand = .now()
|
||||
let active = lowAmp != 0 || highAmp != 0
|
||||
if active != self.wasActive {
|
||||
self.wasActive = active
|
||||
log.debug(
|
||||
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
||||
}
|
||||
// A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every
|
||||
// other pad (and for a DualSense whose HID device could not be opened).
|
||||
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
||||
guard !self.broken else { return }
|
||||
if active, self.low == nil, self.high == nil, Date() >= self.retryAfter {
|
||||
self.setup()
|
||||
}
|
||||
let ok: Bool
|
||||
if self.high != nil {
|
||||
// Per-handle: low = left/heavy motor, high = right/light — the XInput convention
|
||||
// the wire carries.
|
||||
let okLow = self.drive(&self.low, Float(lowAmp) / 65535)
|
||||
let okHigh = self.drive(&self.high, Float(highAmp) / 65535)
|
||||
ok = okLow && okHigh
|
||||
} else {
|
||||
// Combined engine: whichever motor is stronger wins.
|
||||
ok = self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
|
||||
}
|
||||
// Rebuild on the next nonzero amplitude if an engine errored — and tear down OUTSIDE
|
||||
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
|
||||
// still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
|
||||
// update; once a player is actually running the path has recovered, so clear the backoff.
|
||||
if !ok {
|
||||
self.teardown()
|
||||
self.scheduleRetryBackoff()
|
||||
} else if self.low?.player != nil || self.high?.player != nil {
|
||||
self.consecutiveFailures = 0
|
||||
self.retryAfter = .distantPast
|
||||
}
|
||||
guard (lowAmp, highAmp) != self.target else { return }
|
||||
self.target = (lowAmp, highAmp)
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Silence the motors and drop the engines. Blocks until done — call off the main actor.
|
||||
func stop() {
|
||||
queue.sync {
|
||||
self.ticker?.cancel()
|
||||
self.ticker = nil
|
||||
self.target = (0, 0)
|
||||
self.wasActive = false
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reconciliation (all on `queue`)
|
||||
|
||||
/// Drive the actuators toward `target`. Idempotent — safe to call from every wire update,
|
||||
/// tick, and retarget; when everything already matches it does nothing.
|
||||
private func render() {
|
||||
defer { updateTicker() }
|
||||
if renderHID() { return }
|
||||
guard !broken else { return }
|
||||
let audible = target.low != 0 || target.high != 0
|
||||
if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter {
|
||||
setup()
|
||||
}
|
||||
// Reconcile BOTH motors (no short-circuit skipping the second on a first-motor error),
|
||||
// and tear down OUTSIDE the `inout` accesses so teardown() never mutates a motor a
|
||||
// reconcile call still holds an exclusive reference to.
|
||||
let ok: Bool
|
||||
if high != nil {
|
||||
// Per-handle: low = left/heavy motor, high = right/light — the XInput convention
|
||||
// the wire carries.
|
||||
let okLow = reconcile(&low, to: RumbleTuning.amplitude(target.low))
|
||||
let okHigh = reconcile(&high, to: RumbleTuning.amplitude(target.high))
|
||||
ok = okLow && okHigh
|
||||
} else {
|
||||
let mixed = RumbleTuning.combined(low: target.low, high: target.high)
|
||||
ok = reconcile(&low, to: RumbleTuning.amplitude(mixed))
|
||||
}
|
||||
if !ok {
|
||||
let wasSplit = high != nil
|
||||
teardown()
|
||||
scheduleRetryBackoff()
|
||||
if wasSplit, !preferCombined {
|
||||
preferCombined = true
|
||||
log.info("rumble: split-handle engines failing — will retry with one combined engine")
|
||||
}
|
||||
} else if low?.current != nil || high?.current != nil {
|
||||
// A player is actually running — the path has recovered; clear the backoff.
|
||||
consecutiveFailures = 0
|
||||
retryAfter = DispatchTime(uptimeNanoseconds: 0)
|
||||
reportHealth(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a health transition to the test panel (deduped — transitions only).
|
||||
private func reportHealth(_ problem: String?) {
|
||||
guard problem != lastHealth else { return }
|
||||
lastHealth = problem
|
||||
healthSink?(problem)
|
||||
}
|
||||
|
||||
/// Watchdog + housekeeping heartbeat while audible.
|
||||
private func tick() {
|
||||
if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
|
||||
// The host refreshes rumble state every 500 ms; this much silence means the channel
|
||||
// (or host) died while a motor was on. A direct-connected pad would have been
|
||||
// stopped by its game long ago — force the same outcome.
|
||||
log.warning(
|
||||
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
|
||||
target = (0, 0)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
/// Drive one motor toward `desired`, per the invariants above. Returns false when the
|
||||
/// engine errored — the caller then tears everything down (outside this `inout` access) for
|
||||
/// a lazy, backoff-gated rebuild.
|
||||
private func reconcile(_ slot: inout Motor?, to desired: Float) -> Bool {
|
||||
guard var m = slot else { return true }
|
||||
defer { slot = m }
|
||||
// Release a handed-off predecessor once it has expired on its own.
|
||||
if let r = m.retiring, m.engine.currentTime >= r.endsAt + 0.25 {
|
||||
m.retiring = nil
|
||||
}
|
||||
if desired <= RumbleTuning.levelEpsilon {
|
||||
guard m.level > 0 || m.current != nil || m.retiring != nil else { return true }
|
||||
m.level = 0
|
||||
return stopSegments(&m)
|
||||
}
|
||||
if RumbleTuning.sameLevel(desired, m.level), m.current != nil {
|
||||
return rearmIfNeeded(&m)
|
||||
}
|
||||
// Nonzero level change. Throttled: the ticker re-runs render() and lands the newest
|
||||
// value once the window opens (zero above is never throttled).
|
||||
if m.current != nil, seconds(since: m.lastRebake) < RumbleTuning.minRebakeSeconds {
|
||||
return true
|
||||
}
|
||||
guard stopSegments(&m) else { return false }
|
||||
do {
|
||||
m.current = try makeSegment(
|
||||
m.engine, sharpness: m.sharpness, amplitude: desired, at: CHHapticTimeImmediate)
|
||||
m.level = desired
|
||||
m.lastRebake = .now()
|
||||
return true
|
||||
} catch {
|
||||
// A transient failure (the engine stopped/reset between its handler firing and now).
|
||||
// Signal a rebuild — do NOT latch rumble off for the session.
|
||||
log.warning("rumble: haptic start failed — rebuilding: \(error, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a steady level seamless across the finite-segment boundary: when the current
|
||||
/// segment nears its end, start the successor ON the engine timeline exactly as it expires
|
||||
/// — no stop call, no race, no gap. The old segment is kept as `retiring` until it dies
|
||||
/// naturally, so a level change can still force-stop it.
|
||||
private func rearmIfNeeded(_ m: inout Motor) -> Bool {
|
||||
guard let cur = m.current else { return true }
|
||||
let now = m.engine.currentTime
|
||||
guard RumbleTuning.shouldRearm(endsAt: cur.endsAt, now: now) else { return true }
|
||||
// A predecessor still held this deep into the segment already expired; drop it.
|
||||
m.retiring = nil
|
||||
do {
|
||||
let next = try makeSegment(
|
||||
m.engine, sharpness: m.sharpness, amplitude: m.level,
|
||||
at: RumbleTuning.handoffStart(endsAt: cur.endsAt, now: now))
|
||||
m.retiring = m.current
|
||||
m.current = next
|
||||
return true
|
||||
} catch {
|
||||
log.warning("rumble: segment re-arm failed — rebuilding: \(error, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop every segment on the motor NOW. False = a stop threw, so the engine's real state is
|
||||
/// unknown (a player may still run with its handle gone) — the caller must escalate to a
|
||||
/// full engine teardown, whose `engine.stop()` silences every player the engine hosts.
|
||||
private func stopSegments(_ m: inout Motor) -> Bool {
|
||||
var ok = true
|
||||
for seg in [m.current, m.retiring].compactMap({ $0 }) {
|
||||
do {
|
||||
try seg.player.stop(atTime: CHHapticTimeImmediate)
|
||||
} catch {
|
||||
log.warning(
|
||||
"rumble: player stop failed — escalating to engine stop: \(error, privacy: .public)")
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
m.current = nil
|
||||
m.retiring = nil
|
||||
return ok
|
||||
}
|
||||
|
||||
/// Build + start one finite continuous event at `amplitude`. `at` is `CHHapticTimeImmediate`
|
||||
/// or an absolute engine-timeline instant (a scheduled handoff). The intensity is BAKED into
|
||||
/// the event: a fixed event scaled by a dynamic `.hapticIntensityControl` parameter drives
|
||||
/// the iPhone Taptic Engine but is silent on a controller's haptic engine.
|
||||
private func makeSegment(
|
||||
_ engine: CHHapticEngine, sharpness: Float, amplitude: Float, at start: TimeInterval
|
||||
) throws -> Segment {
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness),
|
||||
],
|
||||
relativeTime: 0,
|
||||
duration: RumbleTuning.segmentSeconds)
|
||||
let player = try engine.makePlayer(
|
||||
with: CHHapticPattern(events: [event], parameters: []))
|
||||
try player.start(atTime: start)
|
||||
let begins = start == CHHapticTimeImmediate ? engine.currentTime : start
|
||||
return Segment(player: player, endsAt: begins + RumbleTuning.segmentSeconds)
|
||||
}
|
||||
|
||||
/// The ticker runs only while something needs tending — any nonzero target (watchdog,
|
||||
/// throttle catch-up, HID keepalive, post-reset engine rebuild) or segments still alive.
|
||||
private func updateTicker() {
|
||||
let needed = target != (0, 0)
|
||||
|| low?.current != nil || low?.retiring != nil
|
||||
|| high?.current != nil || high?.retiring != nil
|
||||
if needed, ticker == nil {
|
||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||
t.schedule(
|
||||
deadline: .now() + RumbleTuning.tickSeconds, repeating: RumbleTuning.tickSeconds)
|
||||
t.setEventHandler { [weak self] in self?.tick() }
|
||||
t.resume()
|
||||
ticker = t
|
||||
} else if !needed, let t = ticker {
|
||||
t.cancel()
|
||||
ticker = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Engine lifecycle
|
||||
|
||||
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
|
||||
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
||||
/// engine otherwise, driven by whichever amplitude is stronger.
|
||||
@@ -130,20 +438,28 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
// the controller changes; latch off (retarget clears it) and say so once.
|
||||
log.info("rumble: active controller exposes no haptics engine — rumble unavailable")
|
||||
broken = true
|
||||
reportHealth("This controller exposes no rumble engine to apps on this OS.")
|
||||
return
|
||||
}
|
||||
let localities = haptics.supportedLocalities
|
||||
if localities.contains(.leftHandle), localities.contains(.rightHandle) {
|
||||
low = makeMotor(haptics, .leftHandle)
|
||||
high = makeMotor(haptics, .rightHandle)
|
||||
let split =
|
||||
!preferCombined && localities.contains(.leftHandle)
|
||||
&& localities.contains(.rightHandle)
|
||||
if split {
|
||||
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
|
||||
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
|
||||
} else {
|
||||
low = makeMotor(haptics, .default)
|
||||
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
|
||||
}
|
||||
if low == nil, high == nil {
|
||||
// Haptics present but no engine could be built right now (server busy / XPC broken). Do
|
||||
// NOT latch broken — back off and the next nonzero amplitude past the cooldown retries.
|
||||
// NOT latch broken — back off and a later render past the cooldown retries.
|
||||
log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
|
||||
scheduleRetryBackoff()
|
||||
if split {
|
||||
preferCombined = true
|
||||
log.info("rumble: split-handle engines failing — will retry with one combined engine")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,10 +469,20 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
private func scheduleRetryBackoff() {
|
||||
consecutiveFailures += 1
|
||||
let shift = min(consecutiveFailures - 1, 4)
|
||||
retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 4))
|
||||
retryAfter = .now() + min(0.5 * Double(1 << shift), 4)
|
||||
if consecutiveFailures >= 2 {
|
||||
// One failure is a hiccup; repeated ones are the wedged-service signature (every
|
||||
// XPC connection to gamecontrollerd.haptics breaks — no app on the device can
|
||||
// rumble until it relaunches). Say so instead of failing silently.
|
||||
reportHealth(
|
||||
"The system haptics service is refusing connections — no app can rumble a "
|
||||
+ "controller right now. Rebooting the device usually clears it.")
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
|
||||
private func makeMotor(
|
||||
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
|
||||
) -> Motor? {
|
||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
||||
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session
|
||||
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
|
||||
@@ -167,7 +493,8 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left
|
||||
// unhandled the players go dead and every later rumble throws, latching rumble off for the
|
||||
// rest of the session (the "rumble worked, then went spotty" failure). Tear down on the
|
||||
// serial queue so the next nonzero amplitude lazily rebuilds the engine, instead.
|
||||
// serial queue; the ticker (or the next wire update) lazily rebuilds the engine and
|
||||
// re-renders the still-current target.
|
||||
engine.stoppedHandler = { [weak self] reason in
|
||||
log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild")
|
||||
self?.queue.async { self?.teardown() }
|
||||
@@ -177,72 +504,42 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
self?.queue.async { self?.teardown() }
|
||||
}
|
||||
do {
|
||||
// Start the engine now; the player that actually moves the motor is built per level
|
||||
// change in `drive` (a fresh event baked at the target intensity).
|
||||
// Start the engine now; the players that actually move the motor are the finite
|
||||
// segments `reconcile` bakes per level.
|
||||
try engine.start()
|
||||
return Motor(engine: engine, player: nil)
|
||||
return Motor(engine: engine, sharpness: sharpness)
|
||||
} catch {
|
||||
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
|
||||
/// is BAKED into the event. On a DualSense this is what actually moves the actuators: a
|
||||
/// fixed-intensity event scaled by a dynamic `.hapticIntensityControl` parameter (the old
|
||||
/// path) drives the iPhone Taptic Engine but is silent on a controller's haptic engine. The
|
||||
/// event carries an explicit sharpness (frequency) so the voice coils respond, and an infinite
|
||||
/// duration so a single host update — the host sends rumble only when the level changes —
|
||||
/// sustains until the next one. Returns false if the engine errored; the caller tears down for
|
||||
/// a rebuild (done outside this `inout` access to avoid an exclusivity violation).
|
||||
private func drive(_ motor: inout Motor?, _ amplitude: Float) -> Bool {
|
||||
guard var m = motor else { return true }
|
||||
// Replace any running player: stop the old, and for a zero level leave the motor idle.
|
||||
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||
m.player = nil
|
||||
guard amplitude > 0 else { motor = m; return true }
|
||||
do {
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
|
||||
],
|
||||
relativeTime: 0,
|
||||
duration: TimeInterval(GCHapticDurationInfinite))
|
||||
let player = try m.engine.makeAdvancedPlayer(
|
||||
with: CHHapticPattern(events: [event], parameters: []))
|
||||
try player.start(atTime: CHHapticTimeImmediate)
|
||||
m.player = player
|
||||
motor = m
|
||||
return true
|
||||
} catch {
|
||||
// A transient failure (the engine stopped/reset between its handler firing and now).
|
||||
// Signal a rebuild — do NOT latch rumble off for the session (the old "spotty" bug).
|
||||
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
|
||||
motor = m
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func teardown() {
|
||||
for m in [low, high].compactMap({ $0 }) {
|
||||
// Disarm the handlers before stopping so stop() can't re-enter teardown via them.
|
||||
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
||||
m.engine.stoppedHandler = { _ in }
|
||||
m.engine.resetHandler = {}
|
||||
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||
for seg in [m.current, m.retiring].compactMap({ $0 }) {
|
||||
try? seg.player.stop(atTime: CHHapticTimeImmediate)
|
||||
}
|
||||
// The authoritative silencer: a stopped engine plays nothing, including any player
|
||||
// whose individual stop was dropped.
|
||||
m.engine.stop()
|
||||
}
|
||||
low = nil
|
||||
high = nil
|
||||
}
|
||||
|
||||
private func seconds(since t: DispatchTime) -> TimeInterval {
|
||||
TimeInterval(DispatchTime.now().uptimeNanoseconds - t.uptimeNanoseconds) / 1_000_000_000
|
||||
}
|
||||
|
||||
// MARK: - DualSense raw-HID rumble (macOS)
|
||||
//
|
||||
// On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
|
||||
// we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path.
|
||||
// All three run on the serial `queue`, like the rest of the renderer state.
|
||||
// Runs on the serial `queue`, like the rest of the renderer state.
|
||||
|
||||
private func openHIDIfDualSense(_ c: GCController?) -> Bool {
|
||||
#if os(macOS)
|
||||
@@ -256,12 +553,19 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Drive the DualSense's motors over HID if that's the active backend; false → not a HID pad,
|
||||
/// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255.
|
||||
private func hidRumble(low: UInt16, high: UInt16) -> Bool {
|
||||
/// 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).
|
||||
private func renderHID() -> Bool {
|
||||
#if os(macOS)
|
||||
guard let hid = dualSenseHID else { return false }
|
||||
hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8))
|
||||
let levels = (RumbleTuning.hidByte(target.low), RumbleTuning.hidByte(target.high))
|
||||
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())
|
||||
}
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
@@ -270,8 +574,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
|
||||
private func closeHID() {
|
||||
#if os(macOS)
|
||||
dualSenseHID?.close()
|
||||
dualSenseHID?.close() // writes (0,0) before releasing
|
||||
dualSenseHID = nil
|
||||
lastHidWrite = ((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// Finger touches → host mouse, for the touchscreen devices: a port of the Android client's
|
||||
// touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel
|
||||
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
||||
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
|
||||
// (text selection / window moves) · three-finger tap = stats-HUD toggle:
|
||||
//
|
||||
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
||||
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
||||
// across, tap to click where it is. This is what makes the cursor reachable on a small
|
||||
// screen.
|
||||
// * pointer: the cursor jumps to the finger and follows it (absolute moves through the
|
||||
// aspect-fit letterbox) — direct pointing for desktop-style use.
|
||||
//
|
||||
// The third `TouchInputMode` (`touch`) never reaches this type: `StreamLayerUIView` forwards
|
||||
// those fingers as REAL wire touches (multi-touch passthrough) instead.
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import PunktfunkCore
|
||||
import UIKit
|
||||
|
||||
/// How touchscreen fingers drive the host — persisted under `DefaultsKey.touchMode`, latched
|
||||
/// per gesture by `StreamLayerUIView` (a Settings change applies from the NEXT touch, and a
|
||||
/// gesture never splits across models). `trackpad` is the default: a cursor is the
|
||||
/// universally workable model; passthrough only helps hosts/apps that actually speak touch.
|
||||
public enum TouchInputMode: String, CaseIterable, Sendable {
|
||||
case trackpad
|
||||
case pointer
|
||||
case touch
|
||||
|
||||
/// The persisted setting, defaulting to trackpad when unset/unknown.
|
||||
public static var current: TouchInputMode {
|
||||
TouchInputMode(
|
||||
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.touchMode) ?? ""
|
||||
) ?? .trackpad
|
||||
}
|
||||
}
|
||||
|
||||
/// The gesture state machine behind the two mouse modes. One instance per stream view, fed
|
||||
/// only the DIRECT touches (fingers/Pencil — indirect pointers have their own path). Runs
|
||||
/// entirely on the main thread (UIKit touch delivery). Touches are tracked by identity key
|
||||
/// with positions cached per event — `UITouch` objects are never retained.
|
||||
final class TouchMouse {
|
||||
/// Gesture/ballistics tuning. Distances are in points where they gate gestures; the
|
||||
/// relative ballistics work in PHYSICAL pixels (point deltas × screen scale) so the
|
||||
/// acceleration curve matches the Android client's pixel-based constants 1:1.
|
||||
enum Tuning {
|
||||
/// Movement under this (pt) still counts as a tap, not a drag.
|
||||
static let tapSlop: CGFloat = 8
|
||||
/// A new touch this soon (s) after a tap, near it, starts a held left-button drag.
|
||||
static let tapDragWindow: TimeInterval = 0.25
|
||||
/// Two-finger pan distance (pt) per 120-unit wheel notch — matches the feel of the
|
||||
/// indirect-trackpad scroll path in StreamViewIOS (~10 pt per notch).
|
||||
static let scrollNotchPt: CGFloat = 10
|
||||
/// Base finger-px → host-px gain (~1:1, never twitchy). The acceleration below lets a
|
||||
/// flick cross the screen while a slow drag stays precise.
|
||||
static let pointerSens: CGFloat = 1.3
|
||||
/// Above `accelSpeedFloor` px/ms the gain ramps by `accelGain` per px/ms, capped at
|
||||
/// `accelMax` (so a fast swipe can't fling the cursor uncontrollably).
|
||||
static let accelGain: CGFloat = 0.6
|
||||
static let accelSpeedFloor: CGFloat = 0.3
|
||||
static let accelMax: CGFloat = 3.0
|
||||
|
||||
/// Acceleration multiplier for a finger speed in physical px per ms.
|
||||
static func accel(forSpeed speed: CGFloat) -> CGFloat {
|
||||
min(1 + accelGain * max(speed - accelSpeedFloor, 0), accelMax)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire events out (the owner gates them on its capture state).
|
||||
var send: ((PunktfunkInputEvent) -> Void)?
|
||||
/// View-space point → host-mode pixels through the letterbox (pointer mode's moves).
|
||||
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
|
||||
|
||||
/// No gesture in flight (all fingers up) — the view uses this to release its mode latch.
|
||||
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
|
||||
|
||||
private var trackpad = true
|
||||
/// Last known position per active finger (identity key) — kept because moved events only
|
||||
/// carry the CHANGED touches while the scroll centroid needs every finger.
|
||||
private var lastPos: [ObjectIdentifier: CGPoint] = [:]
|
||||
private var sessionActive = false
|
||||
private var startPoint = CGPoint.zero
|
||||
private var maxFingers = 0
|
||||
private var moved = false
|
||||
private var scrolling = false
|
||||
private var dragHeld = false
|
||||
// Trackpad relative-motion state: the tracked finger, its last position/time, and the
|
||||
// sub-pixel remainder so a slow drag isn't lost to integer truncation.
|
||||
private var trackKey: ObjectIdentifier?
|
||||
private var prevPoint = CGPoint.zero
|
||||
private var prevTime: TimeInterval = 0
|
||||
private var carryX: CGFloat = 0
|
||||
private var carryY: CGFloat = 0
|
||||
/// Scroll anchor (centroid) — re-anchored every time a notch fires.
|
||||
private var scrollAnchor = CGPoint.zero
|
||||
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
|
||||
private var lastTapUp: TimeInterval = 0
|
||||
private var lastTapPoint = CGPoint.zero
|
||||
|
||||
/// GameStream mouse button ids.
|
||||
private enum Button { static let left: UInt32 = 1; static let right: UInt32 = 3 }
|
||||
|
||||
func began(_ touches: Set<UITouch>, in view: UIView, trackpad: Bool) {
|
||||
let starting = lastPos.isEmpty
|
||||
for touch in touches {
|
||||
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
|
||||
}
|
||||
if starting, let first = touches.first {
|
||||
self.trackpad = trackpad
|
||||
sessionActive = true
|
||||
startPoint = first.location(in: view)
|
||||
maxFingers = 0
|
||||
moved = false
|
||||
scrolling = false
|
||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||
// button for this whole gesture (laptop-trackpad convention).
|
||||
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
|
||||
&& abs(startPoint.x - lastTapPoint.x) < Tuning.tapSlop
|
||||
&& abs(startPoint.y - lastTapPoint.y) < Tuning.tapSlop
|
||||
lastTapUp = 0 // consume the arming either way
|
||||
// Pointer mode jumps the cursor to the finger; trackpad leaves it put (the whole
|
||||
// point — you nudge it with swipes instead).
|
||||
if !trackpad, let h = hostPoint?(startPoint) {
|
||||
send?(.mouseMoveAbs(x: h.x, y: h.y, surfaceWidth: h.w, surfaceHeight: h.h))
|
||||
}
|
||||
if dragHeld { send?(.mouseButton(Button.left, down: true)) }
|
||||
trackKey = ObjectIdentifier(first)
|
||||
prevPoint = startPoint
|
||||
prevTime = first.timestamp
|
||||
carryX = 0
|
||||
carryY = 0
|
||||
}
|
||||
maxFingers = max(maxFingers, lastPos.count)
|
||||
}
|
||||
|
||||
func moved(_ touches: Set<UITouch>, in view: UIView) {
|
||||
guard sessionActive else { return }
|
||||
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
|
||||
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
|
||||
}
|
||||
if lastPos.count >= 2 {
|
||||
scrollByCentroid()
|
||||
} else if !scrolling, let touch = touches.first(where: {
|
||||
lastPos[ObjectIdentifier($0)] != nil
|
||||
}) {
|
||||
singleFinger(touch, in: view)
|
||||
}
|
||||
}
|
||||
|
||||
func ended(_ touches: Set<UITouch>, in view: UIView) {
|
||||
guard sessionActive || !lastPos.isEmpty else { return }
|
||||
var upTime: TimeInterval = 0
|
||||
for touch in touches {
|
||||
lastPos.removeValue(forKey: ObjectIdentifier(touch))
|
||||
if trackKey == ObjectIdentifier(touch) { trackKey = nil }
|
||||
upTime = max(upTime, touch.timestamp)
|
||||
}
|
||||
guard lastPos.isEmpty, sessionActive else { return }
|
||||
sessionActive = false
|
||||
if dragHeld {
|
||||
dragHeld = false
|
||||
send?(.mouseButton(Button.left, down: false)) // end the drag
|
||||
} else if !moved {
|
||||
switch maxFingers {
|
||||
case 3...:
|
||||
Self.toggleHUD() // in-stream stats-overlay toggle, same as Android
|
||||
case 2: // two-finger tap → right click
|
||||
send?(.mouseButton(Button.right, down: true))
|
||||
send?(.mouseButton(Button.right, down: false))
|
||||
default: // tap → left click (at the cursor's current spot), arm tap-drag
|
||||
send?(.mouseButton(Button.left, down: true))
|
||||
send?(.mouseButton(Button.left, down: false))
|
||||
lastTapUp = upTime
|
||||
lastTapPoint = startPoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System-cancelled touches (incoming call, gesture takeover): release anything held but
|
||||
/// never synthesize a click out of a cancellation.
|
||||
func cancelled(_ touches: Set<UITouch>) {
|
||||
for touch in touches {
|
||||
lastPos.removeValue(forKey: ObjectIdentifier(touch))
|
||||
if trackKey == ObjectIdentifier(touch) { trackKey = nil }
|
||||
}
|
||||
if lastPos.isEmpty { abortSession() }
|
||||
}
|
||||
|
||||
/// Session teardown: release anything held on the wire and forget all gesture state.
|
||||
func reset() {
|
||||
lastPos.removeAll()
|
||||
trackKey = nil
|
||||
abortSession()
|
||||
lastTapUp = 0
|
||||
}
|
||||
|
||||
private func abortSession() {
|
||||
if dragHeld {
|
||||
dragHeld = false
|
||||
send?(.mouseButton(Button.left, down: false))
|
||||
}
|
||||
sessionActive = false
|
||||
scrolling = false
|
||||
moved = false
|
||||
}
|
||||
|
||||
// MARK: - Per-event work
|
||||
|
||||
/// Two fingers (or more) → scroll by the centroid delta; never move the cursor. Fires a
|
||||
/// notch per `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger
|
||||
/// right scrolls right (the host WHEEL(120) convention).
|
||||
private func scrollByCentroid() {
|
||||
let n = CGFloat(lastPos.count)
|
||||
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
||||
let cy = lastPos.values.reduce(0) { $0 + $1.y } / n
|
||||
if !scrolling {
|
||||
scrolling = true
|
||||
scrollAnchor = CGPoint(x: cx, y: cy)
|
||||
}
|
||||
let notchesY = Int32((scrollAnchor.y - cy) / Tuning.scrollNotchPt)
|
||||
let notchesX = Int32((cx - scrollAnchor.x) / Tuning.scrollNotchPt)
|
||||
if notchesY != 0 {
|
||||
send?(.scroll(notchesY * 120))
|
||||
scrollAnchor.y = cy
|
||||
moved = true
|
||||
}
|
||||
if notchesX != 0 {
|
||||
send?(.scroll(notchesX * 120, horizontal: true))
|
||||
scrollAnchor.x = cx
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
|
||||
/// One finger (and the gesture never became a scroll — dropping back from two fingers to
|
||||
/// one must not jerk the cursor).
|
||||
private func singleFinger(_ touch: UITouch, in view: UIView) {
|
||||
let loc = touch.location(in: view)
|
||||
if abs(loc.x - startPoint.x) > Tuning.tapSlop || abs(loc.y - startPoint.y) > Tuning.tapSlop {
|
||||
moved = true
|
||||
}
|
||||
guard trackpad else {
|
||||
if let h = hostPoint?(loc) { // pointer mode: the cursor follows the finger
|
||||
send?(.mouseMoveAbs(x: h.x, y: h.y, surfaceWidth: h.w, surfaceHeight: h.h))
|
||||
}
|
||||
return
|
||||
}
|
||||
// Relative: move by the finger delta × (sensitivity × acceleration), carrying the
|
||||
// sub-pixel remainder. Re-anchor (zero delta this frame) if the tracked finger
|
||||
// changed, so lifting one of several fingers never jumps the cursor.
|
||||
let key = ObjectIdentifier(touch)
|
||||
if key != trackKey {
|
||||
trackKey = key
|
||||
prevPoint = loc
|
||||
prevTime = touch.timestamp
|
||||
return
|
||||
}
|
||||
// Ballistics in physical pixels so the curve matches the Android tuning exactly.
|
||||
let scale = view.window?.screen.scale ?? view.traitCollection.displayScale
|
||||
let dx = (loc.x - prevPoint.x) * scale
|
||||
let dy = (loc.y - prevPoint.y) * scale
|
||||
let dtMs = max((touch.timestamp - prevTime) * 1000, 1)
|
||||
prevPoint = loc
|
||||
prevTime = touch.timestamp
|
||||
let gain = Tuning.pointerSens * Tuning.accel(forSpeed: hypot(dx, dy) / dtMs)
|
||||
carryX += dx * gain
|
||||
carryY += dy * gain
|
||||
let outX = Int32(carryX) // truncates toward zero → remainder kept with its sign
|
||||
let outY = Int32(carryY)
|
||||
if outX != 0 || outY != 0 {
|
||||
send?(.mouseMove(dx: outX, dy: outY))
|
||||
carryX -= CGFloat(outX)
|
||||
carryY -= CGFloat(outY)
|
||||
}
|
||||
}
|
||||
|
||||
/// Three-finger tap toggles the stats overlay — through the shared `hudEnabled` default,
|
||||
/// which the app's HUD views observe via @AppStorage (so this needs no wiring to them).
|
||||
private static func toggleHUD() {
|
||||
let defaults = UserDefaults.standard
|
||||
let on = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
|
||||
defaults.set(!on, forKey: DefaultsKey.hudEnabled)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -24,6 +24,12 @@ public enum DefaultsKey {
|
||||
public static let micEnabled = "punktfunk.micEnabled"
|
||||
public static let speakerUID = "punktfunk.speakerUID"
|
||||
public static let micUID = "punktfunk.micUID"
|
||||
/// macOS: which input channel of the chosen mic device feeds the host. 0 = "Auto" (sum every
|
||||
/// channel to mono — a mic on a single input of a multi-channel interface passes at full
|
||||
/// level); n≥1 pins 1-based input channel n. Multi-channel interfaces expose the mic on ONE
|
||||
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
||||
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
||||
public static let micChannel = "punktfunk.micChannel"
|
||||
public static let presenter = "punktfunk.presenter"
|
||||
/// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host
|
||||
/// has HDR content AND this display supports HDR — otherwise the stream stays 8-bit SDR.
|
||||
@@ -41,6 +47,11 @@ public enum DefaultsKey {
|
||||
/// scene and silently falls back to the absolute pointer when it can't (Stage Manager / Slide
|
||||
/// Over). Read by `StreamViewController.prefersPointerLocked`.
|
||||
public static let pointerCapture = "punktfunk.pointerCapture"
|
||||
/// iPhone/iPad: how touchscreen fingers drive the host — a `TouchInputMode` raw value:
|
||||
/// "trackpad" (default: relative cursor with tap-click / two-finger-scroll gestures),
|
||||
/// "pointer" (the cursor jumps to the finger), or "touch" (real multi-touch passthrough).
|
||||
/// Read live per gesture by `StreamLayerUIView`.
|
||||
public static let touchMode = "punktfunk.touchMode"
|
||||
/// Experimental: show the host's game library (browsed over the management API). Off by default.
|
||||
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
||||
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Splits the unified stats model's `host+network` stage (capture→received) into its `host`
|
||||
// (capture→fully-sent, reported per AU by the host on the 0xCF plane) and `network`
|
||||
// (the remainder) terms — design/stats-unification.md Phase 2.
|
||||
//
|
||||
// Receipt samples are recorded per frame from the pump path; host timings are matched to them
|
||||
// by exact pts (the 0xCF datagram carries the AU's own `pts_ns`). Best-effort by construction:
|
||||
// a lost 0xCF datagram, an FEC-dropped AU, or an old host that never emits the plane simply
|
||||
// contributes no split sample — the HUD then keeps the combined `host+network` line. NSLock
|
||||
// rather than an actor — the receipt writer is the non-async pump path (same pattern as
|
||||
// LatencyMeter/FrameMeter).
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Per-frame `host` / `network` sampler: `recordReceipt` at AU receipt (pts + the combined
|
||||
/// capture→received interval), `noteHostTiming` per drained 0xCF report, `drain` the window's
|
||||
/// p50s once a second. The pending ring is bounded (drop-oldest) so an old host — receipts
|
||||
/// forever, timings never — costs a fixed ~4 KB, not growth.
|
||||
public final class HostNetworkSplitter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
/// Received AUs awaiting their 0xCF host timing: (pts, combined capture→received µs).
|
||||
private var pending: [(ptsNs: UInt64, combinedUs: Int64)] = []
|
||||
private var hostUsSamples: [Int64] = []
|
||||
private var networkUsSamples: [Int64] = []
|
||||
/// ~1 s of frames at 240 fps; beyond it the oldest receipt can no longer expect a match.
|
||||
private static let pendingCap = 256
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Record one frame at receipt. `ptsNs` is the host capture clock (the AU's pts),
|
||||
/// `receivedNs` the client `CLOCK_REALTIME` receipt instant (`AccessUnit.receivedNs`),
|
||||
/// `offsetNs` the connect-time host−client clock offset (0 = uncorrected). Same
|
||||
/// absurd-value clamp as LatencyMeter — a sample it would drop must not linger here.
|
||||
public func recordReceipt(ptsNs: UInt64, receivedNs: Int64, offsetNs: Int64) {
|
||||
let combinedNs = receivedNs &+ offsetNs &- Int64(bitPattern: ptsNs)
|
||||
guard combinedNs > 0, combinedNs < 10_000_000_000 else { return }
|
||||
lock.lock()
|
||||
pending.append((ptsNs: ptsNs, combinedUs: combinedNs / 1000))
|
||||
if pending.count > Self.pendingCap {
|
||||
pending.removeFirst(pending.count - Self.pendingCap)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Match one host timing (0xCF) to its receipt: `host` = the reported capture→sent,
|
||||
/// `network` = the combined interval minus it, floored at 0 (the terms tile per frame; a
|
||||
/// slightly-off skew offset must not produce a negative wire time). Unmatched timings —
|
||||
/// the AU was FEC-dropped, or its receipt raced this drain — are simply skipped.
|
||||
public func noteHostTiming(ptsNs: UInt64, hostUs: UInt32) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let i = pending.firstIndex(where: { $0.ptsNs == ptsNs }) else { return }
|
||||
let combinedUs = pending.remove(at: i).combinedUs
|
||||
hostUsSamples.append(Int64(hostUs))
|
||||
networkUsSamples.append(max(0, combinedUs - Int64(hostUs)))
|
||||
}
|
||||
|
||||
public struct Split: Sendable {
|
||||
public let hostP50Ms: Double
|
||||
public let networkP50Ms: Double
|
||||
public let count: Int
|
||||
}
|
||||
|
||||
/// The window's p50s since the last drain, then reset (matched samples only; the pending
|
||||
/// ring survives — a receipt may still match a timing drained next tick). `nil` when no
|
||||
/// timing matched in the interval — the caller falls back to the combined stage.
|
||||
public func drain() -> Split? {
|
||||
lock.lock()
|
||||
let host = hostUsSamples.sorted()
|
||||
let network = networkUsSamples.sorted()
|
||||
hostUsSamples.removeAll(keepingCapacity: true)
|
||||
networkUsSamples.removeAll(keepingCapacity: true)
|
||||
lock.unlock()
|
||||
guard !host.isEmpty else { return nil }
|
||||
func p50(_ sorted: [Int64]) -> Double {
|
||||
Double(sorted[min(sorted.count / 2, sorted.count - 1)]) / 1000.0 // µs → ms
|
||||
}
|
||||
return Split(hostP50Ms: p50(host), networkP50Ms: p50(network), count: host.count)
|
||||
}
|
||||
|
||||
/// Forget everything (pending receipts + window) — a fresh connection starts clean.
|
||||
public func reset() {
|
||||
lock.lock()
|
||||
pending.removeAll()
|
||||
hostUsSamples.removeAll()
|
||||
networkUsSamples.removeAll()
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,25 @@
|
||||
// Per-frame latency sampler for the live HUD: records capture->client-receipt latency and drains
|
||||
// percentiles on demand. NSLock rather than an actor — the writer is the non-async pump/arrival
|
||||
// path (same pattern as the app's FrameMeter).
|
||||
// Per-frame latency-stage sampler for the live HUD: records one interval per frame (an end
|
||||
// instant minus a start instant, both CLOCK_REALTIME ns) and drains percentiles on demand.
|
||||
// NSLock rather than an actor — the writers are the non-async pump/decode/present paths (same
|
||||
// pattern as the app's FrameMeter).
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Samples the **capture->client-receipt** latency of each access unit and reports percentiles.
|
||||
/// Samples one **latency stage** per frame and reports percentiles. One instance per stage of the
|
||||
/// unified stats model (design/stats-unification.md):
|
||||
///
|
||||
/// The latency is `now - pts_ns`, where `pts_ns` is the host's capture wall clock (the AU's pts) and
|
||||
/// `now` is the client's `CLOCK_REALTIME` instant the AU was received, shifted by the connect-time
|
||||
/// **clock-skew offset** (`PunktfunkConnection.clockOffsetNs`, host minus client) so the difference
|
||||
/// is valid across machines. `offsetNs == 0` means an old host that didn't answer the skew handshake
|
||||
/// (or genuinely synced clocks) — the number is then only meaningful same-host.
|
||||
/// - `host+network` = capture→received: `record(ptsNs:offsetNs:)` at AU receipt.
|
||||
/// - `decode` = received→decoded and `display` = decoded→displayed: client-local single-clock
|
||||
/// stages — `record(ptsNs:atNs:offsetNs:)` with the start instant as `ptsNs` and `offsetNs: 0`.
|
||||
/// - `end-to-end` = capture→displayed, measured directly (never summed from the stages):
|
||||
/// `record(ptsNs:atNs:offsetNs:)` at present.
|
||||
///
|
||||
/// SCOPE (stage-1 presenter): this covers host capture -> encode -> FEC -> network -> reassembly ->
|
||||
/// decrypt -> handed to the presenter. It does **not** include the on-device VideoToolbox decode or
|
||||
/// the `AVSampleBufferDisplayLayer` present — that layer decodes and presents compressed samples
|
||||
/// internally with no per-frame callback. True decode->present (the full glass-to-glass) needs the
|
||||
/// stage-2 presenter (`VTDecompressionSession` decode-completion + `CAMetalLayer`/display-link
|
||||
/// present); this meter is the substrate it will extend.
|
||||
/// For the host-anchored intervals (capture→…) the sample is `end + offset - pts_ns`, where
|
||||
/// `pts_ns` is the host's capture wall clock (the AU's pts) and the connect-time **clock-skew
|
||||
/// offset** (`PunktfunkConnection.clockOffsetNs`, host minus client) makes the difference valid
|
||||
/// across machines. `offsetNs == 0` means an old host that didn't answer the skew handshake (or
|
||||
/// genuinely synced clocks) — the number is then only meaningful same-host, and the HUD tags the
|
||||
/// end-to-end line `(same-host clock)`.
|
||||
public final class LatencyMeter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var samplesUs: [Int64] = []
|
||||
@@ -34,12 +36,16 @@ public final class LatencyMeter: @unchecked Sendable {
|
||||
record(ptsNs: ptsNs, atNs: nowNs, offsetNs: offsetNs)
|
||||
}
|
||||
|
||||
/// Record one frame whose latency is `atNs + offsetNs - ptsNs` — an EXPLICIT client instant
|
||||
/// rather than now. The stage-2 presenter uses this to stamp capture→present at the display
|
||||
/// link's target present time (not the moment the present call ran). All in `CLOCK_REALTIME`.
|
||||
/// Record one frame whose sample is `atNs + offsetNs - ptsNs` — an EXPLICIT end instant
|
||||
/// rather than now. `ptsNs` is the stage's start point: the AU pts for the host-anchored
|
||||
/// intervals, or a client stamp (receivedNs / decodedNs, with `offsetNs: 0`) for the local
|
||||
/// decode/display stages. The stage-2 presenter stamps its present-side samples at the
|
||||
/// display link's target present time (not the moment the present call ran). All in
|
||||
/// `CLOCK_REALTIME`.
|
||||
public func record(ptsNs: UInt64, atNs: Int64, offsetNs: Int64) {
|
||||
let latNs = atNs &+ offsetNs &- Int64(bitPattern: ptsNs)
|
||||
// Drop absurd values (a clock step, a wildly wrong offset, or garbage pts).
|
||||
// Drop absurd values (a clock step, a wildly wrong offset, garbage pts, or a stage whose
|
||||
// start stamp is missing/after its end) — samples are clamped to (0, 10 s).
|
||||
guard latNs > 0, latNs < 10_000_000_000 else { return }
|
||||
lock.lock()
|
||||
samplesUs.append(latNs / 1000)
|
||||
|
||||
@@ -38,8 +38,9 @@ final class SessionPresenter {
|
||||
func start(
|
||||
connection: PunktfunkConnection,
|
||||
baseLayer: AVSampleBufferDisplayLayer,
|
||||
presentMeter: LatencyMeter?,
|
||||
presentTailMeter: LatencyMeter? = nil,
|
||||
endToEndMeter: LatencyMeter?,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?
|
||||
@@ -59,7 +60,8 @@ final class SessionPresenter {
|
||||
#endif
|
||||
if !forceStage1,
|
||||
let pipeline = Stage2Pipeline(
|
||||
presentMeter: presentMeter, presentTailMeter: presentTailMeter) {
|
||||
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter) {
|
||||
let metal = pipeline.layer
|
||||
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
||||
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Stage-2 presenter orchestrator: a pump thread pulls AUs → VideoDecoder; the decoder's async output
|
||||
// drops the newest decoded frame into a 1-slot ring; the hosting view's display link calls `renderTick`
|
||||
// once per vsync to draw + present the newest ready frame and stamp capture→present. Mirrors
|
||||
// StreamPump's lifecycle (one per start; cancel is permanent).
|
||||
// once per vsync to draw + present the newest ready frame and stamp the unified latency stages
|
||||
// (end-to-end capture→on-glass, plus the decode and display stage terms —
|
||||
// design/stats-unification.md). Mirrors StreamPump's lifecycle (one per start; cancel is permanent).
|
||||
//
|
||||
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick` +
|
||||
// `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there). Only the ring (lock-guarded)
|
||||
@@ -40,8 +41,8 @@ public final class Stage2Pipeline {
|
||||
private let ring = ReadyRing()
|
||||
private let presenter: MetalVideoPresenter
|
||||
private let decoder: VideoDecoder
|
||||
private let presentMeter: LatencyMeter?
|
||||
private let presentTailMeter: LatencyMeter?
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
private let recovery = KeyframeRecovery()
|
||||
private var token = StopFlag()
|
||||
private var offsetNs: Int64 = 0
|
||||
@@ -56,28 +57,41 @@ public final class Stage2Pipeline {
|
||||
/// The Metal layer the hosting view installs + sizes.
|
||||
public var layer: CAMetalLayer { presenter.layer }
|
||||
|
||||
/// `presentMeter` records capture→present (the glass-to-glass term); `presentTailMeter`
|
||||
/// records decode-completion→present (the ring wait + render — the tail stage-2 exists to
|
||||
/// shorten). Both optional: metering never gates the presenter choice. Returns nil if Metal
|
||||
/// can't be set up (headless / no GPU) — caller falls back to the stage-1 presenter.
|
||||
public init?(presentMeter: LatencyMeter?, presentTailMeter: LatencyMeter? = nil) {
|
||||
/// Unified-stats meters (design/stats-unification.md): `endToEndMeter` records the headline
|
||||
/// end-to-end (capture→on-glass, skew-corrected); `decodeMeter` the decode stage
|
||||
/// (received→decoded); `displayMeter` the display stage (decoded→on-glass, the ring wait +
|
||||
/// render + vsync — the tail stage-2 exists to shorten). All optional: metering never gates
|
||||
/// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) — caller
|
||||
/// falls back to the stage-1 presenter.
|
||||
public init?(
|
||||
endToEndMeter: LatencyMeter?,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil
|
||||
) {
|
||||
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||
self.presenter = presenter
|
||||
self.presentMeter = presentMeter
|
||||
self.presentTailMeter = presentTailMeter
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.displayMeter = displayMeter
|
||||
let ring = ring
|
||||
let recovery = recovery
|
||||
self.decoder = VideoDecoder(
|
||||
onDecoded: { ring.submit($0) },
|
||||
onDecoded: { frame in
|
||||
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
||||
// skew applies). Stamped at decode completion, so it covers every decoded frame,
|
||||
// including ones the newest-wins ring drops before present.
|
||||
decodeMeter?.record(
|
||||
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
|
||||
ring.submit(frame)
|
||||
},
|
||||
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
|
||||
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP — it wouldn't
|
||||
// otherwise come soon). Throttled in KeyframeRecovery.
|
||||
onDecodeError: { _ in recovery.request() })
|
||||
}
|
||||
|
||||
/// Start pulling AUs into the decoder. MAIN THREAD. `onFrame` fires per AU at receipt (capture→client
|
||||
/// meter, exactly as stage-1); `onSessionEnd` on close. `clockOffsetNs` (host minus client) makes the
|
||||
/// present stamp cross-machine valid.
|
||||
/// Start pulling AUs into the decoder. MAIN THREAD. `onFrame` fires per AU at receipt (the
|
||||
/// host+network / capture→received meter, exactly as stage-1); `onSessionEnd` on close.
|
||||
/// `clockOffsetNs` (host minus client) makes the end-to-end stamp cross-machine valid.
|
||||
public func start(
|
||||
connection: PunktfunkConnection,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
@@ -174,14 +188,16 @@ public final class Stage2Pipeline {
|
||||
public func renderTick(targetPresentNs: Int64) {
|
||||
guard let frame = ring.take() else { return }
|
||||
let offsetNs = offsetNs
|
||||
let presentMeter = presentMeter
|
||||
let presentTailMeter = presentTailMeter
|
||||
let endToEndMeter = endToEndMeter
|
||||
let displayMeter = displayMeter
|
||||
let rendered = presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) { presentedNs in
|
||||
let atNs = presentedNs ?? targetPresentNs
|
||||
presentMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
|
||||
// Present tail = decode-completion → on-glass. Both instants are client
|
||||
// CLOCK_REALTIME, so no skew offset applies.
|
||||
presentTailMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||
// End-to-end = capture→on-glass, measured directly (skew-corrected via the
|
||||
// connect-time clock offset) — the HUD headline.
|
||||
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
|
||||
// Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME,
|
||||
// so no skew offset applies.
|
||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||
}
|
||||
if !rendered { ring.putBack(frame) }
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ public enum Stage444Probe {
|
||||
guard created == noErr, let session else { return false }
|
||||
defer { VTDecompressionSessionInvalidate(session) }
|
||||
|
||||
let au = AccessUnit(data: data, ptsNs: 0, frameIndex: 0, flags: 0)
|
||||
let au = AccessUnit(data: data, ptsNs: 0, frameIndex: 0, flags: 0, receivedNs: 0)
|
||||
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
|
||||
|
||||
var produced: OSType = 0
|
||||
|
||||
@@ -15,6 +15,10 @@ import VideoToolbox
|
||||
public struct ReadyFrame: @unchecked Sendable {
|
||||
/// Host capture clock (the AU's pts), in nanoseconds.
|
||||
public let ptsNs: UInt64
|
||||
/// Client `CLOCK_REALTIME` instant the AU was received (`AccessUnit.receivedNs`, threaded
|
||||
/// through the decode via the frame refcon), in nanoseconds. 0 when unknown (a caller that
|
||||
/// didn't stamp receipt) — the decode-stage meter then drops the sample via its sanity guard.
|
||||
public let receivedNs: Int64
|
||||
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
||||
public let decodedNs: Int64
|
||||
/// The decoded image — 8-bit NV12 biplanar (SDR) or 10-bit P010 biplanar (HDR), Metal-compatible.
|
||||
@@ -25,13 +29,16 @@ public struct ReadyFrame: @unchecked Sendable {
|
||||
}
|
||||
|
||||
/// The C output callback can't capture context, so VideoToolbox hands it the refcon we set at
|
||||
/// session creation — a pointer back to the owning `VideoDecoder`.
|
||||
/// session creation — a pointer back to the owning `VideoDecoder`. The per-frame refcon carries
|
||||
/// the AU's `receivedNs` as a pointer bit pattern (a scalar smuggled through the C void*, never
|
||||
/// dereferenced) so the decode stage can be computed against decode-completion.
|
||||
private let decoderOutputCallback: VTDecompressionOutputCallback = {
|
||||
refcon, _, status, _, imageBuffer, pts, _ in
|
||||
refcon, frameRefcon, status, _, imageBuffer, pts, _ in
|
||||
guard let refcon else { return }
|
||||
let receivedNs = frameRefcon.map { Int64(Int(bitPattern: $0)) } ?? 0
|
||||
Unmanaged<VideoDecoder>.fromOpaque(refcon)
|
||||
.takeUnretainedValue()
|
||||
.handleDecoded(status: status, imageBuffer: imageBuffer, pts: pts)
|
||||
.handleDecoded(status: status, imageBuffer: imageBuffer, pts: pts, receivedNs: receivedNs)
|
||||
}
|
||||
|
||||
/// Owns a `VTDecompressionSession` rebuilt whenever the format description changes (every IDR /
|
||||
@@ -112,7 +119,9 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
session,
|
||||
sampleBuffer: sample,
|
||||
flags: [._EnableAsynchronousDecompression],
|
||||
frameRefcon: nil,
|
||||
// The AU's receipt instant rides through as a bit pattern (nil for 0 — the output
|
||||
// callback maps that back to 0); the callback needs it to stamp the decode stage.
|
||||
frameRefcon: UnsafeMutableRawPointer(bitPattern: Int(au.receivedNs)),
|
||||
infoFlagsOut: &infoOut)
|
||||
lock.unlock()
|
||||
if status != noErr {
|
||||
@@ -218,8 +227,11 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
return true
|
||||
}
|
||||
|
||||
/// VT thread. Stamp decode-completion and enqueue, or report the error.
|
||||
fileprivate func handleDecoded(status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime) {
|
||||
/// VT thread. Stamp decode-completion and enqueue, or report the error. `receivedNs` is the
|
||||
/// AU's receipt instant threaded through the frame refcon (0 = unknown).
|
||||
fileprivate func handleDecoded(
|
||||
status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime, receivedNs: Int64
|
||||
) {
|
||||
guard status == noErr, let imageBuffer else {
|
||||
onDecodeError(status)
|
||||
return
|
||||
@@ -242,6 +254,8 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
|| fmt == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|
||||
|| fmt == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
|
||||
onDecoded(
|
||||
ReadyFrame(ptsNs: ptsNs, decodedNs: decodedNs, pixelBuffer: imageBuffer, isHDR: isHDR))
|
||||
ReadyFrame(
|
||||
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
||||
pixelBuffer: imageBuffer, isHDR: isHDR))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,39 +85,45 @@ public struct StreamView: NSViewRepresentable {
|
||||
private let onCaptureChange: ((Bool) -> Void)?
|
||||
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||
private let onSessionEnd: (@Sendable () -> Void)?
|
||||
private let presentMeter: LatencyMeter?
|
||||
private let presentTailMeter: LatencyMeter?
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
|
||||
/// `onFrame`/`onSessionEnd` fire on the pump thread — hop to the main actor for UI.
|
||||
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
|
||||
/// prompt) is layered over the stream; flipping it to true auto-engages capture
|
||||
/// once. `onCaptureChange` (main thread) reports engage/release — drive the HUD's
|
||||
/// "click to capture" / "⌘⎋ releases" hint with it. `presentMeter` records capture→present
|
||||
/// and `presentTailMeter` decode→present when the stage-2 presenter is active.
|
||||
/// "click to capture" / "⌘⎋ releases" hint with it. The meters record the unified latency
|
||||
/// stages when the stage-2 presenter is active (design/stats-unification.md):
|
||||
/// `endToEndMeter` capture→on-glass, `decodeMeter` received→decoded, `displayMeter`
|
||||
/// decoded→on-glass.
|
||||
public init(
|
||||
connection: PunktfunkConnection,
|
||||
captureEnabled: Bool = true,
|
||||
onCaptureChange: ((Bool) -> Void)? = nil,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||
onSessionEnd: (@Sendable () -> Void)? = nil,
|
||||
presentMeter: LatencyMeter? = nil,
|
||||
presentTailMeter: LatencyMeter? = nil
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil
|
||||
) {
|
||||
self.connection = connection
|
||||
self.captureEnabled = captureEnabled
|
||||
self.onCaptureChange = onCaptureChange
|
||||
self.onFrame = onFrame
|
||||
self.onSessionEnd = onSessionEnd
|
||||
self.presentMeter = presentMeter
|
||||
self.presentTailMeter = presentTailMeter
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
}
|
||||
|
||||
public func makeNSView(context: Context) -> StreamLayerView {
|
||||
let view = StreamLayerView()
|
||||
view.onCaptureChange = onCaptureChange
|
||||
view.captureEnabled = captureEnabled
|
||||
view.presentMeter = presentMeter
|
||||
view.presentTailMeter = presentTailMeter
|
||||
view.endToEndMeter = endToEndMeter
|
||||
view.decodeMeter = decodeMeter
|
||||
view.displayMeter = displayMeter
|
||||
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
return view
|
||||
}
|
||||
@@ -125,8 +131,9 @@ public struct StreamView: NSViewRepresentable {
|
||||
public func updateNSView(_ view: StreamLayerView, context: Context) {
|
||||
view.onCaptureChange = onCaptureChange
|
||||
view.captureEnabled = captureEnabled
|
||||
view.presentMeter = presentMeter
|
||||
view.presentTailMeter = presentTailMeter
|
||||
view.endToEndMeter = endToEndMeter
|
||||
view.decodeMeter = decodeMeter
|
||||
view.displayMeter = displayMeter
|
||||
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
||||
// connection identity actually changed.
|
||||
if view.connection !== connection {
|
||||
@@ -141,10 +148,11 @@ public struct StreamView: NSViewRepresentable {
|
||||
|
||||
public final class StreamLayerView: NSView {
|
||||
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||
/// Record capture→present / decode→present when the stage-2 presenter is active.
|
||||
/// Consulted at start().
|
||||
var presentMeter: LatencyMeter?
|
||||
var presentTailMeter: LatencyMeter?
|
||||
/// Record the unified latency stages (end-to-end / decode / display) when the stage-2
|
||||
/// presenter is active. Consulted at start().
|
||||
var endToEndMeter: LatencyMeter?
|
||||
var decodeMeter: LatencyMeter?
|
||||
var displayMeter: LatencyMeter?
|
||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||
private let presenter = SessionPresenter()
|
||||
@@ -571,8 +579,9 @@ public final class StreamLayerView: NSView {
|
||||
presenter.start(
|
||||
connection: connection,
|
||||
baseLayer: displayLayer,
|
||||
presentMeter: presentMeter,
|
||||
presentTailMeter: presentTailMeter,
|
||||
endToEndMeter: endToEndMeter,
|
||||
decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter,
|
||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd)
|
||||
|
||||
@@ -50,8 +50,9 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
private let onCaptureChange: ((Bool) -> Void)?
|
||||
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||
private let onSessionEnd: (@Sendable () -> Void)?
|
||||
private let presentMeter: LatencyMeter?
|
||||
private let presentTailMeter: LatencyMeter?
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
|
||||
public init(
|
||||
connection: PunktfunkConnection,
|
||||
@@ -59,24 +60,27 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
onCaptureChange: ((Bool) -> Void)? = nil,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||
onSessionEnd: (@Sendable () -> Void)? = nil,
|
||||
presentMeter: LatencyMeter? = nil,
|
||||
presentTailMeter: LatencyMeter? = nil
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil
|
||||
) {
|
||||
self.connection = connection
|
||||
self.captureEnabled = captureEnabled
|
||||
self.onCaptureChange = onCaptureChange
|
||||
self.onFrame = onFrame
|
||||
self.onSessionEnd = onSessionEnd
|
||||
self.presentMeter = presentMeter
|
||||
self.presentTailMeter = presentTailMeter
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
}
|
||||
|
||||
public func makeUIViewController(context: Context) -> StreamViewController {
|
||||
let controller = StreamViewController()
|
||||
controller.onCaptureChange = onCaptureChange
|
||||
controller.captureEnabled = captureEnabled
|
||||
controller.presentMeter = presentMeter
|
||||
controller.presentTailMeter = presentTailMeter
|
||||
controller.endToEndMeter = endToEndMeter
|
||||
controller.decodeMeter = decodeMeter
|
||||
controller.displayMeter = displayMeter
|
||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
return controller
|
||||
}
|
||||
@@ -84,8 +88,9 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
public func updateUIViewController(_ controller: StreamViewController, context: Context) {
|
||||
controller.onCaptureChange = onCaptureChange
|
||||
controller.captureEnabled = captureEnabled
|
||||
controller.presentMeter = presentMeter
|
||||
controller.presentTailMeter = presentTailMeter
|
||||
controller.endToEndMeter = endToEndMeter
|
||||
controller.decodeMeter = decodeMeter
|
||||
controller.displayMeter = displayMeter
|
||||
if controller.connection !== connection {
|
||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
}
|
||||
@@ -101,10 +106,11 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
public final class StreamViewController: UIViewController {
|
||||
public private(set) var connection: PunktfunkConnection?
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
/// Record capture→present / decode→present when the stage-2 presenter is active.
|
||||
/// Consulted at start().
|
||||
var presentMeter: LatencyMeter?
|
||||
var presentTailMeter: LatencyMeter?
|
||||
/// Record the unified latency stages (end-to-end / decode / display) when the stage-2
|
||||
/// presenter is active. Consulted at start().
|
||||
var endToEndMeter: LatencyMeter?
|
||||
var decodeMeter: LatencyMeter?
|
||||
var displayMeter: LatencyMeter?
|
||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||
private let presenter = SessionPresenter()
|
||||
@@ -285,8 +291,9 @@ public final class StreamViewController: UIViewController {
|
||||
presenter.start(
|
||||
connection: connection,
|
||||
baseLayer: streamView.displayLayer,
|
||||
presentMeter: presentMeter,
|
||||
presentTailMeter: presentTailMeter,
|
||||
endToEndMeter: endToEndMeter,
|
||||
decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter,
|
||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd)
|
||||
@@ -339,6 +346,9 @@ public final class StreamViewController: UIViewController {
|
||||
setCaptured(false)
|
||||
inputCapture?.stop()
|
||||
inputCapture = nil
|
||||
// Release anything the touch-driven mouse still holds (a mid-drag session end) while
|
||||
// onTouchEvent can still deliver the button-up.
|
||||
streamView.resetTouchInput()
|
||||
streamView.onTouchEvent = nil
|
||||
streamView.onPointerMoveAbs = nil
|
||||
streamView.onPointerButton = nil
|
||||
@@ -454,7 +464,8 @@ final class StreamLayerUIView: UIView {
|
||||
|
||||
/// Reads the LIVE negotiated mode in pixels (the touch/pointer coordinate space).
|
||||
var currentHostMode: (() -> CGSize)?
|
||||
/// Direct fingers / Pencil → wire touch events.
|
||||
/// Direct fingers / Pencil → wire events: real touches in passthrough mode, or the
|
||||
/// touch-driven mouse events (`TouchMouse`) in the trackpad/pointer modes.
|
||||
var onTouchEvent: ((PunktfunkInputEvent) -> Void)?
|
||||
/// Indirect pointer (mouse/trackpad with no lock) → absolute cursor moves.
|
||||
var onPointerMoveAbs: ((HostPoint) -> Void)?
|
||||
@@ -468,6 +479,22 @@ final class StreamLayerUIView: UIView {
|
||||
/// GameStream button held per active indirect-pointer touch (one click/drag session);
|
||||
/// released when that touch ends.
|
||||
private var pointerButtons: [ObjectIdentifier: UInt32] = [:]
|
||||
/// Touch-driven mouse for the trackpad/pointer `TouchInputMode`s (see TouchMouse.swift).
|
||||
private lazy var touchMouse: TouchMouse = {
|
||||
let mouse = TouchMouse()
|
||||
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
|
||||
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
|
||||
return mouse
|
||||
}()
|
||||
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
||||
/// the NEXT touch, so one gesture never splits across input models.
|
||||
private var fingerRoute: TouchInputMode?
|
||||
|
||||
/// Release anything the touch-driven mouse holds and forget gesture state — session stop.
|
||||
func resetTouchInput() {
|
||||
touchMouse.reset()
|
||||
fingerRoute = nil
|
||||
}
|
||||
#endif
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@@ -504,10 +531,10 @@ final class StreamLayerUIView: UIView {
|
||||
route(touches, event: event, kind: .up)
|
||||
}
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
route(touches, event: event, kind: .up)
|
||||
route(touches, event: event, kind: .cancel)
|
||||
}
|
||||
|
||||
private enum TouchKind { case down, move, up }
|
||||
private enum TouchKind { case down, move, up, cancel }
|
||||
|
||||
/// Split a touch batch by kind: an INDIRECT POINTER (mouse/trackpad with no lock) drives
|
||||
/// the host cursor as an absolute mouse; everything else (direct finger, Pencil) is a host
|
||||
@@ -521,7 +548,28 @@ final class StreamLayerUIView: UIView {
|
||||
fingers.insert(touch)
|
||||
}
|
||||
}
|
||||
if !fingers.isEmpty { forwardTouches(fingers, kind: kind) }
|
||||
if !fingers.isEmpty { forwardFingers(fingers, kind: kind) }
|
||||
}
|
||||
|
||||
/// Route direct fingers by the touch-input model, latched for the whole gesture:
|
||||
/// passthrough → real wire touches; trackpad/pointer → the TouchMouse gesture engine.
|
||||
private func forwardFingers(_ touches: Set<UITouch>, kind: TouchKind) {
|
||||
let mode = fingerRoute ?? TouchInputMode.current
|
||||
fingerRoute = mode
|
||||
switch mode {
|
||||
case .touch:
|
||||
// A cancellation lifts the wire touch like a normal up — the host just sees the
|
||||
// contact end.
|
||||
forwardTouches(touches, kind: kind == .cancel ? .up : kind)
|
||||
case .trackpad, .pointer:
|
||||
switch kind {
|
||||
case .down: touchMouse.began(touches, in: self, trackpad: mode == .trackpad)
|
||||
case .move: touchMouse.moved(touches, in: self)
|
||||
case .up: touchMouse.ended(touches, in: self)
|
||||
case .cancel: touchMouse.cancelled(touches)
|
||||
}
|
||||
}
|
||||
if touchIDs.isEmpty, touchMouse.isIdle { fingerRoute = nil }
|
||||
}
|
||||
|
||||
/// An indirect-pointer touch is a button-held click/drag session: forward its position as
|
||||
@@ -537,7 +585,7 @@ final class StreamLayerUIView: UIView {
|
||||
onPointerButton?(button, true)
|
||||
case .move:
|
||||
if let host { onPointerMoveAbs?(host) }
|
||||
case .up:
|
||||
case .up, .cancel:
|
||||
if let host { onPointerMoveAbs?(host) }
|
||||
if let button = pointerButtons.removeValue(forKey: key) {
|
||||
onPointerButton?(button, false)
|
||||
@@ -554,7 +602,7 @@ final class StreamLayerUIView: UIView {
|
||||
case .down:
|
||||
id = nextFreeID()
|
||||
touchIDs[key] = id
|
||||
case .move, .up:
|
||||
case .move, .up, .cancel:
|
||||
guard let known = touchIDs[key] else { continue }
|
||||
id = known
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Multi-channel input → mono fold (SessionAudio.foldToMono): the fix for a mic on one channel of
|
||||
// a multi-channel interface. AVAudioConverter's default N→stereo downmix grabs channels 0/1 — dead
|
||||
// silence when the mic sits higher up — so we fold ourselves. This pins the fiddly bits (the
|
||||
// interleaved stride, channel pinning, the sum-clamp) against regressions without needing hardware.
|
||||
|
||||
#if !os(tvOS)
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class AudioChannelFoldTests: XCTestCase {
|
||||
/// Drive `foldToMono` over channel data expressed as `[[Float]]`, mirroring the two
|
||||
/// `floatChannelData` layouts:
|
||||
/// - deinterleaved: each inner array is one channel (all `frames` long).
|
||||
/// - interleaved: a single inner array already interleaved (c0f0, c1f0, …), with the real
|
||||
/// channel count passed separately.
|
||||
private func fold(
|
||||
_ planes: [[Float]], frames: Int, channels: Int, interleaved: Bool, pinned: Int?
|
||||
) -> [Float] {
|
||||
// One C buffer per plane + a table of pointers to them — the shape of floatChannelData.
|
||||
let buffers: [UnsafeMutablePointer<Float>] = planes.map { plane in
|
||||
let p = UnsafeMutablePointer<Float>.allocate(capacity: plane.count)
|
||||
for i in 0..<plane.count { p[i] = plane[i] }
|
||||
return p
|
||||
}
|
||||
let table = UnsafeMutablePointer<UnsafeMutablePointer<Float>>.allocate(
|
||||
capacity: buffers.count)
|
||||
for (i, b) in buffers.enumerated() { table[i] = b }
|
||||
let out = UnsafeMutablePointer<Float>.allocate(capacity: frames)
|
||||
defer {
|
||||
buffers.forEach { $0.deallocate() }
|
||||
table.deallocate()
|
||||
out.deallocate()
|
||||
}
|
||||
SessionAudio.foldToMono(
|
||||
input: table, frames: frames, channels: channels,
|
||||
interleaved: interleaved, pinned: pinned, out: out)
|
||||
return (0..<frames).map { out[$0] }
|
||||
}
|
||||
|
||||
// A pinned channel is copied verbatim — the exact fix: mic on a HIGH channel, not 0/1.
|
||||
func testPinsHigherChannelDeinterleaved() {
|
||||
let result = fold(
|
||||
[[0, 0, 0], [0, 0, 0], [0.1, 0.2, 0.3], [0, 0, 0]],
|
||||
frames: 3, channels: 4, interleaved: false, pinned: 2)
|
||||
XCTAssertEqual(result, [0.1, 0.2, 0.3])
|
||||
}
|
||||
|
||||
// Same signal, interleaved layout: [c0f0,c1f0,c2f0,c3f0, c0f1,…]. Guards the `i*ch + c` stride.
|
||||
func testPinsHigherChannelInterleaved() {
|
||||
let interleaved: [Float] = [
|
||||
0, 0, 0.1, 0,
|
||||
0, 0, 0.2, 0,
|
||||
0, 0, 0.3, 0,
|
||||
]
|
||||
let result = fold([interleaved], frames: 3, channels: 4, interleaved: true, pinned: 2)
|
||||
XCTAssertEqual(result, [0.1, 0.2, 0.3])
|
||||
}
|
||||
|
||||
// Auto (pinned: nil): a lone hot channel amid silence passes at FULL level, never attenuated.
|
||||
func testAutoSumsAllChannelsSoALoneMicSurvives() {
|
||||
let result = fold(
|
||||
[[0, 0], [0.4, -0.4], [0, 0]],
|
||||
frames: 2, channels: 3, interleaved: false, pinned: nil)
|
||||
XCTAssertEqual(result, [0.4, -0.4])
|
||||
}
|
||||
|
||||
// Two simultaneously-hot channels sum past the unit range → clamped, never wraps/overflows.
|
||||
func testAutoSumClampsToUnitRange() {
|
||||
let result = fold(
|
||||
[[0.8, -0.8], [0.9, -0.9]],
|
||||
frames: 2, channels: 2, interleaved: false, pinned: nil)
|
||||
XCTAssertEqual(result, [1.0, -1.0])
|
||||
}
|
||||
|
||||
// A plain mono device is passed through untouched (no clamp, no attenuation).
|
||||
func testMonoIsIdentity() {
|
||||
let result = fold(
|
||||
[[0.25, -0.5, 0.75]], frames: 3, channels: 1, interleaved: false, pinned: nil)
|
||||
XCTAssertEqual(result, [0.25, -0.5, 0.75])
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: an out-of-range pin (the tap already guards, but the setting is
|
||||
// persisted) is ignored by foldToMono's own `ch < channels` guard, which sums instead of
|
||||
// reading past the buffer.
|
||||
func testOutOfRangePinFallsBackToSum() {
|
||||
let result = fold(
|
||||
[[0, 0], [0.3, 0.3]],
|
||||
frames: 2, channels: 2, interleaved: false, pinned: 2)
|
||||
XCTAssertEqual(result, [0.3, 0.3])
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,107 @@
|
||||
// Unit tests for HostNetworkSplitter (the host/network split of the unified stats model's
|
||||
// host+network stage — design/stats-unification.md Phase 2): pts matching, the per-frame
|
||||
// tiling arithmetic (network = combined − host, floored at 0), drain/reset semantics, the
|
||||
// bounded pending ring, and the absurd-receipt clamp. All samples use explicit instants, so
|
||||
// the expectations are exact.
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class HostNetworkSplitterTests: XCTestCase {
|
||||
/// An arbitrary host-capture pts (ns) far from zero, like a real CLOCK_REALTIME stamp.
|
||||
private let basePts: UInt64 = 1_000_000_000_000
|
||||
|
||||
private func receipt(_ s: HostNetworkSplitter, pts: UInt64, combinedMs: Int64,
|
||||
offsetNs: Int64 = 0) {
|
||||
s.recordReceipt(
|
||||
ptsNs: pts, receivedNs: Int64(pts) + combinedMs * 1_000_000 - offsetNs,
|
||||
offsetNs: offsetNs)
|
||||
}
|
||||
|
||||
func testEmptyDrainIsNil() {
|
||||
XCTAssertNil(HostNetworkSplitter().drain())
|
||||
}
|
||||
|
||||
func testMatchSplitsCombinedIntoHostAndNetwork() {
|
||||
let s = HostNetworkSplitter()
|
||||
receipt(s, pts: basePts, combinedMs: 8) // capture→received 8 ms
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000) // host says 3 ms of it was its own
|
||||
guard let split = s.drain() else { return XCTFail("expected a matched sample") }
|
||||
XCTAssertEqual(split.count, 1)
|
||||
XCTAssertEqual(split.hostP50Ms, 3.0)
|
||||
XCTAssertEqual(split.networkP50Ms, 5.0, "the two terms tile the combined interval")
|
||||
XCTAssertNil(s.drain(), "drain resets the window")
|
||||
}
|
||||
|
||||
func testSkewOffsetAppliesToTheCombinedInterval() {
|
||||
let s = HostNetworkSplitter()
|
||||
// Client clock 2 ms behind the host: the raw difference alone would read 6 ms.
|
||||
receipt(s, pts: basePts, combinedMs: 8, offsetNs: 2_000_000)
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000)
|
||||
XCTAssertEqual(s.drain()?.networkP50Ms, 5.0)
|
||||
}
|
||||
|
||||
func testUnmatchedTimingIsSkipped() {
|
||||
let s = HostNetworkSplitter()
|
||||
receipt(s, pts: basePts, combinedMs: 8)
|
||||
// A timing for an AU we never received (FEC-dropped) must not fabricate a sample.
|
||||
s.noteHostTiming(ptsNs: basePts + 1, hostUs: 3_000)
|
||||
XCTAssertNil(s.drain())
|
||||
}
|
||||
|
||||
func testReceiptSurvivesADrainUntilItsTimingArrives() {
|
||||
let s = HostNetworkSplitter()
|
||||
receipt(s, pts: basePts, combinedMs: 8)
|
||||
XCTAssertNil(s.drain(), "no timing matched yet")
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000) // arrives one tick late — still matches
|
||||
XCTAssertEqual(s.drain()?.hostP50Ms, 3.0)
|
||||
}
|
||||
|
||||
func testEachReceiptMatchesOnce() {
|
||||
let s = HostNetworkSplitter()
|
||||
receipt(s, pts: basePts, combinedMs: 8)
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000)
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000) // duplicate 0xCF — no second sample
|
||||
XCTAssertEqual(s.drain()?.count, 1)
|
||||
}
|
||||
|
||||
func testNetworkFlooredAtZero() {
|
||||
let s = HostNetworkSplitter()
|
||||
// A slightly-off skew offset can make host_us exceed the combined interval.
|
||||
receipt(s, pts: basePts, combinedMs: 2)
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000)
|
||||
guard let split = s.drain() else { return XCTFail("expected a sample") }
|
||||
XCTAssertEqual(split.hostP50Ms, 3.0)
|
||||
XCTAssertEqual(split.networkP50Ms, 0.0)
|
||||
}
|
||||
|
||||
func testPendingRingDropsOldest() {
|
||||
let s = HostNetworkSplitter()
|
||||
for i in 0..<300 { // cap is 256 — the first receipts fall out
|
||||
receipt(s, pts: basePts + UInt64(i), combinedMs: 8)
|
||||
}
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000) // evicted — no match
|
||||
XCTAssertNil(s.drain())
|
||||
s.noteHostTiming(ptsNs: basePts + 299, hostUs: 3_000) // newest — still pending
|
||||
XCTAssertEqual(s.drain()?.count, 1)
|
||||
}
|
||||
|
||||
func testAbsurdReceiptsAreDropped() {
|
||||
let s = HostNetworkSplitter()
|
||||
receipt(s, pts: basePts, combinedMs: -1) // received before capture — clock step
|
||||
receipt(s, pts: basePts + 1, combinedMs: 20_000) // > 10 s — garbage pts/offset
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 1_000)
|
||||
s.noteHostTiming(ptsNs: basePts + 1, hostUs: 1_000)
|
||||
XCTAssertNil(s.drain())
|
||||
}
|
||||
|
||||
func testResetForgetsPendingReceipts() {
|
||||
let s = HostNetworkSplitter()
|
||||
receipt(s, pts: basePts, combinedMs: 8)
|
||||
s.reset()
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000)
|
||||
XCTAssertNil(s.drain(), "a fresh session must not match a previous session's receipts")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
// Unit tests for LatencyMeter: percentiles, the skew-corrected flag, reset-on-drain, and the
|
||||
// absurd-value guard. Latencies are constructed by stamping a pts a known interval in the past, so
|
||||
// the result is that interval plus the (tiny) clock advance between reads — asserted with tolerance.
|
||||
// Unit tests for LatencyMeter (one instance per unified-stats stage — see
|
||||
// design/stats-unification.md): percentiles, the skew-corrected flag, reset-on-drain, the
|
||||
// absurd-value guard, and the explicit-instant stage form (record(ptsNs:atNs:offsetNs:), used for
|
||||
// the client-local decode/display stages and the at-present end-to-end stamp). Receipt-path
|
||||
// latencies are constructed by stamping a pts a known interval in the past, so the result is that
|
||||
// interval plus the (tiny) clock advance between reads — asserted with tolerance; the explicit
|
||||
// form is exact.
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
@@ -38,6 +42,26 @@ final class LatencyMeterTests: XCTestCase {
|
||||
XCTAssertEqual(m.drain()?.skewCorrected, true)
|
||||
}
|
||||
|
||||
func testExplicitStageRecordIsExact() {
|
||||
let m = LatencyMeter()
|
||||
// A client-local stage (decode: received→decoded) — start instant as ptsNs, offset 0.
|
||||
let receivedNs: Int64 = 1_000_000_000_000
|
||||
m.record(ptsNs: UInt64(receivedNs), atNs: receivedNs + 3_000_000, offsetNs: 0)
|
||||
guard let s = m.drain() else { return XCTFail("expected a sample") }
|
||||
XCTAssertEqual(s.count, 1)
|
||||
XCTAssertEqual(s.p50Ms, 3.0, "explicit instants make the sample exact")
|
||||
XCTAssertFalse(s.skewCorrected, "local stages record with offset 0")
|
||||
}
|
||||
|
||||
func testExplicitStageDropsNonPositiveInterval() {
|
||||
let m = LatencyMeter()
|
||||
// A stage whose start stamp is missing (0) or after its end must not pollute the window.
|
||||
let decodedNs: Int64 = 1_000_000_000_000
|
||||
m.record(ptsNs: 0, atNs: decodedNs, offsetNs: 0) // "start unknown" → > 10 s → dropped
|
||||
m.record(ptsNs: UInt64(decodedNs + 1), atNs: decodedNs, offsetNs: 0) // negative → dropped
|
||||
XCTAssertNil(m.drain())
|
||||
}
|
||||
|
||||
func testDropsAbsurdValues() {
|
||||
let m = LatencyMeter()
|
||||
let now = nowRealtimeNs()
|
||||
|
||||
@@ -25,12 +25,18 @@ final class LoopbackIntegrationTests: XCTestCase {
|
||||
XCTAssertEqual(conn.resolvedBitrateKbps, 50_000)
|
||||
|
||||
// Pull 25 synthetic frames and byte-verify the documented pattern:
|
||||
// u32 LE frame index, then data[i] = (idx as u8) &+ (i as u8).
|
||||
// u32 LE frame index, then data[i] = (idx as u8) &+ (i as u8). Alongside, drain the
|
||||
// per-AU host-timing plane (0xCF) the way the app's stats tick does — the connector
|
||||
// ORs VIDEO_CAP_HOST_TIMING in unconditionally and the synthetic host stamps one
|
||||
// report per AU, so the pts correlation must hold end to end through the xcframework.
|
||||
var got = 0
|
||||
var lastIndex: UInt32 = 0
|
||||
var receivedPts = Set<UInt64>()
|
||||
var timings: [PunktfunkConnection.HostTiming] = []
|
||||
let deadline = Date().addingTimeInterval(30)
|
||||
while got < 25 {
|
||||
XCTAssertLessThan(Date(), deadline, "timed out after \(got) frames")
|
||||
while let t = try conn.nextHostTiming(timeoutMs: 0) { timings.append(t) }
|
||||
guard let au = try conn.nextAU(timeoutMs: 2000) else { continue }
|
||||
let idx = au.data.prefix(4).reversed().reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
for (i, byte) in au.data.enumerated().dropFirst(4) {
|
||||
@@ -41,10 +47,22 @@ final class LoopbackIntegrationTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
XCTAssertGreaterThan(au.ptsNs, 0)
|
||||
receivedPts.insert(au.ptsNs)
|
||||
lastIndex = idx
|
||||
got += 1
|
||||
}
|
||||
XCTAssertGreaterThanOrEqual(lastIndex, 24)
|
||||
// Belt-and-braces: the last frame's timing lands just after its AU — give it a bounded
|
||||
// grace drain (the stream keeps running, so this must not loop on fresh timings).
|
||||
var grace = 0
|
||||
while grace < 64, !timings.contains(where: { receivedPts.contains($0.ptsNs) }),
|
||||
let t = try conn.nextHostTiming(timeoutMs: 100) {
|
||||
timings.append(t)
|
||||
grace += 1
|
||||
}
|
||||
XCTAssertTrue(
|
||||
timings.contains { receivedPts.contains($0.ptsNs) },
|
||||
"no 0xCF host timing matched a received AU's pts (got \(timings.count) timings)")
|
||||
|
||||
// Input goes the other way (enqueue-only; the host logs the count on close) —
|
||||
// including the touch kinds, gamepad events, the rich-input plane (DualSense
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Pins the rumble renderer's pure scheduling/mapping decisions and the relations between its
|
||||
/// tuning constants that the design depends on (see `RumbleRenderer`'s invariants). No
|
||||
/// CHHapticEngine or physical pad involved.
|
||||
final class RumbleTuningTests: XCTestCase {
|
||||
func testAmplitudeMapsWireRangeToUnitInterval() {
|
||||
XCTAssertEqual(RumbleTuning.amplitude(0), 0)
|
||||
XCTAssertEqual(RumbleTuning.amplitude(0xFFFF), 1)
|
||||
XCTAssertEqual(RumbleTuning.amplitude(0x8000), Float(0x8000) / 65535, accuracy: 1e-6)
|
||||
// Monotonic — a stronger wire value can never render weaker.
|
||||
XCTAssertLessThan(RumbleTuning.amplitude(0x1000), RumbleTuning.amplitude(0x2000))
|
||||
}
|
||||
|
||||
func testHidByteMapsWireRangeToPadRange() {
|
||||
XCTAssertEqual(RumbleTuning.hidByte(0), 0)
|
||||
XCTAssertEqual(RumbleTuning.hidByte(0xFFFF), 255)
|
||||
XCTAssertEqual(RumbleTuning.hidByte(0x8000), 0x80)
|
||||
}
|
||||
|
||||
func testCombinedActuatorRendersStrongerMotor() {
|
||||
XCTAssertEqual(RumbleTuning.combined(low: 0x4000, high: 0x8000), 0x8000)
|
||||
XCTAssertEqual(RumbleTuning.combined(low: 0x8000, high: 0x4000), 0x8000)
|
||||
XCTAssertEqual(RumbleTuning.combined(low: 0, high: 0), 0)
|
||||
}
|
||||
|
||||
func testLevelDedupeEpsilon() {
|
||||
// An identical host refresh (and LSB jitter) is the same level — no player rebuild.
|
||||
XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5))
|
||||
XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon))
|
||||
// A real level change is not.
|
||||
XCTAssertFalse(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon * 3))
|
||||
XCTAssertFalse(RumbleTuning.sameLevel(0, 1))
|
||||
}
|
||||
|
||||
func testRearmDecision() {
|
||||
let ends: TimeInterval = 100
|
||||
XCTAssertFalse(
|
||||
RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom - 0.1))
|
||||
XCTAssertTrue(
|
||||
RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom + 0.1))
|
||||
// Even a segment already past its end re-arms (the gap already happened; recover).
|
||||
XCTAssertTrue(RumbleTuning.shouldRearm(endsAt: ends, now: ends + 1))
|
||||
}
|
||||
|
||||
func testHandoffStartsAtSegmentEndNeverInThePast() {
|
||||
// Successor starts exactly at the predecessor's end...
|
||||
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 99.5), 100)
|
||||
// ...unless that instant already passed — then start immediately, not in the past.
|
||||
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5)
|
||||
}
|
||||
|
||||
func testPolicies() {
|
||||
// The session policy ties motor life to wire liveness; the manual (test-panel) policy
|
||||
// holds a level indefinitely.
|
||||
XCTAssertNotNil(RumbleRenderer.Policy.session.staleAfter)
|
||||
XCTAssertNil(RumbleRenderer.Policy.manual.staleAfter)
|
||||
}
|
||||
|
||||
/// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call
|
||||
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
||||
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
||||
func testRendererSurvivesCallStormAndTeardownWithoutController() {
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
renderer.retarget(nil)
|
||||
for i in 0..<500 {
|
||||
renderer.apply(
|
||||
low: i % 2 == 0 ? 0x8000 : 0, high: UInt16(truncatingIfNeeded: i &* 37))
|
||||
}
|
||||
// Leave a nonzero target long enough for the ticker to spin a few times.
|
||||
renderer.apply(low: 0x4000, high: 0x4000)
|
||||
Thread.sleep(forTimeInterval: 0.2)
|
||||
renderer.stop()
|
||||
}
|
||||
|
||||
func testTuningRelationsTheDesignDependsOn() {
|
||||
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
|
||||
// but trip well before a stuck rumble reads as "still going".
|
||||
XCTAssertGreaterThan(RumbleTuning.sessionStaleSeconds, 2 * 0.5)
|
||||
XCTAssertLessThanOrEqual(RumbleTuning.sessionStaleSeconds, 2.5)
|
||||
// Re-arm headroom must clear several ticker periods, or a steady rumble could miss the
|
||||
// segment boundary and gap.
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
RumbleTuning.rearmHeadroom, 4 * RumbleTuning.tickSeconds)
|
||||
// The headroom must fit inside a segment, or re-arm would trigger instantly forever.
|
||||
XCTAssertLessThan(RumbleTuning.rearmHeadroom, RumbleTuning.segmentSeconds)
|
||||
// The rebake throttle must be far under the host refresh period, or refreshed level
|
||||
// changes would queue behind it; and under a frame at 30 fps so ramps stay smooth.
|
||||
XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30)
|
||||
// The ticker (which lands throttled levels) must outpace the HID keepalive and the
|
||||
// watchdog, or those deadlines could be overshot by a full period.
|
||||
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
|
||||
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.sessionStaleSeconds)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ final class Stage444Tests: XCTestCase {
|
||||
let data = Data(Probe444Blobs.au444_8bit)
|
||||
let format = try XCTUnwrap(
|
||||
AnnexB.formatDescription(fromIDR: data, codec: .hevc), "the 4:4:4 blob must yield a format description")
|
||||
let au = AccessUnit(data: data, ptsNs: 7_000_000, frameIndex: 0, flags: 0)
|
||||
let au = AccessUnit(data: data, ptsNs: 7_000_000, frameIndex: 0, flags: 0, receivedNs: 0)
|
||||
|
||||
let box = FrameBox()
|
||||
let done = DispatchSemaphore(value: 0)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#if os(iOS)
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Pins the touch-mouse tuning contract (ported 1:1 from the Android client's TouchInput.kt
|
||||
/// so the two touch clients feel identical) and the mode parsing. The gesture state machine
|
||||
/// itself needs UITouch instances and is validated on-glass.
|
||||
final class TouchMouseTests: XCTestCase {
|
||||
func testModeParsingDefaultsToTrackpad() {
|
||||
XCTAssertEqual(TouchInputMode(rawValue: "trackpad"), .trackpad)
|
||||
XCTAssertEqual(TouchInputMode(rawValue: "pointer"), .pointer)
|
||||
XCTAssertEqual(TouchInputMode(rawValue: "touch"), .touch)
|
||||
// Unknown/unset values must fall back to trackpad — never crash or go touch-silent.
|
||||
XCTAssertNil(TouchInputMode(rawValue: "bogus"))
|
||||
}
|
||||
|
||||
func testAccelerationCurve() {
|
||||
// At or below the speed floor: no acceleration — slow drags stay precise.
|
||||
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: 0), 1)
|
||||
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: TouchMouse.Tuning.accelSpeedFloor), 1)
|
||||
// Above the floor the gain ramps...
|
||||
let mid = TouchMouse.Tuning.accel(forSpeed: 1.0)
|
||||
XCTAssertGreaterThan(mid, 1)
|
||||
XCTAssertLessThan(mid, TouchMouse.Tuning.accelMax)
|
||||
// ...and a flick is capped so it can't fling the cursor uncontrollably.
|
||||
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: 100), TouchMouse.Tuning.accelMax)
|
||||
// Monotonic in between.
|
||||
XCTAssertLessThanOrEqual(
|
||||
TouchMouse.Tuning.accel(forSpeed: 0.5), TouchMouse.Tuning.accel(forSpeed: 1.5))
|
||||
}
|
||||
|
||||
func testTuningRelations() {
|
||||
// The tap-drag window must be long enough to hit but short enough not to turn every
|
||||
// second tap into a drag.
|
||||
XCTAssertGreaterThan(TouchMouse.Tuning.tapDragWindow, 0.1)
|
||||
XCTAssertLessThan(TouchMouse.Tuning.tapDragWindow, 0.5)
|
||||
// A wheel notch per ~10 pt of two-finger pan (the indirect-trackpad path's feel).
|
||||
XCTAssertGreaterThan(TouchMouse.Tuning.scrollNotchPt, 0)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -38,7 +38,8 @@ final class VideoToolboxRoundTripTests: XCTestCase {
|
||||
XCTAssertEqual(AnnexB.avcc(from: annexB, codec: .hevc), avccSample)
|
||||
|
||||
// 3) Sample buffer → real decoder → pixels.
|
||||
let au = AccessUnit(data: annexB, ptsNs: 1_000_000, frameIndex: 0, flags: 0)
|
||||
let au = AccessUnit(
|
||||
data: annexB, ptsNs: 1_000_000, frameIndex: 0, flags: 0, receivedNs: 0)
|
||||
let sample = try XCTUnwrap(AnnexB.sampleBuffer(au: au, format: rebuilt, codec: .hevc))
|
||||
|
||||
var session: VTDecompressionSession?
|
||||
@@ -67,13 +68,14 @@ final class VideoToolboxRoundTripTests: XCTestCase {
|
||||
}
|
||||
|
||||
/// Stage-2 decode half: the same known IDR through `VideoDecoder` — assert its async output
|
||||
/// callback fires with a CVPixelBuffer of the right dimensions, the pts round-trips, and
|
||||
/// decode-completion is stamped.
|
||||
/// callback fires with a CVPixelBuffer of the right dimensions, the pts and the receipt stamp
|
||||
/// round-trip (the latter rides the frame refcon), and decode-completion is stamped.
|
||||
func testVideoDecoderAsyncCallbackDeliversPixels() throws {
|
||||
let (formatDesc, avccSample) = try encodeOneHEVCKeyframe()
|
||||
let annexB = try annexBAU(formatDesc: formatDesc, avccSample: avccSample)
|
||||
let format = try XCTUnwrap(AnnexB.formatDescription(fromIDR: annexB, codec: .hevc))
|
||||
let au = AccessUnit(data: annexB, ptsNs: 42_000_000, frameIndex: 0, flags: 0)
|
||||
let au = AccessUnit(
|
||||
data: annexB, ptsNs: 42_000_000, frameIndex: 0, flags: 0, receivedNs: 41_000_000)
|
||||
|
||||
let box = FrameBox()
|
||||
let done = DispatchSemaphore(value: 0)
|
||||
@@ -100,6 +102,8 @@ final class VideoToolboxRoundTripTests: XCTestCase {
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), width)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), height)
|
||||
XCTAssertEqual(ready.ptsNs, 42_000_000, "pts round-trips through the decoder")
|
||||
XCTAssertEqual(
|
||||
ready.receivedNs, 41_000_000, "receivedNs round-trips through the frame refcon")
|
||||
XCTAssertGreaterThan(ready.decodedNs, 0, "decode-completion is stamped")
|
||||
}
|
||||
|
||||
|
||||
+29
-17
@@ -1,7 +1,7 @@
|
||||
# punktfunk — Steam Deck plugin (Decky)
|
||||
# Punktfunk — Steam Deck plugin (Decky)
|
||||
|
||||
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
|
||||
**[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.
|
||||
|
||||
@@ -12,12 +12,22 @@ the panel looks and feels native to Gaming Mode.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Discover** — browses the LAN over mDNS for punktfunk hosts, in both the QAM panel and a
|
||||
fullscreen page.
|
||||
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 hidden Steam shortcut so gamescope focuses it.
|
||||
4. **Settings** — resolution / refresh / bitrate / gamepad / mic, written to the client's config.
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -37,8 +47,10 @@ https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.z
|
||||
```
|
||||
|
||||
(or a pinned `.../punktfunk-decky/<version>/punktfunk.zip`). The plugin then **self-updates** without
|
||||
the Decky store — when a newer build exists, an **Update to vX** button appears and drives Decky
|
||||
Loader's own (SHA-256-verified) install.
|
||||
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.
|
||||
|
||||
## Build & sideload (development)
|
||||
|
||||
@@ -58,20 +70,20 @@ restart is required for an out-of-band install to appear.
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/index.tsx` | Frontend: QAM panel + the `/punktfunk` fullscreen page (host list, PIN keypad, settings). |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. |
|
||||
| `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/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 targets (so the window is focusable). |
|
||||
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / settings / `kill_stream` / `check_update`. |
|
||||
| `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. |
|
||||
| `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
|
||||
|
||||
The client binary is resolved `PATH` → `/usr/bin` → `/usr/local/bin` → `~/.local/bin` → a
|
||||
`flatpak run io.unom.Punktfunk` fallback, so the flatpak install always works.
|
||||
|
||||
## Limitations / next steps
|
||||
|
||||
- **Needs on-Deck validation in Gaming Mode** — the Steam-shortcut launch and headless pairing follow
|
||||
MoonDeck's proven pattern but are verified only at build time here.
|
||||
- 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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -11,13 +11,26 @@
|
||||
#
|
||||
# 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:
|
||||
# every host (and every pinned game):
|
||||
# PF_HOST host[:port] to connect to (required)
|
||||
# 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_APPID flatpak app id (default io.unom.Punktfunk)
|
||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# NO EXEC BIT REQUIRED: the Steam shortcut's exe is `/bin/sh` and this script rides behind
|
||||
# `%command%` as an argument (see src/steam.ts). Decky extracts plugin zips without preserving
|
||||
# permission bits and ~/homebrew/plugins is root-owned (the unprivileged plugin backend can't
|
||||
# chmod), so the launch path must never depend on +x. Keep this script POSIX-sh clean.
|
||||
set -u
|
||||
|
||||
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||
@@ -28,9 +41,23 @@ if [ -z "${PF_HOST:-}" ]; then
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
|
||||
# exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and
|
||||
# Gaming Mode reclaims focus automatically (no manual refocus needed).
|
||||
# --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).
|
||||
if [ -n "${PF_BROWSE:-}" ]; then
|
||||
# The gamepad library launcher: browse the host's games on-screen, A streams one,
|
||||
# session end returns to the launcher, B quits back to Gaming Mode.
|
||||
echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2
|
||||
if [ -n "${PF_MGMT:-}" ]; then
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
||||
fi
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
|
||||
fi
|
||||
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" --fullscreen
|
||||
fi
|
||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --fullscreen
|
||||
|
||||
+366
-23
@@ -12,6 +12,11 @@ The backend's jobs are the things Steam can't do:
|
||||
* **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
|
||||
@@ -20,16 +25,16 @@ The backend's jobs are the things Steam can't do:
|
||||
* **check_update()** — poll the registry's per-channel ``manifest.json`` and report whether a
|
||||
newer build is available (the frontend then drives Decky's own install RPC to apply it).
|
||||
|
||||
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the host
|
||||
advert in ``crates/punktfunk-host/src/discovery.rs``.
|
||||
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by
|
||||
the host advert in ``crates/punktfunk-host/src/discovery.rs``.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import ssl
|
||||
import stat
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
@@ -77,6 +82,46 @@ def _runner_path() -> str:
|
||||
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"
|
||||
|
||||
|
||||
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
|
||||
``clients/linux/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
|
||||
@@ -125,13 +170,68 @@ def _semver_tuple(v: str) -> tuple[int, int, int]:
|
||||
return (parts[0], parts[1], parts[2])
|
||||
|
||||
|
||||
# Decky Loader ships its own embedded (PyInstaller) Python whose compiled-in OpenSSL default
|
||||
# verify paths don't exist on SteamOS — ``ssl.create_default_context()`` then trusts NOTHING
|
||||
# and every HTTPS fetch dies with CERTIFICATE_VERIFY_FAILED (seen live on the Deck). Fix: find
|
||||
# a real CA bundle on disk and load it explicitly. Verification is NEVER disabled — if no
|
||||
# bundle exists the fetch just fails, and check_update() is non-fatal by design.
|
||||
_CA_BUNDLES = (
|
||||
"/etc/ssl/certs/ca-certificates.crt", # SteamOS / Arch / Debian / Ubuntu
|
||||
"/etc/ssl/cert.pem", # Arch/openssl compat symlink
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", # Fedora / Bazzite
|
||||
"/etc/ssl/ca-bundle.pem", # openSUSE
|
||||
)
|
||||
_ssl_context_cache: ssl.SSLContext | None = None
|
||||
|
||||
|
||||
def _build_ssl_context() -> ssl.SSLContext:
|
||||
"""A verifying SSLContext that actually has CA roots under Decky's embedded Python."""
|
||||
ctx = ssl.create_default_context() # honors SSL_CERT_FILE / SSL_CERT_DIR when set
|
||||
if ctx.cert_store_stats().get("x509_ca", 0):
|
||||
return ctx # the interpreter found its own roots (e.g. a system python)
|
||||
|
||||
dvp = ssl.get_default_verify_paths()
|
||||
candidates: list[str | None] = [dvp.cafile, dvp.openssl_cafile, *_CA_BUNDLES]
|
||||
try: # not shipped by Decky's runtime, but honor it when importable
|
||||
import certifi
|
||||
|
||||
candidates.append(certifi.where())
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
tried: set[str] = set()
|
||||
for cafile in candidates:
|
||||
if not cafile or cafile in tried or not Path(cafile).is_file():
|
||||
continue
|
||||
tried.add(cafile)
|
||||
try:
|
||||
ctx.load_verify_locations(cafile=cafile)
|
||||
except (ssl.SSLError, OSError):
|
||||
continue
|
||||
if ctx.cert_store_stats().get("x509_ca", 0):
|
||||
decky.logger.info("TLS roots loaded from %s", cafile)
|
||||
return ctx
|
||||
|
||||
decky.logger.warning(
|
||||
"no CA bundle found — HTTPS update checks will fail certificate verification"
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
def _ssl_context() -> ssl.SSLContext:
|
||||
"""The (cached) context for registry fetches; building it scans disk, so do it once."""
|
||||
global _ssl_context_cache
|
||||
if _ssl_context_cache is None:
|
||||
_ssl_context_cache = _build_ssl_context()
|
||||
return _ssl_context_cache
|
||||
|
||||
|
||||
def _fetch_json(url: str, timeout: float = 8.0) -> dict:
|
||||
"""Blocking HTTPS GET of a small JSON document (run in an executor)."""
|
||||
req = urllib.request.Request(
|
||||
url, headers={"Accept": "application/json", "User-Agent": "punktfunk-decky"}
|
||||
)
|
||||
ctx = ssl.create_default_context()
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
|
||||
return json.loads(resp.read().decode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
@@ -170,6 +270,71 @@ def _flatpak_env() -> dict:
|
||||
return env
|
||||
|
||||
|
||||
async def _flatpak_capture(args: list[str], timeout: float = 20.0) -> tuple[int, str]:
|
||||
"""Run ``flatpak <args>`` with the user-session env, merging stderr into stdout. Returns
|
||||
``(returncode, output)``; ``(-1, "")`` if the binary is missing or the call errors/times out.
|
||||
Best-effort by design — every caller here treats a failure as "no update / can't tell"."""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
return -1, ""
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
flatpak, *args,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
out, _ = 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")
|
||||
except asyncio.TimeoutError:
|
||||
decky.logger.warning("flatpak %s timed out", " ".join(args))
|
||||
if proc:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return -1, ""
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("flatpak %s failed", " ".join(args))
|
||||
return -1, ""
|
||||
|
||||
|
||||
def _field_from(text: str, name: str) -> str:
|
||||
"""Pull ``<name>: value`` out of ``flatpak info`` / ``remote-info`` output (e.g. ``Commit``,
|
||||
``Origin``)."""
|
||||
prefix = f"{name}:"
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith(prefix):
|
||||
return s.split(":", 1)[1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
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
|
||||
it versions independently of this plugin — so we compare the installed commit against the
|
||||
remote's here and let the QAM offer a user-scope update. Best-effort; all-``False`` on any error
|
||||
(not installed, no flatpak, offline)."""
|
||||
state = {"available": False, "installed": "", "remote": ""}
|
||||
rc, info = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
||||
if rc != 0:
|
||||
return state # client not installed as a user app / no flatpak
|
||||
state["installed"] = _field_from(info, "Commit")
|
||||
origin = _field_from(info, "Origin")
|
||||
if not origin:
|
||||
return state
|
||||
rc, rinfo = await _flatpak_capture(["remote-info", "--user", origin, APP_ID], timeout=25.0)
|
||||
if rc != 0:
|
||||
return state # remote unreachable — treat as "up to date", retry next check
|
||||
state["remote"] = _field_from(rinfo, "Commit")
|
||||
state["available"] = bool(
|
||||
state["installed"] and state["remote"] and state["installed"] != state["remote"]
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def _split_txt(txt: str) -> list[str]:
|
||||
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
|
||||
tokens: list[str] = []
|
||||
@@ -219,6 +384,11 @@ def _parse_avahi_browse(stdout: str) -> list[dict]:
|
||||
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,
|
||||
@@ -226,6 +396,8 @@ def _parse_avahi_browse(stdout: str) -> list[dict]:
|
||||
"pair": props.get("pair", "optional"),
|
||||
"fp": props.get("fp", ""),
|
||||
"proto": props.get("proto", ""),
|
||||
"id": props.get("id", ""),
|
||||
"mgmt": mgmt,
|
||||
}
|
||||
key = props.get("id") or f"{address}:{port}"
|
||||
existing = out.get(key)
|
||||
@@ -317,15 +489,142 @@ class Plugin:
|
||||
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
|
||||
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)}
|
||||
|
||||
async def shortcut_art(self) -> dict:
|
||||
"""The Steam-shortcut artwork shipped with the plugin (``assets/``, generated by
|
||||
``scripts/gen-steam-art.py``): base64 PNGs for SetCustomArtworkForApp plus the
|
||||
icon's absolute path for SetShortcutIcon (which wants a file, not bytes). Missing
|
||||
files are simply omitted — artwork is cosmetic and must never block a launch."""
|
||||
art: dict = {}
|
||||
base = Path(decky.DECKY_PLUGIN_DIR) / "assets"
|
||||
for key, fname in (
|
||||
("grid", "grid.png"),
|
||||
("gridwide", "gridwide.png"),
|
||||
("hero", "hero.png"),
|
||||
("logo", "logo.png"),
|
||||
):
|
||||
try:
|
||||
art[key] = base64.b64encode((base / fname).read_bytes()).decode()
|
||||
except OSError:
|
||||
pass
|
||||
icon = base / "icon.png"
|
||||
art["icon_path"] = str(icon) if icon.exists() else ""
|
||||
return art
|
||||
|
||||
async def runner_info(self) -> dict:
|
||||
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam
|
||||
shortcut. Also (re)asserts the script's exec bit — packaging can drop it."""
|
||||
shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
|
||||
exec bit is needed — Decky's zip extraction drops it, and the root-owned plugins dir
|
||||
means this unprivileged backend couldn't chmod it back on anyway."""
|
||||
path = _runner_path()
|
||||
try:
|
||||
st = os.stat(path)
|
||||
os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
except OSError:
|
||||
decky.logger.warning("could not chmod runner %s", path)
|
||||
return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()}
|
||||
|
||||
async def get_settings(self) -> dict:
|
||||
@@ -368,11 +667,37 @@ class Plugin:
|
||||
return {"ok": False}
|
||||
return {"ok": True}
|
||||
|
||||
async def update_client(self) -> dict:
|
||||
"""Update the flatpak **client** (io.unom.Punktfunk) in the USER installation — the scope a
|
||||
Steam Deck install lives in, which ``sudo flatpak update`` (system-scope) never reaches.
|
||||
Returns whether a new commit was actually pulled. Best-effort; non-fatal."""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
return {"ok": False, "updated": False, "error": "flatpak-not-found"}
|
||||
_, before = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
||||
before_commit = _field_from(before, "Commit")
|
||||
rc, out = await _flatpak_capture(["update", "--user", "-y", APP_ID], timeout=300.0)
|
||||
if rc != 0:
|
||||
decky.logger.warning("flatpak client update failed (rc=%s): %s", rc, out[-400:])
|
||||
return {"ok": False, "updated": False, "error": "update-failed"}
|
||||
_, after = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
||||
after_commit = _field_from(after, "Commit")
|
||||
updated = bool(before_commit and after_commit and before_commit != after_commit)
|
||||
decky.logger.info(
|
||||
"flatpak client update: %s -> %s (updated=%s)",
|
||||
before_commit[:10], after_commit[:10], updated,
|
||||
)
|
||||
_update_cache["data"] = None # invalidate the cached "update available" snapshot
|
||||
return {"ok": True, "updated": updated}
|
||||
|
||||
async def check_update(self, force: bool = False) -> dict:
|
||||
"""Is a newer build available in our registry? Compares the installed version
|
||||
(``package.json``) against the per-channel ``manifest.json`` the CI publishes, and
|
||||
returns everything the frontend needs to drive Decky's install RPC. Non-fatal: any
|
||||
failure (no channel baked in, network down) returns ``update_available: False``.
|
||||
"""Report pending updates for BOTH the plugin and the flatpak client.
|
||||
|
||||
The plugin updates via Decky's install RPC (the per-channel ``manifest.json`` the CI
|
||||
publishes); the **client** updates via ``flatpak update --user`` (a per-user install, so
|
||||
``sudo flatpak update`` — system-scope — never touches it) and versions independently, so
|
||||
it's checked here too and applied through :meth:`update_client`. Non-fatal: any failure
|
||||
leaves the respective ``*_update_available`` ``False``.
|
||||
"""
|
||||
current = _installed_version()
|
||||
cfg = _update_config()
|
||||
@@ -383,23 +708,37 @@ class Plugin:
|
||||
"hash": "",
|
||||
"channel": str(cfg.get("channel", "")),
|
||||
"update_available": False,
|
||||
"client_update_available": False,
|
||||
"client_current": "",
|
||||
"client_latest": "",
|
||||
}
|
||||
|
||||
manifest_url = cfg.get("manifest")
|
||||
if not manifest_url:
|
||||
result["error"] = "update-channel-unknown" # dev / sideloaded build
|
||||
return result
|
||||
|
||||
now = time.monotonic()
|
||||
cached = _update_cache["data"]
|
||||
if not force and cached and (now - _update_cache["at"]) < _UPDATE_TTL_S:
|
||||
return cached
|
||||
|
||||
# Client (flatpak) update — checked ALWAYS, even on a dev/sideloaded plugin build.
|
||||
try:
|
||||
cu = await _client_update_state()
|
||||
result["client_update_available"] = bool(cu["available"])
|
||||
result["client_current"] = (cu["installed"] or "")[:10]
|
||||
result["client_latest"] = (cu["remote"] or "")[:10]
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.warning("client update check failed", exc_info=True)
|
||||
|
||||
manifest_url = cfg.get("manifest")
|
||||
if not manifest_url:
|
||||
result["error"] = "update-channel-unknown" # dev / sideloaded plugin build
|
||||
_update_cache["at"] = now
|
||||
_update_cache["data"] = result # the client info is still valid to cache
|
||||
return result
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
manifest = await loop.run_in_executor(None, _fetch_json, manifest_url)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
decky.logger.warning("update check failed: %s", exc)
|
||||
decky.logger.warning("plugin update check failed: %s", exc)
|
||||
result["error"] = "fetch-failed"
|
||||
return result # transient — don't cache, retry next open
|
||||
|
||||
@@ -410,8 +749,12 @@ class Plugin:
|
||||
result["update_available"] = bool(result["artifact"]) and (
|
||||
_semver_tuple(latest) > _semver_tuple(current)
|
||||
)
|
||||
if result["update_available"]:
|
||||
decky.logger.info("update available: %s -> %s (%s)", current, latest, result["channel"])
|
||||
if result["update_available"] or result["client_update_available"]:
|
||||
decky.logger.info(
|
||||
"updates: plugin %s->%s (avail=%s), client->%s (avail=%s)",
|
||||
current, latest, result["update_available"],
|
||||
result["client_latest"], result["client_update_available"],
|
||||
)
|
||||
_update_cache["at"] = now
|
||||
_update_cache["data"] = result
|
||||
return result
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "punktfunk-decky",
|
||||
"version": "0.0.1",
|
||||
"description": "SteamOS / Steam Deck Gaming-Mode launcher for the punktfunk streaming client.",
|
||||
"description": "SteamOS / Steam Deck Gaming-Mode launcher for the Punktfunk streaming client.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"watch": "rollup -c -w",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck",
|
||||
"package": "pnpm build && bash scripts/package.sh",
|
||||
"deploy": "bash scripts/deploy.sh",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "pnpm typecheck"
|
||||
},
|
||||
"keywords": [
|
||||
"decky",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"api_version": 1,
|
||||
"publish": {
|
||||
"tags": ["streaming", "game-streaming", "remote-play"],
|
||||
"description": "Launch the punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS and connect to one.",
|
||||
"description": "Launch the Punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS, pair with a PIN, and stream.",
|
||||
"image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the Steam-shortcut artwork for the Decky plugin (committed, like the tray icons).
|
||||
|
||||
The plugin registers a non-Steam shortcut ("Punktfunk") whose grid/hero/logo/icon Steam
|
||||
would otherwise render as a gray placeholder tile. These assets brand it: the lens mark
|
||||
(same geometry as scripts/gen-tray-icons.py / web's brand-mark.tsx) over the brand-navy
|
||||
gradient, plus a monoline "punktfunk" wordmark built from stroke segments ("punktfunk"
|
||||
needs only p·u·n·k·t·f). The frontend applies them via
|
||||
SteamClient.Apps.SetCustomArtworkForApp / SetShortcutIcon (src/steam.ts).
|
||||
|
||||
Outputs (checked in; re-run only when the brand changes):
|
||||
clients/decky/assets/grid.png 600 x 900 library capsule (portrait)
|
||||
clients/decky/assets/gridwide.png 920 x 430 wide capsule (recent games / search)
|
||||
clients/decky/assets/hero.png 1920 x 620 game-page banner
|
||||
clients/decky/assets/logo.png transparent overlaid on the hero by Steam
|
||||
clients/decky/assets/icon.png 256 x 256 list icon (SetShortcutIcon)
|
||||
|
||||
Pure stdlib. Unlike the tiny tray icons this rasterizes big surfaces, so edges are
|
||||
antialiased analytically from signed distances (one sample per pixel) instead of 4x4
|
||||
supersampling.
|
||||
"""
|
||||
|
||||
import math
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent.parent # clients/decky
|
||||
OUT = HERE / "assets"
|
||||
|
||||
# Brand-mark geometry in its 1000-unit viewbox (identical to gen-tray-icons.py).
|
||||
R = 194.41
|
||||
C1 = (403.037, 597.262) # light circle, behind
|
||||
C2 = (597.8075, 402.8525) # deep circle, in front
|
||||
BB_MIN = (C1[0] - R, C2[1] - R)
|
||||
BB_MAX = (C2[0] + R, C1[1] + R)
|
||||
MARK_CENTER = ((BB_MIN[0] + BB_MAX[0]) / 2, (BB_MIN[1] + BB_MAX[1]) / 2)
|
||||
MARK_SPAN = BB_MAX[0] - BB_MIN[0]
|
||||
|
||||
COL_LIGHT = (0xA7, 0x9F, 0xF8)
|
||||
COL_DEEP = (0x6C, 0x5B, 0xF3)
|
||||
COL_HI = (0xD2, 0xC9, 0xFB)
|
||||
WORD = (0xEF, 0xEC, 0xFD) # wordmark: near-white lavender
|
||||
BG_TOP = (0x28, 0x1E, 0x46)
|
||||
BG_BOT = (0x12, 0x0D, 0x22)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------
|
||||
# Wordmark: monoline glyphs as polylines in a unit box (y down; x-height top y=0, baseline
|
||||
# y=1, ascender to -0.5, descender to +1.5). Arcs are sampled into the polylines, so the
|
||||
# rasterizer only ever measures distance-to-segment; round caps/joins fall out of that.
|
||||
# ------------------------------------------------------------------------------------------
|
||||
def _arc(cx, cy, r, a0, a1, n=24):
|
||||
"""Polyline along a circle arc; degrees, 0 = +x, angles grow clockwise on screen."""
|
||||
pts = []
|
||||
for i in range(n + 1):
|
||||
a = math.radians(a0 + (a1 - a0) * i / n)
|
||||
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
|
||||
return pts
|
||||
|
||||
|
||||
GLYPHS = {
|
||||
# letter: (advance, [polyline, ...])
|
||||
"p": (1.05, [[(0, 0), (0, 1.5)], _arc(0.5, 0.5, 0.5, 0, 360)]),
|
||||
"u": (1.05, [[(0, 0), (0, 0.5)], _arc(0.5, 0.5, 0.5, 0, 180), [(1, 0), (1, 0.5)]]),
|
||||
"n": (1.05, [[(0, 0), (0, 1)], _arc(0.5, 0.5, 0.5, 180, 360), [(1, 0.5), (1, 1)]]),
|
||||
"k": (1.0, [[(0, -0.5), (0, 1)], [(0, 0.62), (0.78, 0)], [(0.30, 0.38), (0.85, 1)]]),
|
||||
"t": (0.85, [[(0.42, -0.42), (0.42, 1)], [(0, 0), (0.84, 0)]]),
|
||||
"f": (
|
||||
0.85,
|
||||
[[(0.42, 1), (0.42, -0.15)] + _arc(0.75, -0.15, 0.33, 180, 270, 12), [(0, 0), (0.78, 0)]],
|
||||
),
|
||||
}
|
||||
GAP = 0.34 # inter-letter gap, in glyph units
|
||||
STROKE = 0.26 # stroke thickness, in glyph units
|
||||
ASCENT, DESCENT = -0.5, 1.5 # glyph-space vertical extent
|
||||
|
||||
|
||||
def word_segments(text):
|
||||
"""The word's stroke segments [(x1,y1,x2,y2)] in glyph units, plus its unit width."""
|
||||
segs = []
|
||||
x = 0.0
|
||||
for ch in text:
|
||||
adv, lines = GLYPHS[ch]
|
||||
for line in lines:
|
||||
for (x1, y1), (x2, y2) in zip(line, line[1:]):
|
||||
segs.append((x + x1, y1, x + x2, y2))
|
||||
x += adv + GAP
|
||||
return segs, x - GAP
|
||||
|
||||
|
||||
def render_word_alpha(text, unit_px):
|
||||
"""Coverage (0..255) buffer of the word at `unit_px` pixels per glyph unit."""
|
||||
segs, width_u = word_segments(text)
|
||||
half = STROKE / 2 * unit_px
|
||||
pad = half + 1.5
|
||||
w = math.ceil(width_u * unit_px + 2 * pad)
|
||||
h = math.ceil((DESCENT - ASCENT) * unit_px + 2 * pad)
|
||||
ox, oy = pad, pad - ASCENT * unit_px
|
||||
px_segs = [(ox + a * unit_px, oy + b * unit_px, ox + c * unit_px, oy + d * unit_px) for a, b, c, d in segs]
|
||||
# Bucket segments per pixel column range so each pixel tests only nearby strokes.
|
||||
buf = bytearray(w * h)
|
||||
for x1, y1, x2, y2 in px_segs:
|
||||
lo_x = max(0, math.floor(min(x1, x2) - pad))
|
||||
hi_x = min(w, math.ceil(max(x1, x2) + pad))
|
||||
lo_y = max(0, math.floor(min(y1, y2) - pad))
|
||||
hi_y = min(h, math.ceil(max(y1, y2) + pad))
|
||||
dx, dy = x2 - x1, y2 - y1
|
||||
len2 = dx * dx + dy * dy
|
||||
for py in range(lo_y, hi_y):
|
||||
row = py * w
|
||||
fy = py + 0.5
|
||||
for px in range(lo_x, hi_x):
|
||||
fx = px + 0.5
|
||||
if len2 > 0:
|
||||
t = max(0.0, min(1.0, ((fx - x1) * dx + (fy - y1) * dy) / len2))
|
||||
else:
|
||||
t = 0.0
|
||||
d = math.hypot(fx - (x1 + t * dx), fy - (y1 + t * dy))
|
||||
cov = 0.5 + (half - d)
|
||||
if cov > 0:
|
||||
v = min(255, round(min(1.0, cov) * 255))
|
||||
if v > buf[row + px]:
|
||||
buf[row + px] = v
|
||||
return buf, w, h
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------
|
||||
# Canvas: RGBA bytearray, straight alpha, painted back to front.
|
||||
# ------------------------------------------------------------------------------------------
|
||||
class Canvas:
|
||||
def __init__(self, w, h):
|
||||
self.w, self.h = w, h
|
||||
self.buf = bytearray(w * h * 4)
|
||||
|
||||
def fill_gradient(self, top, bottom):
|
||||
for y in range(self.h):
|
||||
t = y / max(1, self.h - 1)
|
||||
c = bytes(
|
||||
(
|
||||
round(top[0] + (bottom[0] - top[0]) * t),
|
||||
round(top[1] + (bottom[1] - top[1]) * t),
|
||||
round(top[2] + (bottom[2] - top[2]) * t),
|
||||
255,
|
||||
)
|
||||
)
|
||||
self.buf[y * self.w * 4 : (y + 1) * self.w * 4] = c * self.w
|
||||
|
||||
def _blend(self, i, rgb, a):
|
||||
"""`rgb` over the pixel at byte offset i with coverage a (0..1)."""
|
||||
if a <= 0:
|
||||
return
|
||||
b = self.buf
|
||||
ia = 1.0 - a
|
||||
da = b[i + 3] / 255.0
|
||||
oa = a + da * ia
|
||||
if oa <= 0:
|
||||
return
|
||||
for k in range(3):
|
||||
b[i + k] = round((rgb[k] * a + b[i + k] * da * ia) / oa)
|
||||
b[i + 3] = round(oa * 255)
|
||||
|
||||
def glow(self, cx, cy, radius, rgb, strength):
|
||||
"""Soft gaussian-ish radial glow (for the mark's halo on the big surfaces)."""
|
||||
lo_x = max(0, math.floor(cx - 2.2 * radius))
|
||||
hi_x = min(self.w, math.ceil(cx + 2.2 * radius))
|
||||
lo_y = max(0, math.floor(cy - 2.2 * radius))
|
||||
hi_y = min(self.h, math.ceil(cy + 2.2 * radius))
|
||||
for y in range(lo_y, hi_y):
|
||||
for x in range(lo_x, hi_x):
|
||||
d2 = ((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2) / (radius * radius)
|
||||
a = strength * math.exp(-2.5 * d2)
|
||||
if a > 1 / 255:
|
||||
self._blend((y * self.w + x) * 4, rgb, a)
|
||||
|
||||
def mark(self, cx, cy, span):
|
||||
"""The lens mark centered at (cx, cy) with the given pixel span."""
|
||||
scale = span / MARK_SPAN
|
||||
c1 = (cx + (C1[0] - MARK_CENTER[0]) * scale, cy + (C1[1] - MARK_CENTER[1]) * scale)
|
||||
c2 = (cx + (C2[0] - MARK_CENTER[0]) * scale, cy + (C2[1] - MARK_CENTER[1]) * scale)
|
||||
r = R * scale
|
||||
lo_x = max(0, math.floor(min(c1[0], c2[0]) - r - 2))
|
||||
hi_x = min(self.w, math.ceil(max(c1[0], c2[0]) + r + 2))
|
||||
lo_y = max(0, math.floor(min(c1[1], c2[1]) - r - 2))
|
||||
hi_y = min(self.h, math.ceil(max(c1[1], c2[1]) + r + 2))
|
||||
for y in range(lo_y, hi_y):
|
||||
for x in range(lo_x, hi_x):
|
||||
fx, fy = x + 0.5, y + 0.5
|
||||
cov1 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c1[0], fy - c1[1])))
|
||||
cov2 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c2[0], fy - c2[1])))
|
||||
if cov1 <= 0 and cov2 <= 0:
|
||||
continue
|
||||
i = (y * self.w + x) * 4
|
||||
self._blend(i, COL_LIGHT, cov1)
|
||||
self._blend(i, COL_DEEP, cov2)
|
||||
self._blend(i, COL_HI, min(cov1, cov2))
|
||||
|
||||
def word(self, text, unit_px, cx, cy):
|
||||
"""The wordmark centered at (cx, cy); `unit_px` = pixels per glyph unit."""
|
||||
alpha, w, h = render_word_alpha(text, unit_px)
|
||||
ox = round(cx - w / 2)
|
||||
# Optical vertical centering on the x-height band (0..1 in glyph units), not the
|
||||
# ascender/descender box — the word reads centered that way.
|
||||
pad = STROKE / 2 * unit_px + 1.5
|
||||
band_mid = pad - ASCENT * unit_px + 0.5 * unit_px
|
||||
oy = round(cy - band_mid)
|
||||
for y in range(h):
|
||||
ty = y + oy
|
||||
if not 0 <= ty < self.h:
|
||||
continue
|
||||
for x in range(w):
|
||||
a = alpha[y * w + x]
|
||||
if a:
|
||||
tx = x + ox
|
||||
if 0 <= tx < self.w:
|
||||
self._blend((ty * self.w + tx) * 4, WORD, a / 255.0)
|
||||
|
||||
def round_corners(self, radius):
|
||||
"""Multiply alpha with a rounded-rect mask (icon)."""
|
||||
for y in range(self.h):
|
||||
for x in range(self.w):
|
||||
dx = max(0.0, max(radius - (x + 0.5), (x + 0.5) - (self.w - radius)))
|
||||
dy = max(0.0, max(radius - (y + 0.5), (y + 0.5) - (self.h - radius)))
|
||||
if dx > 0 and dy > 0:
|
||||
cov = min(1.0, max(0.0, 0.5 + radius - math.hypot(dx, dy)))
|
||||
i = (y * self.w + x) * 4
|
||||
self.buf[i + 3] = round(self.buf[i + 3] * cov)
|
||||
|
||||
def png(self):
|
||||
def chunk(tag, data):
|
||||
return (
|
||||
struct.pack(">I", len(data))
|
||||
+ tag
|
||||
+ data
|
||||
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
||||
)
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0)
|
||||
raw = b"".join(
|
||||
b"\x00" + bytes(self.buf[y * self.w * 4 : (y + 1) * self.w * 4]) for y in range(self.h)
|
||||
)
|
||||
return (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", ihdr)
|
||||
+ chunk(b"IDAT", zlib.compress(raw, 9))
|
||||
+ chunk(b"IEND", b"")
|
||||
)
|
||||
|
||||
|
||||
def save(name, canvas):
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
out = OUT / name
|
||||
out.write_bytes(canvas.png())
|
||||
print(f"wrote {out.relative_to(HERE.parent.parent)} ({canvas.w}x{canvas.h})")
|
||||
|
||||
|
||||
def main():
|
||||
# Portrait capsule: mark in the upper half, wordmark beneath.
|
||||
c = Canvas(600, 900)
|
||||
c.fill_gradient(BG_TOP, BG_BOT)
|
||||
c.glow(300, 340, 260, COL_DEEP, 0.35)
|
||||
c.mark(300, 340, 320)
|
||||
c.word("punktfunk", 44, 300, 640)
|
||||
save("grid.png", c)
|
||||
|
||||
# Wide capsule: mark left, wordmark right of it.
|
||||
c = Canvas(920, 430)
|
||||
c.fill_gradient(BG_TOP, BG_BOT)
|
||||
c.glow(230, 215, 200, COL_DEEP, 0.35)
|
||||
c.mark(230, 215, 240)
|
||||
c.word("punktfunk", 40, 620, 220)
|
||||
save("gridwide.png", c)
|
||||
|
||||
# Hero: ambient banner — the mark rides the right third; Steam overlays logo.png itself.
|
||||
c = Canvas(1920, 620)
|
||||
c.fill_gradient(BG_TOP, BG_BOT)
|
||||
c.glow(1500, 310, 330, COL_DEEP, 0.4)
|
||||
c.mark(1500, 310, 400)
|
||||
save("hero.png", c)
|
||||
|
||||
# Logo (transparent): mark + wordmark side by side, overlaid on the hero by Steam.
|
||||
c = Canvas(1120, 300)
|
||||
c.mark(150, 150, 240)
|
||||
c.word("punktfunk", 62, 660, 155)
|
||||
save("logo.png", c)
|
||||
|
||||
# Icon: brand tile, rounded corners, mark only.
|
||||
c = Canvas(256, 256)
|
||||
c.fill_gradient(BG_TOP, BG_BOT)
|
||||
c.glow(128, 128, 110, COL_DEEP, 0.3)
|
||||
c.mark(128, 128, 190)
|
||||
c.round_corners(36)
|
||||
save("icon.png", c)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -20,12 +20,14 @@ VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
DEST="$STAGE/$NAME"
|
||||
mkdir -p "$DEST/dist" "$DEST/bin"
|
||||
mkdir -p "$DEST/dist" "$DEST/bin" "$DEST/assets"
|
||||
cp dist/index.js "$DEST/dist/index.js" # ship the bundle only, not the sourcemap
|
||||
cp main.py plugin.json package.json LICENSE "$DEST/"
|
||||
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
|
||||
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
|
||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||
# Steam-shortcut artwork (grid/hero/logo/icon — scripts/gen-steam-art.py, committed).
|
||||
cp assets/*.png "$DEST/assets/"
|
||||
[ -f decky.pyi ] && cp decky.pyi "$DEST/"
|
||||
[ -f README.md ] && cp README.md "$DEST/"
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/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.
|
||||
|
||||
python3 clients/decky/scripts/test-backend.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
# ---- stub the decky module before importing main.py ------------------------------------
|
||||
decky = types.ModuleType("decky")
|
||||
decky.DECKY_USER_HOME = "/tmp/pf-test-home"
|
||||
decky.DECKY_PLUGIN_DIR = "/tmp/pf-test-plugin"
|
||||
|
||||
|
||||
class _Log:
|
||||
def __getattr__(self, _name):
|
||||
return lambda *a, **k: None
|
||||
|
||||
|
||||
decky.logger = _Log()
|
||||
sys.modules["decky"] = decky
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
import main # noqa: E402 (the plugin backend)
|
||||
|
||||
failures = 0
|
||||
|
||||
|
||||
def check(name: str, cond: bool):
|
||||
global failures
|
||||
print(("ok " if cond else "FAIL") + " " + name)
|
||||
if not cond:
|
||||
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
|
||||
)
|
||||
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
|
||||
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}]}'
|
||||
)
|
||||
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)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"{failures} check(s) FAILED")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -6,8 +6,46 @@ export interface Host {
|
||||
host: string;
|
||||
port: number;
|
||||
pair: string; // "required" | "optional" — the HOST's policy
|
||||
fp: string;
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
id: string;
|
||||
store: string; // "steam" | "custom" | "heroic" | "lutris" | …
|
||||
title: 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 {
|
||||
@@ -22,36 +60,71 @@ export interface RunnerInfo {
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
|
||||
// keys (codec, 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
|
||||
bitrate_kbps: number; // 0 = host default
|
||||
gamepad: string; // "auto" | "xbox360" | "dualsense"
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
inhibit_shortcuts: boolean;
|
||||
mic_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
current: string; // installed version (package.json)
|
||||
latest: string; // newest version in our registry for this channel
|
||||
current: string; // installed PLUGIN version (package.json)
|
||||
latest: string; // newest plugin version in our registry for this channel
|
||||
artifact: string; // immutable zip URL Decky should install
|
||||
hash: string; // sha256 of that zip (Decky verifies it)
|
||||
channel: string; // "latest" (stable) | "canary"
|
||||
update_available: boolean;
|
||||
update_available: boolean; // a newer PLUGIN build is available
|
||||
// The flatpak CLIENT (io.unom.Punktfunk) versions independently and is a per-user install, so
|
||||
// `sudo flatpak update` never touches it — the plugin offers a user-scope update instead.
|
||||
client_update_available: boolean;
|
||||
client_current: string; // installed client commit (short) — informational
|
||||
client_latest: string; // remote client commit (short) — informational
|
||||
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
|
||||
}
|
||||
|
||||
// Steam-shortcut artwork (assets/ in the plugin dir): base64 PNGs keyed grid / gridwide /
|
||||
// hero / logo, plus the icon's absolute path (SetShortcutIcon wants a file). Keys for
|
||||
// missing files are absent.
|
||||
export interface ShortcutArt {
|
||||
grid?: string;
|
||||
gridwide?: string;
|
||||
hero?: string;
|
||||
logo?: string;
|
||||
icon_path: string;
|
||||
}
|
||||
|
||||
export const discover = callable<[], Host[]>("discover");
|
||||
export const pair = callable<
|
||||
[host: 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",
|
||||
);
|
||||
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
||||
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
|
||||
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");
|
||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
|
||||
export const updateClient = callable<
|
||||
[],
|
||||
{ ok: boolean; updated: boolean; error?: string }
|
||||
>("update_client");
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Error boundary — contains ANY render failure in our UI so a single bad render can never take
|
||||
// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic
|
||||
// "Something went wrong while displaying this content" for the entire tab when one plugin
|
||||
// throws). The realistic trigger is a future Steam client update that makes a @decky/ui
|
||||
// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback
|
||||
// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a
|
||||
// (possibly broken) Steam-internal component — it is guaranteed to render.
|
||||
import { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
export class PluginErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
state: { error: Error | null } = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Surface it for diagnosis, but never rethrow — containment is the whole point.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[punktfunk] contained UI render error:", error, info?.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
return (
|
||||
<div style={{ padding: "1em", lineHeight: 1.45 }}>
|
||||
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
|
||||
Punktfunk couldn’t draw this view
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
|
||||
The plugin hit a display error — your Steam Deck is fine. Reload Punktfunk from
|
||||
Decky's plugin list, or update the plugin.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
opacity: 0.55,
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8em",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{String(error?.message ?? error)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
|
||||
import { toaster } from "@decky/api";
|
||||
import { Navigation } from "@decky/ui";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
checkUpdate,
|
||||
discover,
|
||||
GameEntry,
|
||||
getPins,
|
||||
Host,
|
||||
PinnedGame,
|
||||
setPins as setPinsBackend,
|
||||
updateClient,
|
||||
UpdateInfo,
|
||||
} from "./backend";
|
||||
import { LaunchOpts, launchStream } from "./steam";
|
||||
|
||||
export const DOCS_URL = "https://docs.punktfunk.unom.io/docs/steam-deck";
|
||||
|
||||
// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of
|
||||
// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a
|
||||
// loader without it we fall back to manual "Install Plugin from URL". We use it to drive
|
||||
// Decky's own privileged install path (the root loader does the download + SHA-256 verify +
|
||||
// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins
|
||||
// is root-owned, so our unprivileged backend can't swap its own files.
|
||||
declare global {
|
||||
interface Window {
|
||||
DeckyBackend?: {
|
||||
callable: (route: string) => (...args: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
|
||||
const INSTALL_TYPE_UPDATE = 2;
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Discovery — mDNS scan state shared by the QAM panel and the full page.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useHosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
setHosts(await discover());
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { hosts, scanning, refresh };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Self-update — checks our registry on mount (the backend caches for 30 min + is non-fatal
|
||||
// offline); `check(true)` bypasses the cache for the explicit "Check for updates" button.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useUpdate() {
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const check = useCallback(async (force: boolean): Promise<UpdateInfo | null> => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await checkUpdate(force);
|
||||
setInfo(res);
|
||||
return res;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void check(false);
|
||||
}, [check]);
|
||||
|
||||
return { info, checking, check };
|
||||
}
|
||||
|
||||
/** True when EITHER the plugin or the flatpak client has a pending update. */
|
||||
export function hasUpdate(info: UpdateInfo | null | undefined): boolean {
|
||||
return !!info && (info.update_available || info.client_update_available);
|
||||
}
|
||||
|
||||
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
|
||||
export async function checkForUpdatesNow(
|
||||
check: (force: boolean) => Promise<UpdateInfo | null>,
|
||||
): Promise<void> {
|
||||
const res = await check(true);
|
||||
let body: string;
|
||||
if (!res || res.error === "fetch-failed") {
|
||||
body = "Couldn’t reach the update server — are you online?";
|
||||
} else if (hasUpdate(res)) {
|
||||
const parts: string[] = [];
|
||||
if (res.update_available) parts.push(`plugin v${res.current} → v${res.latest}`);
|
||||
if (res.client_update_available) parts.push("client");
|
||||
body = `Update available: ${parts.join(" + ")}.`;
|
||||
} else if (res.error === "update-channel-unknown") {
|
||||
body = "Development build — plugin updates are disabled; the client is up to date.";
|
||||
} else {
|
||||
body = `You’re up to date (plugin v${res.current}).`;
|
||||
}
|
||||
toaster.toast({ title: "Punktfunk", body });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply whichever updates are pending. The flatpak CLIENT is updated first (a user-scope
|
||||
* `flatpak update`, awaited); then, if the PLUGIN itself has an update, Decky's install RPC
|
||||
* reinstalls it — which reloads the plugin and tears this panel down, so it goes last and is
|
||||
* fire-and-forget. `check` (when passed) refreshes the panel state after a client-only update so
|
||||
* the "Update available" button clears.
|
||||
*/
|
||||
export async function applyUpdate(
|
||||
info: UpdateInfo,
|
||||
check?: (force: boolean) => Promise<UpdateInfo | null>,
|
||||
): Promise<void> {
|
||||
if (info.client_update_available) {
|
||||
toaster.toast({ title: "Punktfunk", body: "Updating the client…" });
|
||||
try {
|
||||
const r = await updateClient();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: !r.ok
|
||||
? `Client update failed${r.error ? ` (${r.error})` : ""}.`
|
||||
: r.updated
|
||||
? "Client updated to the latest version."
|
||||
: "Client is already up to date.",
|
||||
});
|
||||
} catch {
|
||||
toaster.toast({ title: "Punktfunk", body: "Client update failed." });
|
||||
}
|
||||
}
|
||||
|
||||
if (info.update_available) {
|
||||
try {
|
||||
const backend = window.DeckyBackend;
|
||||
if (backend?.callable) {
|
||||
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
|
||||
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
|
||||
void backend.callable("utilities/install_plugin")(
|
||||
info.artifact,
|
||||
"punktfunk",
|
||||
info.latest,
|
||||
info.hash,
|
||||
INSTALL_TYPE_UPDATE,
|
||||
);
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
// Decky's installer also phones the plugin store first, which can hang on some
|
||||
// networks before the actual install proceeds — set expectations.
|
||||
body: `Updating the plugin to v${info.latest} — confirm Decky’s prompt. This can take a couple of minutes.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the manual path
|
||||
}
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: "Update the plugin from Decky → Developer → Install Plugin from URL.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Client-only update (no plugin reinstall): refresh so the button clears.
|
||||
if (check) void check(true);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export async function startStream(
|
||||
h: Host,
|
||||
opts: LaunchOpts = {},
|
||||
label?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await launchStream(h.host, h.port, opts);
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${h.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,
|
||||
},
|
||||
online: false,
|
||||
};
|
||||
}
|
||||
+102
-561
@@ -1,591 +1,108 @@
|
||||
// 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.
|
||||
import {
|
||||
ButtonItem,
|
||||
Dropdown,
|
||||
Field,
|
||||
Focusable,
|
||||
DialogButton,
|
||||
ModalRoot,
|
||||
Navigation,
|
||||
PanelSection,
|
||||
PanelSectionRow,
|
||||
SliderField,
|
||||
Spinner,
|
||||
Tabs,
|
||||
ToggleField,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||
import { definePlugin, routerHook } from "@decky/api";
|
||||
import { FC } from "react";
|
||||
import { FaDownload, FaLock, FaLockOpen, FaPlay, FaSyncAlt, FaTv } from "react-icons/fa";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import {
|
||||
Component,
|
||||
CSSProperties,
|
||||
ErrorInfo,
|
||||
FC,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
FaTv,
|
||||
FaSyncAlt,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPlay,
|
||||
FaArrowLeft,
|
||||
FaDownload,
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
discover,
|
||||
getSettings,
|
||||
pair,
|
||||
setSettings,
|
||||
checkUpdate,
|
||||
Host,
|
||||
StreamSettings,
|
||||
UpdateInfo,
|
||||
} from "./backend";
|
||||
import { launchStream } from "./steam";
|
||||
|
||||
const ROUTE = "/punktfunk";
|
||||
|
||||
// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of
|
||||
// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a
|
||||
// loader without it we fall back to manual "Install Plugin from URL". We use it to drive
|
||||
// Decky's own privileged install path (the root loader does the download + SHA-256 verify +
|
||||
// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins
|
||||
// is root-owned, so our unprivileged backend can't swap its own files.
|
||||
declare global {
|
||||
interface Window {
|
||||
DeckyBackend?: {
|
||||
callable: (route: string) => (...args: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
|
||||
const INSTALL_TYPE_UPDATE = 2;
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
hasUpdate,
|
||||
resolvePinHost,
|
||||
startStream,
|
||||
useHosts,
|
||||
usePins,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
import { streamPin } from "./library";
|
||||
import { PunktfunkRoute, ROUTE } from "./page";
|
||||
import { PairModal } from "./pair";
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Error boundary — contains ANY render failure in our UI so a single bad render can never take
|
||||
// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic
|
||||
// "Something went wrong while displaying this content" for the entire tab when one plugin
|
||||
// throws). The realistic trigger is a future Steam client update that makes a @decky/ui
|
||||
// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback
|
||||
// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a
|
||||
// (possibly broken) Steam-internal component — it is guaranteed to render.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
class PluginErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
state: { error: Error | null } = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Surface it for diagnosis, but never rethrow — containment is the whole point.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[punktfunk] contained UI render error:", error, info?.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
return (
|
||||
<div style={{ padding: "1em", lineHeight: 1.45 }}>
|
||||
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
|
||||
punktfunk couldn’t draw this view
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
|
||||
The plugin hit a display error — your Steam Deck is fine. Reload punktfunk from
|
||||
Decky's plugin list, or update the plugin.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
opacity: 0.55,
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8em",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{String(error?.message ?? error)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Checks our registry for a newer build on mount (the backend caches + is non-fatal offline).
|
||||
function useUpdate() {
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||
useEffect(() => {
|
||||
void checkUpdate(false)
|
||||
.then(setInfo)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
return info;
|
||||
}
|
||||
|
||||
async function applyUpdate(info: UpdateInfo) {
|
||||
try {
|
||||
const backend = window.DeckyBackend;
|
||||
if (backend?.callable) {
|
||||
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
|
||||
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
|
||||
void backend.callable("utilities/install_plugin")(
|
||||
info.artifact,
|
||||
"punktfunk",
|
||||
info.latest,
|
||||
info.hash,
|
||||
INSTALL_TYPE_UPDATE,
|
||||
);
|
||||
toaster.toast({
|
||||
title: "punktfunk",
|
||||
body: `Updating to v${info.latest}… confirm the Decky prompt.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the manual path
|
||||
}
|
||||
toaster.toast({
|
||||
title: "punktfunk",
|
||||
body: "Update from Decky → Developer → Install Plugin from URL.",
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Discovery hook — shared by the QAM panel and the full page.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
function useHosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
setHosts(await discover());
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` });
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { hosts, scanning, refresh };
|
||||
}
|
||||
|
||||
async function startStream(h: Host) {
|
||||
try {
|
||||
await launchStream(h.host, h.port);
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "punktfunk", body: `Starting stream — ${h.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "punktfunk", body: `Launch failed: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode).
|
||||
// The host displays the PIN after the operator arms pairing; the user enters it here.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const PairModal: FC<{
|
||||
host: Host;
|
||||
closeModal?: () => void;
|
||||
onPaired: () => void;
|
||||
}> = ({ host, closeModal, onPaired }) => {
|
||||
const [pin, setPin] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d));
|
||||
const back = () => setPin((p) => p.slice(0, -1));
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await pair(host.host, 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");
|
||||
setPin("");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
|
||||
Pair with {host.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
|
||||
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "2.2em",
|
||||
letterSpacing: "0.4em",
|
||||
textAlign: "center",
|
||||
fontFamily: "monospace",
|
||||
minHeight: "1.4em",
|
||||
marginBottom: "0.6em",
|
||||
}}
|
||||
>
|
||||
{pin.padEnd(4, "•")}
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Focusable
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "0.5em",
|
||||
}}
|
||||
>
|
||||
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
|
||||
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
|
||||
{d}
|
||||
</DialogButton>
|
||||
))}
|
||||
<DialogButton disabled={busy} onClick={back}>
|
||||
⌫
|
||||
</DialogButton>
|
||||
<DialogButton disabled={busy} onClick={() => press("0")}>
|
||||
0
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
disabled={busy || pin.length !== 4}
|
||||
onClick={submit}
|
||||
>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Settings section — resolution / refresh / bitrate / gamepad, written to the client's JSON.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
[1280, 720, "1280 × 720"],
|
||||
[1920, 1080, "1920 × 1080"],
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
];
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
const GAMEPADS = ["auto", "xbox360", "dualsense", "steamdeck"];
|
||||
const GAMEPAD_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
xbox360: "Xbox 360",
|
||||
dualsense: "DualSense",
|
||||
steamdeck: "Steam Deck",
|
||||
};
|
||||
|
||||
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"
|
||||
>
|
||||
<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 });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Refresh rate" childrenContainerWidth="max">
|
||||
<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 })}
|
||||
/>
|
||||
</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="Gamepad type" childrenContainerWidth="max">
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</Field>
|
||||
{s.gamepad === "steamdeck" && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. 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."
|
||||
/>
|
||||
)}
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
checked={s.mic_enabled}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// One host row on the full page.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostRow: FC<{ host: Host }> = ({ host }) => {
|
||||
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to
|
||||
// pair again — show it as trusted and go straight to Stream.
|
||||
const needsPair = host.pair === "required" && !host.paired;
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={`${host.host}:${host.port}${
|
||||
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
|
||||
}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em" }}>
|
||||
{needsPair && (
|
||||
<DialogButton
|
||||
style={{ minWidth: "5em" }}
|
||||
onClick={() =>
|
||||
showModal(<PairModal host={host} onPaired={() => {}} />)
|
||||
}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The fullscreen page (registered as the /punktfunk route) — a tabbed Hosts / Settings view.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
// 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",
|
||||
};
|
||||
|
||||
const HostsTab: FC<{
|
||||
hosts: Host[];
|
||||
scanning: boolean;
|
||||
refresh: () => void;
|
||||
}> = ({ hosts, scanning, refresh }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Discover"
|
||||
description={
|
||||
scanning
|
||||
? "Scanning the LAN…"
|
||||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</Field>
|
||||
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<Field
|
||||
focusable={false}
|
||||
description="No punktfunk hosts found. Make sure a host is running on the same network."
|
||||
>
|
||||
No hosts found
|
||||
</Field>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={tabScroll}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
const update = useUpdate();
|
||||
const [tab, setTab] = useState("hosts");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "40px",
|
||||
height: "calc(100% - 40px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1em",
|
||||
padding: "0 2.5em",
|
||||
marginBottom: "0.4em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DialogButton
|
||||
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
||||
onClick={() => Navigation.NavigateBack()}
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</DialogButton>
|
||||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||||
punktfunk
|
||||
</div>
|
||||
{update?.update_available && (
|
||||
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update v{update.latest}
|
||||
</DialogButton>
|
||||
)}
|
||||
</Focusable>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Tabs
|
||||
activeTab={tab}
|
||||
onShowTab={(id: string) => setTab(id)}
|
||||
autoFocusContents
|
||||
tabs={[
|
||||
{
|
||||
id: "hosts",
|
||||
title: "Hosts",
|
||||
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
content: <SettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts.
|
||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
||||
// and pinned games.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const QamPanel: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
const update = useUpdate();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const pins = usePins();
|
||||
|
||||
return (
|
||||
<>
|
||||
{update?.update_available && (
|
||||
<PanelSection title="Update">
|
||||
{hasUpdate(update) && (
|
||||
<PanelSection title="Update available">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => applyUpdate(update)}
|
||||
label={`v${update.current} → v${update.latest}`}
|
||||
onClick={() => applyUpdate(update!, check)}
|
||||
label={
|
||||
update!.update_available
|
||||
? `Plugin v${update!.current} → v${update!.latest}${
|
||||
update!.client_update_available ? " + client" : ""
|
||||
}`
|
||||
: "New client version"
|
||||
}
|
||||
description="Installing can take a couple of minutes"
|
||||
>
|
||||
<FaDownload style={{ marginRight: "0.5em" }} />
|
||||
Update punktfunk
|
||||
Update Punktfunk
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="punktfunk">
|
||||
<PanelSection title="Punktfunk">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Host details, stream settings, and help"
|
||||
onClick={() => {
|
||||
Navigation.Navigate(ROUTE);
|
||||
Navigation.CloseSideMenus();
|
||||
}}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.5em" }} />
|
||||
Open punktfunk
|
||||
Open Punktfunk
|
||||
</ButtonItem>
|
||||
</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="Games">
|
||||
{pins.pins.map((pin) => {
|
||||
const { online } = resolvePinHost(pin, hosts);
|
||||
return (
|
||||
<PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => streamPin(pin, hosts, pins)}
|
||||
label={pin.title}
|
||||
description={`${pin.host_name}${online ? "" : " · offline?"}${
|
||||
pin.paired ? "" : " · pairing required"
|
||||
}`}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.5em" }} />
|
||||
Stream
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
);
|
||||
})}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
|
||||
{scanning ? (
|
||||
@@ -593,15 +110,21 @@ const QamPanel: FC = () => {
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh hosts"}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
{hosts.length === 0 && scanning && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description="Scanning your network…" />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false}>No hosts found.</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts found"
|
||||
description="Start a Punktfunk host on this network, then refresh."
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.map((h) => {
|
||||
@@ -629,24 +152,42 @@ const QamPanel: FC = () => {
|
||||
);
|
||||
})}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="About">
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Version"
|
||||
description={
|
||||
update
|
||||
? `v${update.current}${update.channel ? ` · ${update.channel}` : " · dev build"}`
|
||||
: "…"
|
||||
}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
disabled={checking}
|
||||
onClick={() => void checkForUpdatesNow(check)}
|
||||
>
|
||||
{checking ? "Checking…" : "Check for updates"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Full page behind the boundary — registered as the /punktfunk route.
|
||||
const PunktfunkRoute: FC = () => (
|
||||
<PluginErrorBoundary>
|
||||
<PunktfunkPage />
|
||||
</PluginErrorBoundary>
|
||||
);
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
||||
return {
|
||||
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
|
||||
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
|
||||
name: "punktfunk",
|
||||
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw
|
||||
// at plugin-load time (an error boundary only catches render-time, not load-time, errors).
|
||||
titleView: <div className={staticClasses?.Title}>punktfunk</div>,
|
||||
titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
|
||||
content: (
|
||||
<PluginErrorBoundary>
|
||||
<QamPanel />
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// 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, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui";
|
||||
import { CSSProperties, 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";
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
const pickButton: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "5em",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
// 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"
|
||||
>
|
||||
<DialogButton
|
||||
style={pickButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
void startBrowse(host);
|
||||
}}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</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">
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
{result.error === "not-paired" && (
|
||||
<DialogButton
|
||||
style={pickButton}
|
||||
onClick={() =>
|
||||
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
|
||||
}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={pickButton} onClick={() => setAttempt((n) => n + 1)}>
|
||||
Retry
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</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"
|
||||
>
|
||||
<DialogButton style={pickButton} disabled={!safe} onClick={() => togglePin(g)}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
{pinned ? "Unpin" : "Pin"}
|
||||
</DialogButton>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,448 @@
|
||||
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
|
||||
import {
|
||||
DialogButton,
|
||||
Field,
|
||||
Focusable,
|
||||
ModalRoot,
|
||||
Navigation,
|
||||
Spinner,
|
||||
Tabs,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { CSSProperties, FC, useState } from "react";
|
||||
import {
|
||||
FaArrowLeft,
|
||||
FaDownload,
|
||||
FaExternalLinkAlt,
|
||||
FaInfoCircle,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPlay,
|
||||
FaSyncAlt,
|
||||
FaThLarge,
|
||||
} from "react-icons/fa";
|
||||
import { Host, UpdateInfo, killStream } from "./backend";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import {
|
||||
DOCS_URL,
|
||||
PinsApi,
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
hasUpdate,
|
||||
resolvePinHost,
|
||||
startStream,
|
||||
useHosts,
|
||||
usePins,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
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",
|
||||
};
|
||||
|
||||
// DialogButton stretches to 100% width in the gamepad UI — on a fullscreen row that means a
|
||||
// screen-wide button. Size action buttons to their content instead (right-aligned by the
|
||||
// Field's children container).
|
||||
const actionButton: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "6em",
|
||||
flexShrink: 0,
|
||||
};
|
||||
// Square icon-only button (details ⓘ, header back arrow) — needs an explicit height too, or
|
||||
// the zero padding collapses it to the icon's line height.
|
||||
const iconButton: CSSProperties = {
|
||||
width: "40px",
|
||||
minWidth: "40px",
|
||||
height: "40px",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check
|
||||
// against the host's own log / web console before trusting it.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({
|
||||
host,
|
||||
closeModal,
|
||||
}) => {
|
||||
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not advertised";
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
|
||||
{host.name}
|
||||
</div>
|
||||
<Field focusable={false} label="Address">
|
||||
{host.host}:{host.port}
|
||||
</Field>
|
||||
<Field focusable={false} label="Protocol">
|
||||
{host.proto || "unknown"}
|
||||
</Field>
|
||||
<Field focusable={false} label="Pairing policy">
|
||||
{host.pair === "required" ? "PIN pairing required" : "Open (trust on first connect)"}
|
||||
</Field>
|
||||
<Field focusable={false} label="This Deck">
|
||||
{host.paired ? "Paired" : "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>
|
||||
}
|
||||
/>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// One host row: status icon + address, details / pair / stream actions.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = ({
|
||||
host,
|
||||
onPaired,
|
||||
onGames,
|
||||
}) => {
|
||||
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to
|
||||
// pair again — show it as trusted and go straight to Stream.
|
||||
const needsPair = host.pair === "required" && !host.paired;
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={`${host.host}:${host.port}${
|
||||
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
|
||||
}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
<DialogButton
|
||||
style={iconButton}
|
||||
onClick={() => showModal(<HostDetailsModal host={host} />)}
|
||||
>
|
||||
<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, minWidth: "6em" }} onClick={onGames}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
Games
|
||||
</DialogButton>
|
||||
{needsPair && (
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "5em" }}
|
||||
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() =>
|
||||
needsPair
|
||||
? showModal(
|
||||
<PairModal host={host} onPaired={() => startStream(host)} />,
|
||||
)
|
||||
: startStream(host)
|
||||
}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
const HostsTab: FC<{
|
||||
hosts: Host[];
|
||||
scanning: boolean;
|
||||
refresh: () => void;
|
||||
pins: PinsApi;
|
||||
clientUpdatePending: boolean;
|
||||
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Discover"
|
||||
description={
|
||||
scanning
|
||||
? "Scanning the LAN…"
|
||||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<DialogButton style={{ ...actionButton, minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</Field>
|
||||
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts found"
|
||||
description="Start a Punktfunk host on the same network, then refresh. The setup guide (About tab) covers installing a host."
|
||||
/>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow
|
||||
key={h.fp || `${h.host}:${h.port}`}
|
||||
host={h}
|
||||
onPaired={refresh}
|
||||
onGames={() =>
|
||||
showModal(
|
||||
<GamePickerModal host={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 } = resolvePinHost(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"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
<DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Play
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "5em" }}
|
||||
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
|
||||
>
|
||||
Remove
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={tabScroll}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
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.",
|
||||
});
|
||||
}
|
||||
|
||||
const AboutTab: FC<{
|
||||
update: UpdateInfo | null;
|
||||
checking: boolean;
|
||||
check: (force: boolean) => Promise<UpdateInfo | null>;
|
||||
}> = ({ update, checking, check }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Version"
|
||||
description={
|
||||
update
|
||||
? `v${update.current}${
|
||||
update.channel ? ` · ${update.channel} channel` : " · development build"
|
||||
}`
|
||||
: "…"
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "11em" }}
|
||||
disabled={checking}
|
||||
onClick={() => void checkForUpdatesNow(check)}
|
||||
>
|
||||
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
|
||||
</DialogButton>
|
||||
</Field>
|
||||
{hasUpdate(update) && (
|
||||
<Field
|
||||
label={
|
||||
update!.update_available
|
||||
? `Plugin update — v${update!.latest}${
|
||||
update!.client_update_available ? " + client" : ""
|
||||
}`
|
||||
: "Client update available"
|
||||
}
|
||||
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "9em" }}
|
||||
onClick={() => applyUpdate(update!, check)}
|
||||
>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update
|
||||
</DialogButton>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label="Setup guide"
|
||||
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "8em" }}
|
||||
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
|
||||
>
|
||||
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</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"
|
||||
>
|
||||
<DialogButton style={{ ...actionButton, minWidth: "8em" }} onClick={() => void forceStopStream()}>
|
||||
Force-stop
|
||||
</DialogButton>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const pins = usePins();
|
||||
const [tab, setTab] = useState("hosts");
|
||||
|
||||
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>
|
||||
|
||||
{/* overflow:hidden is load-bearing: Valve's Tabs slides the incoming panel in from the
|
||||
right on L1/R1, and with autoFocusContents it scrollIntoViews a control inside that
|
||||
still-offscreen panel. Without a clip here the scroll pans #GamepadUI itself — the whole
|
||||
Steam UI (top bar included) slides left until you click a tab. Valve's own Tabs always
|
||||
live in a clipped flex box; match that. */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
<Tabs
|
||||
activeTab={tab}
|
||||
onShowTab={(id: string) => setTab(id)}
|
||||
autoFocusContents
|
||||
tabs={[
|
||||
{
|
||||
id: "hosts",
|
||||
title: "Hosts",
|
||||
content: (
|
||||
<HostsTab
|
||||
hosts={hosts}
|
||||
scanning={scanning}
|
||||
refresh={refresh}
|
||||
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} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Full page behind the boundary — registered as the /punktfunk route.
|
||||
export const PunktfunkRoute: FC = () => (
|
||||
<PluginErrorBoundary>
|
||||
<PunktfunkPage />
|
||||
</PluginErrorBoundary>
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode).
|
||||
// The host displays the PIN after the operator arms pairing; the user enters it here.
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { FC, useState } from "react";
|
||||
import { Host, pair } from "./backend";
|
||||
|
||||
export const PairModal: FC<{
|
||||
host: Host;
|
||||
closeModal?: () => void;
|
||||
onPaired: () => void;
|
||||
}> = ({ host, closeModal, onPaired }) => {
|
||||
const [pin, setPin] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d));
|
||||
const back = () => setPin((p) => p.slice(0, -1));
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await pair(host.host, 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");
|
||||
setPin("");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
|
||||
Pair with {host.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
|
||||
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "2.2em",
|
||||
letterSpacing: "0.4em",
|
||||
textAlign: "center",
|
||||
fontFamily: "monospace",
|
||||
minHeight: "1.4em",
|
||||
marginBottom: "0.6em",
|
||||
}}
|
||||
>
|
||||
{pin.padEnd(4, "•")}
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Focusable
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "0.5em",
|
||||
}}
|
||||
>
|
||||
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
|
||||
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
|
||||
{d}
|
||||
</DialogButton>
|
||||
))}
|
||||
<DialogButton disabled={busy} onClick={back}>
|
||||
⌫
|
||||
</DialogButton>
|
||||
<DialogButton disabled={busy} onClick={() => press("0")}>
|
||||
0
|
||||
</DialogButton>
|
||||
<DialogButton disabled={busy || pin.length !== 4} onClick={submit}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
// 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 { FC, useEffect, useState } from "react";
|
||||
import { getSettings, setSettings, StreamSettings } from "./backend";
|
||||
|
||||
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];
|
||||
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",
|
||||
};
|
||||
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"
|
||||
>
|
||||
<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 });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Refresh rate" childrenContainerWidth="max">
|
||||
<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 })}
|
||||
/>
|
||||
</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="Gamepad type"
|
||||
description="Which virtual controller the host creates for your inputs"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<Dropdown
|
||||
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
|
||||
selectedOption={s.compositor}
|
||||
onChange={(o) => patch({ compositor: o.data as string })}
|
||||
/>
|
||||
</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 })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+133
-38
@@ -3,11 +3,12 @@
|
||||
// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to
|
||||
// the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see
|
||||
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE
|
||||
// hidden non-Steam shortcut that points at our wrapper script (bin/punktfunkrun.sh), pass the
|
||||
// per-session host as the shortcut's Steam launch options, and start it with RunGame. The
|
||||
// wrapper then execs `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
||||
// hidden non-Steam shortcut whose exe is `/bin/sh` running our wrapper script
|
||||
// (bin/punktfunkrun.sh), pass the per-session host as the shortcut's Steam launch options,
|
||||
// and start it with RunGame. The wrapper then execs
|
||||
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
||||
|
||||
import { runnerInfo } from "./backend";
|
||||
import { 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
|
||||
@@ -23,24 +24,35 @@ declare const SteamClient: {
|
||||
SetShortcutName(appId: number, name: string): void;
|
||||
SetShortcutExe(appId: number, exe: string): void;
|
||||
SetShortcutStartDir(appId: number, dir: string): void;
|
||||
SetShortcutIcon(appId: number, iconPath: string): void;
|
||||
SetAppLaunchOptions(appId: number, options: string): void;
|
||||
// assetType: 0 = grid (portrait capsule), 1 = hero, 2 = logo, 3 = wide grid.
|
||||
SetCustomArtworkForApp(
|
||||
appId: number,
|
||||
base64Image: string,
|
||||
imageType: string,
|
||||
assetType: number,
|
||||
): Promise<unknown>;
|
||||
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
|
||||
TerminateApp(gameId: string, _b: boolean): void;
|
||||
};
|
||||
};
|
||||
|
||||
// Steam removed `SteamClient.Apps.SetAppHidden`. Hiding a non-Steam shortcut now goes through
|
||||
// `collectionStore.SetAppsAsHidden([appId], true)` — but that looks the app up in appStore, which
|
||||
// only registers a freshly-created shortcut a moment later (calling it immediately throws on a
|
||||
// null overview). So hiding is BEST-EFFORT + DEFERRED and must NEVER block the launch.
|
||||
// Steam removed `SteamClient.Apps.SetAppHidden`; visibility goes through
|
||||
// `collectionStore.SetAppsAsHidden` — but that looks the app up in appStore, which only
|
||||
// registers a freshly-created shortcut a moment later (calling it immediately throws on a
|
||||
// null overview). So visibility changes are BEST-EFFORT + DEFERRED, never launch-blocking.
|
||||
declare const collectionStore:
|
||||
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
||||
| undefined;
|
||||
|
||||
function hideShortcut(appId: number): void {
|
||||
// The shortcut used to be hidden ("implementation detail"); it is user-visible now — it
|
||||
// carries proper artwork and living in the library is how users relaunch their last host.
|
||||
// Existing installs still have theirs hidden, so unhide is applied every ensure (idempotent).
|
||||
function unhideShortcut(appId: number): void {
|
||||
const attempt = () => {
|
||||
try {
|
||||
collectionStore?.SetAppsAsHidden?.([appId], true);
|
||||
collectionStore?.SetAppsAsHidden?.([appId], false);
|
||||
} catch {
|
||||
/* overview not registered yet, or the API changed — cosmetic, ignore */
|
||||
}
|
||||
@@ -49,7 +61,49 @@ function hideShortcut(appId: number): void {
|
||||
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
|
||||
}
|
||||
|
||||
const SHORTCUT_NAME = "punktfunk";
|
||||
// Bump when the shipped artwork changes so existing shortcuts re-apply it once.
|
||||
const ART_VERSION = 1;
|
||||
const ART_KEY = "punktfunk:shortcutArt";
|
||||
|
||||
/**
|
||||
* Apply the plugin's grid/hero/logo/icon to the shortcut (idempotent, once per ART_VERSION).
|
||||
* Cosmetic and fully best-effort: any failure is swallowed and retried on the next launch.
|
||||
*/
|
||||
async function applyArtwork(appId: number): Promise<void> {
|
||||
try {
|
||||
if (localStorage.getItem(ART_KEY) === `${appId}:${ART_VERSION}`) {
|
||||
return;
|
||||
}
|
||||
const art = await shortcutArt();
|
||||
const assets: [string | undefined, number][] = [
|
||||
[art.grid, 0],
|
||||
[art.hero, 1],
|
||||
[art.logo, 2],
|
||||
[art.gridwide, 3],
|
||||
];
|
||||
for (const [data, assetType] of assets) {
|
||||
if (data) {
|
||||
await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType);
|
||||
}
|
||||
}
|
||||
if (art.icon_path) {
|
||||
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
|
||||
}
|
||||
localStorage.setItem(ART_KEY, `${appId}:${ART_VERSION}`);
|
||||
} catch (e) {
|
||||
console.warn("punktfunk: shortcut artwork not applied", e);
|
||||
}
|
||||
}
|
||||
|
||||
// The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it.
|
||||
const SHORTCUT_NAME = "Punktfunk";
|
||||
|
||||
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
||||
// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin
|
||||
// backend can't chmod it back on. Passing the script as an argument to the always-executable
|
||||
// shell removes the +x dependency entirely. SteamOS /bin/sh is bash; the wrapper is plain
|
||||
// POSIX sh regardless.
|
||||
const SHELL = "/bin/sh";
|
||||
|
||||
// The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the
|
||||
// standard non-Steam-game encoding (appid << 32 | 0x02000000). MoonDeck/decky tools use this.
|
||||
@@ -78,39 +132,36 @@ function recallAppId(): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure exactly one hidden "punktfunk" shortcut exists pointing at the wrapper script, and
|
||||
* return its appId. Reuses the remembered one when its exe still matches the current runner
|
||||
* path (the plugin dir can change across reinstalls).
|
||||
* Ensure exactly one "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is
|
||||
* appended per-launch via the launch options), branded and visible in the library, and
|
||||
* return its appId + the current runner path. Reuses the remembered shortcut, re-pointing
|
||||
* it each time — the plugin dir can change across reinstalls, pre-0.4 shortcuts pointed at
|
||||
* the script directly, and pre-0.7 shortcuts were hidden and artless.
|
||||
*/
|
||||
async function ensureShortcut(): Promise<number> {
|
||||
async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||
}
|
||||
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
||||
|
||||
const remembered = recallAppId();
|
||||
if (remembered != null) {
|
||||
// Re-point the existing shortcut at the current runner path (cheap + idempotent).
|
||||
SteamClient.Apps.SetShortcutExe(remembered, info.runner);
|
||||
SteamClient.Apps.SetShortcutStartDir(
|
||||
remembered,
|
||||
info.runner.replace(/\/[^/]*$/, ""),
|
||||
);
|
||||
return remembered;
|
||||
// Re-point + rename the existing shortcut (cheap + idempotent — migrates old installs).
|
||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||
unhideShortcut(remembered); // pre-0.7 installs hid it
|
||||
void applyArtwork(remembered); // fire-and-forget — cosmetic, never blocks the launch
|
||||
return { appId: remembered, runner: info.runner };
|
||||
}
|
||||
|
||||
const appId = await SteamClient.Apps.AddShortcut(
|
||||
SHORTCUT_NAME,
|
||||
info.runner,
|
||||
info.runner.replace(/\/[^/]*$/, ""), // start dir = the bin/ dir
|
||||
"",
|
||||
);
|
||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
// Hide it from the library — it's an implementation detail, launched programmatically.
|
||||
// Best-effort + deferred (see hideShortcut); never let it block the launch.
|
||||
hideShortcut(appId);
|
||||
unhideShortcut(appId);
|
||||
void applyArtwork(appId); // fire-and-forget — cosmetic, never blocks the launch
|
||||
rememberAppId(appId);
|
||||
return appId;
|
||||
return { appId, runner: info.runner };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,18 +184,62 @@ function disableSteamInputForShortcut(appId: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-launch extras beyond the host target (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;
|
||||
}
|
||||
|
||||
// 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]/;
|
||||
export function isSafeLaunchId(id: string): boolean {
|
||||
return (
|
||||
id.length > 0 &&
|
||||
id.length <= 128 &&
|
||||
UNSAFE_LAUNCH_ID.exec(id) === null &&
|
||||
/^[\x21-\x7e]+$/.test(id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a stream to `host:port` fullscreen in Gaming Mode. Encodes the target into the
|
||||
* shortcut's launch options (so one generic shortcut serves every host), then RunGame.
|
||||
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
|
||||
* library title, or into the gamepad library launcher). Encodes the target into the
|
||||
* shortcut's launch options (so one generic shortcut serves every host and every pinned
|
||||
* game), then RunGame.
|
||||
*/
|
||||
export async function launchStream(host: string, port: number): Promise<void> {
|
||||
const appId = await ensureShortcut();
|
||||
export async function launchStream(
|
||||
host: string,
|
||||
port: number,
|
||||
opts: LaunchOpts = {},
|
||||
): Promise<void> {
|
||||
const { appId, runner } = await ensureShortcut();
|
||||
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
|
||||
// disables Steam Input manually — see the Settings instruction).
|
||||
disableSteamInputForShortcut(appId);
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`);
|
||||
const env = [`PF_HOST=${target}`];
|
||||
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}`);
|
||||
}
|
||||
// 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.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
|
||||
|
||||
+12
-4
@@ -26,6 +26,10 @@ Built in Rust, it links the shared **`punktfunk-core`** directly (no C ABI) and
|
||||
shows its games (Steam + custom) as a poster grid; click one to launch it in the session.
|
||||
Fetched from the host's management API over mTLS — paired devices are authorized by their
|
||||
certificate, no extra host setup.
|
||||
- **Gamepad library launcher** (`--browse host`) — a console-style, controller-driven coverflow of
|
||||
a paired host's library (drifting aurora backdrop, center-focus posters, button hints): A plays
|
||||
the focused title, B quits, L1/R1 jump. Built for the Steam Deck plugin's "Open library" launch;
|
||||
session end returns to the launcher. Arrow keys/Enter/Esc drive it too (no pad needed).
|
||||
|
||||
## Get it
|
||||
|
||||
@@ -49,24 +53,28 @@ and SDL3 (with hidapi) development packages.
|
||||
```sh
|
||||
# from the repo root
|
||||
cargo run -p punktfunk-client-linux # launch the app
|
||||
cargo run -p punktfunk-client-linux -- --discover # list hosts on the LAN, then exit
|
||||
cargo run -p punktfunk-client-linux -- --connect HOST[:PORT] # skip the host list and connect
|
||||
cargo run -p punktfunk-client-linux -- --browse HOST # the gamepad library launcher
|
||||
```
|
||||
|
||||
The binary is named **`punktfunk-client`**. Handy flags: `--connect host[:port]` (start a session
|
||||
immediately — for scripting and the Steam Deck launcher), `--discover [secs]`, and
|
||||
immediately — for scripting and the Steam Deck launcher) with optional `--launch <id>` (ask the
|
||||
host to launch that library title, id from `--library`), `--browse host[:port]` (the gamepad
|
||||
library launcher; `--mgmt <port>` overrides the management port it fetches from),
|
||||
`--pair <PIN> --connect host[:port]` (run the pairing ceremony headlessly), and
|
||||
`--library host[:mgmt_port]` (print a host's game library headlessly). Force a decoder with
|
||||
`PUNKTFUNK_DECODER=software|vaapi`.
|
||||
`PUNKTFUNK_DECODER=software|vaapi`; `PUNKTFUNK_FAKE_LIBRARY=<file.json>` feeds the launcher
|
||||
canned entries for UI work with no host.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs · app.rs entry point, GTK application, primary menu, CSS
|
||||
cli.rs CLI paths (--connect, headless --pair, screenshot scenes)
|
||||
cli.rs CLI paths (--connect/--launch, --browse, headless --pair, screenshot scenes)
|
||||
ui_hosts.rs host card grids (saved + discovered) · add-host dialog · banner
|
||||
ui_library.rs game-library poster grid (per-host, launches titles)
|
||||
ui_gamepad_library.rs the --browse gamepad launcher (aurora · coverflow · hint bar)
|
||||
ui_trust.rs TOFU / PIN-pairing / request-access dialogs
|
||||
ui_settings.rs resolution · refresh · decoder · bitrate · compositor · mic
|
||||
ui_stream.rs the stream window (GtkGraphicsOffload present) + input capture
|
||||
|
||||
@@ -22,14 +22,44 @@ const CSS: &str = "
|
||||
color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); }
|
||||
.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); }
|
||||
.pf-pill.pf-accent { color: @accent_color; background: alpha(@accent_color, 0.15); }
|
||||
.pf-pill.pf-neutral { color: alpha(currentColor, 0.75); background: alpha(currentColor, 0.12); }
|
||||
.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px;
|
||||
background: alpha(currentColor, 0.35); }
|
||||
.pf-pip.pf-online { background: @success_color; }
|
||||
.pf-recent { box-shadow: inset 3px 0 0 0 @accent_bg_color; }
|
||||
/* Most-recent host: a full accent ring drawn as an inset outline so it follows the card's
|
||||
rounded corners (an `inset` box-shadow bar gets eaten by the 12px corner clip) and leaves
|
||||
the card's own elevation shadow intact. */
|
||||
.pf-recent { outline: 2px solid @accent_color; outline-offset: -2px; }
|
||||
.pf-discovered { border: 1px dashed alpha(currentColor, 0.35); }
|
||||
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
||||
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
|
||||
.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); }
|
||||
/* Gaming-Mode launches: gamescope displays the window fullscreen but never ACKs the
|
||||
xdg_toplevel fullscreen state, so GTK keeps the floating-CSD styling — libadwaita's
|
||||
rounded corners + shadow margin stay visible over the stream. Flatten them outright. */
|
||||
window.pf-chromeless { border-radius: 0; box-shadow: none; }
|
||||
/* The gamepad library launcher (`--browse`, ui_gamepad_library) — always-dark console
|
||||
chrome over the aurora, independent of the desktop theme. */
|
||||
.pf-gl-page { background: black; color: white; }
|
||||
.pf-gl-host { font-size: 1.15em; font-weight: bold; color: rgba(255, 255, 255, 0.9); }
|
||||
.pf-gl-chip { font-size: 0.8em; color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px; padding: 4px 12px; }
|
||||
/* Solid face, not glass: coverflow side cards OVERLAP — a translucent card would bleed
|
||||
the stack through the one on top. */
|
||||
.pf-gl-poster { border-radius: 16px; background: rgb(30, 30, 37);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07); }
|
||||
.pf-gl-dim { background: black; border-radius: 16px; }
|
||||
.pf-gl-detail-title { font-size: 1.7em; font-weight: bold; color: white; }
|
||||
.pf-gl-detail-store { font-size: 0.75em; font-weight: 600; letter-spacing: 2px;
|
||||
color: rgba(255, 255, 255, 0.5); }
|
||||
.pf-gl-glyph { font-size: 0.85em; font-weight: bold; color: white;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px; min-width: 26px; min-height: 26px; padding: 2px 8px; }
|
||||
.pf-gl-hint { color: rgba(255, 255, 255, 0.85); }
|
||||
.pf-gl-status { font-size: 0.85em; color: #ff938a; }
|
||||
.pf-gl-error-title { font-size: 1.4em; font-weight: bold; color: white; }
|
||||
";
|
||||
|
||||
pub struct App {
|
||||
@@ -44,9 +74,16 @@ pub struct App {
|
||||
pub busy: std::cell::Cell<bool>,
|
||||
/// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts.
|
||||
pub fullscreen: bool,
|
||||
/// Quit when the session ends (Gaming-Mode `--connect` launch): the app IS the stream —
|
||||
/// exiting ends the Steam "game" so the Deck returns to Gaming Mode instead of stranding
|
||||
/// the user on the client's own hosts page.
|
||||
pub quit_on_session_end: bool,
|
||||
/// The hosts page handle (banner + per-card connecting spinner), set right after the
|
||||
/// page is built — `None` only during construction.
|
||||
pub hosts: RefCell<Option<Rc<HostsUi>>>,
|
||||
/// The gamepad library launcher — `Some` only under `--browse`, where it replaces the
|
||||
/// hosts page as the root (and session end returns here instead of quitting).
|
||||
pub browse: RefCell<Option<Rc<crate::ui_gamepad_library::LauncherUi>>>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -58,11 +95,17 @@ impl App {
|
||||
self.hosts.borrow().clone()
|
||||
}
|
||||
|
||||
/// Surface a connect failure on the hosts page banner (toast fallback pre-build).
|
||||
pub fn browse_ui(&self) -> Option<Rc<crate::ui_gamepad_library::LauncherUi>> {
|
||||
self.browse.borrow().clone()
|
||||
}
|
||||
|
||||
/// Surface a connect failure: the launcher in browse mode, else the hosts page banner
|
||||
/// (toast fallback pre-build).
|
||||
pub fn connect_error(&self, msg: &str) {
|
||||
match self.hosts_ui() {
|
||||
Some(h) => h.show_error(msg),
|
||||
None => self.toast(msg),
|
||||
match (self.browse_ui(), self.hosts_ui()) {
|
||||
(Some(l), _) => l.show_error(msg),
|
||||
(_, Some(h)) => h.show_error(msg),
|
||||
_ => self.toast(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,6 +147,14 @@ fn build_ui(gtk_app: &adw::Application) {
|
||||
}
|
||||
};
|
||||
load_css();
|
||||
// Screenshot scenes must capture settled frames: kill every GTK/libadwaita animation
|
||||
// (nav-push slides especially — a headless session may starve the frame clock and
|
||||
// leave a transition frozen mid-flight in the capture).
|
||||
if crate::cli::shot_scene().is_some() {
|
||||
if let Some(s) = gtk::Settings::default() {
|
||||
s.set_gtk_enable_animations(false);
|
||||
}
|
||||
}
|
||||
|
||||
let nav = adw::NavigationView::new();
|
||||
let toasts = adw::ToastOverlay::new();
|
||||
@@ -116,6 +167,14 @@ fn build_ui(gtk_app: &adw::Application) {
|
||||
.content(&toasts)
|
||||
.build();
|
||||
|
||||
let fullscreen = crate::cli::fullscreen_mode();
|
||||
if fullscreen {
|
||||
// Chrome-less shell: no CSD rounding/shadow (see CSS — gamescope never ACKs the
|
||||
// fullscreen state, so GTK would keep them), and ask for fullscreen up front.
|
||||
window.add_css_class("pf-chromeless");
|
||||
window.fullscreen();
|
||||
}
|
||||
|
||||
let app = Rc::new(App {
|
||||
window: window.clone(),
|
||||
nav: nav.clone(),
|
||||
@@ -124,10 +183,36 @@ fn build_ui(gtk_app: &adw::Application) {
|
||||
identity,
|
||||
gamepad: crate::gamepad::GamepadService::start(),
|
||||
busy: std::cell::Cell::new(false),
|
||||
fullscreen: crate::cli::fullscreen_mode(),
|
||||
fullscreen,
|
||||
// (`--browse` makes cli_connect_request None — browse mode returns to the
|
||||
// launcher on session end instead of quitting.)
|
||||
quit_on_session_end: fullscreen && crate::cli::cli_connect_request().is_some(),
|
||||
hosts: RefCell::new(None),
|
||||
browse: RefCell::new(None),
|
||||
});
|
||||
|
||||
// Re-apply the persisted forwarded-controller pin (stable key; the service matches it
|
||||
// whenever such a pad connects) — without this the pin silently resets to Automatic on
|
||||
// every launch, and Automatic may resolve to a gyro-less pad (Steam's virtual gamepad).
|
||||
{
|
||||
let forward = app.settings.borrow().forward_pad.clone();
|
||||
if !forward.is_empty() {
|
||||
app.gamepad.set_pinned(Some(forward));
|
||||
}
|
||||
}
|
||||
|
||||
// Browse mode (`--browse host`): the app IS the gamepad library launcher — it becomes
|
||||
// the ONE root page. No hosts page (whose construction starts the mDNS browse), no
|
||||
// header-menu actions; `Settings::library_enabled` is deliberately ignored (the flag
|
||||
// gates the desktop menu item — asking to browse IS the opt-in here).
|
||||
if let Some((req, paired, mgmt_port)) = crate::cli::cli_browse_request() {
|
||||
let launcher = crate::ui_gamepad_library::open(app.clone(), req, paired, mgmt_port);
|
||||
nav.add(&launcher.page);
|
||||
*app.browse.borrow_mut() = Some(launcher);
|
||||
window.present();
|
||||
return;
|
||||
}
|
||||
|
||||
let hosts_ui = Rc::new(crate::ui_hosts::new(
|
||||
app.settings.clone(),
|
||||
HostsCallbacks {
|
||||
|
||||
+85
-20
@@ -84,19 +84,66 @@ pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||
/// already pinned at this address connects silently on its stored pin; an unknown host is
|
||||
/// routed to the PIN ceremony (never a silent TOFU connect — `fp_hex`/`pair_optional` are
|
||||
/// unset, so `initiate_connect`'s manual arm mandates pairing).
|
||||
///
|
||||
/// `--launch <id>` asks the host to launch that library title (store-qualified id from
|
||||
/// `--library`, e.g. `steam:570` — the Decky wrapper's `PF_LAUNCH`); the raw id doubles
|
||||
/// as the stream title (best-effort — no extra fetch just for a prettier label).
|
||||
pub fn cli_connect_request() -> Option<ConnectRequest> {
|
||||
if arg_value("--browse").is_some() {
|
||||
return None; // browse mode owns the session lifecycle (precedence over --connect)
|
||||
}
|
||||
let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?;
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
// An unparsable port (`host:notaport`) used to make the whole request `None` → the app
|
||||
// silently landed on the hosts page with no session and no message. Fall back to the
|
||||
// native default like the add-host dialog, and say so, instead of doing nothing.
|
||||
let port = port.unwrap_or_else(|| {
|
||||
eprintln!("--connect: unparsable port in '{target}', using default 9777");
|
||||
9777
|
||||
});
|
||||
Some(ConnectRequest {
|
||||
name: addr.clone(),
|
||||
addr,
|
||||
port: port?,
|
||||
port,
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
launch: arg_value("--launch").map(|id| (id.clone(), id)),
|
||||
})
|
||||
}
|
||||
|
||||
/// `--browse host[:port]` — open the gamepad library launcher for that host instead of
|
||||
/// connecting (the Decky wrapper's `PF_BROWSE`; native port, default 9777). The host must
|
||||
/// already be paired: the stored pin is what lets the launcher fetch the library and
|
||||
/// connect silently — no dialog can run under gamescope, so an unpaired target renders
|
||||
/// the launcher's pair-first scene. Returns the request (name + stored fingerprint from
|
||||
/// the known-hosts store), whether it's paired, and the mgmt port (`--mgmt <port>`, the
|
||||
/// wrapper's `PF_MGMT`; default 47990 — browse mode runs no mDNS to learn it).
|
||||
pub fn cli_browse_request() -> Option<(ConnectRequest, bool, u16)> {
|
||||
let target = arg_value("--browse")?;
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
let port = port.unwrap_or(9777);
|
||||
let known = crate::trust::KnownHosts::load();
|
||||
let k = known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port);
|
||||
let mgmt = arg_value("--mgmt")
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(crate::library::DEFAULT_MGMT_PORT);
|
||||
Some((
|
||||
ConnectRequest {
|
||||
name: k.map_or_else(|| addr.clone(), |k| k.name.clone()),
|
||||
addr,
|
||||
port,
|
||||
fp_hex: k.map(|k| k.fp_hex.clone()),
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
},
|
||||
k.is_some_and(|k| k.paired),
|
||||
mgmt,
|
||||
))
|
||||
}
|
||||
|
||||
/// `--library host[:mgmt_port]` — fetch and print the host's game library over the real
|
||||
/// mTLS + pinned-fingerprint client, no GTK window (scripting, and the live-API proof
|
||||
/// that the library HTTP path works against a real host). The pin comes from `--fp HEX`
|
||||
@@ -219,26 +266,17 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
||||
// no-art placeholders (monogram tiles), and one solid-color texture standing in
|
||||
// for a loaded poster (the real poster path, minus the network).
|
||||
"library" | "08-library" => {
|
||||
let game = |id: &str, store: &str, title: &str| crate::library::GameEntry {
|
||||
id: id.to_string(),
|
||||
store: store.to_string(),
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
game("steam:1091500", "steam", "Cyberpunk 2077"),
|
||||
game("custom:emu-1", "custom", "RetroArch"),
|
||||
game("heroic:fortnite", "heroic", "Fortnite"),
|
||||
game("gog:witcher3", "gog", "The Witcher 3"),
|
||||
game("lutris:osu", "lutris", "osu!"),
|
||||
];
|
||||
let art = vec![(
|
||||
"steam:570".to_string(),
|
||||
solid_texture(300, 450, 0x35, 0x84, 0xe4),
|
||||
)];
|
||||
let (games, art) = mock_library();
|
||||
crate::ui_library::open_mock(app.clone(), mock_req(), games, art);
|
||||
}
|
||||
// The gamepad launcher (`--browse`) with the same injected entries — cursor sits
|
||||
// at 1 so both recede directions show; aurora + easing render frozen (shot mode).
|
||||
"gamepad-library" | "09-gamepad-library" => {
|
||||
let (games, art) = mock_library();
|
||||
let ui = crate::ui_gamepad_library::open_mock(app.clone(), mock_req(), games, art);
|
||||
app.nav.push(&ui.page);
|
||||
*app.browse.borrow_mut() = Some(ui);
|
||||
}
|
||||
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
|
||||
}
|
||||
|
||||
@@ -268,6 +306,33 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The mock game set shared by the `library` and `gamepad-library` scenes: mixed stores
|
||||
/// exercising the badge set, plus one solid-colour poster texture.
|
||||
fn mock_library() -> (
|
||||
Vec<crate::library::GameEntry>,
|
||||
Vec<(String, gtk::gdk::Texture)>,
|
||||
) {
|
||||
let game = |id: &str, store: &str, title: &str| crate::library::GameEntry {
|
||||
id: id.to_string(),
|
||||
store: store.to_string(),
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
game("steam:1091500", "steam", "Cyberpunk 2077"),
|
||||
game("custom:emu-1", "custom", "RetroArch"),
|
||||
game("heroic:fortnite", "heroic", "Fortnite"),
|
||||
game("gog:witcher3", "gog", "The Witcher 3"),
|
||||
game("lutris:osu", "lutris", "osu!"),
|
||||
];
|
||||
let art = vec![(
|
||||
"steam:570".to_string(),
|
||||
solid_texture(300, 450, 0x35, 0x84, 0xe4),
|
||||
)];
|
||||
(games, art)
|
||||
}
|
||||
|
||||
/// A WxH single-colour RGBA texture — the `library` scene's stand-in for a fetched poster.
|
||||
fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture {
|
||||
let px = [r, g, b, 0xff].repeat((w * h) as usize);
|
||||
|
||||
+651
-87
@@ -2,12 +2,32 @@
|
||||
//! `GamepadCapture`/`GamepadFeedback`).
|
||||
//!
|
||||
//! One worker thread owns SDL for the process lifetime: it tracks connected pads for the
|
||||
//! Settings UI, selects the ONE controller forwarded as pad 0 (user pin, else the most
|
||||
//! recently connected), and — while a session is attached — forwards buttons/axes,
|
||||
//! DualSense touchpad contacts and motion samples (0xCC), and renders feedback: rumble on
|
||||
//! every pad, lightbar via SDL, and on a real DualSense the raw effects packet
|
||||
//! (adaptive-trigger blocks replayed verbatim, player LEDs). Held state is zeroed on the
|
||||
//! wire when the active pad switches or the session detaches, so nothing sticks down.
|
||||
//! Settings UI (metadata only — see below), selects the ONE controller forwarded as pad 0
|
||||
//! (the user pin — persisted in Settings by stable `vid:pid:name` key — else the most
|
||||
//! recently connected real pad; Steam Input's virtual pad is skipped), and — while a
|
||||
//! session is attached — forwards buttons/axes, DualSense touchpad contacts and motion
|
||||
//! samples (0xCC), and renders feedback: rumble, lightbar via SDL, and on a real DualSense
|
||||
//! the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs). Held
|
||||
//! state is zeroed on the wire when the active pad switches or the session detaches, so
|
||||
//! nothing sticks down.
|
||||
//!
|
||||
//! **Idle means hands off the hardware.** Outside an attached session the worker never
|
||||
//! opens a device and keeps SDL's Valve HIDAPI drivers disabled ([`set_valve_hidapi`]):
|
||||
//! the Steam Deck driver clears the built-in controller's "lizard mode" (trackpad-mouse,
|
||||
//! clicky pads) the moment the device *enumerates* and keeps feeding that watchdog — so an
|
||||
//! idle host-list window would kill the Deck's system input. The pad list for Settings is
|
||||
//! built from SDL's ID-based metadata getters, which need no open.
|
||||
//!
|
||||
//! **Menu mode is the one idle exception.** The gamepad library launcher (`--browse`)
|
||||
//! flips [`GamepadService::set_menu_mode`] on for its lifetime: the worker then holds the
|
||||
//! active pad open and translates its buttons/stick into [`MenuEvent`]s (polled off the
|
||||
//! open handle each loop — Apple `GamepadMenuInput` parity: edge-triggered buttons,
|
||||
//! snapshot-on-entry so a button still held from a previous screen or stream can't ghost-
|
||||
//! fire, stick/dpad direction with initial-delay auto-repeat). The Valve HIDAPI drivers
|
||||
//! stay OFF — a plain SDL open of the virtual X360 / evdev pad doesn't touch lizard mode —
|
||||
//! and an attached session always supersedes menu translation (the stream path is
|
||||
//! untouched); detach re-snapshots so the escape chord that ended the session fires
|
||||
//! nothing in the menu.
|
||||
//!
|
||||
//! This thread is also the single consumer of the rumble and HID-output pull planes.
|
||||
|
||||
@@ -15,7 +35,6 @@ use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -42,14 +61,183 @@ 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);
|
||||
|
||||
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
|
||||
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
|
||||
const MENU_DEADZONE: u16 = 16384;
|
||||
/// A held direction starts auto-repeating after this initial delay…
|
||||
const MENU_REPEAT_DELAY: Duration = Duration::from_millis(380);
|
||||
/// …and then repeats at this cadence until released or changed.
|
||||
const MENU_REPEAT_INTERVAL: Duration = Duration::from_millis(160);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MenuDir {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// One controller action for the launcher UI, translated from the open pad while menu
|
||||
/// mode is on and no session is attached. Buttons are edge-triggered; `Move` debounces
|
||||
/// the stick/dpad and auto-repeats ([`MENU_REPEAT_DELAY`]/[`MENU_REPEAT_INTERVAL`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MenuEvent {
|
||||
Move(MenuDir),
|
||||
/// A — activate the focused item.
|
||||
Confirm,
|
||||
/// B — back / quit.
|
||||
Back,
|
||||
/// Y (Apple "secondary"; unused by the launcher today, kept for parity).
|
||||
Secondary,
|
||||
/// X (Apple "tertiary"; unused).
|
||||
Tertiary,
|
||||
/// L1 — jump back 5.
|
||||
JumpBack,
|
||||
/// R1 — jump forward 5.
|
||||
JumpForward,
|
||||
}
|
||||
|
||||
/// Menu haptic pulses — short rumble ticks on the menu pad (never during a stream).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum MenuPulse {
|
||||
Move,
|
||||
Confirm,
|
||||
Boundary,
|
||||
}
|
||||
|
||||
/// Raw pad state sampled once per worker iteration for menu translation.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct MenuSample {
|
||||
/// a, b, x, y, l1, r1 — the order [`MenuNav::poll`] maps to events.
|
||||
buttons: [bool; 6],
|
||||
/// Left stick, SDL convention (+y = down).
|
||||
lx: i16,
|
||||
ly: i16,
|
||||
/// up, down, left, right.
|
||||
dpad: [bool; 4],
|
||||
}
|
||||
|
||||
/// The pure menu-input state machine (no SDL types — unit-tested below). Port of the
|
||||
/// Swift client's `GamepadMenuInput`: the poll after a [`reset`](Self::reset) adopts the
|
||||
/// currently-held buttons and direction WITHOUT firing, so a press that crossed a screen
|
||||
/// handoff (the B that closed a stream, a held A on mode entry) must be released before
|
||||
/// it can act; buttons fire on the rising edge only.
|
||||
struct MenuNav {
|
||||
/// Adopt the next sample silently (set on mode entry / stream detach / pad change).
|
||||
snapshot_pending: bool,
|
||||
/// Previous button states, [`MenuSample::buttons`] order.
|
||||
was: [bool; 6],
|
||||
dir: Option<MenuDir>,
|
||||
/// When `dir` engaged — start of the initial-repeat delay.
|
||||
dir_since: Instant,
|
||||
last_repeat: Instant,
|
||||
}
|
||||
|
||||
impl MenuNav {
|
||||
fn new() -> MenuNav {
|
||||
MenuNav {
|
||||
snapshot_pending: true,
|
||||
was: [false; 6],
|
||||
dir: None,
|
||||
dir_since: Instant::now(),
|
||||
last_repeat: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm the snapshot: the next poll adopts held state without firing.
|
||||
fn reset(&mut self) {
|
||||
self.snapshot_pending = true;
|
||||
self.dir = None;
|
||||
}
|
||||
|
||||
/// Direction from the left stick (dominant axis wins past the deadzone), falling back
|
||||
/// to the discrete dpad. SDL sticks are +y = down.
|
||||
fn resolve_dir(s: &MenuSample) -> Option<MenuDir> {
|
||||
let (ax, ay) = (s.lx.unsigned_abs(), s.ly.unsigned_abs());
|
||||
if ax > MENU_DEADZONE || ay > MENU_DEADZONE {
|
||||
return Some(if ax >= ay {
|
||||
if s.lx > 0 {
|
||||
MenuDir::Right
|
||||
} else {
|
||||
MenuDir::Left
|
||||
}
|
||||
} else if s.ly > 0 {
|
||||
MenuDir::Down
|
||||
} else {
|
||||
MenuDir::Up
|
||||
});
|
||||
}
|
||||
let [up, down, left, right] = s.dpad;
|
||||
if left {
|
||||
Some(MenuDir::Left)
|
||||
} else if right {
|
||||
Some(MenuDir::Right)
|
||||
} else if up {
|
||||
Some(MenuDir::Up)
|
||||
} else if down {
|
||||
Some(MenuDir::Down)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn poll(&mut self, s: &MenuSample, now: Instant, out: &mut Vec<MenuEvent>) {
|
||||
let dir = Self::resolve_dir(s);
|
||||
if self.snapshot_pending {
|
||||
self.snapshot_pending = false;
|
||||
self.was = s.buttons;
|
||||
self.dir = dir;
|
||||
self.dir_since = now;
|
||||
self.last_repeat = now;
|
||||
return;
|
||||
}
|
||||
// buttons order a, b, x, y, l1, r1 → the matching event per index.
|
||||
const EVENTS: [MenuEvent; 6] = [
|
||||
MenuEvent::Confirm,
|
||||
MenuEvent::Back,
|
||||
MenuEvent::Tertiary,
|
||||
MenuEvent::Secondary,
|
||||
MenuEvent::JumpBack,
|
||||
MenuEvent::JumpForward,
|
||||
];
|
||||
for (i, ev) in EVENTS.iter().enumerate() {
|
||||
if s.buttons[i] && !self.was[i] {
|
||||
out.push(*ev);
|
||||
}
|
||||
self.was[i] = s.buttons[i];
|
||||
}
|
||||
if dir != self.dir {
|
||||
self.dir = dir;
|
||||
self.dir_since = now;
|
||||
self.last_repeat = now;
|
||||
if let Some(d) = dir {
|
||||
out.push(MenuEvent::Move(d));
|
||||
}
|
||||
} else if let Some(d) = dir {
|
||||
if now.duration_since(self.dir_since) >= MENU_REPEAT_DELAY
|
||||
&& now.duration_since(self.last_repeat) >= MENU_REPEAT_INTERVAL
|
||||
{
|
||||
self.last_repeat = now;
|
||||
out.push(MenuEvent::Move(d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PadInfo {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
/// Stable identity (`vid:pid:name`) for pinning across restarts — SDL instance ids are
|
||||
/// per-run, so [`Settings::forward_pad`](crate::trust::Settings) persists this instead.
|
||||
pub key: String,
|
||||
/// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a
|
||||
/// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything
|
||||
/// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path.
|
||||
pub pref: GamepadPref,
|
||||
/// Steam Input's emulated pad ("Steam Virtual Gamepad", Valve 28de:11ff). It shadows the
|
||||
/// physical controller and has no sensors/touchpad, so auto-selection skips it while a real
|
||||
/// pad is connected — otherwise gyro silently dies on Bazzite/Deck game mode.
|
||||
pub steam_virtual: bool,
|
||||
}
|
||||
|
||||
impl PadInfo {
|
||||
@@ -71,6 +259,24 @@ impl PadInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable/disable SDL's Valve HIDAPI drivers at runtime. The Steam Deck driver sends
|
||||
/// `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` in `InitDevice` — at *enumeration*, before
|
||||
/// any open — and its `UpdateDevice` keeps feeding the firmware's lizard-mode watchdog
|
||||
/// (`SDL_hidapi_steamdeck.c`), so a Deck's built-in trackpad-mouse dies for the whole
|
||||
/// system while the driver merely runs. These drivers therefore run ONLY while a session
|
||||
/// is attached (input is captured then anyway, and streaming wants the paddles, both
|
||||
/// trackpads, and gyro first-class). SDL3 applies the hint changes live: disabling detaches
|
||||
/// the driver and the firmware watchdog restores lizard mode within seconds.
|
||||
///
|
||||
/// On a Deck in Game Mode, Steam Input still holds the device — the user must disable
|
||||
/// Steam Input for this app (see the Decky UX); on a desktop client (or a Deck with Steam
|
||||
/// Input off) the in-session enable just works.
|
||||
fn set_valve_hidapi(enabled: bool) {
|
||||
let v = if enabled { "1" } else { "0" };
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", v);
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -82,17 +288,33 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort "this machine is a Steam Deck". The Gaming-Mode env short-circuits; desktop
|
||||
/// mode falls back to DMI (Valve board, Jupiter = LCD / Galileo = OLED — readable inside the
|
||||
/// flatpak sandbox). Cached: the answer can't change while we run.
|
||||
pub fn is_steam_deck() -> bool {
|
||||
static DECK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*DECK.get_or_init(|| {
|
||||
if std::env::var_os("SteamDeck").is_some() {
|
||||
return true;
|
||||
}
|
||||
let dmi = |f: &str| std::fs::read_to_string(format!("/sys/class/dmi/id/{f}"));
|
||||
dmi("board_vendor").is_ok_and(|v| v.trim() == "Valve")
|
||||
&& dmi("product_name").is_ok_and(|p| matches!(p.trim(), "Jupiter" | "Galileo"))
|
||||
})
|
||||
}
|
||||
|
||||
enum Ctl {
|
||||
Attach(Arc<NativeClient>),
|
||||
Detach,
|
||||
Pin(Option<u32>),
|
||||
Pin(Option<String>),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GamepadService {
|
||||
pads: Arc<Mutex<Vec<PadInfo>>>,
|
||||
active: Arc<Mutex<Option<PadInfo>>>,
|
||||
pinned: Arc<Mutex<Option<u32>>>,
|
||||
ctl: Sender<Ctl>,
|
||||
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
|
||||
/// fullscreen + release capture.
|
||||
@@ -100,21 +322,24 @@ pub struct GamepadService {
|
||||
/// Fires once when the [`ESCAPE_CHORD`] is held past [`DISCONNECT_HOLD`]; the stream page
|
||||
/// consumes it to end the session (the controller equivalent of Ctrl+Alt+Shift+D).
|
||||
disconnect_rx: async_channel::Receiver<()>,
|
||||
/// Menu-navigation events while menu mode is on and no session is attached; the
|
||||
/// launcher page consumes them.
|
||||
menu_rx: async_channel::Receiver<MenuEvent>,
|
||||
}
|
||||
|
||||
impl GamepadService {
|
||||
pub fn start() -> GamepadService {
|
||||
let pads = Arc::new(Mutex::new(Vec::new()));
|
||||
let active = Arc::new(Mutex::new(None));
|
||||
let pinned = Arc::new(Mutex::new(None));
|
||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||
let (escape_tx, escape_rx) = async_channel::unbounded();
|
||||
let (disconnect_tx, disconnect_rx) = async_channel::unbounded();
|
||||
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
||||
let (menu_tx, menu_rx) = async_channel::unbounded();
|
||||
let (p, a) = (pads.clone(), active.clone());
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-gamepad".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx, &disconnect_tx) {
|
||||
if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx, &menu_tx) {
|
||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||
}
|
||||
})
|
||||
@@ -124,10 +349,10 @@ impl GamepadService {
|
||||
GamepadService {
|
||||
pads,
|
||||
active,
|
||||
pinned,
|
||||
ctl,
|
||||
escape_rx,
|
||||
disconnect_rx,
|
||||
menu_rx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +368,25 @@ impl GamepadService {
|
||||
self.disconnect_rx.clone()
|
||||
}
|
||||
|
||||
/// Menu-navigation events ([`MenuEvent`]) — flowing only while menu mode is on and no
|
||||
/// session is attached. A fresh clone per call; the launcher spawns a future on it.
|
||||
pub fn menu_events(&self) -> async_channel::Receiver<MenuEvent> {
|
||||
self.menu_rx.clone()
|
||||
}
|
||||
|
||||
/// Turn menu mode on/off: while on (and no session attached) the worker holds the
|
||||
/// active pad open and translates it into [`MenuEvent`]s. The launcher flips this on
|
||||
/// once for its lifetime — an attached session supersedes translation automatically.
|
||||
pub fn set_menu_mode(&self, on: bool) {
|
||||
let _ = self.ctl.send(Ctl::MenuMode(on));
|
||||
}
|
||||
|
||||
/// Play a short menu haptic tick on the menu pad (no-op while a session is attached
|
||||
/// or no pad is open; best-effort on pads without rumble).
|
||||
pub fn menu_rumble(&self, pulse: MenuPulse) {
|
||||
let _ = self.ctl.send(Ctl::MenuRumble(pulse));
|
||||
}
|
||||
|
||||
pub fn pads(&self) -> Vec<PadInfo> {
|
||||
self.pads.lock().unwrap().clone()
|
||||
}
|
||||
@@ -151,12 +395,11 @@ impl GamepadService {
|
||||
self.active.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn pinned(&self) -> Option<u32> {
|
||||
*self.pinned.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn set_pinned(&self, id: Option<u32>) {
|
||||
let _ = self.ctl.send(Ctl::Pin(id));
|
||||
/// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic.
|
||||
/// The pin persists as `Settings::forward_pad` (the UI's source of truth) and survives
|
||||
/// the pad disconnecting: it re-applies the moment a matching controller shows up again.
|
||||
pub fn set_pinned(&self, key: Option<String>) {
|
||||
let _ = self.ctl.send(Ctl::Pin(key));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
@@ -169,8 +412,19 @@ impl GamepadService {
|
||||
|
||||
/// What "Automatic" resolves to right now — the virtual pad matching the physical one
|
||||
/// (Swift parity); no pad connected leaves the host's own default.
|
||||
///
|
||||
/// **Steam Deck special case:** this is read at session start, *before* attach — but the
|
||||
/// Deck's built-in controller is only enumerable with its real 28DE:1205 identity while
|
||||
/// the Valve HIDAPI drivers run, and those are enabled on attach only (see
|
||||
/// [`set_valve_hidapi`]); with Steam Input on, SDL sees nothing but Steam's virtual
|
||||
/// X360 pad anyway. Both cases used to fall through to Xbox 360. On a Deck, a virtual
|
||||
/// pad (or no pad at all) means the physical controller behind it IS the built-in one —
|
||||
/// resolve to the Steam Deck virtual pad so the paddles/trackpads/gyro have somewhere
|
||||
/// to land. A real external controller still wins (it's the one that gets forwarded).
|
||||
pub fn auto_pref(&self) -> GamepadPref {
|
||||
match self.active() {
|
||||
Some(p) if !p.steam_virtual => p.pref,
|
||||
_ if is_steam_deck() => GamepadPref::SteamDeck,
|
||||
Some(p) => p.pref,
|
||||
None => GamepadPref::Auto,
|
||||
}
|
||||
@@ -279,11 +533,16 @@ struct Worker<'a> {
|
||||
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
|
||||
pads_out: &'a Mutex<Vec<PadInfo>>,
|
||||
active_out: &'a Mutex<Option<PadInfo>>,
|
||||
pinned_out: &'a Mutex<Option<u32>>,
|
||||
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
|
||||
/// Connection order; the most recently connected is the auto selection.
|
||||
/// The ONE device held open — the active pad while a session is attached, `None`
|
||||
/// otherwise. Opening is what grabs the hardware (SDL's HIDAPI drivers take the
|
||||
/// hidraw device away from the system), so idle keeps this empty; see the module doc.
|
||||
open: Option<(u32, sdl3::gamepad::Gamepad)>,
|
||||
/// Connected pad ids in connection order (metadata only, no device open); the most
|
||||
/// recently connected is the auto selection.
|
||||
order: Vec<u32>,
|
||||
pinned: Option<u32>,
|
||||
/// Stable key of the user-pinned controller (persisted in Settings) — matched against
|
||||
/// connected pads, so it survives restarts and disconnects.
|
||||
pinned: Option<String>,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||
last_axis: [i32; 6],
|
||||
@@ -304,36 +563,112 @@ struct Worker<'a> {
|
||||
chord_since: Option<Instant>,
|
||||
/// The disconnect signal already fired for the current hold — latched so it fires once.
|
||||
disconnect_fired: bool,
|
||||
/// Menu mode ([`GamepadService::set_menu_mode`]): hold the active pad open while idle
|
||||
/// and translate it into [`MenuEvent`]s. An attached session pauses translation.
|
||||
menu_mode: bool,
|
||||
menu_nav: MenuNav,
|
||||
menu_tx: async_channel::Sender<MenuEvent>,
|
||||
}
|
||||
|
||||
impl Worker<'_> {
|
||||
fn active_id(&self) -> Option<u32> {
|
||||
self.pinned
|
||||
.filter(|id| self.opened.contains_key(id))
|
||||
// The pin matches by stable key (most recently connected wins if two identical pads
|
||||
// share one); an unmatched pin falls through to automatic without being cleared.
|
||||
if let Some(key) = &self.pinned {
|
||||
if let Some(id) = self
|
||||
.order
|
||||
.iter()
|
||||
.rev()
|
||||
.copied()
|
||||
.find(|&id| self.pad_info(id).is_some_and(|p| &p.key == key))
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
// Automatic: the most recently connected pad — but never Steam Input's virtual pad
|
||||
// while a real controller is present (see `PadInfo::steam_virtual`).
|
||||
self.order
|
||||
.iter()
|
||||
.rev()
|
||||
.copied()
|
||||
.find(|&id| self.pad_info(id).is_some_and(|p| !p.steam_virtual))
|
||||
.or_else(|| self.order.last().copied())
|
||||
}
|
||||
|
||||
/// Pad metadata from SDL's ID-based getters — deliberately NO device open (see the
|
||||
/// module doc; an open would grab the hardware).
|
||||
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||
let pad = self.opened.get(&id)?;
|
||||
let mut pref = pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
if !self.order.contains(&id) {
|
||||
return None;
|
||||
}
|
||||
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
|
||||
let mut pref = pref_for_type(self.subsystem.type_for_id(jid));
|
||||
let (vid, pid) = (
|
||||
self.subsystem.vendor_for_id(jid).unwrap_or(0),
|
||||
self.subsystem.product_for_id(jid).unwrap_or(0),
|
||||
);
|
||||
// There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by
|
||||
// VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual
|
||||
// hid-steam pad with the back grips + dual trackpads and the right glyph identity.
|
||||
if pad.vendor_id() == Some(0x28DE)
|
||||
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
|
||||
{
|
||||
if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
|
||||
pref = GamepadPref::SteamDeck;
|
||||
}
|
||||
let name = self
|
||||
.subsystem
|
||||
.name_for_id(jid)
|
||||
.unwrap_or_else(|_| "Controller".into());
|
||||
Some(PadInfo {
|
||||
id,
|
||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||
key: format!("{vid:04x}:{pid:04x}:{name}"),
|
||||
steam_virtual: (vid == 0x28DE && pid == 0x11FF)
|
||||
|| name.starts_with("Steam Virtual Gamepad"),
|
||||
name,
|
||||
pref,
|
||||
})
|
||||
}
|
||||
|
||||
/// Hold exactly the right device: the active pad while a session is attached or menu
|
||||
/// mode owns navigation, nothing otherwise. The single place that decides to open
|
||||
/// (= grab) hardware; dropping the old handle closes it (`SDL_CloseGamepad`) — on a
|
||||
/// Deck the firmware watchdog then restores lizard mode.
|
||||
fn sync_open(&mut self) {
|
||||
let want = if self.attached.is_some() || self.menu_mode {
|
||||
self.active_id()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if self.open.as_ref().map(|(id, _)| *id) == want {
|
||||
return;
|
||||
}
|
||||
self.open = None;
|
||||
let Some(id) = want else { return };
|
||||
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
|
||||
Ok(pad) => {
|
||||
self.open = Some((id, pad));
|
||||
// Sensors stream only for an attached session (USB/BT bandwidth); the
|
||||
// menu needs buttons + stick only.
|
||||
if self.attached.is_some() {
|
||||
self.set_sensors(true);
|
||||
} else {
|
||||
// The menu pad changed under us (hot-plug while the launcher is
|
||||
// open): adopt the new pad's held state instead of firing it.
|
||||
self.menu_nav.reset();
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
|
||||
}
|
||||
}
|
||||
|
||||
/// React to anything that may have moved the active-pad selection (hotplug, pin
|
||||
/// change): flush held wire state if it did, then re-sync the opened device and the
|
||||
/// UI-facing snapshot.
|
||||
fn refresh_active(&mut self, before: Option<u32>) {
|
||||
if self.active_id() != before {
|
||||
self.flush_held();
|
||||
}
|
||||
self.sync_open();
|
||||
self.publish();
|
||||
}
|
||||
|
||||
/// Zero everything the host believes is held — on pad switch and detach.
|
||||
fn flush_held(&mut self) {
|
||||
if let Some(c) = &self.attached {
|
||||
@@ -432,8 +767,7 @@ impl Worker<'_> {
|
||||
|
||||
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
||||
fn set_sensors(&mut self, enabled: bool) {
|
||||
let Some(id) = self.active_id() else { return };
|
||||
if let Some(pad) = self.opened.get_mut(&id) {
|
||||
if let Some((_, pad)) = self.open.as_mut() {
|
||||
use sdl3::sensor::SensorType;
|
||||
for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
|
||||
if unsafe { pad.has_sensor(s) } {
|
||||
@@ -459,9 +793,10 @@ impl Worker<'_> {
|
||||
return;
|
||||
};
|
||||
let multi = self
|
||||
.opened
|
||||
.get(&which)
|
||||
.map(|p| p.touchpads_count() >= 2)
|
||||
.open
|
||||
.as_ref()
|
||||
.filter(|(id, _)| *id == which)
|
||||
.map(|(_, p)| p.touchpads_count() >= 2)
|
||||
.unwrap_or(false);
|
||||
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
|
||||
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
|
||||
@@ -503,7 +838,6 @@ impl Worker<'_> {
|
||||
list.reverse(); // most recent first — the Settings list order
|
||||
*self.pads_out.lock().unwrap() = list;
|
||||
*self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id));
|
||||
*self.pinned_out.lock().unwrap() = self.pinned;
|
||||
}
|
||||
|
||||
/// Apply queued control-plane messages from the UI thread. Returns false when the
|
||||
@@ -515,23 +849,50 @@ impl Worker<'_> {
|
||||
self.attached = Some(c);
|
||||
self.last_axis = [i32::MIN; 6];
|
||||
self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
|
||||
self.set_sensors(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 follows the churn events.
|
||||
set_valve_hidapi(true);
|
||||
self.sync_open();
|
||||
}
|
||||
Ok(Ctl::Detach) => {
|
||||
self.flush_held();
|
||||
self.set_sensors(false);
|
||||
self.attached = None;
|
||||
self.sync_open(); // closes the held device (menu mode keeps it)
|
||||
set_valve_hidapi(false);
|
||||
if self.menu_mode {
|
||||
// Back to the launcher: adopt whatever is still physically held
|
||||
// (the escape chord that ended the session, a lingering B) so it
|
||||
// can't ghost-fire menu actions.
|
||||
self.menu_nav.reset();
|
||||
}
|
||||
}
|
||||
Ok(Ctl::Pin(id)) => {
|
||||
Ok(Ctl::Pin(key)) => {
|
||||
let before = self.active_id();
|
||||
self.pinned = id;
|
||||
if self.active_id() != before {
|
||||
self.flush_held();
|
||||
if self.attached.is_some() {
|
||||
self.set_sensors(true);
|
||||
self.pinned = key;
|
||||
self.refresh_active(before);
|
||||
}
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
self.menu_nav.reset();
|
||||
}
|
||||
self.sync_open();
|
||||
}
|
||||
Ok(Ctl::MenuRumble(pulse)) => {
|
||||
if self.attached.is_none() {
|
||||
if let Some((_, pad)) = self.open.as_mut() {
|
||||
let (low, high, ms) = match pulse {
|
||||
// Light high-freq detent — won't jackhammer at repeat rate.
|
||||
MenuPulse::Move => (0, 0x3000, 25),
|
||||
// Fuller both-motor thunk.
|
||||
MenuPulse::Confirm => (0x5000, 0x5000, 60),
|
||||
// Dull low-freq wall.
|
||||
MenuPulse::Boundary => (0x6000, 0, 60),
|
||||
};
|
||||
let _ = pad.set_rumble(low, high, ms);
|
||||
}
|
||||
}
|
||||
self.publish();
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return true,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone
|
||||
@@ -546,35 +907,31 @@ impl Worker<'_> {
|
||||
let active = self.active_id();
|
||||
match event {
|
||||
Event::ControllerDeviceAdded { which, .. } => {
|
||||
if !self.opened.contains_key(&which) {
|
||||
match self
|
||||
.subsystem
|
||||
.open(sdl3::sys::joystick::SDL_JoystickID(which))
|
||||
{
|
||||
Ok(pad) => {
|
||||
tracing::info!(
|
||||
name = pad.name().unwrap_or_default(),
|
||||
"gamepad attached"
|
||||
);
|
||||
self.opened.insert(which, pad);
|
||||
self.order.push(which);
|
||||
if self.attached.is_some() && self.active_id() == Some(which) {
|
||||
self.set_sensors(true);
|
||||
}
|
||||
self.publish();
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
|
||||
if !self.order.contains(&which) {
|
||||
self.order.push(which);
|
||||
if let Some(p) = self.pad_info(which) {
|
||||
// Full identity: on a Steam Deck this is the one lever for diagnosing an
|
||||
// empty controller list — it tells you whether SDL sees the physical pad
|
||||
// (28DE:1205), Steam Input's virtual pad (28DE:11FF), both, or nothing.
|
||||
tracing::info!(
|
||||
name = p.name,
|
||||
key = p.key,
|
||||
pref = ?p.pref,
|
||||
steam_virtual = p.steam_virtual,
|
||||
"gamepad attached"
|
||||
);
|
||||
}
|
||||
self.refresh_active(active);
|
||||
}
|
||||
}
|
||||
Event::ControllerDeviceRemoved { which, .. } => {
|
||||
if self.opened.remove(&which).is_some() {
|
||||
if self.order.contains(&which) {
|
||||
self.order.retain(|&id| id != which);
|
||||
if active == Some(which) {
|
||||
self.flush_held();
|
||||
if self.open.as_ref().map(|(id, _)| *id) == Some(which) {
|
||||
self.open = None; // the device is gone; drop our handle
|
||||
}
|
||||
tracing::info!("gamepad detached");
|
||||
self.publish();
|
||||
self.refresh_active(active);
|
||||
}
|
||||
}
|
||||
Event::ControllerButtonDown { which, button, .. } if active == Some(which) => {
|
||||
@@ -677,6 +1034,42 @@ impl Worker<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample the open pad and translate it into [`MenuEvent`]s — only while menu mode is
|
||||
/// on and no session is attached (attach supersedes; SDL events merely wake the loop,
|
||||
/// so a press is translated the iteration it arrives).
|
||||
fn menu_poll(&mut self) {
|
||||
if !self.menu_mode || self.attached.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some((_, pad)) = self.open.as_ref() else {
|
||||
return;
|
||||
};
|
||||
use sdl3::gamepad::{Axis, Button};
|
||||
let s = MenuSample {
|
||||
buttons: [
|
||||
pad.button(Button::South),
|
||||
pad.button(Button::East),
|
||||
pad.button(Button::West),
|
||||
pad.button(Button::North),
|
||||
pad.button(Button::LeftShoulder),
|
||||
pad.button(Button::RightShoulder),
|
||||
],
|
||||
lx: pad.axis(Axis::LeftX),
|
||||
ly: pad.axis(Axis::LeftY),
|
||||
dpad: [
|
||||
pad.button(Button::DPadUp),
|
||||
pad.button(Button::DPadDown),
|
||||
pad.button(Button::DPadLeft),
|
||||
pad.button(Button::DPadRight),
|
||||
],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
self.menu_nav.poll(&s, Instant::now(), &mut out);
|
||||
for e in out {
|
||||
let _ = self.menu_tx.try_send(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
|
||||
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single
|
||||
/// consumer. The host re-sends rumble state periodically, so a generous duration with
|
||||
@@ -687,7 +1080,7 @@ impl Worker<'_> {
|
||||
};
|
||||
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
||||
if pad == 0 {
|
||||
if let Some(p) = self.active_id().and_then(|id| self.opened.get_mut(&id)) {
|
||||
if let Some((_, p)) = self.open.as_mut() {
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
|
||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
||||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||
@@ -703,9 +1096,12 @@ impl Worker<'_> {
|
||||
}
|
||||
}
|
||||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||
let Some(id) = self.active_id() else { continue };
|
||||
let is_ds = self.pad_info(id).is_some_and(|p| p.is_dualsense());
|
||||
let Some(pad) = self.opened.get_mut(&id) else {
|
||||
let is_ds = self
|
||||
.open
|
||||
.as_ref()
|
||||
.and_then(|(id, _)| self.pad_info(*id))
|
||||
.is_some_and(|p| p.is_dualsense());
|
||||
let Some((_, pad)) = self.open.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
match hid {
|
||||
@@ -734,21 +1130,19 @@ impl Worker<'_> {
|
||||
fn run(
|
||||
pads_out: &Mutex<Vec<PadInfo>>,
|
||||
active_out: &Mutex<Option<PadInfo>>,
|
||||
pinned_out: &Mutex<Option<u32>>,
|
||||
ctl: &Receiver<Ctl>,
|
||||
escape_tx: &async_channel::Sender<()>,
|
||||
disconnect_tx: &async_channel::Sender<()>,
|
||||
menu_tx: &async_channel::Sender<MenuEvent>,
|
||||
) -> Result<(), String> {
|
||||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
||||
// own thread.
|
||||
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the
|
||||
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs. On a Deck in Game
|
||||
// Mode, Steam Input still holds the device — the user must disable Steam Input for this app (see
|
||||
// the Decky UX); on a desktop client (or a Deck with Steam Input off) the hints just work.
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
|
||||
// The Valve HIDAPI drivers start DISABLED (SDL defaults the Deck one ON, and its mere
|
||||
// enumeration kills the Deck's trackpad-mouse system-wide — see set_valve_hidapi);
|
||||
// they are enabled for the duration of an attached session only.
|
||||
set_valve_hidapi(false);
|
||||
let sdl = sdl3::init().map_err(|e| e.to_string())?;
|
||||
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
||||
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||
@@ -757,8 +1151,7 @@ fn run(
|
||||
subsystem,
|
||||
pads_out,
|
||||
active_out,
|
||||
pinned_out,
|
||||
opened: HashMap::new(),
|
||||
open: None,
|
||||
order: Vec::new(),
|
||||
pinned: None,
|
||||
attached: None,
|
||||
@@ -771,6 +1164,9 @@ fn run(
|
||||
chord_armed: false,
|
||||
chord_since: None,
|
||||
disconnect_fired: false,
|
||||
menu_mode: false,
|
||||
menu_nav: MenuNav::new(),
|
||||
menu_tx: menu_tx.clone(),
|
||||
};
|
||||
|
||||
loop {
|
||||
@@ -785,8 +1181,13 @@ fn run(
|
||||
// rumble/HID feedback, and the escape-chord hold check all run once per wakeup,
|
||||
// so their worst case is one timeout (~10 ms attached, imperceptible for
|
||||
// haptics; DISCONNECT_HOLD is 1500 ms, so 10 ms hold-check granularity is far
|
||||
// inside tolerance). Idle (no session) wakes lazily at 30 ms for hotplug + ctl.
|
||||
let timeout = Duration::from_millis(if w.attached.is_some() { 10 } else { 30 });
|
||||
// inside tolerance; menu mode needs the same cadence for its repeat timing).
|
||||
// Idle (no session, no menu) wakes lazily at 30 ms for hotplug + ctl.
|
||||
let timeout = Duration::from_millis(if w.attached.is_some() || w.menu_mode {
|
||||
10
|
||||
} else {
|
||||
30
|
||||
});
|
||||
if let Some(event) = pump.wait_event_timeout(timeout) {
|
||||
w.handle_event(event);
|
||||
// Drain whatever else queued while we were waiting or handling.
|
||||
@@ -799,6 +1200,169 @@ fn run(
|
||||
// new button events; the chord itself is only detected while a session is attached).
|
||||
w.maybe_fire_disconnect();
|
||||
|
||||
w.menu_poll();
|
||||
w.render_feedback();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod menu_nav_tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> MenuSample {
|
||||
MenuSample::default()
|
||||
}
|
||||
|
||||
fn events(nav: &mut MenuNav, s: &MenuSample, at: Instant) -> Vec<MenuEvent> {
|
||||
let mut out = Vec::new();
|
||||
nav.poll(s, at, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_adopts_held_state_without_firing() {
|
||||
let mut nav = MenuNav::new();
|
||||
let t = Instant::now();
|
||||
let mut held = sample();
|
||||
held.buttons[0] = true; // A held on entry
|
||||
held.lx = 30000; // stick already deflected right
|
||||
assert!(events(&mut nav, &held, t).is_empty(), "snapshot poll fired");
|
||||
// Still held: nothing (no rising edge, direction unchanged since snapshot).
|
||||
assert!(events(&mut nav, &held, t + Duration::from_millis(10)).is_empty());
|
||||
// Release, then press again → now it fires.
|
||||
assert!(events(&mut nav, &sample(), t + Duration::from_millis(20)).is_empty());
|
||||
assert_eq!(
|
||||
events(&mut nav, &held, t + Duration::from_millis(30)),
|
||||
vec![MenuEvent::Confirm, MenuEvent::Move(MenuDir::Right)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buttons_fire_on_rising_edge_only() {
|
||||
let mut nav = MenuNav::new();
|
||||
let t = Instant::now();
|
||||
events(&mut nav, &sample(), t); // consume the snapshot
|
||||
let mut s = sample();
|
||||
s.buttons[1] = true; // B down
|
||||
assert_eq!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(10)),
|
||||
vec![MenuEvent::Back]
|
||||
);
|
||||
for i in 2..20 {
|
||||
assert!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(10 * i)).is_empty(),
|
||||
"held button re-fired"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_rearms_the_snapshot() {
|
||||
let mut nav = MenuNav::new();
|
||||
let t = Instant::now();
|
||||
events(&mut nav, &sample(), t);
|
||||
nav.reset();
|
||||
let mut s = sample();
|
||||
s.buttons[1] = true;
|
||||
assert!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(10)).is_empty(),
|
||||
"post-reset poll fired a held button"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_repeats_after_delay_at_interval() {
|
||||
let mut nav = MenuNav::new();
|
||||
let t = Instant::now();
|
||||
events(&mut nav, &sample(), t);
|
||||
let mut s = sample();
|
||||
s.dpad[3] = true; // dpad right
|
||||
// Engage: fires immediately.
|
||||
assert_eq!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(10)),
|
||||
vec![MenuEvent::Move(MenuDir::Right)]
|
||||
);
|
||||
// Inside the initial delay: silent.
|
||||
assert!(events(&mut nav, &s, t + Duration::from_millis(300)).is_empty());
|
||||
// Past the delay: repeats…
|
||||
assert_eq!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(400)),
|
||||
vec![MenuEvent::Move(MenuDir::Right)]
|
||||
);
|
||||
// …but not faster than the interval…
|
||||
assert!(events(&mut nav, &s, t + Duration::from_millis(500)).is_empty());
|
||||
// …and again once it elapses.
|
||||
assert_eq!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(570)),
|
||||
vec![MenuEvent::Move(MenuDir::Right)]
|
||||
);
|
||||
// Release cancels; re-engage fires immediately again.
|
||||
assert!(events(&mut nav, &sample(), t + Duration::from_millis(580)).is_empty());
|
||||
assert_eq!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(590)),
|
||||
vec![MenuEvent::Move(MenuDir::Right)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_change_fires_immediately() {
|
||||
let mut nav = MenuNav::new();
|
||||
let t = Instant::now();
|
||||
events(&mut nav, &sample(), t);
|
||||
let mut right = sample();
|
||||
right.lx = 30000;
|
||||
let mut left = sample();
|
||||
left.lx = -30000;
|
||||
assert_eq!(
|
||||
events(&mut nav, &right, t + Duration::from_millis(10)),
|
||||
vec![MenuEvent::Move(MenuDir::Right)]
|
||||
);
|
||||
assert_eq!(
|
||||
events(&mut nav, &left, t + Duration::from_millis(20)),
|
||||
vec![MenuEvent::Move(MenuDir::Left)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_resolution() {
|
||||
// Below the deadzone: nothing.
|
||||
let mut s = sample();
|
||||
s.lx = MENU_DEADZONE as i16;
|
||||
assert_eq!(MenuNav::resolve_dir(&s), None);
|
||||
// Dominant axis wins; SDL +y = down.
|
||||
s.lx = 20000;
|
||||
s.ly = 25000;
|
||||
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Down));
|
||||
s.ly = -25000;
|
||||
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Up));
|
||||
s.lx = 26000;
|
||||
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Right));
|
||||
s.lx = -26000;
|
||||
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Left));
|
||||
// Dpad fallback…
|
||||
let mut d = sample();
|
||||
d.dpad[1] = true;
|
||||
assert_eq!(MenuNav::resolve_dir(&d), Some(MenuDir::Down));
|
||||
// …but the stick overrides it.
|
||||
d.lx = 30000;
|
||||
assert_eq!(MenuNav::resolve_dir(&d), Some(MenuDir::Right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shoulder_and_face_button_mapping() {
|
||||
let mut nav = MenuNav::new();
|
||||
let t = Instant::now();
|
||||
events(&mut nav, &sample(), t);
|
||||
let mut s = sample();
|
||||
s.buttons = [false, false, true, true, true, true]; // x, y, l1, r1
|
||||
assert_eq!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(10)),
|
||||
vec![
|
||||
MenuEvent::Tertiary,
|
||||
MenuEvent::Secondary,
|
||||
MenuEvent::JumpBack,
|
||||
MenuEvent::JumpForward,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,13 +265,19 @@ impl SessionUi {
|
||||
stop: self.stop.clone(),
|
||||
inhibit_shortcuts: self.inhibit,
|
||||
show_stats: self.show_stats,
|
||||
chromeless: self.app.fullscreen,
|
||||
// The attach just went out, so a Deck's built-in pad may not have enumerated
|
||||
// yet — chromeless (controller-first) shows the chord hint regardless.
|
||||
pad_connected: self.app.gamepad.active().is_some(),
|
||||
title,
|
||||
});
|
||||
self.app.nav.push(&p.page);
|
||||
// Steam Deck / Gaming Mode: gamescope fullscreens the window but GTK doesn't
|
||||
// know it, so its header bar stays drawn. Enter GTK fullscreen explicitly —
|
||||
// the stream page's `connect_fullscreened_notify` then hides all chrome.
|
||||
if self.app.fullscreen {
|
||||
// Streams start fullscreen by default (Settings toggle) — a streaming window with
|
||||
// chrome is never what anyone wants mid-game; F11 / the controller chord / the
|
||||
// top-edge header reveal lead back out. Gaming-Mode launches (`--fullscreen`)
|
||||
// fullscreen regardless: gamescope fullscreens the window at its level but GTK
|
||||
// doesn't know it, so the header bar would stay drawn.
|
||||
if self.app.fullscreen || self.app.settings.borrow().fullscreen_on_stream {
|
||||
self.app.window.fullscreen();
|
||||
}
|
||||
self.page = Some(p);
|
||||
@@ -293,21 +299,49 @@ impl SessionUi {
|
||||
}
|
||||
// A pinned connect rejected on trust grounds means the host's cert no
|
||||
// longer matches the stored pin (rotated cert or impostor) — route to
|
||||
// the PIN ceremony to re-establish trust rather than dead-ending.
|
||||
if trust_rejected && !self.tofu {
|
||||
// the PIN ceremony to re-establish trust rather than dead-ending. Browse
|
||||
// mode can't: gamescope never maps dialogs, so it renders the advice instead
|
||||
// (re-pairing is the plugin's job there).
|
||||
if trust_rejected && !self.tofu && self.app.browse_ui().is_none() {
|
||||
self.app
|
||||
.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||
crate::ui_trust::pin_dialog(self.app.clone(), self.req.clone());
|
||||
} else if trust_rejected && !self.tofu {
|
||||
self.app
|
||||
.connect_error("Host identity changed — re-pair from the Punktfunk plugin.");
|
||||
} else {
|
||||
// Errors land on the hosts page banner, not a transient toast.
|
||||
// Errors land on the hosts page banner / launcher strip, not a transient toast.
|
||||
self.app.connect_error(&format!("Couldn't connect — {msg}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ended`: detach gamepads, pop back to the hosts page, and surface the reason.
|
||||
/// `Ended`: detach gamepads, pop back to the launcher (browse mode) or the hosts
|
||||
/// page, and surface the reason.
|
||||
fn on_ended(&mut self, err: Option<String>) {
|
||||
self.close_waiting();
|
||||
self.app.gamepad.detach();
|
||||
// Gaming-Mode `--connect` launch: the app IS the stream. Quit so Steam ends the
|
||||
// "game" and the Deck returns to Gaming Mode — popping to our own hosts page would
|
||||
// strand the user in a fullscreen shell with no way back.
|
||||
if self.app.quit_on_session_end {
|
||||
if let Some(e) = err {
|
||||
tracing::warn!(error = %e, "session ended");
|
||||
}
|
||||
self.app.window.close();
|
||||
return;
|
||||
}
|
||||
// Browse mode: back to the launcher to pick the next game — B there quits to
|
||||
// Gaming Mode. (The gamepad worker re-opened the pad and armed the held-state
|
||||
// snapshot on the detach above, so the chord that ended the session fires nothing.)
|
||||
if let Some(l) = self.app.browse_ui() {
|
||||
self.app.nav.pop_to_tag("launcher");
|
||||
l.on_session_ended();
|
||||
if let Some(e) = err {
|
||||
self.app.connect_error(&e);
|
||||
}
|
||||
self.app.busy.set(false);
|
||||
return;
|
||||
}
|
||||
self.app.nav.pop_to_tag("hosts");
|
||||
if let Some(h) = self.app.hosts_ui() {
|
||||
h.set_connecting(None);
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
//! verified by its pinned SHA-256 fingerprint (`KnownHost::fp_hex`), not a CA chain.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The management API's default port — matches `mgmt::DEFAULT_PORT` on the host. A
|
||||
@@ -181,6 +182,57 @@ pub fn fetch_art(pinned: &ureq::Agent, base: &str, url: &str) -> Result<Vec<u8>,
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Concurrent poster fetches — a handful is plenty for a LAN art proxy without turning a
|
||||
/// big library into a connection burst.
|
||||
const ART_WORKERS: usize = 3;
|
||||
|
||||
/// Fetch poster bytes for `jobs` (entry id → candidate URLs, walked in order until one
|
||||
/// loads) on a small worker pool; results stream on the returned channel as they land.
|
||||
/// Dropping the receiver (the consuming page popped) winds the workers down. Shared by
|
||||
/// the touch grid and the gamepad launcher — the consumer does its own texture decode on
|
||||
/// the main loop.
|
||||
pub fn spawn_art_fetch(
|
||||
base: String,
|
||||
identity: (String, String),
|
||||
pin: Option<[u8; 32]>,
|
||||
jobs: VecDeque<(String, Vec<String>)>,
|
||||
) -> async_channel::Receiver<(String, Vec<u8>)> {
|
||||
let queue = Arc::new(Mutex::new(jobs));
|
||||
let (tx, rx) = async_channel::unbounded::<(String, Vec<u8>)>();
|
||||
for _ in 0..ART_WORKERS {
|
||||
let queue = queue.clone();
|
||||
let tx = tx.clone();
|
||||
let base = base.clone();
|
||||
let identity = identity.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-lib-art".into())
|
||||
.spawn(move || {
|
||||
let Ok(agent) = agent(&identity, pin) else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
let job = queue.lock().unwrap().pop_front();
|
||||
let Some((id, candidates)) = job else { break };
|
||||
for url in &candidates {
|
||||
match fetch_art(&agent, &base, url) {
|
||||
Ok(bytes) => {
|
||||
// Receiver gone (page popped) — stop fetching.
|
||||
if tx.send_blocking((id, bytes)).is_err() {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// 404 on a guessed CDN path is routine — try the next kind.
|
||||
Err(e) => tracing::debug!(%id, url, error = %e, "poster miss"),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn art thread");
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
fn classify(e: ureq::Error) -> LibraryError {
|
||||
match e {
|
||||
ureq::Error::Status(401 | 403, _) => LibraryError::NotPaired,
|
||||
|
||||
@@ -26,6 +26,8 @@ mod session;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod trust;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_gamepad_library;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_hosts;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_library;
|
||||
|
||||
+125
-21
@@ -45,18 +45,55 @@ pub struct SessionParams {
|
||||
pub connect_timeout: Duration,
|
||||
}
|
||||
|
||||
/// The session pump's share of the unified stats window (design/stats-unification.md):
|
||||
/// stream facts plus the two stages measured before the presenter. The frame consumer in
|
||||
/// `ui_stream` contributes the `display` stage and the end-to-end percentiles.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Stats {
|
||||
/// AUs received (reassembled) per second, actual-elapsed-time denominator.
|
||||
pub fps: f32,
|
||||
/// Received payload bytes × 8 / elapsed (goodput, excludes FEC overhead).
|
||||
pub mbps: f32,
|
||||
/// p50 `host+network` stage: capture → received, host-clock corrected (ms).
|
||||
pub host_net_ms: f32,
|
||||
/// p50 `host` stage: the host's own capture→fully-sent, from the per-AU 0xCF host
|
||||
/// timings (design/stats-unification.md Phase 2). Valid only when `split`.
|
||||
pub host_ms: f32,
|
||||
/// p50 `network` stage: capture→received minus the host-reported share
|
||||
/// (`hostnet − host`, per-frame, saturating). Valid only when `split`.
|
||||
pub net_ms: f32,
|
||||
/// The window had matched host timings — the OSD splits `host+network` into
|
||||
/// `host + network`. An old host never emits 0xCF, so this stays false and the
|
||||
/// combined stage renders unchanged.
|
||||
pub split: bool,
|
||||
/// p50 `decode` stage: received → decoded, single-clock client-local (ms).
|
||||
pub decode_ms: f32,
|
||||
/// Median capture→decoded latency over the last window (host-clock corrected).
|
||||
pub latency_ms: f32,
|
||||
/// Unrecoverable network frame drops this window, and their share of
|
||||
/// received+lost (%). The OSD renders the counter line only when nonzero.
|
||||
pub lost: u32,
|
||||
pub lost_pct: f32,
|
||||
/// The decode path frames actually took this window (`"vaapi"`/`"software"`, empty
|
||||
/// until the first frame) — the OSD's trailing tag; tracks a mid-session fallback.
|
||||
pub decoder: &'static str,
|
||||
}
|
||||
|
||||
/// Frames the pump keeps waiting for their 0xCF host timing (pts → capture→received µs).
|
||||
/// ~2 s at 120 Hz — a timing arrives within a frame or two of its AU, and against an old
|
||||
/// host (no 0xCF at all) this just caps the dead-weight ring.
|
||||
const PENDING_SPLIT_CAP: usize = 256;
|
||||
|
||||
/// Sort a window of µs samples in place and return `(p50, p95)` per the spec's index
|
||||
/// rules (`sorted[len/2]`, `sorted[min(len*95/100, len-1)]`); an empty window reads 0.
|
||||
pub fn window_percentiles(samples: &mut [u64]) -> (u64, u64) {
|
||||
if samples.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
samples.sort_unstable();
|
||||
let p50 = samples[samples.len() / 2];
|
||||
let p95 = samples[(samples.len() * 95 / 100).min(samples.len() - 1)];
|
||||
(p50, p95)
|
||||
}
|
||||
|
||||
pub enum SessionEvent {
|
||||
Connected {
|
||||
connector: Arc<NativeClient>,
|
||||
@@ -219,13 +256,23 @@ fn pump(
|
||||
let mut window_start = Instant::now();
|
||||
let mut frames_n = 0u32;
|
||||
let mut bytes_n = 0u64;
|
||||
let mut decode_us_sum = 0u64;
|
||||
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
|
||||
// Stage windows (µs samples): `host+network` = capture→received (host-clock
|
||||
// corrected), `decode` = received→decoded (client-local). p50 per 1 s window.
|
||||
let mut hostnet_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut decode_us: Vec<u64> = Vec::with_capacity(256);
|
||||
// Host/network split (Phase 2): frames awaiting their per-AU 0xCF host timing,
|
||||
// correlated by pts_ns. Bounded — an old host never sends any, so entries just age out.
|
||||
let mut pending_split: std::collections::VecDeque<(u64, u64)> =
|
||||
std::collections::VecDeque::with_capacity(PENDING_SPLIT_CAP);
|
||||
let mut host_us_win: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut net_us_win: Vec<u64> = Vec::with_capacity(256);
|
||||
// What actually decoded the last frame — a VAAPI failure demotes mid-session, so
|
||||
// this is read off each frame's image variant rather than fixed at startup.
|
||||
let mut dec_path: &'static str = "";
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
||||
let mut last_dropped = connector.frames_dropped();
|
||||
// The stats window keeps its own drop cursor — the OSD shows the per-window delta.
|
||||
let mut window_dropped = last_dropped;
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
|
||||
let end: Option<String> = loop {
|
||||
@@ -237,7 +284,11 @@ fn pump(
|
||||
// every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream).
|
||||
match connector.next_frame(Duration::from_millis(20)) {
|
||||
Ok(frame) => {
|
||||
let t0 = Instant::now();
|
||||
// The `received` point: AU fully reassembled, in hand, before decode.
|
||||
let received_ns = now_ns();
|
||||
// fps / goodput count every received AU (spec), decoded or not.
|
||||
frames_n += 1;
|
||||
bytes_n += frame.data.len() as u64;
|
||||
match decoder.decode(&frame.data) {
|
||||
Ok(Some(image)) => {
|
||||
total_frames += 1;
|
||||
@@ -252,18 +303,27 @@ fn pump(
|
||||
};
|
||||
tracing::info!(width = w, height = h, path, "first frame decoded");
|
||||
}
|
||||
// Latency: our wall clock expressed in the host's capture clock,
|
||||
// minus the host-stamped capture pts (same math as client-rs).
|
||||
let lat = (now_ns() as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
||||
// The `decoded` point — travels with the frame so the presenter
|
||||
// can measure its `display` stage against it.
|
||||
let decoded_ns = now_ns();
|
||||
// `host+network` stage: received expressed in the host's capture
|
||||
// clock, minus the host-stamped capture pts (clamped (0, 10 s)).
|
||||
let hn = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if lat > 0 && lat < 10_000_000_000 {
|
||||
lat_us.push(lat / 1000);
|
||||
if hn > 0 && hn < 10_000_000_000 {
|
||||
hostnet_us.push(hn / 1000);
|
||||
// Remember the sample for the host/network split — matched
|
||||
// against the AU's 0xCF host timing when it arrives.
|
||||
if pending_split.len() >= PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front();
|
||||
}
|
||||
pending_split.push_back((frame.pts_ns, hn / 1000));
|
||||
}
|
||||
decode_us_sum += t0.elapsed().as_micros() as u64;
|
||||
frames_n += 1;
|
||||
bytes_n += frame.data.len() as u64;
|
||||
// `decode` stage: received→decoded, single clock, no skew.
|
||||
decode_us.push(decoded_ns.saturating_sub(received_ns) / 1000);
|
||||
let _ = frame_tx.force_send(DecodedFrame {
|
||||
pts_ns: frame.pts_ns,
|
||||
decoded_ns,
|
||||
image,
|
||||
});
|
||||
}
|
||||
@@ -271,12 +331,39 @@ fn pump(
|
||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
||||
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
|
||||
}
|
||||
// A decode error / VAAPI→software demotion asks for a fresh IDR: the infinite
|
||||
// GOP has no periodic keyframe, so a rebuilt/erroring decoder would stay
|
||||
// gray/frozen until an unrelated packet drop happened to request one. Route it
|
||||
// through the same throttle as loss recovery below.
|
||||
if decoder.take_keyframe_request() {
|
||||
let now = Instant::now();
|
||||
if last_kf_req
|
||||
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
let _ = connector.request_keyframe();
|
||||
tracing::debug!("requested keyframe (decoder recovery)");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(PunktfunkError::NoFrame) => {}
|
||||
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
|
||||
Err(e) => break Some(format!("session: {e:?}")),
|
||||
}
|
||||
|
||||
// Drain the per-AU host timings (0xCF) non-blockingly and match them to received
|
||||
// frames by pts: host = the host's own capture→sent, network = our
|
||||
// capture→received minus it (the two tile per frame by construction). An old
|
||||
// host never emits any — the deque fills to its cap and the OSD keeps the
|
||||
// combined `host+network` stage.
|
||||
while let Ok(t) = connector.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|(p, _)| *p == t.pts_ns) {
|
||||
let (_, hn_us) = pending_split.remove(i).unwrap();
|
||||
host_us_win.push(t.host_us as u64);
|
||||
net_us_win.push(hn_us.saturating_sub(t.host_us as u64));
|
||||
}
|
||||
}
|
||||
|
||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
||||
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
|
||||
// reference-missing delta frames that follow and returns Ok, so keying off a decode error
|
||||
@@ -295,30 +382,47 @@ fn pump(
|
||||
|
||||
if window_start.elapsed() >= Duration::from_secs(1) {
|
||||
let secs = window_start.elapsed().as_secs_f32();
|
||||
lat_us.sort_unstable();
|
||||
let p50 = lat_us.get(lat_us.len() / 2).copied().unwrap_or(0);
|
||||
let (hn_p50, _) = window_percentiles(&mut hostnet_us);
|
||||
let (dec_p50, _) = window_percentiles(&mut decode_us);
|
||||
// Host/network split — present only when this window matched 0xCF timings.
|
||||
let split = !host_us_win.is_empty();
|
||||
let (host_p50, _) = window_percentiles(&mut host_us_win);
|
||||
let (net_p50, _) = window_percentiles(&mut net_us_win);
|
||||
let lost = dropped.saturating_sub(window_dropped) as u32;
|
||||
window_dropped = dropped;
|
||||
tracing::debug!(
|
||||
fps = frames_n,
|
||||
lat_p50_us = p50,
|
||||
hostnet_p50_us = hn_p50,
|
||||
host_p50_us = host_p50,
|
||||
net_p50_us = net_p50,
|
||||
decode_p50_us = dec_p50,
|
||||
lost,
|
||||
total_frames,
|
||||
"stream window"
|
||||
);
|
||||
let _ = ev_tx.try_send(SessionEvent::Stats(Stats {
|
||||
fps: frames_n as f32 / secs,
|
||||
mbps: bytes_n as f32 * 8.0 / 1e6 / secs,
|
||||
decode_ms: if frames_n > 0 {
|
||||
decode_us_sum as f32 / frames_n as f32 / 1000.0
|
||||
host_net_ms: hn_p50 as f32 / 1000.0,
|
||||
host_ms: host_p50 as f32 / 1000.0,
|
||||
net_ms: net_p50 as f32 / 1000.0,
|
||||
split,
|
||||
decode_ms: dec_p50 as f32 / 1000.0,
|
||||
lost,
|
||||
lost_pct: if lost > 0 {
|
||||
lost as f32 * 100.0 / (frames_n + lost) as f32
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
latency_ms: p50 as f32 / 1000.0,
|
||||
decoder: dec_path,
|
||||
}));
|
||||
window_start = Instant::now();
|
||||
frames_n = 0;
|
||||
bytes_n = 0;
|
||||
decode_us_sum = 0;
|
||||
lat_us.clear();
|
||||
hostnet_us.clear();
|
||||
decode_us.clear();
|
||||
host_us_win.clear();
|
||||
net_us_win.clear();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -182,6 +182,10 @@ pub struct Settings {
|
||||
/// Requested encoder bitrate (kbps); 0 = host default.
|
||||
pub bitrate_kbps: u32,
|
||||
pub gamepad: String,
|
||||
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
|
||||
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
|
||||
/// gamepad service at startup so the choice survives restarts.
|
||||
pub forward_pad: String,
|
||||
/// Which host compositor backend to request (advisory; the host falls back to
|
||||
/// auto-detect when unavailable).
|
||||
pub compositor: String,
|
||||
@@ -201,6 +205,9 @@ pub struct Settings {
|
||||
pub decoder: String,
|
||||
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
|
||||
pub show_stats: bool,
|
||||
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
|
||||
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
|
||||
pub fullscreen_on_stream: bool,
|
||||
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||
pub library_enabled: bool,
|
||||
@@ -230,6 +237,7 @@ impl Default for Settings {
|
||||
refresh_hz: 0,
|
||||
bitrate_kbps: 0,
|
||||
gamepad: "auto".into(),
|
||||
forward_pad: String::new(),
|
||||
compositor: "auto".into(),
|
||||
inhibit_shortcuts: true,
|
||||
mic_enabled: false,
|
||||
@@ -237,6 +245,7 @@ impl Default for Settings {
|
||||
codec: "auto".into(),
|
||||
decoder: "auto".into(),
|
||||
show_stats: true,
|
||||
fullscreen_on_stream: true,
|
||||
library_enabled: false,
|
||||
}
|
||||
}
|
||||
@@ -263,3 +272,19 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
|
||||
#[test]
|
||||
fn settings_forward_pad_defaults_empty() {
|
||||
let old = r#"{"width":1280,"height":720,"refresh_hz":60,"bitrate_kbps":0,
|
||||
"gamepad":"auto","compositor":"auto","inhibit_shortcuts":true,"mic_enabled":true}"#;
|
||||
let s: Settings = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(s.forward_pad, "");
|
||||
let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(round.forward_pad, "");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -153,6 +153,15 @@ pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
|
||||
let disc_heading = heading("On this network");
|
||||
let disc_flow = make_flow();
|
||||
|
||||
// A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never
|
||||
// the child's own `activate` signal — so bridge it back to the child, where each card wires
|
||||
// its connect handler (`saved_card`/`discovered_card`). Without this, clicking a card is dead.
|
||||
for flow in [&saved_flow, &disc_flow] {
|
||||
flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
}
|
||||
|
||||
// Shown under the discovered heading while no (unsaved) advert is live yet.
|
||||
let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
let spinner = gtk::Spinner::new();
|
||||
|
||||
@@ -14,11 +14,6 @@ use gtk::{gdk, glib};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Concurrent poster fetches — a handful is plenty for a LAN art proxy without turning a
|
||||
/// big library into a connection burst.
|
||||
const ART_WORKERS: usize = 3;
|
||||
|
||||
/// Everything the page re-renders from. Kept alive by the widget closures (reload/retry/
|
||||
/// card activation); dropped when the page is popped, which also winds down any in-flight
|
||||
@@ -76,6 +71,11 @@ fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
|
||||
.row_spacing(18)
|
||||
.valign(gtk::Align::Start)
|
||||
.build();
|
||||
// Click/keyboard activation fires `child-activated` on the FlowBox, not the child's own
|
||||
// `activate` — bridge it so each poster's connect handler (below) runs on click.
|
||||
flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
content.set_margin_top(24);
|
||||
content.set_margin_bottom(24);
|
||||
@@ -295,39 +295,7 @@ fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
||||
}
|
||||
let identity = state.app.identity.clone();
|
||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let queue = Arc::new(Mutex::new(jobs));
|
||||
let (tx, rx) = async_channel::unbounded::<(String, Vec<u8>)>();
|
||||
for _ in 0..ART_WORKERS {
|
||||
let queue = queue.clone();
|
||||
let tx = tx.clone();
|
||||
let base = base.clone();
|
||||
let identity = identity.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-lib-art".into())
|
||||
.spawn(move || {
|
||||
let Ok(agent) = library::agent(&identity, pin) else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
let job = queue.lock().unwrap().pop_front();
|
||||
let Some((id, candidates)) = job else { break };
|
||||
for url in &candidates {
|
||||
match library::fetch_art(&agent, &base, url) {
|
||||
Ok(bytes) => {
|
||||
// Receiver gone (page popped) — stop fetching.
|
||||
if tx.send_blocking((id, bytes)).is_err() {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// 404 on a guessed CDN path is routine — try the next kind.
|
||||
Err(e) => tracing::debug!(%id, url, error = %e, "poster miss"),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn art thread");
|
||||
}
|
||||
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
|
||||
let weak = Rc::downgrade(state);
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok((id, bytes)) = rx.recv().await {
|
||||
@@ -349,7 +317,8 @@ fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
||||
|
||||
/// The store badge text — `store` comes from the entry (today `steam`/`custom`; future
|
||||
/// stores per the host's provider list), with the id prefix as a fallback spelling.
|
||||
fn store_label(store: &str) -> &'static str {
|
||||
/// Shared with the gamepad launcher's posters.
|
||||
pub fn store_label(store: &str) -> &'static str {
|
||||
match store {
|
||||
"steam" => "Steam",
|
||||
"custom" => "Custom",
|
||||
@@ -363,7 +332,8 @@ fn store_label(store: &str) -> &'static str {
|
||||
}
|
||||
|
||||
/// Monogram for the placeholder tile: the first letters of the first two words.
|
||||
fn initials(title: &str) -> String {
|
||||
/// Shared with the gamepad launcher's posters.
|
||||
pub fn initials(title: &str) -> String {
|
||||
title
|
||||
.split_whitespace()
|
||||
.take(2)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use crate::trust::Settings;
|
||||
use adw::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
|
||||
@@ -16,7 +16,14 @@ const RESOLUTIONS: &[(u32, u32)] = &[
|
||||
];
|
||||
/// `0` = the monitor's native refresh, resolved at connect.
|
||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"];
|
||||
const GAMEPADS: &[&str] = &[
|
||||
"auto",
|
||||
"xbox360",
|
||||
"dualsense",
|
||||
"xboxone",
|
||||
"dualshock4",
|
||||
"steamdeck",
|
||||
];
|
||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
/// Codec setting values (persisted) paired with their display labels below.
|
||||
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
||||
@@ -25,7 +32,7 @@ const DECODERS: &[&str] = &["auto", "vaapi", "software"];
|
||||
|
||||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||||
const APP_LICENSE: &str = concat!(
|
||||
"punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n",
|
||||
"Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n",
|
||||
"================================ MIT ================================\n\n",
|
||||
include_str!("../../../LICENSE-MIT"),
|
||||
"\n\n=============================== Apache-2.0 ===============================\n\n",
|
||||
@@ -39,7 +46,7 @@ const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt
|
||||
/// from the primary menu (app.rs `win.about`).
|
||||
pub fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||||
let about = adw::AboutDialog::builder()
|
||||
.application_name("punktfunk")
|
||||
.application_name("Punktfunk")
|
||||
.developer_name("unom")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.website("https://git.unom.io/unom/punktfunk")
|
||||
@@ -67,6 +74,179 @@ pub fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||||
about.present(Some(parent));
|
||||
}
|
||||
|
||||
/// True inside a gamescope session (Steam game mode on the Deck / Bazzite): GTK popovers
|
||||
/// are xdg_popups, which gamescope never maps for nested apps — a ComboRow's dropdown
|
||||
/// flashes the row but no list ever appears. Selection UI must stay inside the toplevel.
|
||||
fn gamescope_session() -> bool {
|
||||
std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope"))
|
||||
|| std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok()
|
||||
}
|
||||
|
||||
type ChangedFn = Rc<RefCell<Option<Box<dyn Fn(u32)>>>>;
|
||||
|
||||
/// A titled single-choice preference row. On a desktop this is a stock popover
|
||||
/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable
|
||||
/// row that pushes an in-window selection subpage onto the preferences dialog instead.
|
||||
struct ChoiceRow {
|
||||
row: adw::PreferencesRow,
|
||||
selected: Rc<Cell<u32>>,
|
||||
/// Fires on user changes only — [`connect_changed`](Self::connect_changed) is installed
|
||||
/// after seeding, so programmatic `set_selected` during setup never fires it.
|
||||
changed: ChangedFn,
|
||||
/// Subpage mode only: the current value rendered as the row's suffix.
|
||||
value_label: Option<gtk::Label>,
|
||||
options: Rc<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ChoiceRow {
|
||||
/// `inline` = subpage mode (gamescope): computed once per dialog via
|
||||
/// [`gamescope_session`] and passed in so tests can drive both modes directly.
|
||||
fn new(
|
||||
dialog: &adw::PreferencesDialog,
|
||||
inline: bool,
|
||||
title: &str,
|
||||
subtitle: &str,
|
||||
options: &[&str],
|
||||
) -> ChoiceRow {
|
||||
let options: Rc<Vec<String>> = Rc::new(options.iter().map(|s| s.to_string()).collect());
|
||||
let selected = Rc::new(Cell::new(0u32));
|
||||
let changed: ChangedFn = Rc::new(RefCell::new(None));
|
||||
|
||||
if !inline {
|
||||
let row = adw::ComboRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.model(>k::StringList::new(
|
||||
&options.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
))
|
||||
.build();
|
||||
let (sel, chg) = (selected.clone(), changed.clone());
|
||||
row.connect_selected_notify(move |r| {
|
||||
if sel.replace(r.selected()) != r.selected() {
|
||||
if let Some(f) = chg.borrow().as_ref() {
|
||||
f(r.selected());
|
||||
}
|
||||
}
|
||||
});
|
||||
return ChoiceRow {
|
||||
row: row.upcast(),
|
||||
selected,
|
||||
changed,
|
||||
value_label: None,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
let value = gtk::Label::builder().css_classes(["dim-label"]).build();
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.add_suffix(&value);
|
||||
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
{
|
||||
let dialog = dialog.downgrade();
|
||||
let (options, sel, chg, value) = (
|
||||
options.clone(),
|
||||
selected.clone(),
|
||||
changed.clone(),
|
||||
value.clone(),
|
||||
);
|
||||
let title = title.to_string();
|
||||
row.connect_activated(move |_| {
|
||||
let Some(dialog) = dialog.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(["boxed-list"])
|
||||
.build();
|
||||
for (i, opt) in options.iter().enumerate() {
|
||||
let check = gtk::Image::from_icon_name("object-select-symbolic");
|
||||
check.set_visible(i as u32 == sel.get());
|
||||
let opt_row = adw::ActionRow::builder()
|
||||
.title(opt)
|
||||
.use_markup(false)
|
||||
.activatable(true)
|
||||
.build();
|
||||
opt_row.add_suffix(&check);
|
||||
let idx = i as u32;
|
||||
let dlg = dialog.downgrade();
|
||||
let (sel, chg, value, label) =
|
||||
(sel.clone(), chg.clone(), value.clone(), opt.clone());
|
||||
opt_row.connect_activated(move |_| {
|
||||
let user_change = sel.replace(idx) != idx;
|
||||
value.set_text(&label);
|
||||
if user_change {
|
||||
if let Some(f) = chg.borrow().as_ref() {
|
||||
f(idx);
|
||||
}
|
||||
}
|
||||
if let Some(d) = dlg.upgrade() {
|
||||
d.pop_subpage();
|
||||
}
|
||||
});
|
||||
list.append(&opt_row);
|
||||
}
|
||||
let clamp = adw::Clamp::builder()
|
||||
.child(&list)
|
||||
.margin_top(24)
|
||||
.margin_bottom(24)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
let view = adw::ToolbarView::new();
|
||||
view.add_top_bar(&adw::HeaderBar::new());
|
||||
view.set_content(Some(&scroll));
|
||||
dialog.push_subpage(&adw::NavigationPage::new(&view, &title));
|
||||
});
|
||||
}
|
||||
let cr = ChoiceRow {
|
||||
row: row.upcast(),
|
||||
selected,
|
||||
changed,
|
||||
value_label: Some(value),
|
||||
options,
|
||||
};
|
||||
cr.sync_value();
|
||||
cr
|
||||
}
|
||||
|
||||
/// Subpage mode: reflect the current selection in the row's suffix label.
|
||||
fn sync_value(&self) {
|
||||
if let Some(l) = &self.value_label {
|
||||
let i = self.selected.get() as usize;
|
||||
l.set_text(self.options.get(i).map(String::as_str).unwrap_or(""));
|
||||
}
|
||||
}
|
||||
|
||||
fn widget(&self) -> &adw::PreferencesRow {
|
||||
&self.row
|
||||
}
|
||||
|
||||
fn selected(&self) -> u32 {
|
||||
self.selected.get()
|
||||
}
|
||||
|
||||
fn set_selected(&self, i: u32) {
|
||||
if let Some(combo) = self.row.downcast_ref::<adw::ComboRow>() {
|
||||
combo.set_selected(i); // the notify handler syncs the cell
|
||||
} else {
|
||||
self.selected.set(i);
|
||||
self.sync_value();
|
||||
}
|
||||
}
|
||||
|
||||
fn connect_changed(&self, f: impl Fn(u32) + 'static) {
|
||||
*self.changed.borrow_mut() = Some(Box::new(f));
|
||||
}
|
||||
}
|
||||
|
||||
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
|
||||
/// there so the experimental library toggle takes effect without a nav round-trip).
|
||||
pub fn show(
|
||||
@@ -75,6 +255,11 @@ pub fn show(
|
||||
gamepads: &crate::gamepad::GamepadService,
|
||||
on_closed: impl Fn() + 'static,
|
||||
) {
|
||||
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
|
||||
// subpage onto it.
|
||||
let dialog = adw::PreferencesDialog::new();
|
||||
dialog.set_title("Preferences");
|
||||
let inline = gamescope_session();
|
||||
let page = adw::PreferencesPage::new();
|
||||
|
||||
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
||||
@@ -88,13 +273,13 @@ pub fn show(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let res_row = adw::ComboRow::builder()
|
||||
.title("Resolution")
|
||||
.subtitle("The host creates a virtual output at exactly this size")
|
||||
.model(>k::StringList::new(
|
||||
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
))
|
||||
.build();
|
||||
let res_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Resolution",
|
||||
"The host creates a virtual output at exactly this size",
|
||||
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let hz_names: Vec<String> = REFRESH
|
||||
.iter()
|
||||
.map(|&r| {
|
||||
@@ -105,123 +290,154 @@ pub fn show(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let hz_row = adw::ComboRow::builder()
|
||||
.title("Refresh rate")
|
||||
.model(>k::StringList::new(
|
||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
))
|
||||
.build();
|
||||
let hz_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Refresh rate",
|
||||
"",
|
||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||
bitrate_row.set_title("Bitrate");
|
||||
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
||||
let compositor_row = adw::ComboRow::builder()
|
||||
.title("Host compositor")
|
||||
.subtitle("Advisory — the host falls back to auto-detect when unavailable")
|
||||
.model(>k::StringList::new(&[
|
||||
let compositor_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Host compositor",
|
||||
"Advisory — the host falls back to auto-detect when unavailable",
|
||||
&[
|
||||
"Automatic",
|
||||
"KWin",
|
||||
"wlroots (Sway/Hyprland)",
|
||||
"Mutter (GNOME)",
|
||||
"gamescope",
|
||||
]))
|
||||
.build();
|
||||
let decoder_row = adw::ComboRow::builder()
|
||||
.title("Video decoder")
|
||||
.subtitle("Automatic tries VAAPI hardware decode, then software")
|
||||
.model(>k::StringList::new(&[
|
||||
],
|
||||
);
|
||||
let decoder_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Video decoder",
|
||||
"Automatic tries VAAPI hardware decode, then software",
|
||||
&[
|
||||
"Automatic (VAAPI → software)",
|
||||
"Hardware (VAAPI)",
|
||||
"Software",
|
||||
]))
|
||||
.build();
|
||||
],
|
||||
);
|
||||
let stats_row = adw::SwitchRow::builder()
|
||||
.title("Show statistics overlay")
|
||||
.subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live")
|
||||
.build();
|
||||
stream.add(&res_row);
|
||||
stream.add(&hz_row);
|
||||
let fullscreen_row = adw::SwitchRow::builder()
|
||||
.title("Start streams in fullscreen")
|
||||
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
||||
.build();
|
||||
stream.add(res_row.widget());
|
||||
stream.add(hz_row.widget());
|
||||
stream.add(&bitrate_row);
|
||||
stream.add(&compositor_row);
|
||||
stream.add(&decoder_row);
|
||||
stream.add(compositor_row.widget());
|
||||
stream.add(decoder_row.widget());
|
||||
stream.add(&fullscreen_row);
|
||||
stream.add(&stats_row);
|
||||
|
||||
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||
// Which physical controller forwards as pad 0: automatic = the most recently
|
||||
// connected; pinning survives until the app exits (Swift parity).
|
||||
// Which physical controller forwards as pad 0: automatic = the most recently connected
|
||||
// real pad (Steam's virtual pad skipped). A 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.
|
||||
let pads = gamepads.pads();
|
||||
let saved_pin = settings.borrow().forward_pad.clone();
|
||||
let mut pad_names = vec!["Automatic (most recent)".to_string()];
|
||||
pad_names.extend(pads.iter().map(|p| {
|
||||
let mut pad_keys: Vec<String> = Vec::new();
|
||||
for p in &pads {
|
||||
let kind = p.kind_label();
|
||||
if kind.is_empty() {
|
||||
pad_names.push(if kind.is_empty() {
|
||||
p.name.clone()
|
||||
} else {
|
||||
format!("{} · {kind}", p.name)
|
||||
}
|
||||
}));
|
||||
let forward_row = adw::ComboRow::builder()
|
||||
.title("Forwarded controller")
|
||||
.subtitle(if pads.is_empty() {
|
||||
});
|
||||
pad_keys.push(p.key.clone());
|
||||
}
|
||||
if !saved_pin.is_empty() && !pad_keys.contains(&saved_pin) {
|
||||
let name = saved_pin
|
||||
.splitn(3, ':')
|
||||
.nth(2)
|
||||
.unwrap_or("Saved controller");
|
||||
pad_names.push(format!("{name} (not connected)"));
|
||||
pad_keys.push(saved_pin.clone());
|
||||
}
|
||||
let forward_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Forwarded controller",
|
||||
if pads.is_empty() {
|
||||
"No controllers detected"
|
||||
} else {
|
||||
"Exactly one controller is forwarded to the host"
|
||||
})
|
||||
.model(>k::StringList::new(
|
||||
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
))
|
||||
.build();
|
||||
let pinned_i = gamepads
|
||||
.pinned()
|
||||
.and_then(|id| pads.iter().position(|p| p.id == id))
|
||||
},
|
||||
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let pinned_i = pad_keys
|
||||
.iter()
|
||||
.position(|k| k == &saved_pin)
|
||||
.map_or(0, |i| i + 1);
|
||||
forward_row.set_selected(pinned_i as u32);
|
||||
// The dialog-local choice, written into Settings on close (reading the service back
|
||||
// would race its worker thread applying the Pin message).
|
||||
let chosen_pin: Rc<RefCell<String>> = Rc::new(RefCell::new(saved_pin));
|
||||
{
|
||||
let svc = gamepads.clone();
|
||||
let ids: Vec<u32> = pads.iter().map(|p| p.id).collect();
|
||||
forward_row.connect_selected_notify(move |row| {
|
||||
let sel = row.selected() as usize;
|
||||
svc.set_pinned(if sel == 0 {
|
||||
let keys = pad_keys.clone();
|
||||
let chosen = chosen_pin.clone();
|
||||
forward_row.connect_changed(move |sel| {
|
||||
let key = if sel == 0 {
|
||||
None
|
||||
} else {
|
||||
ids.get(sel - 1).copied()
|
||||
});
|
||||
keys.get(sel as usize - 1).cloned()
|
||||
};
|
||||
*chosen.borrow_mut() = key.clone().unwrap_or_default();
|
||||
svc.set_pinned(key);
|
||||
});
|
||||
}
|
||||
let pad_row = adw::ComboRow::builder()
|
||||
.title("Gamepad type")
|
||||
.subtitle("The virtual pad the host creates — Automatic matches the physical pad")
|
||||
.model(>k::StringList::new(&[
|
||||
let pad_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Gamepad type",
|
||||
"The virtual pad the host creates — Automatic matches the physical pad",
|
||||
&[
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
"DualSense",
|
||||
"Xbox One",
|
||||
"DualShock 4",
|
||||
]))
|
||||
.build();
|
||||
"Steam Deck",
|
||||
],
|
||||
);
|
||||
let inhibit_row = adw::SwitchRow::builder()
|
||||
.title("Capture system shortcuts")
|
||||
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
||||
.build();
|
||||
input.add(&forward_row);
|
||||
input.add(&pad_row);
|
||||
input.add(forward_row.widget());
|
||||
input.add(pad_row.widget());
|
||||
input.add(&inhibit_row);
|
||||
|
||||
let audio = adw::PreferencesGroup::builder().title("Audio").build();
|
||||
let surround_row = adw::ComboRow::builder()
|
||||
.title("Audio channels")
|
||||
.subtitle("Request stereo or surround (the host downmixes if its output has fewer)")
|
||||
.model(>k::StringList::new(&[
|
||||
"Stereo",
|
||||
"5.1 Surround",
|
||||
"7.1 Surround",
|
||||
]))
|
||||
.build();
|
||||
audio.add(&surround_row);
|
||||
let codec_row = adw::ComboRow::builder()
|
||||
.title("Video codec")
|
||||
.subtitle("Preferred codec — the host falls back if it can't encode this one")
|
||||
.model(>k::StringList::new(CODEC_LABELS))
|
||||
.build();
|
||||
stream.add(&codec_row);
|
||||
let surround_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Audio channels",
|
||||
"Request stereo or surround (the host downmixes if its output has fewer)",
|
||||
&["Stereo", "5.1 Surround", "7.1 Surround"],
|
||||
);
|
||||
audio.add(surround_row.widget());
|
||||
let codec_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Video codec",
|
||||
"Preferred codec — the host falls back if it can't encode this one",
|
||||
CODEC_LABELS,
|
||||
);
|
||||
stream.add(codec_row.widget());
|
||||
let mic_row = adw::SwitchRow::builder()
|
||||
.title("Stream microphone")
|
||||
.subtitle("Send the default input device to the host's virtual microphone")
|
||||
@@ -268,6 +484,7 @@ pub fn show(
|
||||
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
|
||||
decoder_row.set_selected(dec_i as u32);
|
||||
stats_row.set_active(s.show_stats);
|
||||
fullscreen_row.set_active(s.fullscreen_on_stream);
|
||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||
mic_row.set_active(s.mic_enabled);
|
||||
library_row.set_active(s.library_enabled);
|
||||
@@ -280,8 +497,6 @@ pub fn show(
|
||||
codec_row.set_selected(codec_i as u32);
|
||||
}
|
||||
|
||||
let dialog = adw::PreferencesDialog::new();
|
||||
dialog.set_title("Preferences");
|
||||
dialog.add(&page);
|
||||
dialog.connect_closed(move |_| {
|
||||
let mut s = settings.borrow_mut();
|
||||
@@ -290,10 +505,12 @@ pub fn show(
|
||||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||
s.forward_pad = chosen_pin.borrow().clone();
|
||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||
.to_string();
|
||||
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
||||
s.show_stats = stats_row.is_active();
|
||||
s.fullscreen_on_stream = fullscreen_row.is_active();
|
||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
s.audio_channels = match surround_row.selected() {
|
||||
@@ -309,3 +526,97 @@ pub fn show(
|
||||
});
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Depth-first search for an [`adw::ActionRow`] with the given title.
|
||||
fn find_action_row(root: >k::Widget, title: &str) -> Option<adw::ActionRow> {
|
||||
if let Some(row) = root.downcast_ref::<adw::ActionRow>() {
|
||||
if row.title() == title {
|
||||
return Some(row.clone());
|
||||
}
|
||||
}
|
||||
let mut child = root.first_child();
|
||||
while let Some(c) = child {
|
||||
if let Some(hit) = find_action_row(&c, title) {
|
||||
return Some(hit);
|
||||
}
|
||||
child = c.next_sibling();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn pump() {
|
||||
let ctx = gtk::glib::MainContext::default();
|
||||
while ctx.iteration(false) {}
|
||||
}
|
||||
|
||||
/// Both ChoiceRow modes in ONE test (GTK is thread-affine and libtest gives every test
|
||||
/// its own thread, so the display tests can't be split). Gamescope mode: activating the
|
||||
/// row pushes the in-window selection subpage; activating an option updates the
|
||||
/// selection + suffix label, fires the change callback, and pops the subpage. Combo
|
||||
/// mode: cell sync + change callback. Needs a display — run manually with
|
||||
/// `cargo test -p punktfunk-client-linux -- --ignored` on a session box.
|
||||
#[test]
|
||||
#[ignore = "needs a Wayland/X display"]
|
||||
fn choice_row_modes() {
|
||||
assert!(gtk::init().is_ok() && adw::init().is_ok(), "no display");
|
||||
let win = adw::Window::new();
|
||||
let dialog = adw::PreferencesDialog::new();
|
||||
let page = adw::PreferencesPage::new();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let row = ChoiceRow::new(&dialog, true, "Resolution", "sub", &["A", "B", "C"]);
|
||||
group.add(row.widget());
|
||||
page.add(&group);
|
||||
dialog.add(&page);
|
||||
let fired = Rc::new(Cell::new(u32::MAX));
|
||||
{
|
||||
let f = fired.clone();
|
||||
row.connect_changed(move |i| f.set(i));
|
||||
}
|
||||
win.present();
|
||||
dialog.present(Some(&win));
|
||||
pump();
|
||||
|
||||
// Suffix label reflects the seed.
|
||||
assert_eq!(row.value_label.as_ref().unwrap().text(), "A");
|
||||
|
||||
// Row activation → subpage with the options list.
|
||||
row.widget()
|
||||
.downcast_ref::<adw::ActionRow>()
|
||||
.unwrap()
|
||||
.emit_by_name::<()>("activated", &[]);
|
||||
pump();
|
||||
let opt_b = find_action_row(dialog.upcast_ref(), "B").expect("subpage option missing");
|
||||
|
||||
// Option activation → state + label + callback, subpage popped.
|
||||
opt_b.emit_by_name::<()>("activated", &[]);
|
||||
pump();
|
||||
assert_eq!(row.selected(), 1);
|
||||
assert_eq!(fired.get(), 1);
|
||||
assert_eq!(row.value_label.as_ref().unwrap().text(), "B");
|
||||
|
||||
// Re-activating shows the check on the new selection (fresh subpage each time).
|
||||
row.widget()
|
||||
.downcast_ref::<adw::ActionRow>()
|
||||
.unwrap()
|
||||
.emit_by_name::<()>("activated", &[]);
|
||||
pump();
|
||||
assert!(find_action_row(dialog.upcast_ref(), "B").is_some());
|
||||
|
||||
// Desktop (ComboRow) mode: cell sync + change callback on selection change.
|
||||
let combo = ChoiceRow::new(&dialog, false, "Codec", "", &["X", "Y"]);
|
||||
combo.set_selected(1);
|
||||
assert_eq!(combo.selected(), 1);
|
||||
let combo_fired = Rc::new(Cell::new(u32::MAX));
|
||||
{
|
||||
let f = combo_fired.clone();
|
||||
combo.connect_changed(move |i| f.set(i));
|
||||
}
|
||||
combo.set_selected(0);
|
||||
assert_eq!(combo.selected(), 0);
|
||||
assert_eq!(combo_fired.get(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user