Compare commits
64 Commits
a4833e4780
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e2d4c40167 | |||
| 580b1ea7a7 | |||
| 831b37b4b7 | |||
| 4f0b4aa68f | |||
| 963c406f33 | |||
| 7ab8acaf55 | |||
| c8e19396e4 | |||
| 78020cd66c | |||
| 8870e85233 | |||
| a81f1304cd | |||
| c75f39fd8e | |||
| 37c3e2bed2 | |||
| 4f40fa3cb7 | |||
| 486a292845 | |||
| d8c254281e | |||
| ae71e4628d | |||
| 01c55aed38 | |||
| 95308d352b | |||
| 9ff7d41bfe | |||
| 2b47d8cc28 | |||
| 7cd9364c9e | |||
| 3e498cd40d | |||
| 60de506f66 | |||
| 2865368771 | |||
| 6e2e946bc9 | |||
| b5f02000d6 | |||
| fe562f0562 | |||
| 4e00037a89 | |||
| 46b9aa8cf0 | |||
| 372b27540b | |||
| db4d15bf8b | |||
| 8e24ea9ed7 | |||
| 73c0125843 | |||
| ed54f22997 | |||
| 031ee86ed5 | |||
| 7591425f6f | |||
| d1d2ca293d | |||
| 705a8fa94e | |||
| 4ba63b7da6 | |||
| bee1f0416d | |||
| 54d9246ca7 | |||
| 91bb955d0c | |||
| 36259b264f | |||
| 6f903f79bc | |||
| 3532e35b75 | |||
| 6b846913f5 | |||
| 26c6c939a2 | |||
| b6e6f2bff5 | |||
| e3034958ee | |||
| 8672026e97 | |||
| 75627c8afe | |||
| 6383e5f4fd | |||
| 6a93d164a0 | |||
| 9e98618e5f | |||
| 1bd60ffb34 | |||
| 30d0d36efe | |||
| 3947d5b07a | |||
| 238501597e | |||
| 04dd3e3a19 | |||
| 61aa1053e7 | |||
| 50e17b3508 | |||
| 94c556f0e3 | |||
| 32c1929948 | |||
| 3915a82780 |
+25
-8
@@ -5,16 +5,33 @@
|
|||||||
# means the audit job stops flagging it, so the reasoning must hold up.
|
# means the audit job stops flagging it, so the reasoning must hold up.
|
||||||
#
|
#
|
||||||
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
|
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
|
||||||
# `unmaintained` warnings (audiopus_sys / paste / rustls-pemfile). Those are left visible on purpose
|
# `unmaintained` warnings (audiopus_sys via opus, paste via utoipa-axum). Both are transitive, at
|
||||||
# so we keep getting the maintenance signal — they do not fail CI.
|
# their latest published version with no successor, so there's nothing to bump — left visible on
|
||||||
|
# purpose so we keep getting the maintenance signal; they do not fail CI. (rustls-pemfile was dropped
|
||||||
|
# 2026-06-29 by removing axum-server's unused tls-rustls feature + moving our own PEM parsing to
|
||||||
|
# rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11 bump.)
|
||||||
|
|
||||||
[advisories]
|
[advisories]
|
||||||
ignore = [
|
ignore = [
|
||||||
# rsa "Marvin Attack" — a timing sidechannel in RSA *decryption* (PKCS#1 v1.5 padding oracle).
|
# rsa "Marvin Attack" (RUSTSEC-2023-0071): a timing side-channel in the rsa crate's variable-time
|
||||||
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream), and rsa
|
# modular exponentiation of the SECRET exponent. IMPORTANT — this affects the RSA private-key op in
|
||||||
# is required for GameStream/Moonlight pairing. Crucially, the host uses rsa ONLY for PKCS#1 v1.5
|
# general, INCLUDING signing (m^d mod n), which the host DOES perform (gamestream/pairing.rs
|
||||||
# SIGNING / VERIFYING (gamestream/cert.rs + gamestream/pairing.rs: SigningKey / VerifyingKey /
|
# `signing_key.sign(&serversecret)`). It is NOT, as an earlier version of this note wrongly claimed,
|
||||||
# Signer / Verifier) — it never performs RSA decryption, which is the operation Marvin targets.
|
# limited to decryption — so "the vulnerable path isn't exercised" is false; signing exercises it.
|
||||||
# So the vulnerable code path is not exercised. Revisit if a fixed rsa ships or we add RSA decrypt.
|
# We accept it because the attack is not practically reachable here, NOT because the path is unused:
|
||||||
|
# * No RSA decryption / PKCS#1v1.5 padding oracle exists anywhere (every `decrypt` in the tree is
|
||||||
|
# AES/AES-GCM), so the classic Bleichenbacher/Marvin chosen-ciphertext oracle is absent.
|
||||||
|
# * The only signed message (`serversecret`) is HOST-generated random, never attacker-chosen — so
|
||||||
|
# there's no adaptive chosen-input probing (the lever remote RSA-timing key recovery needs); and
|
||||||
|
# signing is gated behind the operator-entered pairing PIN, ONE signature per ceremony (a
|
||||||
|
# repeated phase-3 is rejected — gamestream/pairing.rs — to deny a passive timing-sample harvester).
|
||||||
|
# * GameStream is OFF by default (bare `serve` is native-only); the secure native QUIC plane uses
|
||||||
|
# rustls' constant-time backend, NOT the rsa crate. RSA is touched only on the opt-in,
|
||||||
|
# trusted-LAN GameStream/Moonlight pairing handshake. Moonlight mandates RSA-2048, so the
|
||||||
|
# GameStream identity cannot move to Ed25519/ECDSA (only the native identity could, and it
|
||||||
|
# already avoids the rsa crate).
|
||||||
|
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream). Revisit if:
|
||||||
|
# a constant-time rsa ships (then drop this), the host ever signs an attacker-chosen message with
|
||||||
|
# this key, or any RSA decryption / key-transport using the private key is added.
|
||||||
"RUSTSEC-2023-0071",
|
"RUSTSEC-2023-0071",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Android client screenshots for the Play listing / marketing. Roborazzi renders the real Compose
|
||||||
|
# UI with mock state on the host JVM via Robolectric — NO emulator, GPU, KVM, host, or JNI core
|
||||||
|
# (`-PskipRustBuild` skips the cargo-ndk native build). The Android analogue of apple.yml's
|
||||||
|
# `screenshots` job, gated to STABLE RELEASE tags only. Standalone + best-effort: a failure here
|
||||||
|
# reds nothing else. PNGs land as a 30-day artifact; not committed or published.
|
||||||
|
name: android-screenshots
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
screenshots:
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: JDK 21 (AGP 9.2 + Robolectric's SDK-36 android-all jar both want 17–21)
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
|
||||||
|
- name: Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
# No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP
|
||||||
|
# if the platform channel lacks it (same note as android.yml).
|
||||||
|
- name: platform-tools + platform 36 + build-tools
|
||||||
|
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0"
|
||||||
|
|
||||||
|
- name: Cache (gradle)
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: android-screenshots-${{ hashFiles('clients/android/**/*.gradle.kts') }}
|
||||||
|
restore-keys: android-screenshots-
|
||||||
|
|
||||||
|
# Roborazzi renders Compose on the JVM (Robolectric Native Graphics). `-PskipRustBuild` keeps
|
||||||
|
# the cargo-ndk native build out of the graph — the tests never load libpunktfunk_android.so.
|
||||||
|
- name: Capture screenshots (Roborazzi)
|
||||||
|
working-directory: clients/android
|
||||||
|
run: ./gradlew :app:testDebugUnitTest -PskipRustBuild --stacktrace
|
||||||
|
|
||||||
|
- name: Upload screenshots
|
||||||
|
if: always()
|
||||||
|
# v3: Gitea's API rejects upload-artifact@v4 (see apple.yml). Download is a zip.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: punktfunk-android-screenshots
|
||||||
|
path: clients/android/app/build/outputs/roborazzi
|
||||||
|
retention-days: 30
|
||||||
@@ -32,6 +32,25 @@ jobs:
|
|||||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
|
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
|
|
||||||
|
# `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling
|
||||||
|
# `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a
|
||||||
|
# system Opus — so the xcframework is self-contained (no runtime libopus.dylib on end-user Macs).
|
||||||
|
# CMake must be on PATH; install it self-healing on a fresh runner.
|
||||||
|
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||||
|
run: |
|
||||||
|
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||||
|
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||||
|
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||||
|
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||||
|
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||||
|
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||||
|
command -v cmake >/dev/null || "$BREW" install cmake
|
||||||
|
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||||
|
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||||
|
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||||
|
# inherits this from the env during the xcframework build).
|
||||||
|
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build PunktfunkCore.xcframework
|
- name: Build PunktfunkCore.xcframework
|
||||||
run: bash scripts/build-xcframework.sh
|
run: bash scripts/build-xcframework.sh
|
||||||
|
|
||||||
@@ -71,6 +90,22 @@ jobs:
|
|||||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||||
|
|
||||||
|
# See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake.
|
||||||
|
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||||
|
run: |
|
||||||
|
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||||
|
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||||
|
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||||
|
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||||
|
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||||
|
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||||
|
command -v cmake >/dev/null || "$BREW" install cmake
|
||||||
|
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||||
|
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||||
|
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||||
|
# inherits this from the env during the xcframework build).
|
||||||
|
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
|
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
|
||||||
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||||
|
|
||||||
|
|||||||
+44
-13
@@ -11,12 +11,18 @@
|
|||||||
# punktfunk.zip
|
# punktfunk.zip
|
||||||
# punktfunk/ <- single top-level dir == plugin.json "name"
|
# punktfunk/ <- single top-level dir == plugin.json "name"
|
||||||
# plugin.json [required]
|
# plugin.json [required]
|
||||||
# package.json [required]
|
# package.json [required; CI stamps "version" — Decky reads the installed version here]
|
||||||
# main.py [required: python backend]
|
# main.py [required: python backend]
|
||||||
# dist/index.js [required: rollup output]
|
# dist/index.js [required: rollup output]
|
||||||
|
# update.json [CI-baked {channel, manifest}: where the plugin's self-update check polls]
|
||||||
# README.md (recommended)
|
# README.md (recommended)
|
||||||
# LICENSE [required by the plugin store]
|
# LICENSE [required by the plugin store]
|
||||||
#
|
#
|
||||||
|
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
|
||||||
|
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
|
||||||
|
# plugin polls it (main.py check_update), and the frontend drives Decky's own install RPC to
|
||||||
|
# apply a newer build. See clients/decky/README.md "Updating".
|
||||||
|
#
|
||||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker).
|
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker).
|
||||||
name: decky
|
name: decky
|
||||||
|
|
||||||
@@ -56,20 +62,26 @@ jobs:
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm run build # rollup -> clients/decky/dist/index.js
|
pnpm run build # rollup -> clients/decky/dist/index.js
|
||||||
|
|
||||||
- name: Version + channel
|
- name: Version + channel + stamp
|
||||||
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.0-ciN.g<sha>
|
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.<run>
|
||||||
# (`canary/` alias). Used for the registry version path + the zip name (the plugin.json
|
# (`canary/` alias). Decky reads a plugin's INSTALLED version from package.json (NOT
|
||||||
# version is the source of truth Decky reads after install — bump it in the release commit).
|
# 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
|
||||||
|
# into plugin.json for store parity). Canary is a PLAIN numeric semver, never a
|
||||||
|
# `-ci<N>` prerelease: compare-versions orders prerelease identifiers lexically
|
||||||
|
# (ci10 < ci9), which would break update detection; the run number is monotonic.
|
||||||
working-directory: ${{ gitea.workspace }}
|
working-directory: ${{ gitea.workspace }}
|
||||||
run: |
|
run: |
|
||||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;;
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;;
|
||||||
*) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; ALIAS=canary ;;
|
*) V="0.3.${GITHUB_RUN_NUMBER}"; ALIAS=canary ;;
|
||||||
esac
|
esac
|
||||||
|
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
||||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||||
echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
|
echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
|
||||||
|
echo "BASE=$BASE" >> "$GITHUB_ENV"
|
||||||
echo "decky version $V -> alias '$ALIAS'"
|
echo "decky version $V -> alias '$ALIAS'"
|
||||||
|
VERSION="$V" node -e 'const fs=require("fs");for(const f of ["clients/decky/package.json","clients/decky/plugin.json"]){const j=JSON.parse(fs.readFileSync(f,"utf8"));j.version=process.env.VERSION;fs.writeFileSync(f,JSON.stringify(j,null,2)+"\n");}'
|
||||||
|
|
||||||
- name: Assemble store-layout zip
|
- name: Assemble store-layout zip
|
||||||
working-directory: ${{ gitea.workspace }}
|
working-directory: ${{ gitea.workspace }}
|
||||||
@@ -89,9 +101,20 @@ jobs:
|
|||||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||||
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
|
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
|
||||||
cp LICENSE-MIT "$DEST/LICENSE"
|
cp LICENSE-MIT "$DEST/LICENSE"
|
||||||
|
# Self-update channel pointer the backend reads (main.py check_update). It points at
|
||||||
|
# THIS channel's manifest.json (published below); that manifest in turn points at the
|
||||||
|
# immutable per-version zip, so its sha256 stays valid across future alias re-uploads.
|
||||||
|
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
|
||||||
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||||
ls -lh "$RUNNER_TEMP/punktfunk.zip"
|
ls -lh "$RUNNER_TEMP/punktfunk.zip"
|
||||||
unzip -l "$RUNNER_TEMP/punktfunk.zip"
|
unzip -l "$RUNNER_TEMP/punktfunk.zip"
|
||||||
|
# The update manifest the plugin polls: the immutable per-version artifact + its
|
||||||
|
# sha256 (Decky's installer verifies the download against this hash, aborting on
|
||||||
|
# mismatch — so it MUST be the per-version URL, never the mutable alias).
|
||||||
|
SHA=$(sha256sum "$RUNNER_TEMP/punktfunk.zip" | cut -d' ' -f1)
|
||||||
|
printf '{"version":"%s","artifact":"%s/%s/punktfunk.zip","sha256":"%s"}\n' \
|
||||||
|
"$VERSION" "$BASE" "$VERSION" "$SHA" > "$RUNNER_TEMP/manifest.json"
|
||||||
|
cat "$RUNNER_TEMP/manifest.json"
|
||||||
|
|
||||||
- name: Publish to the Gitea generic registry
|
- name: Publish to the Gitea generic registry
|
||||||
working-directory: ${{ gitea.workspace }}
|
working-directory: ${{ gitea.workspace }}
|
||||||
@@ -99,18 +122,26 @@ jobs:
|
|||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
||||||
# 1) Immutable, versioned URL.
|
# 1) Immutable, versioned URL + its update manifest (the manifest's `artifact` points
|
||||||
|
# here, so the published sha256 keeps matching what Decky later downloads).
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
||||||
"$BASE/$VERSION/punktfunk.zip"
|
"$BASE/$VERSION/punktfunk.zip"
|
||||||
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \
|
||||||
|
"$BASE/$VERSION/manifest.json"
|
||||||
echo "published $BASE/$VERSION/punktfunk.zip"
|
echo "published $BASE/$VERSION/punktfunk.zip"
|
||||||
# 2) Channel alias (stable release -> latest/, canary main build -> canary/) — the link
|
# 2) Channel alias (stable release -> latest/, canary main build -> canary/) — the
|
||||||
# to paste into Decky's "install from URL". The generic registry rejects re-uploading
|
# zip is the "install from URL" link; manifest.json is what the installed plugin
|
||||||
# an existing version/file (409), so delete the prior alias first (ignore 404 on run #1).
|
# polls for updates. The generic registry rejects re-uploading an existing
|
||||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
# version/file (409), so delete the prior alias copies first (ignore 404 on run #1).
|
||||||
"$BASE/$ALIAS/punktfunk.zip" || true
|
for f in punktfunk.zip manifest.json; do
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$ALIAS/$f" || true
|
||||||
|
done
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
||||||
"$BASE/$ALIAS/punktfunk.zip"
|
"$BASE/$ALIAS/punktfunk.zip"
|
||||||
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \
|
||||||
|
"$BASE/$ALIAS/manifest.json"
|
||||||
echo "install-from-URL link: $BASE/$ALIAS/punktfunk.zip"
|
echo "install-from-URL link: $BASE/$ALIAS/punktfunk.zip"
|
||||||
|
echo "update manifest: $BASE/$ALIAS/manifest.json"
|
||||||
|
|
||||||
- name: Attach zip to the Gitea release (stable tags only)
|
- name: Attach zip to the Gitea release (stable tags only)
|
||||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Native Linux client screenshots for the app/marketing listings. The client renders
|
||||||
|
# host-free mock scenes (PUNKTFUNK_SHOT_SCENE) under a virtual X display; the driver
|
||||||
|
# (clients/linux/tools/screenshots.sh) grabs each one — no host, GPU, or Wayland. The
|
||||||
|
# Linux analogue of apple.yml's `screenshots` job, gated to STABLE RELEASE tags only.
|
||||||
|
# Standalone + best-effort: a failure here reds nothing else. PNGs land as a 30-day
|
||||||
|
# artifact; they are not committed or published.
|
||||||
|
name: linux-client-screenshots
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
screenshots:
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
# Same image as ci.yml/deb.yml — already carries the Rust toolchain + GTK/SDL build deps.
|
||||||
|
container:
|
||||||
|
image: git.unom.io/unom/punktfunk-rust-ci:latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Client link deps (baked into the image; kept here so the job is green across image
|
||||||
|
# rebuilds — a no-op once present) PLUS the headless-render extras: a virtual X server,
|
||||||
|
# software GL+Vulkan (llvmpipe/lavapipe), the icon theme + fonts the UI draws with, and a
|
||||||
|
# root-window grab tool.
|
||||||
|
- name: Client link + headless-render deps
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
||||||
|
xvfb x11-utils imagemagick scrot \
|
||||||
|
libgl1-mesa-dri mesa-vulkan-drivers \
|
||||||
|
adwaita-icon-theme fonts-cantarell fonts-dejavu-core
|
||||||
|
|
||||||
|
# Reuse the workspace cargo caches (same keys as ci.yml/deb.yml).
|
||||||
|
- name: Cache keys
|
||||||
|
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/usr/local/cargo/registry
|
||||||
|
/usr/local/cargo/git
|
||||||
|
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-home-
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||||
|
|
||||||
|
- name: Build client
|
||||||
|
run: cargo build --release -p punktfunk-client-linux --locked
|
||||||
|
|
||||||
|
- name: Capture screenshots
|
||||||
|
run: bash clients/linux/tools/screenshots.sh
|
||||||
|
|
||||||
|
- name: Upload screenshots
|
||||||
|
if: always()
|
||||||
|
# v3: Gitea's API rejects upload-artifact@v4 (see apple.yml). Download is a zip.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: punktfunk-linux-client-screenshots
|
||||||
|
path: clients/linux/screenshots
|
||||||
|
retention-days: 30
|
||||||
@@ -118,6 +118,23 @@ jobs:
|
|||||||
"$RUSTUP" toolchain install nightly --profile minimal
|
"$RUSTUP" toolchain install nightly --profile minimal
|
||||||
"$RUSTUP" component add rust-src --toolchain nightly
|
"$RUSTUP" component add rust-src --toolchain nightly
|
||||||
|
|
||||||
|
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
|
||||||
|
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
|
||||||
|
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||||
|
run: |
|
||||||
|
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||||
|
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||||
|
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||||
|
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||||
|
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||||
|
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||||
|
command -v cmake >/dev/null || "$BREW" install cmake
|
||||||
|
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||||
|
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||||
|
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||||
|
# inherits this from the env during the xcframework build).
|
||||||
|
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||||
@@ -190,10 +207,20 @@ jobs:
|
|||||||
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
|
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
# Separate archive from the Developer ID one above: App Store needs a profile-signed
|
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
|
||||||
# archive (manual signing), not the unsigned-then-codesign DMG path. Same App-Manager
|
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
|
||||||
# ASC-key constraint as iOS/tvOS — MANUAL signing, NOT -allowProvisioningUpdates
|
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
|
||||||
# (cloud signing the key can't do). Quit Xcode so it can't prune the dropped profile.
|
# profile (as this step used to do): the in-app license screens added a SwiftPM resource
|
||||||
|
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
|
||||||
|
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
|
||||||
|
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
||||||
|
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
||||||
|
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
||||||
|
# entitlements in. No -allowProvisioningUpdates → it stays OFFLINE and never cloud-signs
|
||||||
|
# (the App-Manager ASC key can't), so the runner must have a macOS *development* profile
|
||||||
|
# for io.unom.punktfunk installed. DISTRIBUTION signing happens in the export step below
|
||||||
|
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
||||||
|
# distribution profile that export needs.
|
||||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk macOS App Store Distribution"
|
PROFILE="Punktfunk macOS App Store Distribution"
|
||||||
@@ -201,11 +228,10 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||||
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
CODE_SIGN_STYLE=Manual \
|
CODE_SIGN_STYLE=Automatic \
|
||||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
|
||||||
PROVISIONING_PROFILE_SPECIFIER="$PROFILE"
|
|
||||||
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
|
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
@@ -235,35 +261,27 @@ jobs:
|
|||||||
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
|
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
# MANUAL App Store signing: the local (valid) Apple Distribution identity + the App
|
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
|
||||||
# Store provisioning profile. NOT -allowProvisioningUpdates — with an App-Manager-role
|
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
|
||||||
# ASC key that forces Xcode's CLOUD-managed signing, which the role can't do ("Cloud
|
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
||||||
# signing permission error"). The profile must be installed on the runner under
|
# this step used to set matched it and failed the archive ("does not support provisioning
|
||||||
# ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ (install it once with
|
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
||||||
# Xcode.app quit, or it prunes the manually-dropped distribution profile).
|
# the macOS-host macro plugins) alone. No -allowProvisioningUpdates → OFFLINE, never
|
||||||
# A running Xcode.app prunes unrecognized profiles from that dir — quit it so the App
|
# cloud-signs (the App-Manager ASC key can't), so the runner needs an iOS *development*
|
||||||
# Store profile survives this build; headless xcodebuild doesn't need the GUI app.
|
# profile for io.unom.punktfunk installed. DISTRIBUTION signing is the export step below
|
||||||
|
# (manual, via the plist). A running Xcode.app prunes unrecognized profiles — quit it so the
|
||||||
|
# manually-installed App Store distribution profile survives for export.
|
||||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk iOS App Store Distribution"
|
PROFILE="Punktfunk iOS App Store Distribution"
|
||||||
# Scope signing to the iOS device SDK via an xcconfig — see the tvOS step below for the
|
|
||||||
# full rationale. A global (CLI) profile specifier would also be forced onto the shared
|
|
||||||
# macOS-host SwiftPM macro plugins, which reject it and fail the archive; [sdk=iphoneos*]
|
|
||||||
# in an xcconfig lands it on the app/framework slices only.
|
|
||||||
SIGN_XCCONFIG="$RUNNER_TEMP/sign-ios.xcconfig"
|
|
||||||
cat > "$SIGN_XCCONFIG" <<XCCONF
|
|
||||||
CODE_SIGN_STYLE = Manual
|
|
||||||
DEVELOPMENT_TEAM = $TEAM_ID
|
|
||||||
CODE_SIGN_IDENTITY[sdk=iphoneos*] = Apple Distribution
|
|
||||||
PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*] = $PROFILE
|
|
||||||
XCCONF
|
|
||||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||||
-destination 'generic/platform=iOS' \
|
-destination 'generic/platform=iOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-xcconfig "$SIGN_XCCONFIG" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM"
|
CODE_SIGN_STYLE=Automatic \
|
||||||
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF
|
cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
@@ -295,33 +313,24 @@ jobs:
|
|||||||
# on the runner (xcodebuild -downloadPlatform tvOS).
|
# on the runner (xcodebuild -downloadPlatform tvOS).
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
# Same manual App Store signing as iOS (the App-Manager ASC key can't cloud-sign).
|
# Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
|
||||||
|
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
||||||
|
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
||||||
|
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
||||||
|
# AssociationMacro) alone. No -allowProvisioningUpdates → OFFLINE, never cloud-signs (the
|
||||||
|
# App-Manager ASC key can't), so the runner needs a tvOS *development* profile for
|
||||||
|
# io.unom.punktfunk installed. DISTRIBUTION signing is the export step below (manual, plist).
|
||||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk tvOS App Store Distribution"
|
PROFILE="Punktfunk tvOS App Store Distribution"
|
||||||
# Scope signing to the tvOS device SDK via an xcconfig. A global (CLI) profile specifier
|
|
||||||
# hits EVERY target, including the shared SwiftPM macro plugins (OnceMacro/SwizzlingMacro/
|
|
||||||
# AssociationMacro) which build for the macOS host and reject a provisioning profile
|
|
||||||
# ("<macro> does not support provisioning profiles"), failing the archive. Conditionals
|
|
||||||
# work only in an xcconfig (xcodebuild mis-parses a CLI "SETTING[sdk=..]=val"), and a
|
|
||||||
# command-line -xcconfig outranks target settings, so [sdk=appletvos*] puts the profile on
|
|
||||||
# the app/framework slices only — the macosx-host macros get nothing. (The macOS archive
|
|
||||||
# above is immune: its host-SDK macros are CODE_SIGNING_ALLOWED=NO, so a global specifier
|
|
||||||
# is ignored there.)
|
|
||||||
SIGN_XCCONFIG="$RUNNER_TEMP/sign-tvos.xcconfig"
|
|
||||||
cat > "$SIGN_XCCONFIG" <<XCCONF
|
|
||||||
CODE_SIGN_STYLE = Manual
|
|
||||||
DEVELOPMENT_TEAM = $TEAM_ID
|
|
||||||
CODE_SIGN_IDENTITY[sdk=appletvos*] = Apple Distribution
|
|
||||||
PROVISIONING_PROFILE_SPECIFIER[sdk=appletvos*] = $PROFILE
|
|
||||||
XCCONF
|
|
||||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||||
-destination 'generic/platform=tvOS' \
|
-destination 'generic/platform=tvOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-xcconfig "$SIGN_XCCONFIG" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM"
|
CODE_SIGN_STYLE=Automatic \
|
||||||
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF
|
cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Management-console screenshots for the app/marketing listings. Captured from the
|
||||||
|
# built Storybook with headless Chromium (web/tools/screenshots.mjs) — the page
|
||||||
|
# stories render from fixtures, so no live mgmt API, login, or GPU is needed. This
|
||||||
|
# is the web analogue of apple.yml's `screenshots` job, but gated to STABLE RELEASE
|
||||||
|
# tags only (the console has no release workflow of its own — it ships inside the
|
||||||
|
# host packaging). Best-effort: a standalone workflow, so a failure here reds
|
||||||
|
# nothing else. PNGs land as a 30-day artifact; they are not committed or published.
|
||||||
|
name: web-screenshots
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
screenshots:
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
container:
|
||||||
|
image: oven/bun:1
|
||||||
|
timeout-minutes: 30
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: web
|
||||||
|
steps:
|
||||||
|
# oven/bun ships neither git nor a real node (the driver runs under node), and
|
||||||
|
# the slim Debian base lacks a CA bundle — without it actions/checkout's HTTPS
|
||||||
|
# fetch dies with "Problem with the SSL CA cert" (same as ci.yml's web job).
|
||||||
|
- name: Install git + node + CA certs
|
||||||
|
working-directory: /
|
||||||
|
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen
|
||||||
|
# explicitly since build-storybook has no prebuild hook of its own.
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile --ignore-scripts
|
||||||
|
- name: Generate API client + i18n messages
|
||||||
|
run: bun run codegen
|
||||||
|
# Pulls the matching Chromium build + the apt libs it needs (root in-container).
|
||||||
|
- name: Install Chromium
|
||||||
|
run: bunx playwright install --with-deps chromium
|
||||||
|
- name: Build Storybook
|
||||||
|
run: bun run build-storybook
|
||||||
|
- name: Capture screenshots
|
||||||
|
run: bun run screenshots
|
||||||
|
- name: Upload screenshots
|
||||||
|
if: always()
|
||||||
|
# v3: Gitea's API rejects upload-artifact@v4 (see apple.yml). Download is a zip.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: punktfunk-web-console-screenshots
|
||||||
|
path: web/screenshots
|
||||||
|
retention-days: 30
|
||||||
@@ -24,8 +24,9 @@
|
|||||||
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
# 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
|
# - 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).
|
# .def with llvm-dlltool (no GPU/SDK at build time).
|
||||||
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN gpl-shared
|
# - 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.
|
# 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).
|
||||||
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
|
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
|
||||||
name: windows-host
|
name: windows-host
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ jobs:
|
|||||||
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
|
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
|
||||||
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
# FFMPEG_DIR: the same BtbN gpl-shared x64 tree the Windows CLIENT links against (provisioned
|
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
|
||||||
# by scripts/ci/setup-windows-runner.ps1). The host's AMD/Intel AMF/QSV encode backend
|
# by scripts/ci/setup-windows-runner.ps1). The host's AMD/Intel AMF/QSV encode backend
|
||||||
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
||||||
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ clients/apple/PunktfunkCore.xcframework/
|
|||||||
clients/apple/.swiftpm/
|
clients/apple/.swiftpm/
|
||||||
# Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact)
|
# Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact)
|
||||||
clients/apple/screenshots/
|
clients/apple/screenshots/
|
||||||
|
clients/linux/screenshots/
|
||||||
# Xcode per-user state
|
# Xcode per-user state
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
|
||||||
|
|||||||
@@ -346,7 +346,23 @@ FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKT
|
|||||||
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1`, `PUNKTFUNK_GAMESCOPE_APP=...`,
|
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1`, `PUNKTFUNK_GAMESCOPE_APP=...`,
|
||||||
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
|
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
|
||||||
test), `PUNKTFUNK_FEC_PCT=N`, `PUNKTFUNK_DSCP=1` (opt-in DSCP/SO_PRIORITY media QoS on the data +
|
test), `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).
|
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
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Contributing to punktfunk
|
||||||
|
|
||||||
|
Thanks for your interest in contributing!
|
||||||
|
|
||||||
|
## Licensing of contributions (inbound = outbound)
|
||||||
|
|
||||||
|
punktfunk is dual-licensed under **MIT OR Apache-2.0**.
|
||||||
|
|
||||||
|
> Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
|
||||||
|
> the work by you, as defined in the Apache-2.0 license, shall be dual licensed as **MIT OR
|
||||||
|
> Apache-2.0**, without any additional terms or conditions.
|
||||||
|
|
||||||
|
By opening a pull request you agree to license your contribution under these terms. This is the
|
||||||
|
standard Rust-ecosystem "inbound = outbound" model; it keeps the project's licensing unambiguous
|
||||||
|
(including the Apache-2.0 §5 contributor patent grant) and any future relicensing clean. You retain
|
||||||
|
the copyright to your contributions.
|
||||||
|
|
||||||
|
### Do not paste copyleft (or otherwise incompatibly-licensed) code
|
||||||
|
|
||||||
|
The single thing that could poison the permissive license is **copied source from a copyleft
|
||||||
|
project**. Several adjacent projects (Sunshine, Apollo, Moonlight) are GPL-3.0. You may study them
|
||||||
|
and reimplement a *technique*, protocol, or wire format — those are not copyrightable — but **never
|
||||||
|
paste their code**, and do not translate a GPL implementation line-by-line. When a comment credits
|
||||||
|
prior art, make clear it is an independent reimplementation, not a copy. The same applies to any
|
||||||
|
third party's code under a license incompatible with MIT/Apache.
|
||||||
|
|
||||||
|
If you add a new third-party dependency, it must be permissive (MIT / Apache-2.0 / BSD / ISC / Zlib /
|
||||||
|
Unicode-3.0 / etc.). `about.toml` holds the accepted-license allow-list; regenerate the attribution
|
||||||
|
file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
||||||
|
|
||||||
|
## Before you push
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt --all --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated artifacts are checked in and CI fails on drift: `include/punktfunk_core.h` (cbindgen) and
|
||||||
|
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
|
||||||
|
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
|
||||||
|
|
||||||
|
See [`CLAUDE.md`](CLAUDE.md) for the full build/test/run guide and design invariants.
|
||||||
Generated
+114
-294
@@ -137,18 +137,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.103"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arc-swap"
|
|
||||||
version = "1.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
|
||||||
dependencies = [
|
|
||||||
"rustversion",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ash"
|
name = "ash"
|
||||||
@@ -161,13 +152,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ashpd"
|
name = "ashpd"
|
||||||
version = "0.13.11"
|
version = "0.13.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "340e0f6bf7f9ee78549c61454f1460a3ed97c011902ee76b58301bbc6d502a32"
|
checksum = "281e6645758940dee594495e28807a7672ce40f11ebf4df6c22c4fcd59e2689f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -358,23 +349,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum-server"
|
name = "axum-server"
|
||||||
version = "0.7.3"
|
version = "0.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
|
checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"either",
|
||||||
"fs-err",
|
"fs-err",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"pin-project-lite",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pemfile",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -476,9 +462,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cairo-rs"
|
name = "cairo-rs"
|
||||||
@@ -520,9 +506,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cbindgen"
|
name = "cbindgen"
|
||||||
version = "0.29.3"
|
version = "0.29.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c95537b45400390270fae69ac098d057c8f5399001cde9d04f700c105ddfff2d"
|
checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"heck",
|
"heck",
|
||||||
@@ -539,9 +525,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.63"
|
version = "1.2.65"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -906,9 +892,6 @@ name = "deranged"
|
|||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
dependencies = [
|
|
||||||
"powerfmt",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
@@ -1127,9 +1110,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
|
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
@@ -1142,12 +1125,6 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "foldhash"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1376,15 +1353,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
"wasip2",
|
|
||||||
"wasip3",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1595,9 +1570,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.14"
|
version = "0.4.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1623,22 +1598,13 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.15.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
|
||||||
dependencies = [
|
|
||||||
"foldhash 0.1.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1647,7 +1613,7 @@ version = "0.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1858,12 +1824,6 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "id-arena"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -2014,9 +1974,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.100"
|
version = "0.3.103"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
|
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -2035,7 +1995,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2046,12 +2006,6 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "leb128fmt"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libadwaita"
|
name = "libadwaita"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -2167,13 +2121,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.32"
|
version = "0.4.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2201,9 +2155,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mdns-sd"
|
name = "mdns-sd"
|
||||||
version = "0.20.0"
|
version = "0.20.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "892f96f6d2ebe1ea641279f986ac52a2a6bac71e8f743bb258315cfe2bd7e88e"
|
checksum = "fb75febbe5fa1837a52fdbd1c735e168286c5c645fc2ddd31526f65c49941c2e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"flume",
|
"flume",
|
||||||
@@ -2216,15 +2170,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.1"
|
version = "2.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memmap2"
|
name = "memmap2"
|
||||||
version = "0.9.10"
|
version = "0.9.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
|
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -2377,6 +2331,17 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-derive"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.46"
|
||||||
@@ -2716,16 +2681,6 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prettyplease"
|
|
||||||
version = "0.2.37"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
@@ -2765,7 +2720,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -2779,7 +2734,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2799,7 +2754,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2819,7 +2774,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2828,6 +2783,7 @@ dependencies = [
|
|||||||
"fec-rs",
|
"fec-rs",
|
||||||
"hmac",
|
"hmac",
|
||||||
"libc",
|
"libc",
|
||||||
|
"opus",
|
||||||
"proptest",
|
"proptest",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
@@ -2848,14 +2804,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
"audiopus_sys",
|
|
||||||
"axum",
|
"axum",
|
||||||
"axum-server",
|
"axum-server",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -2885,7 +2840,6 @@ dependencies = [
|
|||||||
"rsa",
|
"rsa",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pemfile",
|
|
||||||
"rusty_enet",
|
"rusty_enet",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2896,12 +2850,14 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
"usbip-sim",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"utoipa-axum",
|
"utoipa-axum",
|
||||||
"utoipa-scalar",
|
"utoipa-scalar",
|
||||||
"wasapi",
|
"wasapi",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
"wayland-protocols-misc",
|
"wayland-protocols-misc",
|
||||||
"wayland-protocols-wlr",
|
"wayland-protocols-wlr",
|
||||||
"wayland-scanner",
|
"wayland-scanner",
|
||||||
@@ -2914,7 +2870,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -2943,9 +2899,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
@@ -2963,9 +2919,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.14"
|
version = "0.11.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"fastbloom",
|
"fastbloom",
|
||||||
@@ -3000,9 +2956,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
@@ -3156,9 +3112,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.3"
|
version = "1.12.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -3179,9 +3135,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-syntax"
|
name = "regex-syntax"
|
||||||
version = "0.8.10"
|
version = "0.8.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reis"
|
name = "reis"
|
||||||
@@ -3309,9 +3265,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.40"
|
version = "0.23.41"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"log",
|
"log",
|
||||||
@@ -3335,15 +3291,6 @@ dependencies = [
|
|||||||
"security-framework",
|
"security-framework",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls-pemfile"
|
|
||||||
version = "2.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
|
||||||
dependencies = [
|
|
||||||
"rustls-pki-types",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.1"
|
version = "1.14.1"
|
||||||
@@ -3740,19 +3687,19 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.1"
|
version = "1.15.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket-pktinfo"
|
name = "socket-pktinfo"
|
||||||
version = "0.3.2"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f"
|
checksum = "3e8e43b4bdce7cff8a4d3f8025ee38fce5ca138fab868ebbf9529c81328fbf9d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"socket2",
|
"socket2",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3828,9 +3775,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.118"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -3880,7 +3827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -3937,12 +3884,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.47"
|
version = "0.3.51"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"itoa",
|
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -3952,15 +3898,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time-core"
|
name = "time-core"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time-macros"
|
name = "time-macros"
|
||||||
version = "0.2.27"
|
version = "0.2.30"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"time-core",
|
"time-core",
|
||||||
@@ -4259,12 +4205,6 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-xid"
|
|
||||||
version = "0.2.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "universal-hash"
|
name = "universal-hash"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -4309,6 +4249,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "usbip-sim"
|
||||||
|
version = "0.8.0"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"serde",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -4372,9 +4323,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.2"
|
version = "1.23.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -4445,27 +4396,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasip2"
|
name = "wasip2"
|
||||||
version = "1.0.3+wasi-0.2.9"
|
version = "1.0.4+wasi-0.2.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wit-bindgen 0.57.1",
|
"wit-bindgen",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasip3"
|
|
||||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
|
||||||
dependencies = [
|
|
||||||
"wit-bindgen 0.51.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.123"
|
version = "0.2.126"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
|
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -4476,9 +4418,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.123"
|
version = "0.2.126"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
|
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -4486,9 +4428,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.123"
|
version = "0.2.126"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
|
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -4499,47 +4441,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.123"
|
version = "0.2.126"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
|
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-encoder"
|
|
||||||
version = "0.244.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
|
||||||
dependencies = [
|
|
||||||
"leb128fmt",
|
|
||||||
"wasmparser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-metadata"
|
|
||||||
version = "0.244.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"indexmap",
|
|
||||||
"wasm-encoder",
|
|
||||||
"wasmparser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmparser"
|
|
||||||
version = "0.244.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"hashbrown 0.15.5",
|
|
||||||
"indexmap",
|
|
||||||
"semver",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-backend"
|
name = "wayland-backend"
|
||||||
version = "0.3.15"
|
version = "0.3.15"
|
||||||
@@ -4567,9 +4475,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-protocols"
|
name = "wayland-protocols"
|
||||||
version = "0.32.12"
|
version = "0.32.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
|
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
@@ -4635,9 +4543,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-root-certs"
|
name = "webpki-root-certs"
|
||||||
version = "1.0.7"
|
version = "1.0.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
@@ -5195,100 +5103,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen"
|
|
||||||
version = "0.51.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
|
||||||
dependencies = [
|
|
||||||
"wit-bindgen-rust-macro",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.57.1"
|
version = "0.57.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen-core"
|
|
||||||
version = "0.51.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"heck",
|
|
||||||
"wit-parser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen-rust"
|
|
||||||
version = "0.51.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"heck",
|
|
||||||
"indexmap",
|
|
||||||
"prettyplease",
|
|
||||||
"syn",
|
|
||||||
"wasm-metadata",
|
|
||||||
"wit-bindgen-core",
|
|
||||||
"wit-component",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen-rust-macro"
|
|
||||||
version = "0.51.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"prettyplease",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
"wit-bindgen-core",
|
|
||||||
"wit-bindgen-rust",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-component"
|
|
||||||
version = "0.244.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"bitflags",
|
|
||||||
"indexmap",
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"wasm-encoder",
|
|
||||||
"wasm-metadata",
|
|
||||||
"wasmparser",
|
|
||||||
"wit-parser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-parser"
|
|
||||||
version = "0.244.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"id-arena",
|
|
||||||
"indexmap",
|
|
||||||
"log",
|
|
||||||
"semver",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"unicode-xid",
|
|
||||||
"wasmparser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@@ -5419,18 +5239,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.50"
|
version = "0.8.52"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.50"
|
version = "0.8.52"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -5460,9 +5280,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.8.2"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|||||||
+4
-1
@@ -3,6 +3,7 @@ resolver = "2"
|
|||||||
members = [
|
members = [
|
||||||
"crates/punktfunk-core",
|
"crates/punktfunk-core",
|
||||||
"crates/punktfunk-host",
|
"crates/punktfunk-host",
|
||||||
|
"crates/punktfunk-host/vendor/usbip-sim",
|
||||||
"crates/pf-driver-proto",
|
"crates/pf-driver-proto",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
@@ -11,9 +12,11 @@ members = [
|
|||||||
"tools/latency-probe",
|
"tools/latency-probe",
|
||||||
"tools/loss-harness",
|
"tools/loss-harness",
|
||||||
]
|
]
|
||||||
|
# Standalone PoC (built on its own; pulls usbip/tokio/libusb we don't want in the workspace).
|
||||||
|
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -155,4 +155,31 @@ tools/ latency-probe · loss-harness (measurement)
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT OR Apache-2.0.
|
Licensed under either of
|
||||||
|
|
||||||
|
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
|
||||||
|
<https://www.apache.org/licenses/LICENSE-2.0>)
|
||||||
|
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <https://opensource.org/licenses/MIT>)
|
||||||
|
|
||||||
|
at your option — `SPDX-License-Identifier: MIT OR Apache-2.0`.
|
||||||
|
|
||||||
|
### Contribution
|
||||||
|
|
||||||
|
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
|
||||||
|
the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any
|
||||||
|
additional terms or conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
|
### Third-party components
|
||||||
|
|
||||||
|
punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
|
||||||
|
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
|
||||||
|
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
|
||||||
|
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
|
||||||
|
notice ship in the installed `licenses/` folder).
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
|
||||||
|
NVIDIA, Microsoft, Sony, Valve, or the Moonlight project. "GameStream", "Moonlight", "Xbox",
|
||||||
|
"DualSense", "DualShock", and "PlayStation" are trademarks of their respective owners and are used
|
||||||
|
here only to describe interoperability.
|
||||||
|
|||||||
+16154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
THIRD-PARTY SOFTWARE NOTICES
|
||||||
|
============================================================================
|
||||||
|
|
||||||
|
punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||||
|
The binaries it ships statically/dynamically link the third-party Rust crates below.
|
||||||
|
Each is distributed under its own permissive license; full texts follow.
|
||||||
|
Generated by `cargo about generate about.hbs` (see about.toml) — do not edit by hand.
|
||||||
|
|
||||||
|
Overview:
|
||||||
|
{{#each overview}}
|
||||||
|
{{name}} ({{id}}): {{count}} crate(s)
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
{{#each licenses}}
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
{{name}} ({{id}})
|
||||||
|
Used by:
|
||||||
|
{{#each used_by}} - {{crate.name}} {{crate.version}}{{#if crate.repository}} ({{crate.repository}}){{/if}}
|
||||||
|
{{/each}}
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
{{text}}
|
||||||
|
|
||||||
|
{{/each}}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
# cargo-about config — full-fidelity third-party license harvest for CI.
|
||||||
|
#
|
||||||
|
# cargo install cargo-about
|
||||||
|
# cargo about generate about.hbs > THIRD-PARTY-NOTICES.txt # (or use scripts/gen-third-party-notices.sh)
|
||||||
|
#
|
||||||
|
# `accepted` is the allow-list of SPDX licenses permitted in the dependency tree. CI fails if a crate
|
||||||
|
# carries anything not listed here — which is exactly the regression guard we want against a copyleft
|
||||||
|
# dependency silently entering the linked set. All entries
|
||||||
|
# below are permissive / attribution-only; deliberately NO GPL/LGPL/AGPL/MPL-link/SSPL/EPL.
|
||||||
|
#
|
||||||
|
# The dependency-free fallback is scripts/gen-third-party-notices.py (reads the cargo registry cache),
|
||||||
|
# which is what produced the committed baseline when cargo-about is unavailable offline.
|
||||||
|
|
||||||
|
accepted = [
|
||||||
|
"MIT",
|
||||||
|
"MIT-0",
|
||||||
|
"Apache-2.0",
|
||||||
|
"Apache-2.0 WITH LLVM-exception",
|
||||||
|
"BSD-2-Clause",
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"ISC",
|
||||||
|
"Zlib",
|
||||||
|
"0BSD",
|
||||||
|
"BSL-1.0",
|
||||||
|
"Unicode-3.0",
|
||||||
|
"Unicode-DFS-2016",
|
||||||
|
"CDLA-Permissive-2.0",
|
||||||
|
"CC0-1.0",
|
||||||
|
"Unlicense",
|
||||||
|
"WTFPL",
|
||||||
|
"OpenSSL",
|
||||||
|
]
|
||||||
|
|
||||||
|
# cbindgen is MPL-2.0 but it is a BUILD-ONLY codegen tool that never links into a shipped artifact
|
||||||
|
# (its generated header is not a derivative work), so it is excluded from the notices rather than
|
||||||
|
# accepted as a linked license.
|
||||||
|
ignore-build-dependencies = true
|
||||||
|
ignore-dev-dependencies = true
|
||||||
|
|
||||||
|
# r-efi offers an LGPL-2.1-or-later arm but is tri-licensed; take a permissive arm. (It is also
|
||||||
|
# UEFI-target-gated out of every shipped build.)
|
||||||
|
[r-efi.clarify]
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
|
[ring.clarify]
|
||||||
|
license = "MIT AND ISC AND OpenSSL"
|
||||||
|
|
||||||
|
[aws-lc-sys.clarify]
|
||||||
|
license = "ISC AND Apache-2.0 AND MIT AND BSD-3-Clause AND OpenSSL"
|
||||||
+9
-1
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.0.1"
|
"version": "0.3.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
@@ -1354,6 +1354,14 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Arm-native-pairing request body.",
|
"description": "Arm-native-pairing request body.",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"fingerprint": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).\nWhen set, only a pairing attempt from that fingerprint consumes the window — so an unpaired\nLAN peer can neither pair nor burn a window armed for a specific device (security-review #9).\nOmit for an unbound window (any device may use the PIN — trusted-LAN only).",
|
||||||
|
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||||
|
},
|
||||||
"ttl_secs": {
|
"ttl_secs": {
|
||||||
"type": [
|
"type": [
|
||||||
"integer",
|
"integer",
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ android {
|
|||||||
|
|
||||||
buildFeatures { compose = true }
|
buildFeatures { compose = true }
|
||||||
|
|
||||||
|
// Roborazzi/Robolectric render Compose on the host JVM (the CI screenshot harness) and need the
|
||||||
|
// merged Android resources + the app's manifest/theme available to the unit tests.
|
||||||
|
testOptions { unitTests { isIncludeAndroidResources = true } }
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
targetCompatibility = JavaVersion.VERSION_21
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
@@ -99,4 +103,21 @@ dependencies {
|
|||||||
// Android TV components (we target phone + TV) land in the TV-UI milestone:
|
// Android TV components (we target phone + TV) land in the TV-UI milestone:
|
||||||
// implementation("androidx.tv:tv-material:1.1.0")
|
// implementation("androidx.tv:tv-material:1.1.0")
|
||||||
// The manifest already declares leanback so the scaffold installs on TV.
|
// The manifest already declares leanback so the scaffold installs on TV.
|
||||||
|
|
||||||
|
// --- CI screenshot harness (Roborazzi on the JVM via Robolectric — no emulator/GPU). The
|
||||||
|
// screenshot tests render the real Compose UI with mock state; never load the JNI core, so the
|
||||||
|
// job runs `:app:testDebugUnitTest -PskipRustBuild` (see kit/build.gradle.kts). ---
|
||||||
|
testImplementation(composeBom)
|
||||||
|
testImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||||
|
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.64.0")
|
||||||
|
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.64.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record (write) the screenshots when the unit tests run. These tests exist to GENERATE marketing
|
||||||
|
// images, not to diff goldens, so always capture rather than verify.
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
systemProperty("roborazzi.test.record", "true")
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -74,10 +74,31 @@ import io.unom.punktfunk.kit.security.KnownHostStore
|
|||||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||||
import io.unom.punktfunk.models.HostStatus
|
import io.unom.punktfunk.models.HostStatus
|
||||||
import io.unom.punktfunk.models.PendingTrust
|
import io.unom.punktfunk.models.PendingTrust
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Handshake budget for a normal connect (the prior hardcoded value, now passed explicitly). */
|
||||||
|
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handshake budget for the no-PIN "request access" connect. Must exceed the host's approval-park
|
||||||
|
* window (~180 s) so a slow operator approval still lands on this same parked connection rather than
|
||||||
|
* timing the client out first. Mirrors the Linux client's 185 s.
|
||||||
|
*/
|
||||||
|
private const val REQUEST_ACCESS_TIMEOUT_MS = 185_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A no-PIN "request access" connect in flight — the host being requested (drives the cancelable
|
||||||
|
* "Waiting for approval…" dialog) and a per-attempt flag the Cancel button trips. The connect is a
|
||||||
|
* blocking call with no abort, so Cancel returns the UI immediately and a late result checks
|
||||||
|
* [cancelled] and tears the (possibly just-approved) session down silently rather than navigating.
|
||||||
|
*/
|
||||||
|
private class RequestAccessState(val target: PendingTrust) {
|
||||||
|
val cancelled = AtomicBoolean(false)
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||||
@@ -128,8 +149,11 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
.onSuccess { identity = it }
|
.onSuccess { identity = it }
|
||||||
.onFailure { status = "Identity unavailable: ${it.message} — re-pair may be required" }
|
.onFailure { status = "Identity unavailable: ${it.message} — re-pair may be required" }
|
||||||
}
|
}
|
||||||
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
|
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing / the
|
||||||
|
// request-access-or-PIN choice).
|
||||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||||
|
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
||||||
|
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
||||||
// A saved host whose label is being edited (the Rename dialog).
|
// A saved host whose label is being edited (the Rename dialog).
|
||||||
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||||
|
|
||||||
@@ -151,9 +175,9 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
status = "Connecting to $targetHost:$targetPort…"
|
status = "Connecting to $targetHost:$targetPort…"
|
||||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Advertise HDR only when this device's display can present it (else the host sends a
|
// Advertise HDR only when the user enabled it AND this device's display can present it
|
||||||
// proper SDR stream rather than PQ the panel would mis-tone-map).
|
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||||
val hdrEnabled = displaySupportsHdr(context)
|
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
|
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
|
||||||
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
|
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
|
||||||
// explicit choice is passed through unchanged.
|
// explicit choice is passed through unchanged.
|
||||||
@@ -163,7 +187,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
targetHost, targetPort, w, h, hz,
|
targetHost, targetPort, w, h, hz,
|
||||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
id.certPem, id.privateKeyPem, pinHex ?: "",
|
||||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||||
hdrEnabled,
|
hdrEnabled, settings.audioChannels, CONNECT_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
connecting = false
|
connecting = false
|
||||||
@@ -182,10 +206,66 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust state is
|
// The no-PIN "request access" path (delegated approval): open a normal identified connect that
|
||||||
|
// the host PARKS until the operator clicks Approve in its console/web UI, showing a cancelable
|
||||||
|
// "Waiting for approval…" dialog meanwhile. The SAME connection is admitted on approval (no
|
||||||
|
// reconnect), so on success we record the host as PAIRED — the operator's approval IS the pairing.
|
||||||
|
// The connect can't be aborted, so Cancel returns the UI immediately and a late result is torn
|
||||||
|
// down silently via the per-attempt flag (mirrors the Linux client's request-access flow).
|
||||||
|
fun requestAccess(target: PendingTrust) {
|
||||||
|
val id = identity
|
||||||
|
if (id == null) {
|
||||||
|
status = "Identity not ready yet — try again in a moment"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val req = RequestAccessState(target)
|
||||||
|
awaiting = req
|
||||||
|
connecting = true
|
||||||
|
status = null
|
||||||
|
discovery.stop() // free the Wi-Fi radio before the (parked) stream session
|
||||||
|
scope.launch {
|
||||||
|
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||||
|
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||||
|
// Pin the advertised fingerprint for a discovered host (defence against an impostor while
|
||||||
|
// we wait); a manually-typed host has none, so trust-on-first-use.
|
||||||
|
val pinHex = target.advertisedFp ?: ""
|
||||||
|
val handle = withContext(Dispatchers.IO) {
|
||||||
|
NativeBridge.nativeConnect(
|
||||||
|
target.host, target.port, w, h, hz,
|
||||||
|
id.certPem, id.privateKeyPem, pinHex,
|
||||||
|
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||||
|
hdrEnabled, settings.audioChannels, REQUEST_ACCESS_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Cancelled while we were parked: tear the (possibly just-approved) session down and
|
||||||
|
// don't touch UI a fresh action may now own.
|
||||||
|
if (req.cancelled.get()) {
|
||||||
|
if (handle != 0L) withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
awaiting = null
|
||||||
|
connecting = false
|
||||||
|
if (handle != 0L) {
|
||||||
|
// Approved — save the host as PAIRED, pinning the fingerprint it presented, so
|
||||||
|
// future connects are silent (exactly like after a PIN ceremony).
|
||||||
|
val fp = NativeBridge.nativeHostFingerprint(handle)
|
||||||
|
if (fp.isNotEmpty()) {
|
||||||
|
knownHostStore.save(KnownHost(target.host, target.port, target.name, fp, paired = true))
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
}
|
||||||
|
onConnected(handle)
|
||||||
|
} else {
|
||||||
|
status = "Request timed out — approve this device in the host's console, then retry."
|
||||||
|
discovery.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide pinned-reconnect vs fp-changed vs TOFU vs pairing before connecting. Trust state is
|
||||||
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
|
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
|
||||||
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
|
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
|
||||||
// pair=required host, or a manual/unknown-policy host, must pair by PIN.
|
// pair=required host, or a manual/unknown-policy host, must pair — either by no-PIN request
|
||||||
|
// access (approve in the console) or by the SPAKE2 PIN ceremony.
|
||||||
fun connect(
|
fun connect(
|
||||||
targetHost: String,
|
targetHost: String,
|
||||||
targetPort: Int,
|
targetPort: Int,
|
||||||
@@ -208,9 +288,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
|
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
|
||||||
dh?.pairingRequired == false -> pendingTrust =
|
dh?.pairingRequired == false -> pendingTrust =
|
||||||
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
|
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
|
||||||
// pair=required, or a manual/unknown-policy host → PIN pairing is mandatory.
|
// pair=required, or a manual/unknown-policy host → offer the two ways in: a no-PIN
|
||||||
|
// "request access" (approve in the console) or the SPAKE2 PIN ceremony.
|
||||||
else -> pendingTrust =
|
else -> pendingTrust =
|
||||||
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.PAIR)
|
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,6 +552,33 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
// A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request
|
||||||
|
// access" is the no-PIN path — connect and wait for the operator to click Approve in the
|
||||||
|
// host's console; "Use a PIN…" switches to the SPAKE2 ceremony.
|
||||||
|
PendingTrust.Kind.REQUEST_ACCESS -> AlertDialog(
|
||||||
|
onDismissRequest = { pendingTrust = null },
|
||||||
|
title = { Text("Pairing required") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||||
|
Text(
|
||||||
|
"Request access and approve this device in the host's console (or web " +
|
||||||
|
"UI) — no PIN needed. Or pair with the 4-digit PIN the host displays.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton({ pendingTrust = null; requestAccess(pt) }) { Text("Request access") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row {
|
||||||
|
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) {
|
||||||
|
Text("Use a PIN…")
|
||||||
|
}
|
||||||
|
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
PendingTrust.Kind.PAIR -> {
|
PendingTrust.Kind.PAIR -> {
|
||||||
var pin by remember(pt) { mutableStateOf("") }
|
var pin by remember(pt) { mutableStateOf("") }
|
||||||
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
||||||
@@ -537,6 +645,44 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The no-PIN "request access" wait: the connect is parked on the host until the operator
|
||||||
|
// approves this device. Cancel returns the UI immediately — it trips the per-attempt flag so a
|
||||||
|
// late approval is torn down silently (see requestAccess) and resumes discovery.
|
||||||
|
awaiting?.let { req ->
|
||||||
|
fun cancel() {
|
||||||
|
req.cancelled.set(true)
|
||||||
|
awaiting = null
|
||||||
|
connecting = false
|
||||||
|
discovery.start() // the request may still be pending on the host; keep scanning
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { cancel() },
|
||||||
|
title = { Text("Waiting for approval") },
|
||||||
|
text = {
|
||||||
|
val deviceName = Build.MODEL ?: "this device"
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
|
Text("Approve this device on ${req.target.name}.")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
|
||||||
|
"automatically once you approve — no PIN needed.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { cancel() }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
||||||
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
||||||
renameTarget?.let { kh ->
|
renameTarget?.let { kh ->
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open-source licenses: punktfunk's own license (MIT OR Apache-2.0) plus the third-party software
|
||||||
|
* notices, read from the bundled `THIRD-PARTY-NOTICES.txt` asset (generated by
|
||||||
|
* scripts/gen-third-party-notices.sh). Reached from [SettingsScreen]; Back returns there.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LicensesScreen(onBack: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
|
val notices = remember {
|
||||||
|
runCatching {
|
||||||
|
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
|
||||||
|
}.getOrDefault("Third-party notices unavailable.")
|
||||||
|
}
|
||||||
|
val version = remember {
|
||||||
|
runCatching {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text("Open-source licenses", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
if (version != null) {
|
||||||
|
Text(
|
||||||
|
"punktfunk $version",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
||||||
|
"components below, each under its own license.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
notices,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,18 @@ data class Settings(
|
|||||||
val height: Int = 0,
|
val height: Int = 0,
|
||||||
val hz: Int = 0,
|
val hz: Int = 0,
|
||||||
val bitrateKbps: Int = 0,
|
val bitrateKbps: Int = 0,
|
||||||
|
/**
|
||||||
|
* Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that
|
||||||
|
* can actually present HDR10 (see [displaySupportsHdr]) — on an SDR display HDR is never
|
||||||
|
* advertised regardless, so the host sends a proper 8-bit BT.709 stream rather than PQ the panel
|
||||||
|
* would mis-tone-map. Turning this off forces SDR even on a capable panel.
|
||||||
|
*/
|
||||||
|
val hdrEnabled: Boolean = true,
|
||||||
val compositor: Int = 0,
|
val compositor: Int = 0,
|
||||||
val gamepad: Int = 0,
|
val gamepad: Int = 0,
|
||||||
|
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||||
|
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||||
|
val audioChannels: Int = 2,
|
||||||
val micEnabled: Boolean = false,
|
val micEnabled: Boolean = false,
|
||||||
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
|
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
|
||||||
val statsHudEnabled: Boolean = true,
|
val statsHudEnabled: Boolean = true,
|
||||||
@@ -37,8 +47,10 @@ class SettingsStore(context: Context) {
|
|||||||
height = prefs.getInt(K_H, 0),
|
height = prefs.getInt(K_H, 0),
|
||||||
hz = prefs.getInt(K_HZ, 0),
|
hz = prefs.getInt(K_HZ, 0),
|
||||||
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
||||||
|
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
||||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||||
|
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||||
statsHudEnabled = prefs.getBoolean(K_HUD, true),
|
statsHudEnabled = prefs.getBoolean(K_HUD, true),
|
||||||
trackpadMode = prefs.getBoolean(K_TRACKPAD, true),
|
trackpadMode = prefs.getBoolean(K_TRACKPAD, true),
|
||||||
@@ -50,8 +62,10 @@ class SettingsStore(context: Context) {
|
|||||||
.putInt(K_H, s.height)
|
.putInt(K_H, s.height)
|
||||||
.putInt(K_HZ, s.hz)
|
.putInt(K_HZ, s.hz)
|
||||||
.putInt(K_BITRATE, s.bitrateKbps)
|
.putInt(K_BITRATE, s.bitrateKbps)
|
||||||
|
.putBoolean(K_HDR, s.hdrEnabled)
|
||||||
.putInt(K_COMPOSITOR, s.compositor)
|
.putInt(K_COMPOSITOR, s.compositor)
|
||||||
.putInt(K_GAMEPAD, s.gamepad)
|
.putInt(K_GAMEPAD, s.gamepad)
|
||||||
|
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||||
.putBoolean(K_MIC, s.micEnabled)
|
.putBoolean(K_MIC, s.micEnabled)
|
||||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||||
.putBoolean(K_TRACKPAD, s.trackpadMode)
|
.putBoolean(K_TRACKPAD, s.trackpadMode)
|
||||||
@@ -63,8 +77,10 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_H = "height"
|
const val K_H = "height"
|
||||||
const val K_HZ = "hz"
|
const val K_HZ = "hz"
|
||||||
const val K_BITRATE = "bitrate_kbps"
|
const val K_BITRATE = "bitrate_kbps"
|
||||||
|
const val K_HDR = "hdr_enabled"
|
||||||
const val K_COMPOSITOR = "compositor"
|
const val K_COMPOSITOR = "compositor"
|
||||||
const val K_GAMEPAD = "gamepad"
|
const val K_GAMEPAD = "gamepad"
|
||||||
|
const val K_AUDIO_CH = "audio_channels"
|
||||||
const val K_MIC = "mic_enabled"
|
const val K_MIC = "mic_enabled"
|
||||||
const val K_HUD = "stats_hud_enabled"
|
const val K_HUD = "stats_hud_enabled"
|
||||||
const val K_TRACKPAD = "trackpad_mode"
|
const val K_TRACKPAD = "trackpad_mode"
|
||||||
@@ -133,6 +149,13 @@ val REFRESH_OPTIONS = listOf(
|
|||||||
240 to "240 Hz",
|
240 to "240 Hz",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** (channel count, label). 2 = stereo (default), 6 = 5.1, 8 = 7.1. */
|
||||||
|
val AUDIO_CHANNEL_OPTIONS = listOf(
|
||||||
|
2 to "Stereo",
|
||||||
|
6 to "5.1 Surround",
|
||||||
|
8 to "7.1 Surround",
|
||||||
|
)
|
||||||
|
|
||||||
/** (kbps, label). `0` = host default. */
|
/** (kbps, label). `0` = host default. */
|
||||||
val BITRATE_OPTIONS = listOf(
|
val BITRATE_OPTIONS = listOf(
|
||||||
0 to "Automatic",
|
0 to "Automatic",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.pm.PackageManager
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
@@ -44,6 +45,7 @@ import androidx.core.content.ContextCompat
|
|||||||
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
|
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
|
||||||
var s by remember { mutableStateOf(initial) }
|
var s by remember { mutableStateOf(initial) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
var showLicenses by remember { mutableStateOf(false) }
|
||||||
fun update(next: Settings) {
|
fun update(next: Settings) {
|
||||||
s = next
|
s = next
|
||||||
onChange(next)
|
onChange(next)
|
||||||
@@ -56,6 +58,11 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { granted -> update(s.copy(micEnabled = granted)) }
|
) { granted -> update(s.copy(micEnabled = granted)) }
|
||||||
|
|
||||||
|
if (showLicenses) {
|
||||||
|
LicensesScreen(onBack = { showLicenses = false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -87,6 +94,22 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
options = BITRATE_OPTIONS,
|
options = BITRATE_OPTIONS,
|
||||||
selected = s.bitrateKbps,
|
selected = s.bitrateKbps,
|
||||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
||||||
|
|
||||||
|
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle
|
||||||
|
// is disabled (and HDR is never advertised regardless) so the host doesn't send PQ the
|
||||||
|
// panel would mis-tone-map. The capability is fixed for the device, so read it once.
|
||||||
|
val hdrCapable = remember { displaySupportsHdr(context) }
|
||||||
|
ToggleRow(
|
||||||
|
title = "HDR",
|
||||||
|
subtitle = if (hdrCapable) {
|
||||||
|
"Stream 10-bit HDR (BT.2020 PQ) when the host supports it"
|
||||||
|
} else {
|
||||||
|
"This display can't present HDR10 — streams stay SDR"
|
||||||
|
},
|
||||||
|
checked = s.hdrEnabled && hdrCapable,
|
||||||
|
enabled = hdrCapable,
|
||||||
|
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsGroup("Host") {
|
SettingsGroup("Host") {
|
||||||
@@ -104,6 +127,12 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsGroup("Audio") {
|
SettingsGroup("Audio") {
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Audio channels",
|
||||||
|
options = AUDIO_CHANNEL_OPTIONS,
|
||||||
|
selected = s.audioChannels,
|
||||||
|
) { ch -> update(s.copy(audioChannels = ch)) }
|
||||||
|
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
title = "Microphone",
|
title = "Microphone",
|
||||||
subtitle = "Send your mic to the host's virtual microphone",
|
subtitle = "Send your mic to the host's virtual microphone",
|
||||||
@@ -137,6 +166,14 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsGroup("About") {
|
||||||
|
ClickableRow(
|
||||||
|
title = "Open-source licenses",
|
||||||
|
subtitle = "Third-party notices and credits",
|
||||||
|
onClick = { showLicenses = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,15 +197,41 @@ private fun SettingsGroup(title: String, content: @Composable ColumnScope.() ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A title + subtitle on the left, a Switch on the right. */
|
/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToggleRow(
|
private fun ToggleRow(
|
||||||
title: String,
|
title: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
checked: Boolean,
|
checked: Boolean,
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
|
// Dim the labels when disabled so the row reads as inactive (the Switch dims itself).
|
||||||
|
val labelAlpha = if (enabled) 1f else 0.38f
|
||||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = labelAlpha),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = labelAlpha),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A title + subtitle on the left; the whole row is clickable (opens a sub-screen). */
|
||||||
|
@Composable
|
||||||
|
private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||||
Text(
|
Text(
|
||||||
@@ -177,7 +240,6 @@ private fun ToggleRow(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
@@ -102,6 +103,13 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||||
}
|
}
|
||||||
|
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
|
||||||
|
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
|
||||||
|
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
||||||
|
// recreating the activity (no stream restart). On TV (fixed landscape) it's a harmless no-op.
|
||||||
|
// The prior request is captured and restored on the way out.
|
||||||
|
val priorOrientation = activity?.requestedOrientation
|
||||||
|
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
activity?.streamHandle = handle // route hardware keys to this session
|
activity?.streamHandle = handle // route hardware keys to this session
|
||||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
||||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||||
@@ -114,6 +122,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
activity?.streamHandle = 0L
|
activity?.streamHandle = 0L
|
||||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
// Release the landscape lock so the rest of the app follows the device/system again.
|
||||||
|
activity?.requestedOrientation =
|
||||||
|
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
// Leaving the stream: stop the mic + audio + decode threads and tear down the session.
|
// Leaving the stream: stop the mic + audio + decode threads and tear down the session.
|
||||||
NativeBridge.nativeStopMic(handle)
|
NativeBridge.nativeStopMic(handle)
|
||||||
NativeBridge.nativeStopAudio(handle)
|
NativeBridge.nativeStopAudio(handle)
|
||||||
@@ -314,12 +325,14 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The live stats overlay — mirrors the Apple client's HUD. Reads the 10-double layout from
|
* The live stats overlay — mirrors the Apple client's HUD. Reads the 14-double layout from
|
||||||
* [NativeBridge.nativeVideoStats]:
|
* [NativeBridge.nativeVideoStats]:
|
||||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped]`.
|
* `[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.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||||
if (s.size < 10) return
|
if (s.size < 10) return
|
||||||
val w = s[6].toInt()
|
val w = s[6].toInt()
|
||||||
val h = s[7].toInt()
|
val h = s[7].toInt()
|
||||||
@@ -338,6 +351,14 @@ private fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
|||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
|
videoFeedLine(s)?.let { feed ->
|
||||||
|
Text(
|
||||||
|
feed,
|
||||||
|
color = Color.White,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (latValid) {
|
if (latValid) {
|
||||||
val tag = if (skew) "" else " (same-host)"
|
val tag = if (skew) "" else " (same-host)"
|
||||||
Text(
|
Text(
|
||||||
@@ -357,3 +378,31 @@ private fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the negotiated video-feed descriptor from the trailing four stats doubles
|
||||||
|
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||||
|
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
|
||||||
|
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
|
||||||
|
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The
|
||||||
|
* Android decoder is always HEVC (`video/hevc`).
|
||||||
|
*/
|
||||||
|
private fun videoFeedLine(s: DoubleArray): String? {
|
||||||
|
if (s.size < 14) return null
|
||||||
|
val bitDepth = s[10].toInt()
|
||||||
|
val primaries = s[11].toInt()
|
||||||
|
val transfer = s[12].toInt()
|
||||||
|
val chromaIdc = s[13].toInt()
|
||||||
|
val depthLabel = if (bitDepth > 0) "$bitDepth-bit" else "8-bit"
|
||||||
|
val (dynamicRange, colorSpace) = when (transfer) {
|
||||||
|
16 -> "HDR" to "BT.2020 PQ"
|
||||||
|
18 -> "HDR" to "BT.2020 HLG"
|
||||||
|
else -> "SDR" to if (primaries == 9) "BT.2020" else "BT.709"
|
||||||
|
}
|
||||||
|
val chromaLabel = when (chromaIdc) {
|
||||||
|
3 -> "4:4:4"
|
||||||
|
2 -> "4:2:2"
|
||||||
|
else -> "4:2:0"
|
||||||
|
}
|
||||||
|
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
|
|
||||||
// punktfunk brand violets (from the app icon: #6C5BF3 / #A79FF8 / #D2C9FB on a #16132A indigo).
|
// punktfunk brand violets (from the app icon: #6C5BF3 / #A79FF8 / #D2C9FB on a #16132A indigo).
|
||||||
// Used as the fallback dark scheme on pre-Android-12 devices; on 12+ we defer to Material You.
|
// Used as the fallback dark scheme on pre-Android-12 devices; on 12+ we defer to Material You.
|
||||||
private val BrandDark = darkColorScheme(
|
// `internal` (not private) so the CI screenshot tests can force the deterministic brand palette —
|
||||||
|
// Material You dynamic colour has no wallpaper to seed from under the Robolectric JVM renderer.
|
||||||
|
internal val BrandDark = darkColorScheme(
|
||||||
primary = Color(0xFFA79FF8),
|
primary = Color(0xFFA79FF8),
|
||||||
onPrimary = Color(0xFF1B1442),
|
onPrimary = Color(0xFF1B1442),
|
||||||
primaryContainer = Color(0xFF4C3FB3),
|
primaryContainer = Color(0xFF4C3FB3),
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ enum class Tab(val label: String, val icon: ImageVector) {
|
|||||||
/**
|
/**
|
||||||
* A trust decision awaiting the user before a connect proceeds. [name] is the label to save the
|
* A trust decision awaiting the user before a connect proceeds. [name] is the label to save the
|
||||||
* host under. Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED
|
* host under. Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED
|
||||||
* pair=optional; a pair=required host or a manually-typed/unknown-policy host goes straight to PIN
|
* pair=optional; a pair=required host or a manually-typed/unknown-policy host is offered the
|
||||||
* pairing ([Kind.PAIR]), and a changed fingerprint forces re-pairing — never a silent re-trust.
|
* two ways in ([Kind.REQUEST_ACCESS]): a no-PIN "request access" connect the operator approves in
|
||||||
|
* the host's console, or the SPAKE2 PIN ceremony ([Kind.PAIR]). A changed fingerprint forces
|
||||||
|
* re-pairing by PIN ([Kind.FP_CHANGED]) — never a silent re-trust.
|
||||||
*/
|
*/
|
||||||
data class PendingTrust(
|
data class PendingTrust(
|
||||||
val host: String,
|
val host: String,
|
||||||
@@ -24,7 +26,7 @@ data class PendingTrust(
|
|||||||
val advertisedFp: String?,
|
val advertisedFp: String?,
|
||||||
val kind: Kind,
|
val kind: Kind,
|
||||||
) {
|
) {
|
||||||
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR }
|
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Trust state of a host, shown as a colored pill on its card. */
|
/** Trust state of a host, shown as a colored pill on its card. */
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package io.unom.punktfunk.screenshots
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import com.github.takahirom.roborazzi.captureScreenRoboImage
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-store / marketing screenshots of the native Android client, rendered on the JVM by Roborazzi
|
||||||
|
* (Robolectric Native Graphics) — no emulator, GPU, host, or JNI core. The scenes (ShotScenes.kt)
|
||||||
|
* render the REAL Compose UI with mock state.
|
||||||
|
*
|
||||||
|
* `sdk = [36]` is mandatory: Robolectric ships android-all jars only up to API 36 (Android 16), and
|
||||||
|
* the app's compileSdk is 37. PNGs land in build/outputs/roborazzi/.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||||
|
class ScreenshotTest {
|
||||||
|
@get:Rule
|
||||||
|
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
private val out = "build/outputs/roborazzi"
|
||||||
|
|
||||||
|
// Pausing the animation clock before composing (then advancing once past the entrance animation
|
||||||
|
// and freezing) is what makes a text-field-bearing scene capturable: a focused field blinks its
|
||||||
|
// cursor via an infinite animation that otherwise keeps Compose perpetually "busy", so
|
||||||
|
// setContent's wait-for-idle never returns. Frozen, the capture is also deterministic.
|
||||||
|
|
||||||
|
/** Full-screen content scenes: the compose root fills the device, so a root capture is the shot. */
|
||||||
|
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||||
|
compose.mainClock.autoAdvance = false
|
||||||
|
compose.setContent { ShotTheme(content) }
|
||||||
|
compose.mainClock.advanceTimeBy(800)
|
||||||
|
compose.onRoot().captureRoboImage("$out/phone-$name.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dialog scenes: the AlertDialog is a separate window, so capture the whole screen (all windows). */
|
||||||
|
private fun shootScreen(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||||
|
compose.mainClock.autoAdvance = false
|
||||||
|
compose.setContent { ShotTheme(content) }
|
||||||
|
compose.mainClock.advanceTimeBy(800)
|
||||||
|
captureScreenRoboImage("$out/phone-$name.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hosts() = shootRoot("hosts") { HostsScene() }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settings() = shootRoot("settings") { SettingsScene() }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
||||||
|
fun stream() = shootRoot("stream") { StreamScene() }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trust() = shootScreen("trust") {
|
||||||
|
HostsScene()
|
||||||
|
TrustDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pair() = shootScreen("pair") {
|
||||||
|
HostsScene()
|
||||||
|
PairDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package io.unom.punktfunk.screenshots
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.SettingsScreen
|
||||||
|
import io.unom.punktfunk.StatsOverlay
|
||||||
|
import io.unom.punktfunk.components.HostCard
|
||||||
|
import io.unom.punktfunk.components.SectionLabel
|
||||||
|
import io.unom.punktfunk.models.HostStatus
|
||||||
|
|
||||||
|
// The CI screenshot scenes: the REAL app composables, fed embedded mock state, under the forced
|
||||||
|
// brand palette (Material You has no wallpaper to seed from on the JVM). The stream-video surface
|
||||||
|
// and ConnectScreen/App are intentionally absent — they require the live JNI core / a session.
|
||||||
|
|
||||||
|
/** Forces the deterministic punktfunk brand scheme (see Theme.kt) instead of dynamic colour. */
|
||||||
|
@Composable
|
||||||
|
internal fun ShotTheme(content: @Composable () -> Unit) {
|
||||||
|
MaterialTheme(colorScheme = BrandDark, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class MockHost(val name: String, val address: String, val status: HostStatus)
|
||||||
|
|
||||||
|
private val SAVED = listOf(
|
||||||
|
MockHost("Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED),
|
||||||
|
MockHost("Office", "192.168.1.50:9777", HostStatus.TOFU),
|
||||||
|
)
|
||||||
|
private val DISCOVERED = listOf(
|
||||||
|
MockHost("studio-deck", "192.168.1.61:9777", HostStatus.PAIRING),
|
||||||
|
MockHost("HTPC", "192.168.1.70:9777", HostStatus.TOFU),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The connect screen's host grid, reconstructed from the real HostCard/SectionLabel components. */
|
||||||
|
@Composable
|
||||||
|
internal fun HostsScene() {
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Punktfunk", style = MaterialTheme.typography.headlineLarge)
|
||||||
|
Text(
|
||||||
|
"stream a remote desktop",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
|
||||||
|
items(SAVED) { h ->
|
||||||
|
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onRename = {})
|
||||||
|
}
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
SectionLabel("Discovered on the network")
|
||||||
|
}
|
||||||
|
items(DISCOVERED) { h ->
|
||||||
|
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The real SettingsScreen, fed a representative non-default Settings. */
|
||||||
|
@Composable
|
||||||
|
internal fun SettingsScene() {
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
SettingsScreen(
|
||||||
|
initial = Settings(
|
||||||
|
width = 1920,
|
||||||
|
height = 1080,
|
||||||
|
hz = 120,
|
||||||
|
bitrateKbps = 50_000,
|
||||||
|
compositor = 1,
|
||||||
|
gamepad = 2,
|
||||||
|
micEnabled = true,
|
||||||
|
statsHudEnabled = true,
|
||||||
|
trackpadMode = true,
|
||||||
|
),
|
||||||
|
onChange = {},
|
||||||
|
onBack = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The real TOFU AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.TRUST_NEW), shown over the host grid. */
|
||||||
|
@Composable
|
||||||
|
internal fun TrustDialog() {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Trust this host?") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("First connection to 192.168.1.61:9777.")
|
||||||
|
Text("Fingerprint 9f8e7d6c5b4a3928…")
|
||||||
|
Text(
|
||||||
|
"This host allows trust-on-first-use, but that can't tell an impostor " +
|
||||||
|
"from the real host. Pairing with a PIN is stronger — it proves both sides.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton({}) { Text("Trust (TOFU)") } },
|
||||||
|
dismissButton = { TextButton({}) { Text("Pair with PIN…") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The PIN-pairing AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.PAIR). The live screen
|
||||||
|
* uses OutlinedTextFields, but a TextField inside a Dialog window never reaches idle under
|
||||||
|
* Robolectric (its focus/cursor machinery animates forever) — so the PIN is shown as a static
|
||||||
|
* display here, which also reads better in a marketing shot. */
|
||||||
|
@Composable
|
||||||
|
internal fun PairDialog() {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Pair with PIN") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("Enter the 4-digit PIN shown on the host.")
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"4 8 2 7",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
"This device: Pixel 9 Pro",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton({}) { Text("Pair") } },
|
||||||
|
dismissButton = { TextButton({}) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient. */
|
||||||
|
@Composable
|
||||||
|
internal fun StreamScene() {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
// [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped,
|
||||||
|
// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc] — the last four = a 10-bit
|
||||||
|
// BT.2020 PQ (HDR) 4:2:0 feed, so the HUD renders its video-feed line.
|
||||||
|
StatsOverlay(
|
||||||
|
doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0, 10.0, 9.0, 16.0, 1.0),
|
||||||
|
Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,12 @@ val cargoNdkDebug = registerCargoNdk("cargoNdkDebug", release = false)
|
|||||||
val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true)
|
val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true)
|
||||||
|
|
||||||
afterEvaluate {
|
afterEvaluate {
|
||||||
tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
|
// `-PskipRustBuild` skips the cargo-ndk native build — for JVM-only tasks (the Roborazzi
|
||||||
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
// screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so
|
||||||
|
// CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired
|
||||||
|
// for every normal APK/AAR build.
|
||||||
|
if (!project.hasProperty("skipRustBuild")) {
|
||||||
|
tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
|
||||||
|
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,15 +50,25 @@ object Gamepad {
|
|||||||
const val PREF_DUALSENSE = 2
|
const val PREF_DUALSENSE = 2
|
||||||
const val PREF_XBOXONE = 3
|
const val PREF_XBOXONE = 3
|
||||||
const val PREF_DUALSHOCK4 = 4
|
const val PREF_DUALSHOCK4 = 4
|
||||||
|
const val PREF_STEAMCONTROLLER = 5
|
||||||
|
const val PREF_STEAMDECK = 6
|
||||||
|
|
||||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||||
private const val VID_SONY = 0x054C
|
private const val VID_SONY = 0x054C
|
||||||
private const val VID_MICROSOFT = 0x045E
|
private const val VID_MICROSOFT = 0x045E
|
||||||
|
private const val VID_VALVE = 0x28DE
|
||||||
|
|
||||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||||
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
||||||
|
|
||||||
|
// Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) /
|
||||||
|
// dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles /
|
||||||
|
// trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard
|
||||||
|
// buttons + sticks reach the host for now — parity with the desktop type resolution.
|
||||||
|
private val PID_STEAMDECK = setOf(0x1205)
|
||||||
|
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
|
||||||
|
|
||||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||||
private val PID_XBOXONE = setOf(
|
private val PID_XBOXONE = setOf(
|
||||||
@@ -82,6 +92,8 @@ object Gamepad {
|
|||||||
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
||||||
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||||
|
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
||||||
|
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
|
||||||
else -> PREF_XBOX360
|
else -> PREF_XBOX360
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ object NativeBridge {
|
|||||||
* trust-on-first-use — read [nativeHostFingerprint] after; else 64-hex host SHA-256, mismatch →
|
* trust-on-first-use — read [nativeHostFingerprint] after; else 64-hex host SHA-256, mismatch →
|
||||||
* `0`). [width]/[height]/[refreshHz] are the requested virtual-output mode (the host streams at
|
* `0`). [width]/[height]/[refreshHz] are the requested virtual-output mode (the host streams at
|
||||||
* exactly this); [bitrateKbps] 0 = host default; [compositorPref]/[gamepadPref] are the
|
* exactly this); [bitrateKbps] 0 = host default; [compositorPref]/[gamepadPref] are the
|
||||||
* `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). Returns an opaque session handle, or `0`
|
* `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). [timeoutMs] is the handshake budget — the
|
||||||
* on failure. Pair with exactly one [nativeClose].
|
* normal path passes a short value, the no-PIN "request access" path a long one (≥ the host's
|
||||||
|
* approval-park window) so a slow operator approval lands on this same parked connection. Returns
|
||||||
|
* an opaque session handle, or `0` on failure. Pair with exactly one [nativeClose].
|
||||||
*/
|
*/
|
||||||
external fun nativeConnect(
|
external fun nativeConnect(
|
||||||
host: String,
|
host: String,
|
||||||
@@ -45,6 +47,8 @@ object NativeBridge {
|
|||||||
compositorPref: Int,
|
compositorPref: Int,
|
||||||
gamepadPref: Int,
|
gamepadPref: Int,
|
||||||
hdrEnabled: Boolean,
|
hdrEnabled: Boolean,
|
||||||
|
audioChannels: Int,
|
||||||
|
timeoutMs: Int,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||||
@@ -99,9 +103,12 @@ object NativeBridge {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||||
* Returns 10 doubles:
|
* Returns 14 doubles:
|
||||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]`
|
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped,
|
||||||
* (the two flags are 1.0/0.0). Poll ~1 Hz; each call resets the measurement window.
|
* 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.
|
||||||
*/
|
*/
|
||||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
external fun nativeVideoStats(handle: Long): DoubleArray?
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
//! Android audio playback (android-only): pull Opus packets from the connector, decode to
|
//! Android audio playback (android-only): pull Opus packets from the connector, decode to
|
||||||
//! interleaved f32 stereo, and feed AAudio (LowLatency) via its realtime data callback through a
|
//! interleaved f32 (stereo or 5.1/7.1 surround), and feed AAudio (LowLatency) via its realtime data
|
||||||
//! jitter ring. Mirrors [`crate::decode`]: one thread we own (the Opus decode producer) plus a
|
//! callback through a jitter ring. Mirrors [`crate::decode`]: one thread we own (the Opus decode
|
||||||
//! shutdown flag; the realtime callback thread is owned by AAudio.
|
//! producer) plus a shutdown flag; the realtime callback thread is owned by AAudio.
|
||||||
|
//!
|
||||||
|
//! The layout is the host-RESOLVED channel count (`NativeClient::audio_channels`, negotiated at
|
||||||
|
//! connect), so an older/clamping host that can only capture stereo is decoded + played as stereo.
|
||||||
|
//! 2 = stereo / 6 = 5.1 / 8 = 7.1, in the canonical wire order FL FR FC LFE RL RR SL SR.
|
||||||
//!
|
//!
|
||||||
//! The ring started as a port of `punktfunk-client-linux/src/audio.rs`, but AAudio — unlike
|
//! The ring started as a port of `punktfunk-client-linux/src/audio.rs`, but AAudio — unlike
|
||||||
//! PipeWire, which adaptively rate-matches the stream and absorbs a shallow buffer — hands us a raw
|
//! PipeWire, which adaptively rate-matches the stream and absorbs a shallow buffer — hands us a raw
|
||||||
@@ -26,36 +30,72 @@ use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const CHANNELS: usize = 2;
|
|
||||||
const SAMPLE_RATE: i32 = 48_000;
|
const SAMPLE_RATE: i32 = 48_000;
|
||||||
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
|
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
|
||||||
const RING_CHUNKS: usize = 64;
|
const RING_CHUNKS: usize = 64;
|
||||||
/// Opus decode scratch: worst-case 120 ms stereo frame (5760 samples/ch × 2 ch).
|
|
||||||
const PCM_SCRATCH: usize = 5760 * CHANNELS;
|
|
||||||
|
|
||||||
// --- Jitter-ring depths, in interleaved-f32 samples (all expressed in ms via `MS`). -----------
|
// --- Jitter-ring depths, in MILLISECONDS (scaled to interleaved-f32 samples at runtime). --------
|
||||||
|
// The channel count is negotiated, not a compile-time const, so these are kept in ms and multiplied
|
||||||
|
// by `ms` (interleaved-f32 samples per millisecond at the resolved layout) inside `start`.
|
||||||
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
|
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
|
||||||
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
|
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
|
||||||
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
|
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
|
||||||
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
|
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
|
||||||
/// Interleaved f32 samples per millisecond (48 kHz × 2 ch).
|
|
||||||
const MS: usize = (SAMPLE_RATE as usize / 1000) * CHANNELS; // 96
|
|
||||||
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
|
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
|
||||||
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
|
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
|
||||||
const PRIME_FLOOR: usize = 40 * MS;
|
const PRIME_FLOOR_MS: usize = 40;
|
||||||
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
|
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
|
||||||
const PRIME_CEIL: usize = 80 * MS;
|
const PRIME_CEIL_MS: usize = 80;
|
||||||
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
|
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
|
||||||
/// without overflowing.
|
/// without overflowing.
|
||||||
const JITTER_HEADROOM: usize = 80 * MS;
|
const JITTER_HEADROOM_MS: usize = 80;
|
||||||
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
|
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
|
||||||
const HARD_CAP: usize = 150 * MS;
|
const HARD_CAP_MS: usize = 150;
|
||||||
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
|
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
|
||||||
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
|
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
|
||||||
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
|
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
|
||||||
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
|
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
|
||||||
const XRUN_CHECK_EVERY: u32 = 128;
|
const XRUN_CHECK_EVERY: u32 = 128;
|
||||||
|
|
||||||
|
/// Opus decoder for the audio plane: a plain stereo decoder (the validated path) or a multistream
|
||||||
|
/// decoder for 5.1/7.1, both behind one `decode_float`. Built from the host-RESOLVED channel count
|
||||||
|
/// via the shared layout table. Mirrors the Linux client's `AudioDec`.
|
||||||
|
enum AudioDec {
|
||||||
|
Stereo(opus::Decoder),
|
||||||
|
Surround(opus::MSDecoder),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioDec {
|
||||||
|
fn new(channels: u8) -> Result<AudioDec, opus::Error> {
|
||||||
|
if channels == 2 {
|
||||||
|
Ok(AudioDec::Stereo(opus::Decoder::new(
|
||||||
|
SAMPLE_RATE as u32,
|
||||||
|
opus::Channels::Stereo,
|
||||||
|
)?))
|
||||||
|
} else {
|
||||||
|
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||||
|
Ok(AudioDec::Surround(opus::MSDecoder::new(
|
||||||
|
SAMPLE_RATE as u32,
|
||||||
|
l.streams,
|
||||||
|
l.coupled,
|
||||||
|
l.mapping,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_float(
|
||||||
|
&mut self,
|
||||||
|
input: &[u8],
|
||||||
|
out: &mut [f32],
|
||||||
|
fec: bool,
|
||||||
|
) -> Result<usize, opus::Error> {
|
||||||
|
match self {
|
||||||
|
AudioDec::Stereo(d) => d.decode_float(input, out, fec),
|
||||||
|
AudioDec::Surround(d) => d.decode_float(input, out, fec),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Diagnostics — written by the decode thread + the realtime callback, logged periodically. The
|
/// Diagnostics — written by the decode thread + the realtime callback, logged periodically. The
|
||||||
/// audio analogue of the video `fed`/`rendered` counters (we can't "screenshot" sound).
|
/// audio analogue of the video `fed`/`rendered` counters (we can't "screenshot" sound).
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -74,9 +114,20 @@ pub struct AudioPlayback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AudioPlayback {
|
impl AudioPlayback {
|
||||||
/// Open AAudio (LowLatency, 48 kHz/stereo/f32) with a realtime callback draining a jitter ring,
|
/// Open AAudio (LowLatency, 48 kHz/f32, the host-resolved channel layout) with a realtime
|
||||||
/// then spawn the Opus decode thread. `None` on failure (the caller leaves video streaming).
|
/// callback draining a jitter ring, then spawn the Opus decode thread. `None` on failure (the
|
||||||
|
/// caller leaves video streaming).
|
||||||
pub fn start(client: Arc<NativeClient>) -> Option<AudioPlayback> {
|
pub fn start(client: Arc<NativeClient>) -> Option<AudioPlayback> {
|
||||||
|
// Build playback from the host-RESOLVED channel count (never the request): 2 = stereo /
|
||||||
|
// 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
|
||||||
|
let channels = punktfunk_core::audio::normalize_channels(client.audio_channels) as usize;
|
||||||
|
// Interleaved f32 samples per millisecond at this layout (48 kHz × channels); the ms-
|
||||||
|
// denominated jitter-ring depths scale by it.
|
||||||
|
let ms = (SAMPLE_RATE as usize / 1000) * channels;
|
||||||
|
let prime_floor = PRIME_FLOOR_MS * ms;
|
||||||
|
let prime_ceil = PRIME_CEIL_MS * ms;
|
||||||
|
let jitter_headroom = JITTER_HEADROOM_MS * ms;
|
||||||
|
let hard_cap_max = HARD_CAP_MS * ms;
|
||||||
let counters = Arc::new(Counters::default());
|
let counters = Arc::new(Counters::default());
|
||||||
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||||
// Recycle free-list: drained PCM buffers go BACK to the decode thread to be refilled, so the
|
// Recycle free-list: drained PCM buffers go BACK to the decode thread to be refilled, so the
|
||||||
@@ -92,13 +143,13 @@ impl AudioPlayback {
|
|||||||
// before the trim below = the hard cap plus one full channel of 5 ms (480-f32) frames — the
|
// before the trim below = the hard cap plus one full channel of 5 ms (480-f32) frames — the
|
||||||
// punktfunk protocol always sends 5 ms Opus frames (host `audio_thread`); a larger frame
|
// punktfunk protocol always sends 5 ms Opus frames (host `audio_thread`); a larger frame
|
||||||
// would force a one-time realloc, asserted (not silently corrupted) in `decode_loop`.
|
// would force a one-time realloc, asserted (not silently corrupted) in `decode_loop`.
|
||||||
let mut ring: VecDeque<f32> = VecDeque::with_capacity(HARD_CAP + RING_CHUNKS * 5 * MS);
|
let mut ring: VecDeque<f32> = VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms);
|
||||||
let mut primed = false;
|
let mut primed = false;
|
||||||
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
|
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
|
||||||
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
|
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
|
||||||
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
|
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
|
||||||
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
||||||
let want = num_frames as usize * CHANNELS;
|
let want = num_frames as usize * channels;
|
||||||
// SAFETY: AAudio provides `num_frames * channel_count` F32 slots at `data`.
|
// SAFETY: AAudio provides `num_frames * channel_count` F32 slots at `data`.
|
||||||
let out = unsafe { std::slice::from_raw_parts_mut(data as *mut f32, want) };
|
let out = unsafe { std::slice::from_raw_parts_mut(data as *mut f32, want) };
|
||||||
// Drain decoded chunks into the ring WITHOUT freeing on the RT thread: `drain(..)` empties
|
// Drain decoded chunks into the ring WITHOUT freeing on the RT thread: `drain(..)` empties
|
||||||
@@ -108,11 +159,11 @@ impl AudioPlayback {
|
|||||||
ring.extend(chunk.drain(..));
|
ring.extend(chunk.drain(..));
|
||||||
let _ = free_tx.try_send(chunk);
|
let _ = free_tx.try_send(chunk);
|
||||||
}
|
}
|
||||||
// Jitter buffer: prime to ~40 ms (PRIME_FLOOR) before playing and after a sustained drain;
|
// Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained drain;
|
||||||
// drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst `want` (tiny
|
// drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst `want` (tiny
|
||||||
// on the LowLatency MMAP path) so the depth doesn't collapse to a single quantum.
|
// on the LowLatency MMAP path) so the depth doesn't collapse to a single quantum.
|
||||||
let target = (3 * want).clamp(PRIME_FLOOR, PRIME_CEIL);
|
let target = (3 * want).clamp(prime_floor, prime_ceil);
|
||||||
let hard_cap = (target + JITTER_HEADROOM).min(HARD_CAP);
|
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
|
||||||
while ring.len() > hard_cap {
|
while ring.len() > hard_cap {
|
||||||
ring.pop_front();
|
ring.pop_front();
|
||||||
}
|
}
|
||||||
@@ -166,7 +217,11 @@ impl AudioPlayback {
|
|||||||
.ok()?
|
.ok()?
|
||||||
.direction(AudioDirection::Output)
|
.direction(AudioDirection::Output)
|
||||||
.sample_rate(SAMPLE_RATE)
|
.sample_rate(SAMPLE_RATE)
|
||||||
.channel_count(CHANNELS as i32)
|
// The wire order (FL FR FC LFE RL RR SL SR) is the standard AAudio/Android channel
|
||||||
|
// order, so this is an IDENTITY mapping — no permute. AAudio infers the 5.1/7.1 mask
|
||||||
|
// from `channel_count` (the ndk crate's builder exposes no setChannelMask); the host
|
||||||
|
// captures + Opus-encodes in exactly this order.
|
||||||
|
.channel_count(channels as i32)
|
||||||
.format(AudioFormat::PCM_Float)
|
.format(AudioFormat::PCM_Float)
|
||||||
.performance_mode(AudioPerformanceMode::LowLatency)
|
.performance_mode(AudioPerformanceMode::LowLatency)
|
||||||
.sharing_mode(AudioSharingMode::Shared)
|
.sharing_mode(AudioSharingMode::Shared)
|
||||||
@@ -206,7 +261,7 @@ impl AudioPlayback {
|
|||||||
let sd = shutdown.clone();
|
let sd = shutdown.clone();
|
||||||
let join = std::thread::Builder::new()
|
let join = std::thread::Builder::new()
|
||||||
.name("pf-audio".into())
|
.name("pf-audio".into())
|
||||||
.spawn(move || decode_loop(client, tx, free_rx, sd, counters))
|
.spawn(move || decode_loop(client, tx, free_rx, sd, counters, channels))
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
Some(AudioPlayback {
|
Some(AudioPlayback {
|
||||||
@@ -236,29 +291,34 @@ fn decode_loop(
|
|||||||
free_rx: Receiver<Vec<f32>>,
|
free_rx: Receiver<Vec<f32>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
counters: Arc<Counters>,
|
counters: Arc<Counters>,
|
||||||
|
channels: usize,
|
||||||
) {
|
) {
|
||||||
let mut dec = match opus::Decoder::new(SAMPLE_RATE as u32, opus::Channels::Stereo) {
|
// 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.
|
||||||
|
let pcm_scratch = 5760 * channels;
|
||||||
|
let mut dec = match AudioDec::new(channels as u8) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("audio: opus decoder init: {e} — audio disabled");
|
log::error!("audio: opus decoder init: {e} — audio disabled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut pcm = vec![0f32; PCM_SCRATCH];
|
let mut pcm = vec![0f32; pcm_scratch];
|
||||||
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
|
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
|
||||||
while !shutdown.load(Ordering::Relaxed) {
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
match client.next_audio(Duration::from_millis(5)) {
|
match client.next_audio(Duration::from_millis(5)) {
|
||||||
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) {
|
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||||
Ok(samples) => {
|
Ok(samples) => {
|
||||||
let n = samples * CHANNELS;
|
let n = samples * channels;
|
||||||
for &s in &pcm[..n] {
|
for &s in &pcm[..n] {
|
||||||
window_peak = window_peak.max(s.abs());
|
window_peak = window_peak.max(s.abs());
|
||||||
}
|
}
|
||||||
// The ring's pre-reservation in `start` assumes the protocol's 5 ms (≤480-f32)
|
// The ring's pre-reservation in `start` assumes the protocol's 5 ms (≤480-f32/ch)
|
||||||
// frames; a larger frame would force a one-time realloc on the RT thread. Catch a
|
// frames; a larger frame would force a one-time realloc on the RT thread. Catch a
|
||||||
// future host frame-size change here in debug, not as a silent audio glitch.
|
// future host frame-size change here in debug, not as a silent audio glitch.
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
n <= 5 * MS,
|
n <= 5 * ms,
|
||||||
"audio frame {n} f32 exceeds the 5 ms ring reserve"
|
"audio frame {n} f32 exceeds the 5 ms ring reserve"
|
||||||
);
|
);
|
||||||
let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1;
|
let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
@@ -266,7 +326,7 @@ fn decode_loop(
|
|||||||
// free-list is momentarily empty (startup / after a backpressure drop).
|
// free-list is momentarily empty (startup / after a backpressure drop).
|
||||||
let mut buf = free_rx
|
let mut buf = free_rx
|
||||||
.try_recv()
|
.try_recv()
|
||||||
.unwrap_or_else(|_| Vec::with_capacity(PCM_SCRATCH));
|
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
|
||||||
buf.clear();
|
buf.clear();
|
||||||
buf.extend_from_slice(&pcm[..n]);
|
buf.extend_from_slice(&pcm[..n]);
|
||||||
match tx.try_send(buf) {
|
match tx.try_send(buf) {
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|||||||
out[2..n].copy_from_slice(&effect);
|
out[2..n].copy_from_slice(&effect);
|
||||||
n
|
n
|
||||||
}
|
}
|
||||||
|
HidOutput::TrackpadHaptic { .. } => {
|
||||||
|
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
|
||||||
|
// rumble already rides the universal 0xCA plane).
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
n as jint
|
n as jint
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -140,11 +140,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||||
/// compositorPref, gamepadPref): Long`. `certPem`/`keyPem` empty = anonymous, else presented as the
|
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, timeoutMs): Long`. `certPem`/`keyPem`
|
||||||
/// persistent identity. `pinHex` empty = TOFU (read `nativeHostFingerprint` after), else 64-hex
|
/// empty = anonymous, else presented as the persistent identity. `pinHex` empty = TOFU (read
|
||||||
/// SHA-256 to pin the host (mismatch → 0). `bitrateKbps` 0 = host default. `compositorPref`/
|
/// `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0). `bitrateKbps`
|
||||||
/// `gamepadPref` are `CompositorPref`/`GamepadPref` wire bytes (0 = Auto; unknown → Auto).
|
/// 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref` wire bytes
|
||||||
/// Returns an opaque handle, or 0 on failure (logged).
|
/// (0 = Auto; unknown → Auto). `audioChannels` is the requested surround layout (2/6/8; normalized,
|
||||||
|
/// anything else → stereo) — the host clamps it and the resolved count drives playback. `timeoutMs`
|
||||||
|
/// is the handshake budget: the normal path passes a short value, the no-PIN "request access" path a
|
||||||
|
/// long one (≥ the host's approval-park window) so a slow operator approval lands on this same parked
|
||||||
|
/// connection rather than timing the client out first. Returns an opaque handle, or 0 on failure.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
||||||
@@ -162,6 +166,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
compositor_pref: jint,
|
compositor_pref: jint,
|
||||||
gamepad_pref: jint,
|
gamepad_pref: jint,
|
||||||
hdr_enabled: jboolean,
|
hdr_enabled: jboolean,
|
||||||
|
audio_channels: jint,
|
||||||
|
timeout_ms: jint,
|
||||||
) -> jlong {
|
) -> jlong {
|
||||||
let host: String = match env.get_string(&host) {
|
let host: String = match env.get_string(&host) {
|
||||||
Ok(s) => s.into(),
|
Ok(s) => s.into(),
|
||||||
@@ -213,10 +219,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
},
|
},
|
||||||
|
// Requested surround layout (2 = stereo / 6 = 5.1 / 8 = 7.1). The host clamps to what it can
|
||||||
|
// capture and echoes the resolved count in `connector.audio_channels`, which drives the
|
||||||
|
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
|
||||||
|
// normalizes to stereo here.
|
||||||
|
punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8),
|
||||||
None, // launch: default app
|
None, // launch: default app
|
||||||
pin, // Some → Crypto on host-fp mismatch
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||||
Duration::from_secs(10),
|
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||||
|
// (the host parks the connection until the operator approves the device — see ConnectScreen).
|
||||||
|
Duration::from_millis(timeout_ms.max(0) as u64),
|
||||||
) {
|
) {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
let handle = SessionHandle {
|
let handle = SessionHandle {
|
||||||
@@ -396,11 +409,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
|
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
|
||||||
/// Returns 10 doubles
|
/// Returns 14 doubles
|
||||||
/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]`
|
/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped,
|
||||||
/// (the two flags are 1.0/0.0), or `null` when no decode thread is running. Poll ~1 Hz from the UI;
|
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`
|
||||||
/// each call resets the measurement window. Not android-gated — pure `jni` + connector reads, so it
|
/// (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — see below), or
|
||||||
/// links on the host build too (Kotlin only ever calls it on device).
|
/// `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]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||||
env: JNIEnv,
|
env: JNIEnv,
|
||||||
@@ -418,7 +433,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
|||||||
None => return std::ptr::null_mut(), // not streaming → no stats
|
None => return std::ptr::null_mut(), // not streaming → no stats
|
||||||
};
|
};
|
||||||
let mode = h.client.mode();
|
let mode = h.client.mode();
|
||||||
let buf: [f64; 10] = [
|
let color = h.client.color;
|
||||||
|
let buf: [f64; 14] = [
|
||||||
snap.fps,
|
snap.fps,
|
||||||
snap.mbps,
|
snap.mbps,
|
||||||
snap.lat_p50_ms,
|
snap.lat_p50_ms,
|
||||||
@@ -429,6 +445,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
|||||||
mode.height as f64,
|
mode.height as f64,
|
||||||
mode.refresh_hz as f64,
|
mode.refresh_hz as f64,
|
||||||
h.client.frames_dropped() as f64,
|
h.client.frames_dropped() as f64,
|
||||||
|
// Video-feed properties the host resolved at the handshake (Welcome): encode bit depth
|
||||||
|
// (8 / 10), the CICP colour primaries + transfer code points (Kotlin maps these to a
|
||||||
|
// colour-space / HDR label — transfer 16 = PQ, 18 = HLG ⇒ HDR), and the HEVC
|
||||||
|
// chroma_format_idc (1 = 4:2:0, 3 = 4:4:4). Static for the session unless renegotiated.
|
||||||
|
h.client.bit_depth as f64,
|
||||||
|
color.primaries as f64,
|
||||||
|
color.transfer as f64,
|
||||||
|
h.client.chroma_format as f64,
|
||||||
];
|
];
|
||||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "PunktfunkKit",
|
name: "PunktfunkKit",
|
||||||
dependencies: ["PunktfunkCore"],
|
dependencies: ["PunktfunkCore"],
|
||||||
|
// OSS attribution shown by the app's Acknowledgements screen. Bundled here (not in the
|
||||||
|
// app target) so it rides along via Bundle.module in both `swift build` and the Xcode
|
||||||
|
// app, which links the PunktfunkKit product. Refresh with
|
||||||
|
// scripts/gen-third-party-notices.sh (it copies the generated file into Resources/).
|
||||||
|
resources: [
|
||||||
|
.copy("Resources/THIRD-PARTY-NOTICES.txt"),
|
||||||
|
.copy("Resources/LICENSE-MIT.txt"),
|
||||||
|
.copy("Resources/LICENSE-APACHE.txt"),
|
||||||
|
// Geist (SIL OFL 1.1) — the brand typeface, shared with punktfunk-website.
|
||||||
|
// Registered with Core Text at first use; see BrandFont.swift.
|
||||||
|
.copy("Resources/Fonts"),
|
||||||
|
],
|
||||||
linkerSettings: [
|
linkerSettings: [
|
||||||
// Rust staticlib system deps.
|
// Rust staticlib system deps.
|
||||||
.linkedFramework("Security"),
|
.linkedFramework("Security"),
|
||||||
|
|||||||
@@ -364,7 +364,7 @@
|
|||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
@@ -398,7 +398,7 @@
|
|||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
@@ -429,7 +429,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
@@ -468,7 +468,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
@@ -506,7 +506,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
@@ -536,7 +536,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Open-source acknowledgements: punktfunk's own license (MIT OR Apache-2.0) followed by the
|
||||||
|
/// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS.
|
||||||
|
struct AcknowledgementsView: View {
|
||||||
|
private var version: String? {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks,
|
||||||
|
// ~885 KB total) load lazily as they scroll into view — a single Text that large overshoots
|
||||||
|
// the text-rendering height limit (blank below the limit + very slow). spacing 0 keeps the
|
||||||
|
// notice chunks visually continuous; the header block carries its own spacing + bottom pad.
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
Text("punktfunk")
|
||||||
|
.font(.geist(22, .bold, relativeTo: .title2))
|
||||||
|
if let version {
|
||||||
|
Text("Version \(version)")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(Licenses.appLicense)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.modifier(SelectableText())
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Text("Bundled font")
|
||||||
|
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
|
Text("punktfunk ships the Geist typeface (Geist Sans), "
|
||||||
|
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
|
||||||
|
+ "License 1.1.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if !Licenses.fontLicense.isEmpty {
|
||||||
|
Text(Licenses.fontLicense)
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.modifier(SelectableText())
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Text("Third-party software")
|
||||||
|
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
|
Text(
|
||||||
|
"punktfunk uses the open-source components below, each under its own license. "
|
||||||
|
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||||
|
+ "(dynamically linked, replaceable)."
|
||||||
|
)
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
ForEach(Licenses.thirdPartyNoticesChunks.indices, id: \.self) { i in
|
||||||
|
Text(Licenses.thirdPartyNoticesChunks[i])
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.modifier(SelectableText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 900, alignment: .leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding()
|
||||||
|
#if os(tvOS)
|
||||||
|
.padding(40)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.navigationTitle("Acknowledgements")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `textSelection(.enabled)` is unavailable on tvOS, so apply it only where it exists.
|
||||||
|
private struct SelectableText: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
content
|
||||||
|
#else
|
||||||
|
content.textSelection(.enabled)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,11 @@ struct AddHostSheet: View {
|
|||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
#endif
|
#endif
|
||||||
|
#if os(iOS)
|
||||||
|
// The detent below is sized to fit all 3 rows + the action button exactly, so the
|
||||||
|
// Form must NOT scroll/bounce inside it — lock it. (iOS 16+; safe at iOS 17.)
|
||||||
|
.scrollDisabled(true)
|
||||||
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// macOS: UNCHANGED — Cancel + Spacer + Add in an HStack, both wired to the
|
// macOS: UNCHANGED — Cancel + Spacer + Add in an HStack, both wired to the
|
||||||
// window's default/cancel keyboard actions. The 380-wide .fixedSize panel below
|
// window's default/cancel keyboard actions. The 380-wide .fixedSize panel below
|
||||||
@@ -120,8 +125,8 @@ struct AddHostSheet: View {
|
|||||||
// Form + the full-width action row, instead of the half-screen .medium it used to rest
|
// Form + the full-width action row, instead of the half-screen .medium it used to rest
|
||||||
// at. A single fixed detent is enough: the system keeps the content above the keyboard
|
// at. A single fixed detent is enough: the system keeps the content above the keyboard
|
||||||
// when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a
|
// when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a
|
||||||
// centered formSheet card). If Dynamic Type grows the rows past this height the Form just
|
// centered formSheet card). The Form itself is .scrollDisabled (above) so it can't
|
||||||
// scrolls inside the detent — nothing is clipped. (.height(_:) is iOS 16+, safe at iOS 17.)
|
// bounce/scroll inside this fixed detent. (.height(_:) is iOS 16+, safe at iOS 17.)
|
||||||
.presentationDetents([.height(320)])
|
.presentationDetents([.height(320)])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// App-wide brand chrome. SwiftUI has no single switch to put a custom font on every navigation
|
||||||
|
// title, so the iOS large/inline nav titles are themed through UINavigationBar's appearance proxy
|
||||||
|
// (set once at launch). Backgrounds are left at the system defaults — transparent at the scroll
|
||||||
|
// edge (the large title floats on the content), blurred once scrolled — so only the typeface
|
||||||
|
// changes: Geist, matching the cards and the website.
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import PunktfunkKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum BrandTheme {
|
||||||
|
static func apply() {
|
||||||
|
BrandFont.registerIfNeeded()
|
||||||
|
|
||||||
|
let scrollEdge = UINavigationBarAppearance()
|
||||||
|
scrollEdge.configureWithTransparentBackground()
|
||||||
|
applyFonts(to: scrollEdge)
|
||||||
|
|
||||||
|
let standard = UINavigationBarAppearance()
|
||||||
|
standard.configureWithDefaultBackground()
|
||||||
|
applyFonts(to: standard)
|
||||||
|
|
||||||
|
let proxy = UINavigationBar.appearance()
|
||||||
|
proxy.scrollEdgeAppearance = scrollEdge
|
||||||
|
proxy.standardAppearance = standard
|
||||||
|
proxy.compactAppearance = standard
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Override only the title fonts; leave colors/backgrounds at the configured defaults.
|
||||||
|
private static func applyFonts(to appearance: UINavigationBarAppearance) {
|
||||||
|
if let large = UIFont(name: "Geist-Bold", size: 34) {
|
||||||
|
appearance.largeTitleTextAttributes[.font] = large
|
||||||
|
}
|
||||||
|
if let inline = UIFont(name: "Geist-SemiBold", size: 17) {
|
||||||
|
appearance.titleTextAttributes[.font] = inline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -4,10 +4,12 @@
|
|||||||
// (HomeView/HostCards), the trust prompt (TrustCardView), and the HUD (StreamHUDView) live in
|
// (HomeView/HostCards), the trust prompt (TrustCardView), and the HUD (StreamHUDView) live in
|
||||||
// their own files.
|
// their own files.
|
||||||
//
|
//
|
||||||
// Two ways to establish trust on first contact: the TOFU prompt (host fingerprint over the
|
// Ways to establish trust on first contact: the TOFU prompt (host fingerprint over the
|
||||||
// live-but-blurred stream, compared with the host's log) or the PIN pairing ceremony — pairing
|
// live-but-blurred stream, compared with the host's log; only for a host advertising pair=optional),
|
||||||
// verifies both sides at once and is the only way into hosts running --require-pairing. Once
|
// the PIN pairing ceremony (verifies both sides at once), or — for a host that requires pairing —
|
||||||
// pinned, reconnects are silent and a changed host identity refuses to connect.
|
// delegated approval ("Request Access": a plain identified connect the host parks until the operator
|
||||||
|
// approves this device in its console, no PIN). Once pinned, reconnects are silent and a changed
|
||||||
|
// host identity refuses to connect.
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -25,11 +27,19 @@ struct ContentView: View {
|
|||||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||||
|
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||||
|
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@State private var showAddHost = false
|
@State private var showAddHost = false
|
||||||
@State private var pairingTarget: StoredHost?
|
@State private var pairingTarget: StoredHost?
|
||||||
|
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
||||||
|
/// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b).
|
||||||
|
@State private var approvalChoice: ApprovalRequest?
|
||||||
|
/// A delegated-approval connect is in flight (host parks it until the operator approves):
|
||||||
|
/// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success.
|
||||||
|
@State private var awaitingApproval: ApprovalRequest?
|
||||||
@State private var speedTestTarget: StoredHost?
|
@State private var speedTestTarget: StoredHost?
|
||||||
@State private var libraryTarget: StoredHost?
|
@State private var libraryTarget: StoredHost?
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@@ -54,10 +64,31 @@ struct ContentView: View {
|
|||||||
autoConnectIfAsked()
|
autoConnectIfAsked()
|
||||||
}
|
}
|
||||||
.onChange(of: model.phase) { _, phase in
|
.onChange(of: model.phase) { _, phase in
|
||||||
// A session actually started — remember it on the card ("Connected … ago"
|
switch phase {
|
||||||
// plus the accent ring on the most recent host).
|
case .streaming:
|
||||||
if case .streaming = phase, let host = model.activeHost {
|
// A session actually started — remember it on the card ("Connected … ago"
|
||||||
store.markConnected(host.id)
|
// plus the accent ring on the most recent host).
|
||||||
|
guard let host = model.activeHost else { break }
|
||||||
|
// Delegated approval just succeeded: the operator let this device in, so pin the
|
||||||
|
// host's observed fingerprint and remember it as paired — future connects are then
|
||||||
|
// silent (rule 1), exactly like after a PIN/TOFU success. Dismisses the wait prompt.
|
||||||
|
let approvedFingerprint = awaitingApproval?.host.id == host.id
|
||||||
|
? model.connection?.hostFingerprint : nil
|
||||||
|
if awaitingApproval?.host.id == host.id { awaitingApproval = nil }
|
||||||
|
// Persist on the next runloop tick: HostStore is an ObservableObject, and mutating
|
||||||
|
// its @Published from inside .onChange (a view-update callback) trips SwiftUI's
|
||||||
|
// "Publishing changes from within view updates". A one-tick delay is imperceptible.
|
||||||
|
let store = store
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
store.markConnected(host.id)
|
||||||
|
if let approvedFingerprint { store.pin(host.id, fingerprint: approvedFingerprint) }
|
||||||
|
}
|
||||||
|
case .idle:
|
||||||
|
// The delegated-approval connect failed, timed out, or was cancelled — drop the
|
||||||
|
// wait prompt (SessionModel surfaces any error via `errorMessage`).
|
||||||
|
if awaitingApproval != nil { awaitingApproval = nil }
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
|
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
|
||||||
@@ -89,6 +120,47 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
// Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an
|
||||||
|
// alert) so it never collides with the wait alert below. "Request Access" is the no-PIN
|
||||||
|
// delegated-approval path; "Pair with PIN…" runs the SPAKE2 ceremony. The follow-on
|
||||||
|
// presentation is deferred a tick so this dialog is fully dismissed first.
|
||||||
|
.confirmationDialog(
|
||||||
|
"Pairing required",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { approvalChoice != nil },
|
||||||
|
set: { if !$0 { approvalChoice = nil } }),
|
||||||
|
titleVisibility: .visible,
|
||||||
|
presenting: approvalChoice
|
||||||
|
) { req in
|
||||||
|
Button("Request Access") {
|
||||||
|
DispatchQueue.main.async { requestAccess(req) }
|
||||||
|
}
|
||||||
|
Button("Pair with PIN…") {
|
||||||
|
DispatchQueue.main.async { pairingTarget = req.host }
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: { req in
|
||||||
|
Text("\(req.host.displayName) requires pairing. Request access and approve this "
|
||||||
|
+ "device in the host's web console (port 3000 → Pairing) — no PIN needed. Or "
|
||||||
|
+ "pair with the 4-digit PIN it can display.")
|
||||||
|
}
|
||||||
|
// The delegated-approval wait: the host holds the connection open until the operator
|
||||||
|
// approves it. Cancel returns the UI at once; the in-flight connect is left to time out
|
||||||
|
// and its late result is discarded by SessionModel's connect guard (disconnect resets the
|
||||||
|
// phase/host it checks).
|
||||||
|
.alert(
|
||||||
|
"Waiting for approval",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { awaitingApproval != nil },
|
||||||
|
set: { if !$0 { awaitingApproval = nil } }),
|
||||||
|
presenting: awaitingApproval
|
||||||
|
) { _ in
|
||||||
|
Button("Cancel", role: .cancel) { model.disconnect() }
|
||||||
|
} message: { req in
|
||||||
|
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
|
||||||
|
+ "console (port 3000 → Pairing). This device connects automatically once you "
|
||||||
|
+ "approve it — no need to reconnect.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var home: some View {
|
private var home: some View {
|
||||||
@@ -229,19 +301,32 @@ struct ContentView: View {
|
|||||||
// A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when
|
// A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when
|
||||||
// the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already
|
// the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already
|
||||||
// know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set:
|
// know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set:
|
||||||
// an unpinned host with no matching `pair=optional` advert routes to PIN pairing instead
|
// an unpinned host with no matching `pair=optional` advert routes to the approval choice
|
||||||
// of silently entering the trust prompt (rules 3b + 4). A pinned host ignores all of this.
|
// (request access / pair with PIN) instead of silently entering the trust prompt (rules
|
||||||
|
// 3b + 4). A pinned host ignores all of this.
|
||||||
if host.pinnedSHA256 == nil {
|
if host.pinnedSHA256 == nil {
|
||||||
let tofuOK = allowTofu ?? discovery.hosts.contains {
|
let tofuOK = allowTofu ?? discovery.hosts.contains {
|
||||||
host.matches($0) && $0.allowsTofu
|
host.matches($0) && $0.allowsTofu
|
||||||
}
|
}
|
||||||
if !tofuOK {
|
if !tofuOK {
|
||||||
pairingTarget = host
|
// pair=required / unknown policy / manual entry (rule 3b): never a silent
|
||||||
|
// connect — offer no-PIN delegated approval or the PIN ceremony.
|
||||||
|
approvalChoice = ApprovalRequest(
|
||||||
|
host: host, advertisedFingerprint: advertisedFingerprint(for: host))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The gamepad-type setting resolves NOW (Automatic → match the active physical
|
startSession(host, launchID: launchID, allowTofu: host.pinnedSHA256 == nil)
|
||||||
// controller): the host's virtual pad backend is fixed per session.
|
}
|
||||||
|
|
||||||
|
/// Resolve the @AppStorage stream mode + input prefs and hand off to the session model. The
|
||||||
|
/// gamepad-type setting resolves NOW (Automatic → match the active physical controller): the
|
||||||
|
/// host's virtual pad backend is fixed per session. `requestAccess` opens the no-PIN
|
||||||
|
/// delegated-approval connect (host parks it until the operator approves).
|
||||||
|
private func startSession(
|
||||||
|
_ host: StoredHost, launchID: String? = nil,
|
||||||
|
allowTofu: Bool, requestAccess: Bool = false
|
||||||
|
) {
|
||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||||
@@ -252,8 +337,25 @@ struct ContentView: View {
|
|||||||
setting: PunktfunkConnection.GamepadType(
|
setting: PunktfunkConnection.GamepadType(
|
||||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
||||||
bitrateKbps: UInt32(clamping: bitrateKbps),
|
bitrateKbps: UInt32(clamping: bitrateKbps),
|
||||||
|
audioChannels: UInt8(clamping: audioChannels),
|
||||||
|
hdrEnabled: hdrEnabled,
|
||||||
launchID: launchID,
|
launchID: launchID,
|
||||||
allowTofu: host.pinnedSHA256 == nil)
|
allowTofu: allowTofu,
|
||||||
|
requestAccess: requestAccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The no-PIN delegated-approval flow: open an identified connect the host parks until the
|
||||||
|
/// operator approves it in the console, showing the cancelable "Waiting for approval" prompt
|
||||||
|
/// meanwhile. On success the SAME connection is admitted (no reconnect) and the host is pinned
|
||||||
|
/// as paired (see the `.streaming` branch of `onChange`).
|
||||||
|
private func requestAccess(_ req: ApprovalRequest) {
|
||||||
|
guard !model.isBusy else { return }
|
||||||
|
awaitingApproval = req
|
||||||
|
// Pin the advertised certificate for a discovered host (impostor defence during the long
|
||||||
|
// wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||||
|
var host = req.host
|
||||||
|
host.pinnedSHA256 = req.advertisedFingerprint
|
||||||
|
startSession(host, allowTofu: false, requestAccess: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
||||||
@@ -266,8 +368,9 @@ struct ContentView: View {
|
|||||||
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
|
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
|
||||||
/// persists), then connect or pair per the host's advertised policy. The host is the policy
|
/// persists), then connect or pair per the host's advertised policy. The host is the policy
|
||||||
/// authority — TOFU is offered ONLY when it explicitly advertised `pair=optional` (rule 3a);
|
/// authority — TOFU is offered ONLY when it explicitly advertised `pair=optional` (rule 3a);
|
||||||
/// a `pair=required` host, or one with no/unknown `pair` field, goes straight to the PIN
|
/// a `pair=required` host, or one with no/unknown `pair` field, gets the approval choice
|
||||||
/// pairing ceremony (rule 3b). (A pinned discovered host connects silently inside `connect`.)
|
/// (request access / pair with PIN) (rule 3b). (A pinned discovered host connects silently
|
||||||
|
/// inside `connect`.)
|
||||||
private func connectDiscovered(_ d: DiscoveredHost) {
|
private func connectDiscovered(_ d: DiscoveredHost) {
|
||||||
guard !model.isBusy else { return }
|
guard !model.isBusy else { return }
|
||||||
let host = StoredHost(name: d.name, address: d.host, port: d.port)
|
let host = StoredHost(name: d.name, address: d.host, port: d.port)
|
||||||
@@ -275,7 +378,9 @@ struct ContentView: View {
|
|||||||
if d.allowsTofu {
|
if d.allowsTofu {
|
||||||
connect(host, allowTofu: true)
|
connect(host, allowTofu: true)
|
||||||
} else {
|
} else {
|
||||||
pairingTarget = host
|
// pair=required / unknown policy (rule 3b): offer no-PIN delegated approval or PIN.
|
||||||
|
approvalChoice = ApprovalRequest(
|
||||||
|
host: host, advertisedFingerprint: pinFingerprint(d.fingerprintHex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +394,30 @@ struct ContentView: View {
|
|||||||
connect(pinned)
|
connect(pinned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The certificate fingerprint a live mDNS advert carries for this saved host (advisory — see
|
||||||
|
/// `HostDiscovery`), to pin during a delegated-approval wait. nil if the host isn't currently
|
||||||
|
/// advertising or advertised no/invalid `fp`.
|
||||||
|
private func advertisedFingerprint(for host: StoredHost) -> Data? {
|
||||||
|
pinFingerprint(discovery.hosts.first { host.matches($0) }?.fingerprintHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an advertised cert fingerprint (lowercase hex) into the 32-byte pin the connect
|
||||||
|
/// expects; nil unless it's exactly a 32-byte (SHA-256) value, so a malformed advert falls
|
||||||
|
/// back to trust-on-first-use rather than failing the connect closed.
|
||||||
|
private func pinFingerprint(_ hex: String?) -> Data? {
|
||||||
|
guard let hex, let data = Data(hexString: hex), data.count == 32 else { return nil }
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the host lists this device in its approval prompt (matches PairSheet's client name).
|
||||||
|
private var localDeviceName: String {
|
||||||
|
#if os(macOS)
|
||||||
|
Host.current().localizedName ?? "Mac"
|
||||||
|
#else
|
||||||
|
UIDevice.current.name
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - First-run + dev hooks
|
// MARK: - First-run + dev hooks
|
||||||
|
|
||||||
/// First run on iOS: default the stream mode to this device's native screen so the
|
/// First run on iOS: default the stream mode to this device's native screen so the
|
||||||
@@ -351,6 +480,8 @@ struct ContentView: View {
|
|||||||
compositor: pref,
|
compositor: pref,
|
||||||
gamepad: pad,
|
gamepad: pad,
|
||||||
bitrateKbps: bitrate,
|
bitrateKbps: bitrate,
|
||||||
|
audioChannels: UInt8(clamping: audioChannels),
|
||||||
|
hdrEnabled: hdrEnabled,
|
||||||
autoTrust: true)
|
autoTrust: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,3 +506,31 @@ private struct FullscreenController: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
/// A fresh `pair=required`/unknown host pending a trust decision: drives both the "request access
|
||||||
|
/// vs. pair with PIN" choice and the subsequent approval wait. `advertisedFingerprint` is the
|
||||||
|
/// discovered host's advertised cert (nil for a manually-typed host → trust-on-first-use).
|
||||||
|
private struct ApprovalRequest {
|
||||||
|
let host: StoredHost
|
||||||
|
let advertisedFingerprint: Data?
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Data {
|
||||||
|
/// Parse an even-length hex string into bytes; nil on any non-hex character or odd length.
|
||||||
|
/// Used to turn an mDNS-advertised cert fingerprint into a connect pin.
|
||||||
|
init?(hexString: String) {
|
||||||
|
let chars = Array(hexString)
|
||||||
|
guard chars.count.isMultiple(of: 2) else { return nil }
|
||||||
|
var bytes = [UInt8]()
|
||||||
|
bytes.reserveCapacity(chars.count / 2)
|
||||||
|
var i = 0
|
||||||
|
while i < chars.count {
|
||||||
|
guard let hi = chars[i].hexDigitValue, let lo = chars[i + 1].hexDigitValue else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bytes.append(UInt8(hi << 4 | lo))
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
self = Data(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ struct ControllerTestView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Test Controller").font(.headline)
|
Text("Test Controller").font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||||
}
|
}
|
||||||
@@ -99,8 +99,8 @@ struct ControllerTestView: View {
|
|||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(c.name).font(.headline)
|
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
Text(c.productCategory).font(.caption).foregroundStyle(.secondary)
|
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -209,7 +209,7 @@ struct ControllerTestView: View {
|
|||||||
) -> some View {
|
) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||||
.font(.caption2).foregroundStyle(.secondary)
|
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||||
ZStack {
|
ZStack {
|
||||||
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||||
fingerDot(tp.primary, color: .accentColor)
|
fingerDot(tp.primary, color: .accentColor)
|
||||||
@@ -230,7 +230,7 @@ struct ControllerTestView: View {
|
|||||||
private func motionReadout(_ m: GCMotion) -> some View {
|
private func motionReadout(_ m: GCMotion) -> some View {
|
||||||
let a = Self.totalAccel(m)
|
let a = Self.totalAccel(m)
|
||||||
return VStack(alignment: .leading, spacing: 2) {
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Motion").font(.caption2).foregroundStyle(.secondary)
|
Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||||
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||||
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||||
.font(.caption2.monospaced())
|
.font(.caption2.monospaced())
|
||||||
@@ -254,11 +254,11 @@ struct ControllerTestView: View {
|
|||||||
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||||
Toggle("Light motor (right)", isOn: $lightOn)
|
Toggle("Light motor (right)", isOn: $lightOn)
|
||||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
|
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 "
|
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
||||||
+ "can't reach its motors on macOS).")
|
+ "can't reach its motors on macOS).")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
||||||
.onChange(of: lightOn) { _, _ in applyRumble() }
|
.onChange(of: lightOn) { _, _ in applyRumble() }
|
||||||
@@ -289,11 +289,11 @@ struct ControllerTestView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
|
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("Adaptive triggers need a DualSense.")
|
Text("Adaptive triggers need a DualSense.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,7 +348,7 @@ struct ControllerTestView: View {
|
|||||||
_ title: String, @ViewBuilder _ content: () -> Content
|
_ title: String, @ViewBuilder _ content: () -> Content
|
||||||
) -> some View {
|
) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text(title).font(.subheadline.weight(.semibold))
|
Text(title).font(.geist(15, .semibold, relativeTo: .subheadline))
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -127,14 +127,13 @@ struct HomeView: View {
|
|||||||
AddHostSheet { store.add($0) }
|
AddHostSheet { store.add($0) }
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
|
||||||
|
// is presented directly — wrapping it in a NavigationStack here would nest a split view in
|
||||||
|
// a stack (double title bars). `settingsSheetSizing()` widens the sheet on iPad for the
|
||||||
|
// two-column layout.
|
||||||
.sheet(isPresented: $showSettings) {
|
.sheet(isPresented: $showSettings) {
|
||||||
NavigationStack {
|
SettingsView()
|
||||||
SettingsView()
|
.settingsSheetSizing()
|
||||||
.navigationTitle("Settings")
|
|
||||||
.toolbar {
|
|
||||||
Button("Done") { showSettings = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
@@ -172,7 +171,7 @@ struct HomeView: View {
|
|||||||
private var discoveredSection: some View {
|
private var discoveredSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
||||||
.font(.headline)
|
.font(.geist(15, .semibold, relativeTo: .headline))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||||
@@ -249,8 +248,10 @@ struct HomeView: View {
|
|||||||
/// the width so the cards stay edge-aligned with the title and bars — sized touch-first: one
|
/// the width so the cards stay edge-aligned with the title and bars — sized touch-first: one
|
||||||
/// column on iPhone portrait, 3–4 generous cards on iPad.
|
/// column on iPhone portrait, 3–4 generous cards on iPad.
|
||||||
private var gridColumns: [GridItem] {
|
private var gridColumns: [GridItem] {
|
||||||
|
// Wider than before: the monogram card is a horizontal module (tile + address line), so
|
||||||
|
// it needs room for a monospaced "IP:port" without truncating.
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
|
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
|
||||||
#elseif os(tvOS)
|
#elseif os(tvOS)
|
||||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||||
#else
|
#else
|
||||||
|
|||||||
@@ -1,26 +1,75 @@
|
|||||||
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
||||||
// host (tap to save + connect). Both share the same platform-tuned sizing.
|
// host (tap to save + connect). Both share the "monogram module" look — a squared brand-purple
|
||||||
|
// monogram tile + a left-aligned bold Geist name over monospaced technical metadata
|
||||||
|
// (address, status), framed by a hairline panel border. Industrial, not soft.
|
||||||
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Shared host-card sizing — touch-first on iOS, compact on macOS/tvOS.
|
/// Shared host-card sizing — touch-first on iOS, compact on macOS, roomy on tvOS.
|
||||||
private struct CardMetrics {
|
private struct CardMetrics {
|
||||||
let iconSize: CGFloat
|
let tile: CGFloat // monogram tile side
|
||||||
let iconBox: CGFloat
|
let monogram: CGFloat // monogram letter point size
|
||||||
let cardPadding: CGFloat
|
let name: CGFloat // host-name point size
|
||||||
let nameFont: Font
|
let meta: CGFloat // address (mono) point size
|
||||||
|
let status: CGFloat // status-label (mono) point size
|
||||||
|
let padding: CGFloat
|
||||||
|
let spacing: CGFloat // tile ↔ text gap
|
||||||
|
let radius: CGFloat
|
||||||
|
|
||||||
static var current: CardMetrics {
|
static var current: CardMetrics {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold))
|
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
||||||
|
padding: 16, spacing: 14, radius: 12)
|
||||||
|
#elseif os(tvOS)
|
||||||
|
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
|
||||||
|
padding: 18, spacing: 18, radius: 14)
|
||||||
#else
|
#else
|
||||||
CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline)
|
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
||||||
|
padding: 13, spacing: 12, radius: 10)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A saved host. The accent ring marks the most-recently-connected one; the context menu
|
/// First letter of a host name, uppercased — the monogram glyph. Falls back to a bullet.
|
||||||
|
private func monogram(_ name: String) -> String {
|
||||||
|
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
|
||||||
|
return String(first).uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The squared monogram tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a
|
||||||
|
/// tinted outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
|
||||||
|
private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, filled: Bool) -> some View {
|
||||||
|
let shape = RoundedRectangle(cornerRadius: m.radius - 3, style: .continuous)
|
||||||
|
return ZStack {
|
||||||
|
shape.fill(filled
|
||||||
|
? AnyShapeStyle(LinearGradient(
|
||||||
|
colors: [Color.brand, Color.brand.opacity(0.72)],
|
||||||
|
startPoint: .top, endPoint: .bottom))
|
||||||
|
: AnyShapeStyle(Color.brand.opacity(0.14)))
|
||||||
|
if connecting {
|
||||||
|
ProgressView().tint(filled ? .white : Color.brand)
|
||||||
|
} else {
|
||||||
|
// Fixed size (not Dynamic Type): the glyph is pinned inside a fixed tile, so it must
|
||||||
|
// not scale up and spill out at large accessibility text sizes. minimumScaleFactor +
|
||||||
|
// the clip below are belt-and-suspenders for an unusually wide glyph.
|
||||||
|
Text(letter)
|
||||||
|
.font(.geistFixed(m.monogram, .bold))
|
||||||
|
.minimumScaleFactor(0.5)
|
||||||
|
.lineLimit(1)
|
||||||
|
.foregroundStyle(filled ? Color.white : Color.brand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: m.tile, height: m.tile)
|
||||||
|
.clipShape(shape)
|
||||||
|
.overlay {
|
||||||
|
if !filled {
|
||||||
|
shape.strokeBorder(Color.brand.opacity(0.45), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A saved host. A left accent bar marks the most-recently-connected one; the context menu
|
||||||
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
||||||
struct HostCardView: View {
|
struct HostCardView: View {
|
||||||
let host: StoredHost
|
let host: StoredHost
|
||||||
@@ -41,66 +90,44 @@ struct HostCardView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
let m = CardMetrics.current
|
let m = CardMetrics.current
|
||||||
return Button(action: onConnect) {
|
return Button(action: onConnect) {
|
||||||
VStack(spacing: 10) {
|
HStack(spacing: m.spacing) {
|
||||||
ZStack {
|
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
|
||||||
Image(systemName: "play.display")
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
.font(.system(size: m.iconSize, weight: .light))
|
Text(host.displayName)
|
||||||
.foregroundStyle(.tint)
|
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||||
.opacity(isConnecting ? 0.3 : 1)
|
.foregroundStyle(.primary)
|
||||||
if isConnecting {
|
.lineLimit(1)
|
||||||
ProgressView()
|
Text("\(host.address):\(String(host.port))")
|
||||||
}
|
.font(.geist(m.meta, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
statusRow(m)
|
||||||
}
|
}
|
||||||
.frame(height: m.iconBox)
|
Spacer(minLength: 0)
|
||||||
VStack(spacing: 2) {
|
}
|
||||||
HStack(spacing: 6) {
|
.padding(m.padding)
|
||||||
// Presence dot: green = advertising on the LAN now; grey = not seen.
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Circle()
|
#if !os(tvOS)
|
||||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.35))
|
// tvOS: the .card button style owns platter + focus motion; extra chrome mutes it.
|
||||||
.frame(width: 7, height: 7)
|
// Elsewhere: a flat material panel with a hairline border (industrial, not a soft blob),
|
||||||
.accessibilityLabel(isOnline ? "Online" : "Offline")
|
// and a brand accent bar down the leading edge for the most-recent host.
|
||||||
Text(host.displayName)
|
.background(.regularMaterial)
|
||||||
.font(m.nameFont)
|
.overlay(alignment: .leading) {
|
||||||
.lineLimit(1)
|
if isMostRecent {
|
||||||
}
|
Rectangle().fill(Color.brand).frame(width: 3)
|
||||||
HStack(spacing: 4) {
|
|
||||||
if host.pinnedSHA256 != nil {
|
|
||||||
Image(systemName: "lock.fill")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Text("\(host.address):\(String(host.port))")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
if let last = host.lastConnected {
|
|
||||||
Text("Connected \(last, format: .relative(presentation: .named))")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||||
.padding(.vertical, m.cardPadding)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
#if !os(tvOS)
|
|
||||||
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
|
||||||
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
|
||||||
// Deliberately .regularMaterial, not Liquid Glass: HIG keeps glass off content
|
|
||||||
// tiles (it flattens hierarchy over an opaque grid) — see GlassStyle.swift.
|
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
||||||
.overlay {
|
.overlay {
|
||||||
if isMostRecent {
|
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||||
RoundedRectangle(cornerRadius: 14)
|
.strokeBorder(.quaternary, lineWidth: 1)
|
||||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.buttonStyle(.card)
|
.buttonStyle(.card)
|
||||||
|
#elseif os(iOS)
|
||||||
|
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||||
#else
|
#else
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
#endif
|
#endif
|
||||||
@@ -119,10 +146,31 @@ struct HostCardView: View {
|
|||||||
Button("Remove", role: .destructive, action: onRemove)
|
Button("Remove", role: .destructive, action: onRemove)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a
|
||||||
|
/// certificate is pinned (the lock state, spelled out).
|
||||||
|
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
RoundedRectangle(cornerRadius: 1.5)
|
||||||
|
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
// The state is spelled out in the adjacent text, so the pip is decorative —
|
||||||
|
// otherwise VoiceOver reads the status twice ("Online, ONLINE …").
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text(isOnline ? "ONLINE" : "OFFLINE")
|
||||||
|
if host.pinnedSHA256 != nil {
|
||||||
|
Text("· PAIRED")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||||
|
.tracking(0.8)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A host found on the LAN but not yet saved. A dashed ring distinguishes it from saved cards;
|
/// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
|
||||||
/// tapping saves it and connects (or pairs, if the host requires it).
|
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
|
||||||
struct DiscoveredCardView: View {
|
struct DiscoveredCardView: View {
|
||||||
let discovered: DiscoveredHost
|
let discovered: DiscoveredHost
|
||||||
let isBusy: Bool
|
let isBusy: Bool
|
||||||
@@ -131,47 +179,77 @@ struct DiscoveredCardView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
let m = CardMetrics.current
|
let m = CardMetrics.current
|
||||||
return Button(action: onConnect) {
|
return Button(action: onConnect) {
|
||||||
VStack(spacing: 10) {
|
HStack(spacing: m.spacing) {
|
||||||
Image(systemName: "play.display")
|
monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false)
|
||||||
.font(.system(size: m.iconSize, weight: .light))
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
.foregroundStyle(.tint)
|
|
||||||
.frame(height: m.iconBox)
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
Text(discovered.name)
|
Text(discovered.name)
|
||||||
.font(m.nameFont)
|
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
HStack(spacing: 4) {
|
Text("\(discovered.host):\(String(discovered.port))")
|
||||||
Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi")
|
.font(.geist(m.meta, relativeTo: .caption))
|
||||||
.font(.system(size: 9))
|
.foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
.lineLimit(1)
|
||||||
Text("\(discovered.host):\(String(discovered.port))")
|
HStack(spacing: 6) {
|
||||||
.font(.caption)
|
Image(systemName: discovered.requiresPairing
|
||||||
.foregroundStyle(.secondary)
|
? "lock.fill" : "antenna.radiowaves.left.and.right")
|
||||||
.lineLimit(1)
|
.font(.system(size: m.status))
|
||||||
|
.accessibilityHidden(true) // decorative; the adjacent text says the state
|
||||||
|
Text(discovered.requiresPairing ? "PAIRING REQUIRED" : "DISCOVERED")
|
||||||
}
|
}
|
||||||
Text(discovered.requiresPairing ? "Pairing required" : "Discovered")
|
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||||
.font(.caption2)
|
.tracking(0.8)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.padding(m.padding)
|
||||||
.padding(.vertical, m.cardPadding)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 12)
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
.background(.regularMaterial)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 14)
|
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
Color.secondary.opacity(0.25),
|
Color.secondary.opacity(0.3),
|
||||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.buttonStyle(.card)
|
.buttonStyle(.card)
|
||||||
|
#elseif os(iOS)
|
||||||
|
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||||
#else
|
#else
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
#endif
|
#endif
|
||||||
.disabled(isBusy)
|
.disabled(isBusy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// The iOS host-card press/hover treatment, one style for both idioms:
|
||||||
|
/// - iPhone: a subtle scale-down on press + a light impact haptic on press-down. (`hoverEffect` is
|
||||||
|
/// inert without a pointer.)
|
||||||
|
/// - iPad: the system pointer "magnet" — the cursor morphs into a highlight that conforms to the
|
||||||
|
/// card's rounded rect on hover. (`sensoryFeedback` is inert without a Taptic Engine, and the
|
||||||
|
/// press scale doubles as click feedback.)
|
||||||
|
struct HostCardButtonStyle: ButtonStyle {
|
||||||
|
var cornerRadius: CGFloat
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.scaleEffect(configuration.isPressed ? 0.96 : 1)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.65), value: configuration.isPressed)
|
||||||
|
// Conform the pointer highlight to the card's rounded rect, not its square bounds.
|
||||||
|
.contentShape(.hoverEffect, RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||||
|
.hoverEffect(.highlight)
|
||||||
|
// Light tap on press-down (nil on release so it fires once, on touch). No haptic
|
||||||
|
// hardware on iPad → silently ignored there.
|
||||||
|
.sensoryFeedback(trigger: configuration.isPressed) { _, pressed in
|
||||||
|
pressed ? .impact(weight: .light) : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ private struct GameCard: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
.overlay(alignment: .topLeading) { storeBadge }
|
.overlay(alignment: .topLeading) { storeBadge }
|
||||||
Text(game.title)
|
Text(game.title)
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ private struct GameCard: View {
|
|||||||
|
|
||||||
private var storeBadge: some View {
|
private var storeBadge: some View {
|
||||||
Text(game.isCustom ? "Custom" : "Steam")
|
Text(game.isCustom ? "Custom" : "Steam")
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
.background(.ultraThinMaterial, in: Capsule())
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
@@ -193,7 +193,7 @@ private struct PosterImage: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Rectangle().fill(.quaternary)
|
Rectangle().fill(.quaternary)
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.headline)
|
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ struct PairSheet: View {
|
|||||||
+ "(http://<host>:3000 → Pairing). "
|
+ "(http://<host>:3000 → Pairing). "
|
||||||
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||||
+ "needed.")
|
+ "needed.")
|
||||||
.font(.callout)
|
.font(.geist(16, relativeTo: .callout))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
TVFieldRow(
|
TVFieldRow(
|
||||||
@@ -59,7 +59,7 @@ struct PairSheet: View {
|
|||||||
) { editing = .clientName }
|
) { editing = .clientName }
|
||||||
if let errorText {
|
if let errorText {
|
||||||
Text(errorText)
|
Text(errorText)
|
||||||
.font(.callout)
|
.font(.geist(16, relativeTo: .callout))
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
HStack(spacing: 32) {
|
HStack(spacing: 32) {
|
||||||
@@ -121,13 +121,13 @@ struct PairSheet: View {
|
|||||||
+ "(http://<host>:3000 → Pairing). "
|
+ "(http://<host>:3000 → Pairing). "
|
||||||
+ "Pairing verifies both sides at once — no fingerprint "
|
+ "Pairing verifies both sides at once — no fingerprint "
|
||||||
+ "comparison needed.")
|
+ "comparison needed.")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if let errorText {
|
if let errorText {
|
||||||
Section {
|
Section {
|
||||||
Text(errorText)
|
Text(errorText)
|
||||||
.font(.callout)
|
.font(.geist(16, relativeTo: .callout))
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,20 +12,36 @@ struct PunktfunkClientApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
init() {
|
||||||
|
#if os(iOS)
|
||||||
|
// Put Geist on the navigation titles before any bar is built.
|
||||||
|
BrandTheme.apply()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup("Punktfunk") {
|
WindowGroup("Punktfunk") {
|
||||||
#if DEBUG
|
// Pin the whole app's tint to the brand purple explicitly — the asset-catalog accent
|
||||||
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
// resolution is environment/timing-sensitive and can fall back to system blue. Wraps the
|
||||||
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
|
// screenshot harness too, so captured screens are on-brand.
|
||||||
// the whole path is absent from Release builds.
|
Group {
|
||||||
if let scene = ScreenshotMode.requestedScene {
|
#if DEBUG
|
||||||
ScreenshotHostView(scene: scene)
|
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
||||||
} else {
|
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
|
||||||
|
// the whole path is absent from Release builds.
|
||||||
|
if let scene = ScreenshotMode.requestedScene {
|
||||||
|
ScreenshotHostView(scene: scene)
|
||||||
|
} else {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
#else
|
||||||
ContentView()
|
ContentView()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#else
|
.tint(.brand)
|
||||||
ContentView()
|
// Geist Sans is the app's typeface. This sets the default for unstyled text and the
|
||||||
#endif
|
// form row labels; views that pick an explicit size/weight use `.geist(…)` directly.
|
||||||
|
.font(.geist(17, relativeTo: .body))
|
||||||
}
|
}
|
||||||
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
||||||
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
||||||
@@ -34,7 +50,10 @@ struct PunktfunkClientApp: App {
|
|||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Settings {
|
Settings {
|
||||||
|
// A separate scene — `.tint` does not cross scene boundaries, so re-apply the brand
|
||||||
|
// tint here or the Preferences window falls back to the (unreliable) asset accent.
|
||||||
SettingsView()
|
SettingsView()
|
||||||
|
.tint(.brand)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,11 +103,11 @@ private struct ShotSettings: View {
|
|||||||
.shadow(radius: 40, y: 16)
|
.shadow(radius: 40, y: 16)
|
||||||
}
|
}
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
NavigationStack {
|
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
|
||||||
SettingsView()
|
// rendered directly — a wrapping NavigationStack would nest a split view in a stack. Open
|
||||||
.navigationTitle("Settings")
|
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
// the General page) instead of the bare category list.
|
||||||
}
|
SettingsView(initialCategory: .general)
|
||||||
#else
|
#else
|
||||||
NavigationStack { SettingsView() }
|
NavigationStack { SettingsView() }
|
||||||
#endif
|
#endif
|
||||||
@@ -175,10 +175,10 @@ private struct ShotHUD: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Text("⌘⎋ releases the mouse")
|
Text("⌘⎋ releases the mouse")
|
||||||
.font(.caption2).foregroundStyle(.secondary)
|
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||||
#elseif os(tvOS)
|
#elseif os(tvOS)
|
||||||
Text("Press Menu to disconnect")
|
Text("Press Menu to disconnect")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
@@ -259,7 +259,7 @@ private struct ShotDesktopFrame: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "gamecontroller.fill")
|
Image(systemName: "gamecontroller.fill")
|
||||||
Text("Streaming from Battlestation")
|
Text("Streaming from Battlestation")
|
||||||
.font(.system(.callout, weight: .semibold))
|
.font(.geist(16, .semibold, relativeTo: .callout))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14).padding(.vertical, 9)
|
.padding(.horizontal, 14).padding(.vertical, 9)
|
||||||
.glassBackground(Capsule())
|
.glassBackground(Capsule())
|
||||||
|
|||||||
@@ -95,14 +95,23 @@ final class SessionModel: ObservableObject {
|
|||||||
/// field — TOFU is forbidden (rule 3b): the connect refuses rather than offering trust, and
|
/// field — TOFU is forbidden (rule 3b): the connect refuses rather than offering trust, and
|
||||||
/// the user is routed to PIN pairing by the caller. (A pinned host connects regardless: its
|
/// the user is routed to PIN pairing by the caller. (A pinned host connects regardless: its
|
||||||
/// stored fingerprint is the trust decision.)
|
/// stored fingerprint is the trust decision.)
|
||||||
|
///
|
||||||
|
/// `requestAccess` is the no-PIN delegated-approval path: open an identified connect the host
|
||||||
|
/// PARKS until the operator clicks Approve in its console, then admits the SAME connection (no
|
||||||
|
/// reconnect). The handshake budget is widened to exceed the host's park window, and a
|
||||||
|
/// successful connect streams directly (the approval IS the trust decision) — the caller pins
|
||||||
|
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
|
||||||
|
/// for the wait; nil = trust-on-first-use.
|
||||||
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
||||||
compositor: PunktfunkConnection.Compositor = .auto,
|
compositor: PunktfunkConnection.Compositor = .auto,
|
||||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||||
bitrateKbps: UInt32 = 0,
|
bitrateKbps: UInt32 = 0,
|
||||||
|
audioChannels: UInt8 = 2,
|
||||||
hdrEnabled: Bool = true,
|
hdrEnabled: Bool = true,
|
||||||
launchID: String? = nil,
|
launchID: String? = nil,
|
||||||
allowTofu: Bool = false,
|
allowTofu: Bool = false,
|
||||||
autoTrust: Bool = false) {
|
autoTrust: Bool = false,
|
||||||
|
requestAccess: Bool = false) {
|
||||||
guard phase == .idle else { return }
|
guard phase == .idle else { return }
|
||||||
phase = .connecting
|
phase = .connecting
|
||||||
activeHost = host
|
activeHost = host
|
||||||
@@ -137,7 +146,11 @@ final class SessionModel: ObservableObject {
|
|||||||
width: width, height: height, refreshHz: hz,
|
width: width, height: height, refreshHz: hz,
|
||||||
pinSHA256: pin, identity: identity, compositor: compositor,
|
pinSHA256: pin, identity: identity, compositor: compositor,
|
||||||
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||||
launchID: launchID) }
|
audioChannels: audioChannels, launchID: launchID,
|
||||||
|
// Delegated approval: the host holds this connect open until the operator approves
|
||||||
|
// it (~180 s) — outwait that window so a slow approval still lands here. Normal
|
||||||
|
// connects keep the snappy default.
|
||||||
|
timeoutMs: requestAccess ? 185_000 : 10_000) }
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// The user may have abandoned this attempt (window closed, another host
|
// The user may have abandoned this attempt (window closed, another host
|
||||||
@@ -151,7 +164,9 @@ final class SessionModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let conn):
|
case .success(let conn):
|
||||||
if pin != nil || autoTrust {
|
if pin != nil || autoTrust || requestAccess {
|
||||||
|
// requestAccess: the operator approved this device on the host, so the
|
||||||
|
// session is trusted — stream directly (the caller pins it as paired).
|
||||||
self.connection = conn
|
self.connection = conn
|
||||||
self.startStatsTimer()
|
self.startStatsTimer()
|
||||||
self.beginStreaming()
|
self.beginStreaming()
|
||||||
@@ -173,16 +188,25 @@ final class SessionModel: ObservableObject {
|
|||||||
case .failure:
|
case .failure:
|
||||||
self.phase = .idle
|
self.phase = .idle
|
||||||
self.activeHost = nil
|
self.activeHost = nil
|
||||||
self.errorMessage = pin != nil
|
if requestAccess {
|
||||||
? "Could not connect to \(host.displayName) — host unreachable, "
|
// The delegated-approval connect ended without being admitted: the
|
||||||
+ "not running, its identity no longer matches the pinned "
|
// operator didn't approve it before the host's park window elapsed (or
|
||||||
+ "fingerprint, or it requires pairing and no longer "
|
// the host was unreachable).
|
||||||
+ "recognizes this Mac (right-click the host card to pair "
|
self.errorMessage = "\(host.displayName) didn't let this device in. "
|
||||||
+ "again)."
|
+ "Approve it in the host's web console (port 3000 → Pairing), then "
|
||||||
: "Could not connect to \(host.displayName) — is punktfunk-host "
|
+ "request access again — the request expires after a few minutes."
|
||||||
+ "running on \(host.address):\(host.port)? If it requires "
|
} else {
|
||||||
+ "pairing, right-click the host card and pair with its PIN "
|
self.errorMessage = pin != nil
|
||||||
+ "first."
|
? "Could not connect to \(host.displayName) — host unreachable, "
|
||||||
|
+ "not running, its identity no longer matches the pinned "
|
||||||
|
+ "fingerprint, or it requires pairing and no longer "
|
||||||
|
+ "recognizes this Mac (right-click the host card to pair "
|
||||||
|
+ "again)."
|
||||||
|
: "Could not connect to \(host.displayName) — is punktfunk-host "
|
||||||
|
+ "running on \(host.address):\(host.port)? If it requires "
|
||||||
|
+ "pairing, right-click the host card and pair with its PIN "
|
||||||
|
+ "first."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
||||||
// there is no scaling anywhere in the pipeline.
|
// there is no scaling anywhere in the pipeline.
|
||||||
//
|
//
|
||||||
// Navigation differs per platform: macOS uses a tabbed preferences window (the sections had
|
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||||
// outgrown one scrolling pane); iOS uses a single grouped Form; tvOS uses a focus-native
|
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||||
// pushed-picker layout. The individual sections (`streamModeSection`, `audioSection`, …) are
|
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||||||
// shared across all three so a setting is defined exactly once.
|
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
||||||
|
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
|
||||||
|
// `audioSection`, …) are shared across all three so a setting is defined exactly once.
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -21,16 +23,33 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||||
@AppStorage(DefaultsKey.presenter) private var presenter = "stage1"
|
@AppStorage(DefaultsKey.presenter) private var presenter = "stage2"
|
||||||
|
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||||
|
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@ObservedObject private var gamepads = GamepadManager.shared
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
@State private var showControllerTest = false
|
@State private var showControllerTest = false
|
||||||
#endif
|
#endif
|
||||||
|
#if os(iOS)
|
||||||
|
// 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).
|
||||||
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
@State private var settingsSelection: SettingsCategory?
|
||||||
|
// Tracked so the detail can show its own Done whenever the sidebar (and its Done) is off screen
|
||||||
|
// — not just on iPhone, but on any iPad layout that collapses the sidebar to an overlay. Starts
|
||||||
|
// .doubleColumn so iPad reliably opens with the sidebar (and its Done) visible.
|
||||||
|
@State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn
|
||||||
|
// Sticky once the wheel lands on "Custom…", so editing a width/height that briefly equals a
|
||||||
|
// preset doesn't snap the wheel back off Custom. A stored non-preset value reads as custom even
|
||||||
|
// when this is false (see `isCustomResolution`), so it survives relaunches without persisting.
|
||||||
|
@State private var customMode = false
|
||||||
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
||||||
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
||||||
@@ -38,6 +57,15 @@ struct SettingsView: View {
|
|||||||
@State private var inputDevices: [AudioDevice] = []
|
@State private var inputDevices: [AudioDevice] = []
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// `initialCategory` is nil in the app (the list opens un-selected on iPhone; iPad lands on
|
||||||
|
/// General via `onAppear`). The screenshot harness passes an explicit category so the captured
|
||||||
|
/// shot opens on a real settings page (a populated detail) rather than the bare category list.
|
||||||
|
init(initialCategory: SettingsCategory? = nil) {
|
||||||
|
_settingsSelection = State(initialValue: initialCategory)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// Native tv pattern: no inline text entry (typing numbers with a remote is
|
// Native tv pattern: no inline text entry (typing numbers with a remote is
|
||||||
@@ -65,6 +93,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Form {
|
Form {
|
||||||
presenterSection
|
presenterSection
|
||||||
|
hdrSection
|
||||||
windowSection
|
windowSection
|
||||||
statisticsSection
|
statisticsSection
|
||||||
}
|
}
|
||||||
@@ -97,31 +126,123 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
||||||
|
|
||||||
|
AcknowledgementsView()
|
||||||
|
.tabItem { Label("About", systemImage: "info.circle") }
|
||||||
}
|
}
|
||||||
.frame(width: 480, height: 460)
|
.frame(width: 480, height: 460)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - iOS: one grouped Form
|
// MARK: - iOS / iPadOS: adaptive split view
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private var iosBody: some View {
|
private var iosBody: some View {
|
||||||
Form {
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||||
streamModeSection
|
List(selection: $settingsSelection) {
|
||||||
audioSection
|
ForEach(SettingsCategory.allCases) { category in
|
||||||
compositorSection
|
// On iPhone the split view collapses to a push list, but a selection List
|
||||||
presenterSection
|
// draws no disclosure indicator of its own — add one in compact width for the
|
||||||
statisticsSection
|
// expected drill-in affordance. On iPad the selected row highlights instead, so
|
||||||
experimentalSection
|
// the chevron is omitted there.
|
||||||
controllersSection
|
HStack {
|
||||||
|
Label(category.title, systemImage: category.symbol)
|
||||||
|
if horizontalSizeClass == .compact {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.forward")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
// Purely a drill-in affordance — the row's button trait already
|
||||||
|
// conveys "opens"; keep it out of the VoiceOver announcement.
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tag(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} detail: {
|
||||||
|
// NavigationSplitView hosts the detail in its own navigation context (its title bar),
|
||||||
|
// so no inner NavigationStack — that would double the bar on iPad. On iPhone the split
|
||||||
|
// view collapses to one stack and pushes this when a row is tapped. `?? .general` only
|
||||||
|
// backs the brief pre-selection window; the list never auto-pushes on a nil selection.
|
||||||
|
settingsDetail(settingsSelection ?? .general)
|
||||||
|
// Keep a Done on the detail whenever the sidebar (and its Done) isn't on screen: the
|
||||||
|
// iPhone push, or any iPad layout that collapsed the sidebar to an overlay. When the
|
||||||
|
// sidebar is showing, its Done is the only one — so this stays hidden to avoid two.
|
||||||
|
.toolbar {
|
||||||
|
if horizontalSizeClass == .compact || columnVisibility == .detailOnly {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
if horizontalSizeClass == .regular, settingsSelection == nil {
|
||||||
|
settingsSelection = .general
|
||||||
|
}
|
||||||
gamepads.refresh()
|
gamepads.refresh()
|
||||||
gamepads.startDiscovery()
|
gamepads.startDiscovery()
|
||||||
}
|
}
|
||||||
|
// A regular→regular launch sets the default above; this catches a compact→regular change
|
||||||
|
// (e.g. an iPad leaving narrow split-screen multitasking) so the detail pane fills in.
|
||||||
|
.onChange(of: horizontalSizeClass) { _, newValue in
|
||||||
|
if newValue == .regular, settingsSelection == nil {
|
||||||
|
settingsSelection = .general
|
||||||
|
}
|
||||||
|
}
|
||||||
.onDisappear { gamepads.stopDiscovery() }
|
.onDisappear { gamepads.stopDiscovery() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func settingsDetail(_ category: SettingsCategory) -> some View {
|
||||||
|
switch category {
|
||||||
|
case .general:
|
||||||
|
Form {
|
||||||
|
streamModeSection
|
||||||
|
compositorSection
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle("General")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
case .display:
|
||||||
|
Form {
|
||||||
|
presenterSection
|
||||||
|
hdrSection
|
||||||
|
statisticsSection
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle("Display")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
case .audio:
|
||||||
|
Form { audioSection }
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle("Audio")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
case .controllers:
|
||||||
|
Form { controllersSection }
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle("Controllers")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
case .advanced:
|
||||||
|
Form { experimentalSection }
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle("Advanced")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
case .about:
|
||||||
|
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
||||||
|
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
||||||
|
// the large title from the "Settings" sidebar root).
|
||||||
|
AcknowledgementsView()
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - tvOS
|
// MARK: - tvOS
|
||||||
@@ -149,6 +270,10 @@ struct SettingsView: View {
|
|||||||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var hdrEnabledTag: Binding<String> {
|
||||||
|
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
||||||
|
}
|
||||||
|
|
||||||
private var tvBody: some View {
|
private var tvBody: some View {
|
||||||
let currentTag = "\(width)x\(height)x\(hz)"
|
let currentTag = "\(width)x\(height)x\(hz)"
|
||||||
let bounds = UIScreen.main.nativeBounds
|
let bounds = UIScreen.main.nativeBounds
|
||||||
@@ -173,22 +298,31 @@ struct SettingsView: View {
|
|||||||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Bitrate", options: bitrateOptions, selection: $bitrateKbps)
|
title: "Bitrate", options: bitrateOptions, selection: $bitrateKbps)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Audio channels",
|
||||||
|
options: [("Stereo", 2), ("5.1 Surround", 6), ("7.1 Surround", 8)],
|
||||||
|
selection: $audioChannels)
|
||||||
if bitrateKbps > 1_000_000 {
|
if bitrateKbps > 1_000_000 {
|
||||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Compositor", options: compositors, selection: $compositor)
|
title: "Compositor", options: compositors, selection: $compositor)
|
||||||
|
#if DEBUG
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Presenter",
|
title: "Presenter (debug)",
|
||||||
options: [("Stage 1 (default)", "stage1"), ("Stage 2 (experimental)", "stage2")],
|
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")],
|
||||||
selection: $presenter)
|
selection: $presenter)
|
||||||
|
#endif
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "10-bit HDR",
|
||||||
|
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||||
Text("The host creates a virtual output at exactly this mode — native "
|
Text("The host creates a virtual output at exactly this mode — native "
|
||||||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||||
+ "is honored only if available on the host.")
|
+ "is honored only if available on the host.")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@@ -208,10 +342,12 @@ struct SettingsView: View {
|
|||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Controller type", options: Self.padTypes, selection: $gamepadType)
|
title: "Controller type", options: Self.padTypes, selection: $gamepadType)
|
||||||
Text(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 1000)
|
.frame(maxWidth: 1000)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -230,6 +366,63 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
@ViewBuilder private var streamModeSection: some View {
|
@ViewBuilder private var streamModeSection: some View {
|
||||||
Section {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Use this display's mode") { fillFromMainScreen() }
|
||||||
|
#elseif os(macOS)
|
||||||
HStack {
|
HStack {
|
||||||
TextField("Resolution", value: $width, format: .number.grouping(.never))
|
TextField("Resolution", value: $width, format: .number.grouping(.never))
|
||||||
Text("×")
|
Text("×")
|
||||||
@@ -240,6 +433,7 @@ struct SettingsView: View {
|
|||||||
LabeledContent("") {
|
LabeledContent("") {
|
||||||
Button("Use this display's mode") { fillFromMainScreen() }
|
Button("Use this display's mode") { fillFromMainScreen() }
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||||
if bitrateKbps != 0 {
|
if bitrateKbps != 0 {
|
||||||
@@ -254,7 +448,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
if bitrateKbps > 1_000_000 {
|
if bitrateKbps > 1_000_000 {
|
||||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,13 +458,92 @@ struct SettingsView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("The host creates a virtual output at exactly this mode — "
|
Text("The host creates a virtual output at exactly this mode — "
|
||||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
// MARK: - Stream mode (iOS wheel)
|
||||||
|
|
||||||
|
/// 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"
|
||||||
|
|
||||||
|
/// 16:9 then ultrawide presets; the device's native mode is prepended at runtime.
|
||||||
|
private static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
|
||||||
|
("720p", 1280, 720),
|
||||||
|
("1080p", 1920, 1080),
|
||||||
|
("1440p", 2560, 1440),
|
||||||
|
("4K", 3840, 2160),
|
||||||
|
("Ultrawide 1080p", 2560, 1080),
|
||||||
|
("Ultrawide 1440p", 3440, 1440),
|
||||||
|
("Super ultrawide", 5120, 1440),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The non-custom wheel rows: this device's native mode first, then the presets, deduped by
|
||||||
|
/// dimensions (native wins a tie).
|
||||||
|
private var resolutionModes: [(name: String, w: Int, h: Int)] {
|
||||||
|
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
|
||||||
|
let native = (w: Int(max(bounds.width, bounds.height)), h: Int(min(bounds.width, bounds.height)))
|
||||||
|
let all = [(name: "This device", w: native.w, h: native.h)] + Self.resolutionPresets
|
||||||
|
var seen = Set<String>()
|
||||||
|
return all.filter { seen.insert("\($0.w)x\($0.h)").inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wheel rows: the resolution modes, then a "Custom…" row that reveals the numeric fields.
|
||||||
|
private var resolutionChoices: [(label: String, tag: String)] {
|
||||||
|
resolutionModes.map { (label: "\($0.name) · \($0.w) × \($0.h)", tag: "\($0.w)x\($0.h)") }
|
||||||
|
+ [(label: "Custom…", tag: Self.customResolutionTag)]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var presetResolutionTags: Set<String> {
|
||||||
|
Set(resolutionModes.map { "\($0.w)x\($0.h)" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the editable custom fields should show: the wheel is parked on "Custom…" (sticky),
|
||||||
|
/// or the stored size simply isn't one of the presets (e.g. a value synced from a Mac) — so a
|
||||||
|
/// non-preset mode stays editable across relaunches without a persisted flag.
|
||||||
|
private var isCustomResolution: Bool {
|
||||||
|
customMode || !presetResolutionTags.contains("\(width)x\(height)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wheel works in "WxH" tags so one selection drives both width and height; the custom
|
||||||
|
/// sentinel toggles `customMode` instead of writing a size.
|
||||||
|
private var resolutionSelection: Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { isCustomResolution ? Self.customResolutionTag : "\(width)x\(height)" },
|
||||||
|
set: { tag in
|
||||||
|
if tag == Self.customResolutionTag {
|
||||||
|
customMode = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
customMode = false
|
||||||
|
let parts = tag.split(separator: "x").compactMap { Int($0) }
|
||||||
|
guard parts.count == 2 else { return }
|
||||||
|
width = parts[0]
|
||||||
|
height = parts[1]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh rates the device can actually display (no point asking the host to render frames the
|
||||||
|
/// screen can't show), plus any stored custom value so it stays selectable.
|
||||||
|
private var refreshChoices: [Int] {
|
||||||
|
let maxHz = UIScreen.main.maximumFramesPerSecond
|
||||||
|
var rates = [60, 120, 240].filter { $0 <= maxHz }
|
||||||
|
if rates.isEmpty { rates = [maxHz] }
|
||||||
|
if !rates.contains(hz) { rates.append(hz) }
|
||||||
|
return rates.sorted()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@ViewBuilder private var audioSection: some View {
|
@ViewBuilder private var audioSection: some View {
|
||||||
Section {
|
Section {
|
||||||
|
Picker("Audio channels", selection: $audioChannels) {
|
||||||
|
Text("Stereo").tag(2)
|
||||||
|
Text("5.1 Surround").tag(6)
|
||||||
|
Text("7.1 Surround").tag(8)
|
||||||
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Picker("Speaker", selection: $speakerUID) {
|
Picker("Speaker", selection: $speakerUID) {
|
||||||
Text("System default").tag("")
|
Text("System default").tag("")
|
||||||
@@ -303,7 +576,7 @@ struct SettingsView: View {
|
|||||||
Text("Host audio plays through the speaker; the microphone feeds the "
|
Text("Host audio plays through the speaker; the microphone feeds the "
|
||||||
+ "host's virtual mic. System default follows macOS device changes. "
|
+ "host's virtual mic. System default follows macOS device changes. "
|
||||||
+ "Applies from the next session.")
|
+ "Applies from the next session.")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,7 +596,7 @@ struct SettingsView: View {
|
|||||||
Text("Which compositor drives the virtual output on the host. A specific "
|
Text("Which compositor drives the virtual output on the host. A specific "
|
||||||
+ "choice is honored only if that backend is available there — "
|
+ "choice is honored only if that backend is available there — "
|
||||||
+ "otherwise the host falls back to auto-detection.")
|
+ "otherwise the host falls back to auto-detection.")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,26 +610,47 @@ struct SettingsView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("Take the window fullscreen when a session starts and restore it on the host "
|
Text("Take the window fullscreen when a session starts and restore it on the host "
|
||||||
+ "list, so only the stream is fullscreen — not the picker.")
|
+ "list, so only the stream is fullscreen — not the picker.")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it
|
||||||
|
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
|
||||||
|
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
|
||||||
|
// the controller test. Empty in release builds (no presenter UI; stage-2 always).
|
||||||
@ViewBuilder private var presenterSection: some View {
|
@ViewBuilder private var presenterSection: some View {
|
||||||
|
#if DEBUG
|
||||||
Section {
|
Section {
|
||||||
Picker("Presenter", selection: $presenter) {
|
Picker("Presenter", selection: $presenter) {
|
||||||
Text("Stage 1 (default)").tag("stage1")
|
Text("Stage 2 (default)").tag("stage2")
|
||||||
Text("Stage 2 (experimental)").tag("stage2")
|
Text("Stage 1 (debug)").tag("stage1")
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Video presenter")
|
Text("Video presenter · debug")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Stage 1 feeds compressed video to the system display layer (known-good). "
|
Text("Stage 2 (default) decodes explicitly and presents through Metal with a display "
|
||||||
+ "Stage 2 decodes explicitly and presents through Metal with a display "
|
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD and "
|
||||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD "
|
+ "self-recovers from decode stalls. Stage 1 feeds compressed video straight to the "
|
||||||
+ "and shortens the present tail. Applies from the next session.")
|
+ "system display layer; it freezes on a lost HEVC reference frame, so it's a debug "
|
||||||
.font(.caption)
|
+ "fallback only. Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var hdrSection: some View {
|
||||||
|
Section {
|
||||||
|
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
||||||
|
} header: {
|
||||||
|
Text("HDR")
|
||||||
|
} footer: {
|
||||||
|
Text("Request a 10-bit BT.2020 PQ (HDR10) stream. It only engages when the host is "
|
||||||
|
+ "sending HDR content AND this display supports HDR — otherwise the stream stays "
|
||||||
|
+ "8-bit SDR. Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,7 +668,7 @@ struct SettingsView: View {
|
|||||||
Text("Statistics")
|
Text("Statistics")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(Self.statisticsFooter)
|
Text(Self.statisticsFooter)
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,7 +683,7 @@ struct SettingsView: View {
|
|||||||
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
|
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
|
||||||
+ "The host must expose that API on the LAN with a token "
|
+ "The host must expose that API on the LAN with a token "
|
||||||
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,7 +717,7 @@ struct SettingsView: View {
|
|||||||
Text("Controllers")
|
Text("Controllers")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,13 +869,13 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.caption2)
|
.font(.geist(11, relativeTo: .caption2))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if gamepads.active?.id == controller.id {
|
if gamepads.active?.id == controller.id {
|
||||||
Text("In use")
|
Text("In use")
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
.background(Capsule().fill(.green.opacity(0.2)))
|
.background(Capsule().fill(.green.opacity(0.2)))
|
||||||
@@ -603,6 +897,10 @@ struct SettingsView: View {
|
|||||||
width = Int(max(bounds.width, bounds.height))
|
width = Int(max(bounds.width, bounds.height))
|
||||||
height = Int(min(bounds.width, bounds.height))
|
height = Int(min(bounds.width, bounds.height))
|
||||||
hz = UIScreen.main.maximumFramesPerSecond
|
hz = UIScreen.main.maximumFramesPerSecond
|
||||||
|
#if os(iOS)
|
||||||
|
// The native mode is the "This device" wheel row, so leave Custom mode if it was on.
|
||||||
|
customMode = false
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -613,3 +911,52 @@ extension Double {
|
|||||||
Swift.min(Swift.max(self, lo), hi)
|
Swift.min(Swift.max(self, lo), hi)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// The settings groups, mirroring the macOS preference tabs. On iPad each is a sidebar row that
|
||||||
|
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
|
||||||
|
/// private) so the screenshot harness can open SettingsView on a specific category.
|
||||||
|
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||||
|
case general, display, audio, controllers, advanced, about
|
||||||
|
|
||||||
|
var id: Self { self }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .general: return "General"
|
||||||
|
case .display: return "Display"
|
||||||
|
case .audio: return "Audio"
|
||||||
|
case .controllers: return "Controllers"
|
||||||
|
case .advanced: return "Advanced"
|
||||||
|
case .about: return "About"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .general: return "gearshape"
|
||||||
|
case .display: return "display"
|
||||||
|
case .audio: return "speaker.wave.2"
|
||||||
|
case .controllers: return "gamecontroller"
|
||||||
|
case .advanced: return "slider.horizontal.3"
|
||||||
|
case .about: return "info.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Present the settings sheet large on iPad so the NavigationSplitView has room for its
|
||||||
|
/// sidebar + detail — a default form sheet is too narrow and the split view would collapse to
|
||||||
|
/// the iPhone push list. No-op on iPhone (the standard sheet is already right) and on iOS 17
|
||||||
|
/// (no `presentationSizing` — it falls back to the default sheet, which still degrades cleanly
|
||||||
|
/// to the push list).
|
||||||
|
@ViewBuilder
|
||||||
|
func settingsSheetSizing() -> some View {
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .pad, #available(iOS 18, *) {
|
||||||
|
presentationSizing(.page)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ struct SpeedTestSheet: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
Label("Speed test — \(host.displayName)", systemImage: "gauge.with.needle")
|
Label("Speed test — \(host.displayName)", systemImage: "gauge.with.needle")
|
||||||
.font(.headline)
|
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
|
|
||||||
switch phase {
|
switch phase {
|
||||||
@@ -73,7 +73,7 @@ struct SpeedTestSheet: View {
|
|||||||
resultView(result)
|
resultView(result)
|
||||||
case .failed(let message):
|
case .failed(let message):
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.callout)
|
.font(.geist(16, relativeTo: .callout))
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
@@ -149,13 +149,13 @@ struct SpeedTestSheet: View {
|
|||||||
if let rec = Self.recommendedKbps(result) {
|
if let rec = Self.recommendedKbps(result) {
|
||||||
Text("Recommended bitrate: \(Self.mbpsLabel(kbps: rec)) "
|
Text("Recommended bitrate: \(Self.mbpsLabel(kbps: rec)) "
|
||||||
+ "(~70% of measured, headroom for encoder bursts).")
|
+ "(~70% of measured, headroom for encoder bursts).")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
} else {
|
} else {
|
||||||
Text("Too little data made it through to recommend a bitrate — "
|
Text("Too little data made it through to recommend a bitrate — "
|
||||||
+ "check the network and retry.")
|
+ "check the network and retry.")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,19 +69,19 @@ struct StreamHUDView: View {
|
|||||||
Text(model.mouseCaptured
|
Text(model.mouseCaptured
|
||||||
? "⌘⎋ releases the mouse"
|
? "⌘⎋ releases the mouse"
|
||||||
: "Click the stream to capture input")
|
: "Click the stream to capture input")
|
||||||
.font(.caption2)
|
.font(.geist(11, relativeTo: .caption2))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
// The client-side cursor (⌘⇧C) draws the local cursor over the stream instead of
|
// The client-side cursor (⌘⇧C) draws the local cursor over the stream instead of
|
||||||
// capturing it — the only accurate cursor for gamescope, whose capture has none.
|
// capturing it — the only accurate cursor for gamescope, whose capture has none.
|
||||||
Text("⌘⇧C toggles the on-screen cursor")
|
Text("⌘⇧C toggles the on-screen cursor")
|
||||||
.font(.caption2)
|
.font(.geist(11, relativeTo: .caption2))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
||||||
Text(model.mouseCaptured
|
Text(model.mouseCaptured
|
||||||
? "⌘⎋ releases keyboard & mouse"
|
? "⌘⎋ releases keyboard & mouse"
|
||||||
: "⌘⎋ captures keyboard & mouse")
|
: "⌘⎋ captures keyboard & mouse")
|
||||||
.font(.caption2)
|
.font(.geist(11, relativeTo: .caption2))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
#endif
|
#endif
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@@ -89,13 +89,13 @@ struct StreamHUDView: View {
|
|||||||
// A press (the focus engine consumes it before the host sees it). Disconnect is
|
// A press (the focus engine consumes it before the host sees it). Disconnect is
|
||||||
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
|
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
|
||||||
Text("Press Menu to disconnect")
|
Text("Press Menu to disconnect")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
#else
|
#else
|
||||||
// ⌘D lives on the app's Stream menu (so it still works when the HUD is hidden);
|
// ⌘D lives on the app's Stream menu (so it still works when the HUD is hidden);
|
||||||
// this button is the in-overlay, click-to-disconnect affordance.
|
// this button is the in-overlay, click-to-disconnect affordance.
|
||||||
Button("Disconnect (⌘D)") { model.disconnect() }
|
Button("Disconnect (⌘D)") { model.disconnect() }
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// or drops this and runs the PIN pairing ceremony instead.
|
// or drops this and runs the PIN pairing ceremony instead.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct TrustCardView: View {
|
struct TrustCardView: View {
|
||||||
@@ -18,11 +19,11 @@ struct TrustCardView: View {
|
|||||||
.font(.system(size: 36, weight: .light))
|
.font(.system(size: 36, weight: .light))
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
Text("Verify \(hostName)")
|
Text("Verify \(hostName)")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.geist(20, .semibold, relativeTo: .title3))
|
||||||
Text("First connection. Compare this fingerprint with the one "
|
Text("First connection. Compare this fingerprint with the one "
|
||||||
+ "punktfunk-host logged at startup (\u{201C}clients pin this "
|
+ "punktfunk-host logged at startup (\u{201C}clients pin this "
|
||||||
+ "fingerprint\u{201D}):")
|
+ "fingerprint\u{201D}):")
|
||||||
.font(.callout)
|
.font(.geist(16, relativeTo: .callout))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
Text(Self.format(fingerprint: fingerprint))
|
Text(Self.format(fingerprint: fingerprint))
|
||||||
@@ -58,7 +59,7 @@ struct TrustCardView: View {
|
|||||||
#else
|
#else
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
#endif
|
#endif
|
||||||
.font(.callout)
|
.font(.geist(16, relativeTo: .callout))
|
||||||
}
|
}
|
||||||
.padding(28)
|
.padding(28)
|
||||||
.frame(maxWidth: 440)
|
.frame(maxWidth: 440)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Geist — the punktfunk brand typeface (the same family the website ships). Bundled as static
|
||||||
|
// OTF weights in this kit's resources and registered with Core Text at first use, so it works
|
||||||
|
// identically in the Xcode app and the `swift run` dev shell (Bundle.module resolves to the
|
||||||
|
// package resource bundle in both). Geist Sans carries titles/UI; Geist Mono carries the technical
|
||||||
|
// readouts — host addresses, status labels, the stream-stats HUD — for the industrial look.
|
||||||
|
//
|
||||||
|
// Licensed under the SIL Open Font License 1.1 (Resources/Fonts/Geist-OFL.txt).
|
||||||
|
|
||||||
|
import CoreText
|
||||||
|
import SwiftUI
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public enum BrandFont {
|
||||||
|
public enum Weight {
|
||||||
|
case regular, medium, semibold, bold
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PostScript names of the bundled faces (verified from each OTF's name table). Geist Sans only
|
||||||
|
/// — Geist Mono is intentionally not shipped; the app's typeface is Geist Sans throughout.
|
||||||
|
private static let sansFaces = ["Geist-Regular", "Geist-Medium", "Geist-SemiBold", "Geist-Bold"]
|
||||||
|
|
||||||
|
/// Registered exactly once per process — a static `let` initializer is run lazily and is
|
||||||
|
/// guaranteed thread-safe + run-at-most-once by the runtime.
|
||||||
|
private static let registered: Void = {
|
||||||
|
for face in sansFaces {
|
||||||
|
guard let url = Bundle.module.url(
|
||||||
|
forResource: face, withExtension: "otf", subdirectory: "Fonts") else {
|
||||||
|
#if DEBUG
|
||||||
|
print("BrandFont: bundled face \(face).otf not found — text will fall back to system")
|
||||||
|
#endif
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) {
|
||||||
|
#if DEBUG
|
||||||
|
let message = error?.takeRetainedValue().localizedDescription ?? "unknown error"
|
||||||
|
print("BrandFont: failed to register \(face): \(message)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Force registration before the first `Font.custom` lookup. Cheap to call repeatedly.
|
||||||
|
public static func registerIfNeeded() { _ = registered }
|
||||||
|
|
||||||
|
fileprivate static func sansFace(_ weight: Weight) -> String {
|
||||||
|
switch weight {
|
||||||
|
case .regular: return "Geist-Regular"
|
||||||
|
case .medium: return "Geist-Medium"
|
||||||
|
case .semibold: return "Geist-SemiBold"
|
||||||
|
case .bold: return "Geist-Bold"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Color {
|
||||||
|
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
|
||||||
|
/// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and
|
||||||
|
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
|
||||||
|
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
|
||||||
|
static let brand: Color = {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
return Color(UIColor { traits in
|
||||||
|
traits.userInterfaceStyle == .dark
|
||||||
|
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||||
|
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||||
|
})
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
return Color(NSColor(name: nil) { appearance in
|
||||||
|
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
||||||
|
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||||
|
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||||
|
})
|
||||||
|
#else
|
||||||
|
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
|
||||||
|
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
|
||||||
|
#endif
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Font {
|
||||||
|
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
||||||
|
static func geist(
|
||||||
|
_ size: CGFloat, _ weight: BrandFont.Weight = .regular,
|
||||||
|
relativeTo textStyle: TextStyle = .body
|
||||||
|
) -> Font {
|
||||||
|
BrandFont.registerIfNeeded()
|
||||||
|
return .custom(BrandFont.sansFace(weight), size: size, relativeTo: textStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Geist Sans at a FIXED point size that does not scale with Dynamic Type — for glyphs pinned
|
||||||
|
/// inside a fixed-size container (e.g. the monogram tile), where a scaled letter would overflow.
|
||||||
|
static func geistFixed(_ size: CGFloat, _ weight: BrandFont.Weight = .regular) -> Font {
|
||||||
|
BrandFont.registerIfNeeded()
|
||||||
|
return .custom(BrandFont.sansFace(weight), fixedSize: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,10 +15,16 @@ public enum DefaultsKey {
|
|||||||
public static let gamepadType = "punktfunk.gamepadType"
|
public static let gamepadType = "punktfunk.gamepadType"
|
||||||
public static let gamepadID = "punktfunk.gamepadID"
|
public static let gamepadID = "punktfunk.gamepadID"
|
||||||
public static let bitrateKbps = "punktfunk.bitrateKbps"
|
public static let bitrateKbps = "punktfunk.bitrateKbps"
|
||||||
|
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||||
|
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
|
||||||
|
public static let audioChannels = "punktfunk.audioChannels"
|
||||||
public static let micEnabled = "punktfunk.micEnabled"
|
public static let micEnabled = "punktfunk.micEnabled"
|
||||||
public static let speakerUID = "punktfunk.speakerUID"
|
public static let speakerUID = "punktfunk.speakerUID"
|
||||||
public static let micUID = "punktfunk.micUID"
|
public static let micUID = "punktfunk.micUID"
|
||||||
public static let presenter = "punktfunk.presenter"
|
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.
|
||||||
|
public static let hdrEnabled = "punktfunk.hdrEnabled"
|
||||||
public static let hosts = "punktfunk.hosts"
|
public static let hosts = "punktfunk.hosts"
|
||||||
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
||||||
public static let cursorMode = "punktfunk.cursorMode"
|
public static let cursorMode = "punktfunk.cursorMode"
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
private var broken = false
|
private var broken = false
|
||||||
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
||||||
private var wasActive = false
|
private var wasActive = false
|
||||||
|
// Backoff after an engine failure. A broken `gamecontrollerd.haptics` XPC connection (CoreHaptics
|
||||||
|
// -4811 "server connection broke") fails EVERY rebuild until the service relaunches — and that
|
||||||
|
// 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
|
||||||
|
private var consecutiveFailures = 0
|
||||||
|
|
||||||
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
/// 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
|
/// defined frequency to move at all — an intensity-only event (no sharpness) left them
|
||||||
@@ -91,6 +99,8 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
self.closeHID()
|
self.closeHID()
|
||||||
self.controller = c
|
self.controller = c
|
||||||
self.broken = false
|
self.broken = false
|
||||||
|
self.consecutiveFailures = 0
|
||||||
|
self.retryAfter = .distantPast
|
||||||
_ = self.openHIDIfDualSense(c)
|
_ = self.openHIDIfDualSense(c)
|
||||||
onBackend?(self.backendNote(for: c))
|
onBackend?(self.backendNote(for: c))
|
||||||
}
|
}
|
||||||
@@ -108,7 +118,7 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
// other pad (and for a DualSense whose HID device could not be opened).
|
// other pad (and for a DualSense whose HID device could not be opened).
|
||||||
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
||||||
guard !self.broken else { return }
|
guard !self.broken else { return }
|
||||||
if active, self.low == nil, self.high == nil {
|
if active, self.low == nil, self.high == nil, Date() >= self.retryAfter {
|
||||||
self.setup()
|
self.setup()
|
||||||
}
|
}
|
||||||
let ok: Bool
|
let ok: Bool
|
||||||
@@ -124,8 +134,15 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
// Rebuild on the next nonzero amplitude if an engine errored — and tear down OUTSIDE
|
// 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
|
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
|
||||||
// still holds an exclusive reference to.
|
// still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
|
||||||
if !ok { self.teardown() }
|
// 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,14 +174,29 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
low = makeMotor(haptics, .default)
|
low = makeMotor(haptics, .default)
|
||||||
}
|
}
|
||||||
if low == nil, high == nil {
|
if low == nil, high == nil {
|
||||||
// Haptics present but no engine could be built right now (server busy / a transient
|
// Haptics present but no engine could be built right now (server busy / XPC broken). Do
|
||||||
// error). Do NOT latch broken — the next nonzero amplitude retries setup().
|
// NOT latch broken — back off and the next nonzero amplitude past the cooldown retries.
|
||||||
log.warning("rumble: haptics present but engine setup failed — will retry on next rumble")
|
log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
|
||||||
|
scheduleRetryBackoff()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push the next engine-build attempt out after a failure (capped exponential backoff), so a
|
||||||
|
/// broken `gamecontrollerd.haptics` connection gets time to relaunch instead of being re-hit on
|
||||||
|
/// every rumble update.
|
||||||
|
private func scheduleRetryBackoff() {
|
||||||
|
consecutiveFailures += 1
|
||||||
|
let shift = min(consecutiveFailures - 1, 4)
|
||||||
|
retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 4))
|
||||||
|
}
|
||||||
|
|
||||||
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
|
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
|
||||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
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;
|
||||||
|
// letting a haptics-only engine join it is a needless coupling that can get its
|
||||||
|
// gamecontrollerd XPC connection interrupted (the repeated -4811 server-connection breaks).
|
||||||
|
engine.playsHapticsOnly = true
|
||||||
// The haptic server can stop or reset the engine out from under us — app backgrounding, an
|
// The haptic server can stop or reset the engine out from under us — app backgrounding, an
|
||||||
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left
|
// 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
|
// unhandled the players go dead and every later rumble throws, latching rumble off for the
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Open-source license / attribution text bundled with PunktfunkKit (see `Resources/`).
|
||||||
|
///
|
||||||
|
/// Exposed from the kit so the app shell can show an Acknowledgements screen. The text files are
|
||||||
|
/// bundled as SwiftPM resources and read via `Bundle.module`, which works both for `swift build`
|
||||||
|
/// and for the Xcode app (it links the PunktfunkKit product, so the resource bundle rides along).
|
||||||
|
public enum Licenses {
|
||||||
|
private static func resource(_ name: String) -> String {
|
||||||
|
guard let url = Bundle.module.url(forResource: name, withExtension: "txt"),
|
||||||
|
let text = try? String(contentsOf: url, encoding: .utf8)
|
||||||
|
else { return "" }
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// punktfunk's own license — MIT OR Apache-2.0, at your option.
|
||||||
|
public static var appLicense: String {
|
||||||
|
let mit = resource("LICENSE-MIT")
|
||||||
|
let apache = resource("LICENSE-APACHE")
|
||||||
|
if mit.isEmpty && apache.isEmpty {
|
||||||
|
return "punktfunk is licensed under MIT OR Apache-2.0, at your option."
|
||||||
|
}
|
||||||
|
return "punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n"
|
||||||
|
+ "================================ MIT ================================\n\n"
|
||||||
|
+ mit
|
||||||
|
+ "\n\n============================== Apache-2.0 ==============================\n\n"
|
||||||
|
+ apache
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bundled brand typeface (Geist Sans + Geist Mono) — SIL Open Font License 1.1. The
|
||||||
|
/// license file ships alongside the OTFs in `Resources/Fonts/`, satisfying the OFL's
|
||||||
|
/// distribution requirement; this surfaces it in the Acknowledgements screen too.
|
||||||
|
public static var fontLicense: String {
|
||||||
|
guard let url = Bundle.module.url(
|
||||||
|
forResource: "Geist-OFL", withExtension: "txt", subdirectory: "Fonts"),
|
||||||
|
let text = try? String(contentsOf: url, encoding: .utf8)
|
||||||
|
else { return "" }
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Third-party software notices for the linked Rust crates (generated by
|
||||||
|
/// `scripts/gen-third-party-notices.sh`).
|
||||||
|
public static var thirdPartyNotices: String {
|
||||||
|
let text = resource("THIRD-PARTY-NOTICES")
|
||||||
|
return text.isEmpty ? "Third-party notices unavailable." : text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `thirdPartyNotices` pre-split into render-sized line chunks. The full notices are ~885 KB /
|
||||||
|
/// 16k lines; a single SwiftUI `Text` that large overshoots CoreText/CoreAnimation's max
|
||||||
|
/// renderable height — it lays out for ages and draws blank past the limit — so the
|
||||||
|
/// Acknowledgements screen renders these chunks in a `LazyVStack` (only on-screen chunks lay
|
||||||
|
/// out, and no chunk is tall enough to clip). Split at line boundaries and joined with "\n";
|
||||||
|
/// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once.
|
||||||
|
public static let thirdPartyNoticesChunks: [String] = {
|
||||||
|
let lines = thirdPartyNotices.split(separator: "\n", omittingEmptySubsequences: false)
|
||||||
|
let chunkSize = 200
|
||||||
|
return stride(from: 0, to: lines.count, by: chunkSize).map { start in
|
||||||
|
lines[start..<min(start + chunkSize, lines.count)].joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ import CoreGraphics
|
|||||||
import CoreVideo
|
import CoreVideo
|
||||||
import Metal
|
import Metal
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
|
||||||
|
|
||||||
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and a
|
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and a
|
||||||
/// BT.709 limited-range NV12→RGB fragment shader. uv.y is flipped (1 - p.y) so the top-left-
|
/// BT.709 limited-range NV12→RGB fragment shader. uv.y is flipped (1 - p.y) so the top-left-
|
||||||
@@ -30,11 +33,44 @@ vertex VOut pf_vtx(uint vid [[vertex_id]]) {
|
|||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bicubic (Catmull-Rom) sampling of the single-channel luma plane. When the drawable is larger
|
||||||
|
// than the decoded frame (a window/view bigger than the host's fixed mode), a bilinear upscale
|
||||||
|
// looks soft; Catmull-Rom keeps edges crisp — matching AVSampleBufferDisplayLayer's (stage-1)
|
||||||
|
// scaler — and reduces to the exact texel at 1:1, so a native-resolution present stays pixel-exact.
|
||||||
|
// Nine bilinear taps (TheRealMJP's optimisation of the 16-tap kernel); `s` MUST be a linear
|
||||||
|
// sampler. Luma carries the perceived detail, so only it gets bicubic; chroma stays bilinear.
|
||||||
|
float catmullRomLuma(texture2d<float> tex, sampler s, float2 uv) {
|
||||||
|
float2 texSize = float2(tex.get_width(), tex.get_height());
|
||||||
|
float2 samplePos = uv * texSize;
|
||||||
|
float2 tc1 = floor(samplePos - 0.5) + 0.5;
|
||||||
|
float2 f = samplePos - tc1;
|
||||||
|
float2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
|
||||||
|
float2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
|
||||||
|
float2 w2 = f * (0.5 + f * (2.0 - 1.5 * f));
|
||||||
|
float2 w3 = f * f * (-0.5 + 0.5 * f);
|
||||||
|
float2 w12 = w1 + w2;
|
||||||
|
float2 off12 = w2 / w12;
|
||||||
|
float2 tc0 = (tc1 - 1.0) / texSize;
|
||||||
|
float2 tc3 = (tc1 + 2.0) / texSize;
|
||||||
|
float2 tc12 = (tc1 + off12) / texSize;
|
||||||
|
float r = 0.0;
|
||||||
|
r += tex.sample(s, float2(tc0.x, tc0.y)).r * (w0.x * w0.y);
|
||||||
|
r += tex.sample(s, float2(tc12.x, tc0.y)).r * (w12.x * w0.y);
|
||||||
|
r += tex.sample(s, float2(tc3.x, tc0.y)).r * (w3.x * w0.y);
|
||||||
|
r += tex.sample(s, float2(tc0.x, tc12.y)).r * (w0.x * w12.y);
|
||||||
|
r += tex.sample(s, float2(tc12.x, tc12.y)).r * (w12.x * w12.y);
|
||||||
|
r += tex.sample(s, float2(tc3.x, tc12.y)).r * (w3.x * w12.y);
|
||||||
|
r += tex.sample(s, float2(tc0.x, tc3.y)).r * (w0.x * w3.y);
|
||||||
|
r += tex.sample(s, float2(tc12.x, tc3.y)).r * (w12.x * w3.y);
|
||||||
|
r += tex.sample(s, float2(tc3.x, tc3.y)).r * (w3.x * w3.y);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
fragment float4 pf_frag(VOut in [[stage_in]],
|
fragment float4 pf_frag(VOut in [[stage_in]],
|
||||||
texture2d<float> lumaTex [[texture(0)]],
|
texture2d<float> lumaTex [[texture(0)]],
|
||||||
texture2d<float> chromaTex [[texture(1)]]) {
|
texture2d<float> chromaTex [[texture(1)]]) {
|
||||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||||
float y = lumaTex.sample(s, in.uv).r;
|
float y = catmullRomLuma(lumaTex, s, in.uv);
|
||||||
float2 c = chromaTex.sample(s, in.uv).rg;
|
float2 c = chromaTex.sample(s, in.uv).rg;
|
||||||
// BT.709, 8-bit limited (video) range → full-range RGB.
|
// BT.709, 8-bit limited (video) range → full-range RGB.
|
||||||
y = (y - 16.0/255.0) * (255.0/219.0);
|
y = (y - 16.0/255.0) * (255.0/219.0);
|
||||||
@@ -55,7 +91,7 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
|||||||
texture2d<float> lumaTex [[texture(0)]],
|
texture2d<float> lumaTex [[texture(0)]],
|
||||||
texture2d<float> chromaTex [[texture(1)]]) {
|
texture2d<float> chromaTex [[texture(1)]]) {
|
||||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||||
float y = lumaTex.sample(s, in.uv).r;
|
float y = catmullRomLuma(lumaTex, s, in.uv);
|
||||||
float2 c = chromaTex.sample(s, in.uv).rg;
|
float2 c = chromaTex.sample(s, in.uv).rg;
|
||||||
// BT.2020 10-bit limited (video) range → full-range PQ R'G'B'.
|
// BT.2020 10-bit limited (video) range → full-range PQ R'G'B'.
|
||||||
y = (y - 64.0/1023.0) * (1023.0/876.0);
|
y = (y - 64.0/1023.0) * (1023.0/876.0);
|
||||||
@@ -81,6 +117,11 @@ public final class MetalVideoPresenter {
|
|||||||
private var textureCache: CVMetalTextureCache?
|
private var textureCache: CVMetalTextureCache?
|
||||||
/// Current layer configuration — switched lazily in `configure(hdr:)` when a frame's mode differs.
|
/// Current layer configuration — switched lazily in `configure(hdr:)` when a frame's mode differs.
|
||||||
private var hdrActive = false
|
private var hdrActive = false
|
||||||
|
#if DEBUG
|
||||||
|
/// Last logged "decoded→drawable" signature, so the diagnostic logs only when a size changes
|
||||||
|
/// (on first frame, a resize, or a host Reconfigure) instead of every frame.
|
||||||
|
private var lastSizeSig = ""
|
||||||
|
#endif
|
||||||
|
|
||||||
/// nil if Metal is unavailable (no GPU / a headless CI) — the caller falls back to stage-1.
|
/// nil if Metal is unavailable (no GPU / a headless CI) — the caller falls back to stage-1.
|
||||||
public init?() {
|
public init?() {
|
||||||
@@ -113,6 +154,12 @@ public final class MetalVideoPresenter {
|
|||||||
layer.pixelFormat = .bgra8Unorm
|
layer.pixelFormat = .bgra8Unorm
|
||||||
layer.framebufferOnly = true
|
layer.framebufferOnly = true
|
||||||
layer.isOpaque = true
|
layer.isOpaque = true
|
||||||
|
// Render the drawable at the DECODED frame's resolution (set per-frame in `render`) and let
|
||||||
|
// the system compositor scale it to the layer's bounds — the same `.resizeAspect` path
|
||||||
|
// stage-1's AVSampleBufferDisplayLayer (videoGravity) uses, so stage-2 matches its sharpness.
|
||||||
|
// A native-resolution present is then pixel-exact (1:1, no shader scaling), and any display
|
||||||
|
// scaling uses the system's high-quality scaler rather than the in-shader bicubic.
|
||||||
|
layer.contentsGravity = .resizeAspect
|
||||||
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the
|
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the
|
||||||
// display-link / MAIN thread) has to block waiting for one to free.
|
// display-link / MAIN thread) has to block waiting for one to free.
|
||||||
layer.maximumDrawableCount = 3
|
layer.maximumDrawableCount = 3
|
||||||
@@ -129,12 +176,6 @@ public final class MetalVideoPresenter {
|
|||||||
self.layer = layer
|
self.layer = layer
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track the stream mode (the host can Reconfigure mid-stream). Size is in pixels.
|
|
||||||
public func setDrawableSize(_ size: CGSize) {
|
|
||||||
guard size.width > 0, size.height > 0 else { return }
|
|
||||||
if layer.drawableSize != size { layer.drawableSize = size }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reconfigure the layer for SDR or HDR when the stream mode flips (HDR toggle). HDR uses an
|
/// Reconfigure the layer for SDR or HDR when the stream mode flips (HDR toggle). HDR uses an
|
||||||
/// rgba16Float drawable + a BT.2020 PQ colour space + EDR, so the compositor PQ-maps to the
|
/// rgba16Float drawable + a BT.2020 PQ colour space + EDR, so the compositor PQ-maps to the
|
||||||
/// display; SDR uses the plain 8-bit sRGB path. Main-thread only (called from `render`).
|
/// display; SDR uses the plain 8-bit sRGB path. Main-thread only (called from `render`).
|
||||||
@@ -171,13 +212,33 @@ public final class MetalVideoPresenter {
|
|||||||
let chroma = makeTexture(pixelBuffer, plane: 1, format: chromaFmt, cache: textureCache)
|
let chroma = makeTexture(pixelBuffer, plane: 1, format: chromaFmt, cache: textureCache)
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
// The hosting view owns drawableSize (aspect-fit to its bounds); skip until it's laid
|
// Size the drawable to the decoded frame so the fullscreen triangle samples the texture 1:1
|
||||||
// out. The fullscreen triangle scales the decoded texture to fill the drawable.
|
// (pixel-exact); the layer's contentsGravity then scales it to the on-screen bounds via the
|
||||||
guard layer.drawableSize.width > 0, layer.drawableSize.height > 0,
|
// system compositor (matching stage-1). Re-set only on a change (first frame / Reconfigure).
|
||||||
let drawable = layer.nextDrawable(),
|
let decodedSize = CGSize(
|
||||||
|
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||||
|
if layer.drawableSize != decodedSize { layer.drawableSize = decodedSize }
|
||||||
|
guard let drawable = layer.nextDrawable(),
|
||||||
let commandBuffer = queue.makeCommandBuffer()
|
let commandBuffer = queue.makeCommandBuffer()
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// Diagnose sharpness: decoded should equal the drawable (the shader is 1:1); the layer's
|
||||||
|
// bounds may differ (the system scales). Logged only when a size changes.
|
||||||
|
let decodedW = Int(decodedSize.width)
|
||||||
|
let decodedH = Int(decodedSize.height)
|
||||||
|
let sig = "\(decodedW)x\(decodedH)|\(Int(layer.drawableSize.width))x\(Int(layer.drawableSize.height))"
|
||||||
|
if sig != lastSizeSig {
|
||||||
|
lastSizeSig = sig
|
||||||
|
let msg = "stage2: decoded \(decodedW)x\(decodedH) → drawable "
|
||||||
|
+ "\(Int(layer.drawableSize.width))x\(Int(layer.drawableSize.height)) "
|
||||||
|
+ "(texture \(drawable.texture.width)x\(drawable.texture.height), "
|
||||||
|
+ "contentsScale \(layer.contentsScale), "
|
||||||
|
+ "layerBounds \(Int(layer.bounds.width))x\(Int(layer.bounds.height)))"
|
||||||
|
presenterLog.info("\(msg, privacy: .public)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
let pass = MTLRenderPassDescriptor()
|
let pass = MTLRenderPassDescriptor()
|
||||||
pass.colorAttachments[0].texture = drawable.texture
|
pass.colorAttachments[0].texture = drawable.texture
|
||||||
pass.colorAttachments[0].loadAction = .clear
|
pass.colorAttachments[0].loadAction = .clear
|
||||||
|
|||||||
@@ -182,6 +182,11 @@ public final class PunktfunkConnection {
|
|||||||
case dualSense = 2
|
case dualSense = 2
|
||||||
case xboxOne = 3
|
case xboxOne = 3
|
||||||
case dualShock4 = 4
|
case dualShock4 = 4
|
||||||
|
// Valve Steam Controller / Steam Deck (Linux UHID hid-steam hosts). Parity only on Apple —
|
||||||
|
// GameController never surfaces a 0x28DE HID device, so the client can't capture one; these
|
||||||
|
// exist so the resolved type round-trips and name parsing matches the host.
|
||||||
|
case steamController = 5
|
||||||
|
case steamDeck = 6
|
||||||
|
|
||||||
/// Loose name parsing for env/dev hooks, mirroring the host's
|
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||||
/// `GamepadPref::from_name`.
|
/// `GamepadPref::from_name`.
|
||||||
@@ -192,6 +197,8 @@ public final class PunktfunkConnection {
|
|||||||
case "dualsense", "ds", "ds5", "ps5": self = .dualSense
|
case "dualsense", "ds", "ds5", "ps5": self = .dualSense
|
||||||
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
|
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
|
||||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||||
|
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
||||||
|
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
|
||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,6 +242,12 @@ public final class PunktfunkConnection {
|
|||||||
/// drain `nextHdrMeta`.
|
/// drain `nextHdrMeta`.
|
||||||
public var isHDR: Bool { colorTransfer == 16 || colorTransfer == 18 }
|
public var isHDR: Bool { colorTransfer == 16 || colorTransfer == 18 }
|
||||||
|
|
||||||
|
/// The audio channel count the host resolved for this session (the Welcome's echo of the
|
||||||
|
/// requested `audioChannels`, clamped to what the host can capture): `2` (stereo), `6` (5.1)
|
||||||
|
/// or `8` (7.1). Build the playback layout from THIS, never the request. `2` for an older host.
|
||||||
|
/// PCM from `nextAudioPcm` is interleaved in the canonical wire order FL FR FC LFE RL RR SL SR.
|
||||||
|
public private(set) var resolvedAudioChannels: UInt8 = 2
|
||||||
|
|
||||||
/// Connect and start a session at the requested mode (the host creates a native virtual
|
/// Connect and start a session at the requested mode (the host creates a native virtual
|
||||||
/// output at exactly this size/refresh). Blocks up to `timeoutMs`.
|
/// output at exactly this size/refresh). Blocks up to `timeoutMs`.
|
||||||
///
|
///
|
||||||
@@ -264,6 +277,7 @@ public final class PunktfunkConnection {
|
|||||||
gamepad: GamepadType = .auto,
|
gamepad: GamepadType = .auto,
|
||||||
bitrateKbps: UInt32 = 0,
|
bitrateKbps: UInt32 = 0,
|
||||||
videoCaps: UInt8 = 0,
|
videoCaps: UInt8 = 0,
|
||||||
|
audioChannels: UInt8 = 2,
|
||||||
launchID: String? = nil,
|
launchID: String? = nil,
|
||||||
timeoutMs: UInt32 = 10_000
|
timeoutMs: UInt32 = 10_000
|
||||||
) throws {
|
) throws {
|
||||||
@@ -279,16 +293,16 @@ public final class PunktfunkConnection {
|
|||||||
withOptionalCString(launchID) { launch in
|
withOptionalCString(launchID) { launch in
|
||||||
if let pin = pinSHA256 {
|
if let pin = pinSHA256 {
|
||||||
return pin.withUnsafeBytes { p in
|
return pin.withUnsafeBytes { p in
|
||||||
punktfunk_connect_ex5(
|
punktfunk_connect_ex6(
|
||||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
gamepad.rawValue, bitrateKbps, videoCaps, launch,
|
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, launch,
|
||||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||||
cert, key, timeoutMs)
|
cert, key, timeoutMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return punktfunk_connect_ex5(
|
return punktfunk_connect_ex6(
|
||||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
gamepad.rawValue, bitrateKbps, videoCaps, launch,
|
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, launch,
|
||||||
nil, &observed, cert, key, timeoutMs)
|
nil, &observed, cert, key, timeoutMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,6 +334,9 @@ public final class PunktfunkConnection {
|
|||||||
colorMatrix = mtx
|
colorMatrix = mtx
|
||||||
colorFullRange = fullRange != 0
|
colorFullRange = fullRange != 0
|
||||||
bitDepth = depth
|
bitDepth = depth
|
||||||
|
var ac: UInt8 = 2
|
||||||
|
_ = punktfunk_connection_audio_channels(handle, &ac)
|
||||||
|
resolvedAudioChannels = ac
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||||
@@ -468,6 +485,50 @@ public final class PunktfunkConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One decoded audio frame from `nextAudioPcm`: interleaved 32-bit float at 48 kHz, in the
|
||||||
|
/// canonical wire channel order FL FR FC LFE RL RR SL SR (the first `channels`).
|
||||||
|
public struct AudioPCM: Sendable {
|
||||||
|
/// Interleaved f32 samples (`frameCount * channels` long), wire channel order.
|
||||||
|
public let samples: [Float]
|
||||||
|
/// Samples per channel.
|
||||||
|
public let frameCount: Int
|
||||||
|
/// Channel count (2/6/8) — `resolvedAudioChannels`.
|
||||||
|
public let channels: Int
|
||||||
|
public let ptsNs: UInt64
|
||||||
|
public let seq: UInt32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the next audio frame, **decoded in-core** to interleaved f32 PCM — Apple's AudioToolbox
|
||||||
|
/// Opus path is stereo-only, so surround (and, for uniformity, stereo too) is decoded by the
|
||||||
|
/// Rust core (libopus multistream) and handed back as PCM. nil on timeout, throws `.closed` once
|
||||||
|
/// the session ended. Drain from a dedicated audio thread (do NOT also call `nextAudio` — they
|
||||||
|
/// share the underlying queue). The returned `samples` are copied out, so the buffer is owned.
|
||||||
|
public func nextAudioPcm(timeoutMs: UInt32 = 100) throws -> AudioPCM? {
|
||||||
|
audioLock.lock()
|
||||||
|
defer { audioLock.unlock() }
|
||||||
|
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||||
|
|
||||||
|
var out = PunktfunkAudioPcm()
|
||||||
|
let rc = punktfunk_connection_next_audio_pcm(h, &out, timeoutMs)
|
||||||
|
switch rc {
|
||||||
|
case statusOK:
|
||||||
|
let channels = Int(out.channels)
|
||||||
|
let total = Int(out.frame_count) * channels
|
||||||
|
guard let base = out.samples, total > 0 else { return nil }
|
||||||
|
// Copy: the pointer borrows connection memory only until the next PCM call.
|
||||||
|
let samples = Array(UnsafeBufferPointer(start: base, count: total))
|
||||||
|
return AudioPCM(
|
||||||
|
samples: samples, frameCount: Int(out.frame_count),
|
||||||
|
channels: channels, ptsNs: out.pts_ns, seq: out.seq)
|
||||||
|
case statusNoFrame:
|
||||||
|
return nil
|
||||||
|
case statusClosed:
|
||||||
|
throw PunktfunkClientError.closed
|
||||||
|
default:
|
||||||
|
throw PunktfunkClientError.status(rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Pull the next force-feedback update for the GCController haptics engine:
|
/// Pull the next force-feedback update for the GCController haptics engine:
|
||||||
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop.
|
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop.
|
||||||
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
|
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or Derivative
|
||||||
|
Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2026 unom
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 unom
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -19,13 +19,13 @@ import os
|
|||||||
|
|
||||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
|
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
|
||||||
|
|
||||||
/// SPSC-ish jitter ring (interleaved stereo float), drain thread → render callback.
|
/// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread → render
|
||||||
/// The unfair lock is held for microseconds; fine at render-callback rates. Priming:
|
/// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming:
|
||||||
/// reads return silence until enough is buffered (at least `prefill`, and at least one
|
/// reads return silence until enough is buffered (at least `prefill`, and at least one
|
||||||
/// packet more than the device's render quantum — large-buffer devices would otherwise
|
/// packet more than the device's render quantum — large-buffer devices would otherwise
|
||||||
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime), and an
|
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime), and an
|
||||||
/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle.
|
/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle.
|
||||||
/// All counts stay even (whole stereo frames), so L/R interleave can never flip.
|
/// All counts stay whole frames (multiples of `channels`), so the interleave can never slip.
|
||||||
final class AudioRing: @unchecked Sendable {
|
final class AudioRing: @unchecked Sendable {
|
||||||
private var buf: [Float]
|
private var buf: [Float]
|
||||||
private var readIdx = 0
|
private var readIdx = 0
|
||||||
@@ -34,12 +34,14 @@ final class AudioRing: @unchecked Sendable {
|
|||||||
private var renderQuantum = 0
|
private var renderQuantum = 0
|
||||||
private let prefill: Int
|
private let prefill: Int
|
||||||
private let highWater: Int
|
private let highWater: Int
|
||||||
|
private let channels: Int
|
||||||
private let lock = OSAllocatedUnfairLock()
|
private let lock = OSAllocatedUnfairLock()
|
||||||
|
|
||||||
/// `capacity`/`prefill` in samples (interleaved — 2 per frame, both must be even).
|
/// `capacity`/`prefill` in samples (interleaved — `channels` per frame, both whole frames).
|
||||||
init(capacity: Int, prefill: Int) {
|
init(capacity: Int, prefill: Int, channels: Int) {
|
||||||
buf = [Float](repeating: 0, count: capacity)
|
buf = [Float](repeating: 0, count: capacity)
|
||||||
self.prefill = prefill
|
self.prefill = prefill
|
||||||
|
self.channels = channels
|
||||||
highWater = prefill * 4
|
highWater = prefill * 4
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,8 +76,8 @@ final class AudioRing: @unchecked Sendable {
|
|||||||
renderQuantum = max(renderQuantum, count)
|
renderQuantum = max(renderQuantum, count)
|
||||||
let available = writeIdx - readIdx
|
let available = writeIdx - readIdx
|
||||||
if !primed {
|
if !primed {
|
||||||
// 480 samples = one 5 ms host packet of slack beyond the device's demand.
|
// One 5 ms host packet (240 frames × channels) of slack beyond the device's demand.
|
||||||
if available >= max(prefill, renderQuantum + 480) {
|
if available >= max(prefill, renderQuantum + 240 * channels) {
|
||||||
primed = true
|
primed = true
|
||||||
} else {
|
} else {
|
||||||
for i in 0..<count { out[i] = 0 }
|
for i in 0..<count { out[i] = 0 }
|
||||||
@@ -113,10 +115,55 @@ private final class StopFlag: @unchecked Sendable {
|
|||||||
/// Render-block-owned scratch storage: freed exactly when the closure (and thus the
|
/// Render-block-owned scratch storage: freed exactly when the closure (and thus the
|
||||||
/// last possible render call) is released — never racing CoreAudio.
|
/// last possible render call) is released — never racing CoreAudio.
|
||||||
private final class ScratchBuffer {
|
private final class ScratchBuffer {
|
||||||
let ptr = UnsafeMutablePointer<Float>.allocate(capacity: 8192 * 2)
|
// 8192 frames × up to 8 channels (7.1) — the render block caps `frames` at 8192.
|
||||||
|
let ptr = UnsafeMutablePointer<Float>.allocate(capacity: 8192 * 8)
|
||||||
deinit { ptr.deallocate() }
|
deinit { ptr.deallocate() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
|
||||||
|
/// stereo (the standard layout is correct). For 5.1/7.1 we list explicit channel labels via
|
||||||
|
/// `kAudioChannelLayoutTag_UseChannelDescriptions` — preset tags (DTS_5_1 etc.) don't reliably
|
||||||
|
/// match Moonlight's order. NB the 7.1 mapping (verified against the WASAPI 0x63F + SPA orderings):
|
||||||
|
/// wire idx 4-5 = RL/RR = the WAVE *back* pair → LeftSurround/RightSurround; idx 6-7 = SL/SR = the
|
||||||
|
/// WAVE *side* pair → LeftSurroundDirect/RightSurroundDirect. (Using RearSurround* for 6-7 would
|
||||||
|
/// swap side/back vs the Windows/Linux clients.)
|
||||||
|
private func wireChannelLayout(channels: Int) -> AVAudioChannelLayout? {
|
||||||
|
let labels: [AudioChannelLabel]
|
||||||
|
switch channels {
|
||||||
|
case 6:
|
||||||
|
labels = [
|
||||||
|
kAudioChannelLabel_Left, kAudioChannelLabel_Right, kAudioChannelLabel_Center,
|
||||||
|
kAudioChannelLabel_LFEScreen, kAudioChannelLabel_LeftSurround,
|
||||||
|
kAudioChannelLabel_RightSurround,
|
||||||
|
]
|
||||||
|
case 8:
|
||||||
|
labels = [
|
||||||
|
kAudioChannelLabel_Left, kAudioChannelLabel_Right, kAudioChannelLabel_Center,
|
||||||
|
kAudioChannelLabel_LFEScreen,
|
||||||
|
kAudioChannelLabel_LeftSurround, kAudioChannelLabel_RightSurround, // wire RL/RR (back)
|
||||||
|
kAudioChannelLabel_LeftSurroundDirect, kAudioChannelLabel_RightSurroundDirect, // wire SL/SR (side)
|
||||||
|
]
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let size = MemoryLayout<AudioChannelLayout>.size
|
||||||
|
+ (labels.count - 1) * MemoryLayout<AudioChannelDescription>.stride
|
||||||
|
let raw = UnsafeMutableRawPointer.allocate(byteCount: size, alignment: 16)
|
||||||
|
defer { raw.deallocate() }
|
||||||
|
let layout = raw.bindMemory(to: AudioChannelLayout.self, capacity: 1)
|
||||||
|
layout.pointee.mChannelLayoutTag = kAudioChannelLayoutTag_UseChannelDescriptions
|
||||||
|
layout.pointee.mChannelBitmap = AudioChannelBitmap(rawValue: 0)
|
||||||
|
layout.pointee.mNumberChannelDescriptions = UInt32(labels.count)
|
||||||
|
let descs = UnsafeMutableBufferPointer(
|
||||||
|
start: &layout.pointee.mChannelDescriptions, count: labels.count)
|
||||||
|
for (i, lbl) in labels.enumerated() {
|
||||||
|
descs[i] = AudioChannelDescription(
|
||||||
|
mChannelLabel: lbl, mChannelFlags: AudioChannelFlags(rawValue: 0),
|
||||||
|
mCoordinates: (0, 0, 0))
|
||||||
|
}
|
||||||
|
return AVAudioChannelLayout(layout: layout)
|
||||||
|
}
|
||||||
|
|
||||||
public final class SessionAudio {
|
public final class SessionAudio {
|
||||||
private let connection: PunktfunkConnection
|
private let connection: PunktfunkConnection
|
||||||
private let flag = StopFlag()
|
private let flag = StopFlag()
|
||||||
@@ -130,6 +177,16 @@ public final class SessionAudio {
|
|||||||
private var playbackEngine: AVAudioEngine?
|
private var playbackEngine: AVAudioEngine?
|
||||||
private var captureEngine: AVAudioEngine?
|
private var captureEngine: AVAudioEngine?
|
||||||
private var drainStarted = false
|
private var drainStarted = false
|
||||||
|
#if !os(macOS)
|
||||||
|
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
|
||||||
|
/// they must not run on the main thread (UI stall — AVFoundation warns about it). PROCESS-WIDE
|
||||||
|
/// (static) so every SessionAudio shares one serial queue: the AVAudioSession is a process
|
||||||
|
/// singleton, and across a reconnect the old session's deactivate must be ordered before the
|
||||||
|
/// new session's activate (a per-instance queue would let them race and leave the new session's
|
||||||
|
/// audio deactivated). stop() enqueues its deactivate promptly so it lands before the next
|
||||||
|
/// session's activate.
|
||||||
|
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
|
||||||
|
#endif
|
||||||
|
|
||||||
public init(connection: PunktfunkConnection) {
|
public init(connection: PunktfunkConnection) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -142,37 +199,60 @@ public final class SessionAudio {
|
|||||||
flag.stop()
|
flag.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system
|
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
|
||||||
/// default device; on iOS the UIDs are ignored entirely (routes are
|
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed). On macOS
|
||||||
/// AVAudioSession-managed). Main thread (engine setup); returns after the engines
|
/// the engines start synchronously on the caller's (main) thread. On iOS/tvOS start() is
|
||||||
/// start — the mic may start slightly later if the permission prompt is pending.
|
/// 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, micEnabled: Bool) {
|
||||||
#if os(iOS)
|
#if os(macOS)
|
||||||
// Route + policy live in the session, not per-engine: stereo playback, mic
|
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||||
// capture when enabled, Bluetooth allowed. Failure is non-fatal (defaults).
|
startEngines(speakerUID: speakerUID, micUID: micUID, 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
|
||||||
|
// depend on the active session. A stop() racing in between is caught by the flag guard.
|
||||||
|
Self.sessionQueue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.activateAudioSession(micEnabled: micEnabled)
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self, !self.flag.isStopped else { return }
|
||||||
|
self.startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(macOS)
|
||||||
|
/// Route + policy live in the session, not per-engine: stereo playback, mic capture when
|
||||||
|
/// enabled, Bluetooth allowed. Failure is non-fatal (defaults). Runs on `sessionQueue`.
|
||||||
|
private func activateAudioSession(micEnabled: Bool) {
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
do {
|
do {
|
||||||
|
#if os(iOS)
|
||||||
if micEnabled {
|
if micEnabled {
|
||||||
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone
|
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone EARPIECE; only
|
||||||
// EARPIECE; only affects the built-in route (headphones/BT still win).
|
// affects the built-in route (headphones/BT still win).
|
||||||
try session.setCategory(
|
try session.setCategory(
|
||||||
.playAndRecord, mode: .default,
|
.playAndRecord, mode: .default,
|
||||||
options: [.allowBluetoothA2DP, .defaultToSpeaker])
|
options: [.allowBluetoothA2DP, .defaultToSpeaker])
|
||||||
} else {
|
} else {
|
||||||
try session.setCategory(.playback, mode: .default)
|
try session.setCategory(.playback, mode: .default)
|
||||||
}
|
}
|
||||||
|
#else // tvOS — no app-accessible mic
|
||||||
|
try session.setCategory(.playback, mode: .default)
|
||||||
|
#endif
|
||||||
try session.setActive(true)
|
try session.setActive(true)
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
#elseif os(tvOS)
|
}
|
||||||
do {
|
#endif
|
||||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
|
||||||
try AVAudioSession.sharedInstance().setActive(true)
|
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main
|
||||||
} catch {
|
/// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
|
||||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
private func startEngines(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||||
}
|
|
||||||
#endif
|
|
||||||
startPlayback(speakerUID: speakerUID)
|
startPlayback(speakerUID: speakerUID)
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// No app-accessible microphone input on tvOS — playback only.
|
// No app-accessible microphone input on tvOS — playback only.
|
||||||
@@ -211,27 +291,36 @@ public final class SessionAudio {
|
|||||||
capture.stop()
|
capture.stop()
|
||||||
}
|
}
|
||||||
playback?.stop()
|
playback?.stop()
|
||||||
|
#if !os(macOS)
|
||||||
|
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
|
||||||
|
// activation, setActive is synchronous/blocking — run it on the shared serial session queue
|
||||||
|
// (off the main thread). Enqueued HERE — engines already stopped, and BEFORE the drain wait
|
||||||
|
// below — so across a reconnect it lands ahead of the next session's activate on the shared
|
||||||
|
// queue (otherwise a deferred deactivate could deactivate the new session). Fire-and-forget.
|
||||||
|
Self.sessionQueue.async {
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(
|
||||||
|
false, options: .notifyOthersOnDeactivation)
|
||||||
|
} catch {
|
||||||
|
log.warning("AVAudioSession deactivation failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
if wasDraining {
|
if wasDraining {
|
||||||
_ = drainDone.wait(timeout: .now() + .milliseconds(400))
|
_ = drainDone.wait(timeout: .now() + .milliseconds(400))
|
||||||
}
|
}
|
||||||
#if !os(macOS)
|
|
||||||
// Release the session so audio we interrupted (Music, podcasts) gets its
|
|
||||||
// resume cue.
|
|
||||||
do {
|
|
||||||
try AVAudioSession.sharedInstance().setActive(
|
|
||||||
false, options: .notifyOthersOnDeactivation)
|
|
||||||
} catch {
|
|
||||||
log.warning("AVAudioSession deactivation failed: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Playback (host → speaker)
|
// MARK: - Playback (host → speaker)
|
||||||
|
|
||||||
private func startPlayback(speakerUID: String) {
|
private func startPlayback(speakerUID: String) {
|
||||||
// 1 s of interleaved stereo capacity, ~20 ms prefill: four 5 ms host packets of
|
// Build the playback layout from the host-RESOLVED channel count (never the request):
|
||||||
// jitter absorption before the first sample plays.
|
// 2 = stereo / 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
|
||||||
let ring = AudioRing(capacity: 96_000, prefill: 1920)
|
let channels = Int(connection.resolvedAudioChannels)
|
||||||
|
// 1 s interleaved capacity, ~20 ms prefill (four 5 ms host packets of jitter absorption
|
||||||
|
// before the first sample plays), both scaled by the channel count.
|
||||||
|
let ring = AudioRing(
|
||||||
|
capacity: 48_000 * channels, prefill: 960 * channels, channels: channels)
|
||||||
|
|
||||||
let engine = AVAudioEngine()
|
let engine = AVAudioEngine()
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -247,21 +336,32 @@ public final class SessionAudio {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Engine-native deinterleaved float; the render block deinterleaves from the ring.
|
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
|
||||||
guard let format = AVAudioFormat(standardFormatWithSampleRate: 48_000, channels: 2)
|
// uses an explicit wire-order channel layout; the mixer downmixes to the output device when
|
||||||
else { return }
|
// it has fewer speakers (e.g. an iPhone's stereo built-ins). (Explicit if/else rather than
|
||||||
|
// map/flatMap so it's correct whether the channelLayout initializer is failable or not.)
|
||||||
|
var format: AVAudioFormat?
|
||||||
|
if channels == 2 {
|
||||||
|
format = AVAudioFormat(standardFormatWithSampleRate: 48_000, channels: 2)
|
||||||
|
} else if let layout = wireChannelLayout(channels: channels) {
|
||||||
|
format = AVAudioFormat(standardFormatWithSampleRate: 48_000, channelLayout: layout)
|
||||||
|
}
|
||||||
|
guard let format else {
|
||||||
|
log.error("could not build \(channels)-channel audio format — audio disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
let scratch = ScratchBuffer() // block-owned; freed with the closure
|
let scratch = ScratchBuffer() // block-owned; freed with the closure
|
||||||
let source = AVAudioSourceNode(format: format) { _, _, frameCount, abl -> OSStatus in
|
let source = AVAudioSourceNode(format: format) { _, _, frameCount, abl -> OSStatus in
|
||||||
let frames = Int(frameCount)
|
let frames = Int(frameCount)
|
||||||
guard frames <= 8192 else { return kAudioUnitErr_TooManyFramesToProcess }
|
guard frames <= 8192 else { return kAudioUnitErr_TooManyFramesToProcess }
|
||||||
ring.read(into: scratch.ptr, count: frames * 2)
|
ring.read(into: scratch.ptr, count: frames * channels)
|
||||||
let buffers = UnsafeMutableAudioBufferListPointer(abl)
|
let buffers = UnsafeMutableAudioBufferListPointer(abl)
|
||||||
if buffers.count >= 2,
|
// Deinterleave the wire-order interleaved ring into the engine's per-channel buses.
|
||||||
let left = buffers[0].mData?.assumingMemoryBound(to: Float.self),
|
if buffers.count >= channels {
|
||||||
let right = buffers[1].mData?.assumingMemoryBound(to: Float.self) {
|
for ch in 0..<channels {
|
||||||
for f in 0..<frames {
|
if let dst = buffers[ch].mData?.assumingMemoryBound(to: Float.self) {
|
||||||
left[f] = scratch.ptr[f * 2]
|
for f in 0..<frames { dst[f] = scratch.ptr[f * channels + ch] }
|
||||||
right[f] = scratch.ptr[f * 2 + 1]
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return noErr
|
return noErr
|
||||||
@@ -292,29 +392,20 @@ public final class SessionAudio {
|
|||||||
stateLock.unlock()
|
stateLock.unlock()
|
||||||
let thread = Thread { [connection, flag, drainDone] in
|
let thread = Thread { [connection, flag, drainDone] in
|
||||||
defer { drainDone.signal() }
|
defer { drainDone.signal() }
|
||||||
guard let decoder = try? OpusDecoder(framesPerPacket: 240),
|
// Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is
|
||||||
let pcm = AVAudioPCMBuffer(
|
// stereo-only — and is handed back as interleaved f32 PCM in wire channel order.
|
||||||
pcmFormat: decoder.pcmFormat, frameCapacity: 5760)
|
|
||||||
else {
|
|
||||||
log.error("Opus decoder unavailable — audio playback disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
while !flag.isStopped {
|
while !flag.isStopped {
|
||||||
let packet: AudioPacket?
|
let pcm: PunktfunkConnection.AudioPCM?
|
||||||
do {
|
do {
|
||||||
packet = try connection.nextAudio(timeoutMs: 100)
|
pcm = try connection.nextAudioPcm(timeoutMs: 100)
|
||||||
} catch {
|
} catch {
|
||||||
break // session closed
|
break // session closed
|
||||||
}
|
}
|
||||||
guard let packet else { continue }
|
guard let pcm, pcm.frameCount > 0 else { continue }
|
||||||
do {
|
pcm.samples.withUnsafeBufferPointer { p in
|
||||||
let frames = try decoder.decode(packet.data, into: pcm)
|
if let base = p.baseAddress {
|
||||||
if frames > 0, let p = pcm.floatChannelData?[0] {
|
ring.write(base, count: pcm.frameCount * pcm.channels)
|
||||||
ring.write(p, count: Int(frames) * 2)
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// One corrupt packet ≠ a dead stream; skip it.
|
|
||||||
log.warning("audio decode failed: \(error.localizedDescription)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// capture→present. Mirrors StreamPump's lifecycle (one per start; cancel is permanent).
|
// capture→present. 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`
|
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick`
|
||||||
// + `setDrawableSize` + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there).
|
// + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there).
|
||||||
// Only the ring + decoder cross threads and both are internally locked.
|
// Only the ring + decoder cross threads and both are internally locked.
|
||||||
|
|
||||||
#if canImport(Metal) && canImport(QuartzCore)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
@@ -60,7 +60,7 @@ private final class KeyframeRecovery: @unchecked Sendable {
|
|||||||
func request() {
|
func request() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
let now = DispatchTime.now().uptimeNanoseconds
|
let now = DispatchTime.now().uptimeNanoseconds
|
||||||
let due = lastNs == 0 || now &- lastNs > 250_000_000 // ≥ 250 ms since the last request
|
let due = lastNs == 0 || now &- lastNs > 100_000_000 // ≥ 100 ms since the last request (matches Android)
|
||||||
if due { lastNs = now }
|
if due { lastNs = now }
|
||||||
let conn = due ? connection : nil
|
let conn = due ? connection : nil
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
@@ -114,20 +114,24 @@ public final class Stage2Pipeline {
|
|||||||
let thread = Thread {
|
let thread = Thread {
|
||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
var lastFramesDropped = connection.framesDropped()
|
var lastFramesDropped = connection.framesDropped()
|
||||||
|
// Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale):
|
||||||
|
// the old code advanced lastFramesDropped on the same edge it called recovery.request(),
|
||||||
|
// so a request swallowed by the throttle (the lost recovery IDR being pruned within the
|
||||||
|
// window) was never re-sent and the picture stayed frozen. Keep asking until an IDR lands.
|
||||||
|
var awaitingIDR = false
|
||||||
while token.isLive {
|
while token.isLive {
|
||||||
do {
|
do {
|
||||||
// Loss recovery (the primary recovery path). The reassembler drops unrecoverable
|
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs
|
||||||
// AUs (framesDropped) and the decoder then conceals the reference-missing delta
|
// (framesDropped) and the decoder conceals the reference-missing deltas that
|
||||||
// frames that follow — often rendering them WITHOUT an error callback — so the
|
// follow — often WITHOUT an error callback — so key off the drop count climbing,
|
||||||
// onDecodeError trigger rarely fires after a real network blip. Ask the host for
|
// then keep asking (awaitingIDR) until a fresh IDR re-anchors decode. Polled every
|
||||||
// a fresh IDR whenever the drop count climbs (throttled in KeyframeRecovery).
|
// iteration so a total-loss drought recovers the moment packets resume.
|
||||||
// Polled every iteration so a total-loss drought recovers the moment packets
|
|
||||||
// resume and the reassembler counts the gap.
|
|
||||||
let dropped = connection.framesDropped()
|
let dropped = connection.framesDropped()
|
||||||
if dropped > lastFramesDropped {
|
if dropped > lastFramesDropped {
|
||||||
lastFramesDropped = dropped
|
lastFramesDropped = dropped
|
||||||
recovery.request()
|
awaitingIDR = true
|
||||||
}
|
}
|
||||||
|
if awaitingIDR { recovery.request() }
|
||||||
// Drain any HDR mastering-metadata update (0xCE) and hand it to the decoder, which
|
// Drain any HDR mastering-metadata update (0xCE) and hand it to the decoder, which
|
||||||
// attaches it to subsequent HDR frames. Non-blocking; only HDR sessions emit these.
|
// attaches it to subsequent HDR frames. Non-blocking; only HDR sessions emit these.
|
||||||
if connection.isHDR, let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
if connection.isHDR, let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
||||||
@@ -136,15 +140,16 @@ public final class Stage2Pipeline {
|
|||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
format = f // refreshed on every IDR (mode changes included)
|
||||||
|
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||||
}
|
}
|
||||||
guard let f = format, token.isLive else { continue }
|
guard let f = format, token.isLive else { continue }
|
||||||
if !decoder.decode(au: au, format: f) {
|
if !decoder.decode(au: au, format: f) {
|
||||||
// Submit/decoder error: drop the session and re-gate on the next IDR's
|
// Submit/decoder error: drop the session and re-gate on the next IDR's
|
||||||
// in-band parameter sets (a delta frame can't recover) — stage-1's policy
|
// in-band parameter sets (a delta frame can't recover) — stage-1's policy —
|
||||||
// — and ask the host for that IDR now (infinite GOP; throttled).
|
// and keep asking for that IDR (infinite GOP) until one re-anchors decode.
|
||||||
decoder.reset()
|
decoder.reset()
|
||||||
recovery.request()
|
awaitingIDR = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if token.isLive { onSessionEnd?() }
|
if token.isLive { onSessionEnd?() }
|
||||||
@@ -166,11 +171,6 @@ public final class Stage2Pipeline {
|
|||||||
presentMeter.record(ptsNs: frame.ptsNs, atNs: targetPresentNs, offsetNs: offsetNs)
|
presentMeter.record(ptsNs: frame.ptsNs, atNs: targetPresentNs, offsetNs: offsetNs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MAIN thread. Keep the drawable matched to the negotiated mode (host can Reconfigure).
|
|
||||||
public func setDrawableSize(_ size: CGSize) {
|
|
||||||
presenter.setDrawableSize(size)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop the pump (≤ one poll timeout) and drop the decode session. Does not close the
|
/// Stop the pump (≤ one poll timeout) and drop the decode session. Does not close the
|
||||||
/// connection. A restart needs a fresh Stage2Pipeline (cancel is permanent).
|
/// connection. A restart needs a fresh Stage2Pipeline (cancel is permanent).
|
||||||
public func stop() {
|
public func stop() {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
|
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "video")
|
||||||
|
|
||||||
/// Cancellation handle owned by exactly one pump thread — a restart hands the old pump
|
/// Cancellation handle owned by exactly one pump thread — a restart hands the old pump
|
||||||
/// its own token, so it can never be revived by a newer start().
|
/// its own token, so it can never be revived by a newer start().
|
||||||
@@ -47,44 +50,74 @@ final class StreamPump {
|
|||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
var lastKeyframeRequest = Date.distantPast
|
var lastKeyframeRequest = Date.distantPast
|
||||||
var lastFramesDropped = connection.framesDropped()
|
var lastFramesDropped = connection.framesDropped()
|
||||||
// Coalesced host keyframe request: the decode stays wedged for several frames until
|
// Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a
|
||||||
// the IDR lands, so requesting on every frame would flood the control stream.
|
// decoder reset), retry the throttled request EVERY iteration, and clear it only when a
|
||||||
|
// fresh IDR actually re-anchors decode. The old code advanced `lastFramesDropped` on the
|
||||||
|
// same edge it fired the throttled request — so a request swallowed by the throttle (a
|
||||||
|
// second drop within the window, e.g. the lost recovery IDR itself being pruned) was
|
||||||
|
// never re-sent: the counter went flat, the climb never re-fired, and the picture stayed
|
||||||
|
// frozen for good while audio kept playing. The iPhone's lossy Wi-Fi hits this where the
|
||||||
|
// Mac's Ethernet never does.
|
||||||
|
var awaitingIDR = false
|
||||||
|
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
|
||||||
|
var wasFailed = false
|
||||||
|
// Coalesced host keyframe request. 100 ms throttle (matches the working Android path):
|
||||||
|
// fast enough that a lost recovery IDR is re-requested promptly, bounded so a sustained
|
||||||
|
// freeze can't flood the control stream.
|
||||||
func requestKeyframeThrottled() {
|
func requestKeyframeThrottled() {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
|
if now.timeIntervalSince(lastKeyframeRequest) > 0.1 {
|
||||||
connection.requestKeyframe()
|
connection.requestKeyframe()
|
||||||
lastKeyframeRequest = now
|
lastKeyframeRequest = now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while token.isLive {
|
while token.isLive {
|
||||||
do {
|
do {
|
||||||
// Loss recovery (the primary recovery path). Under the host's infinite GOP the
|
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||||
// only recovery keyframe is one we request. The reassembler drops unrecoverable
|
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||||
// AUs (framesDropped); the decoder then *conceals* the reference-missing delta
|
// (framesDropped); the decoder then *conceals* the reference-missing deltas — a
|
||||||
// frames that follow — a frozen / garbage picture, WITHOUT flipping the layer to
|
// frozen / garbage picture that never flips the layer to .failed — so key off the
|
||||||
// .failed — so the .failed check below rarely fires after a real network blip.
|
// drop count climbing, then keep asking (awaitingIDR) until an IDR lands. Polled
|
||||||
// Ask the host for a fresh IDR whenever the drop count climbs. Polled every
|
// every iteration so a total-loss drought still recovers when packets resume.
|
||||||
// iteration (not just per AU) so a total-loss drought still recovers the moment
|
|
||||||
// packets resume and the reassembler counts the gap.
|
|
||||||
let dropped = connection.framesDropped()
|
let dropped = connection.framesDropped()
|
||||||
if dropped > lastFramesDropped {
|
if dropped > lastFramesDropped {
|
||||||
|
// Log only on the false→true transition (once per recovery cycle), not per
|
||||||
|
// dropped AU, so heavy loss doesn't spam the log.
|
||||||
|
if !awaitingIDR {
|
||||||
|
awaitingSince = Date()
|
||||||
|
pumpLog.notice(
|
||||||
|
"video: unrecoverable drop (framesDropped=\(dropped, privacy: .public)) — requesting recovery IDR")
|
||||||
|
}
|
||||||
lastFramesDropped = dropped
|
lastFramesDropped = dropped
|
||||||
requestKeyframeThrottled()
|
awaitingIDR = true
|
||||||
}
|
}
|
||||||
|
if awaitingIDR { requestKeyframeThrottled() }
|
||||||
|
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
let idrFormat = AnnexB.formatDescription(fromIDR: au.data)
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
if let f = idrFormat {
|
||||||
|
format = f // refreshed on every IDR (mode changes included)
|
||||||
|
if awaitingIDR {
|
||||||
|
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
|
||||||
|
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
|
||||||
|
}
|
||||||
|
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||||
}
|
}
|
||||||
if layer.status == .failed {
|
let failed = layer.status == .failed
|
||||||
|
if failed {
|
||||||
// Decode wedged hard (the cold-first-connect case — a lost/corrupt opening
|
// Decode wedged hard (the cold-first-connect case — a lost/corrupt opening
|
||||||
// IDR): flush and re-gate on the next in-band parameter sets (resuming with
|
// IDR): flush and, unless THIS AU is the recovering IDR (re-anchored above),
|
||||||
// a delta frame can't recover), AND ask the host for a fresh IDR. Throttled:
|
// re-gate on the next in-band parameter sets and keep asking — enqueuing a
|
||||||
// the layer stays .failed across several polls until the IDR lands.
|
// delta into a failed layer can't recover it.
|
||||||
|
if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") }
|
||||||
layer.flush()
|
layer.flush()
|
||||||
format = AnnexB.formatDescription(fromIDR: au.data)
|
if idrFormat == nil {
|
||||||
requestKeyframeThrottled()
|
format = nil
|
||||||
|
awaitingIDR = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
wasFailed = failed
|
||||||
guard let f = format,
|
guard let f = format,
|
||||||
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
||||||
token.isLive // don't enqueue a stale frame after a restart
|
token.isLive // don't enqueue a stale frame after a restart
|
||||||
|
|||||||
@@ -245,6 +245,15 @@ public final class StreamLayerView: NSView {
|
|||||||
layoutMetalLayer() // keep the stage-2 sublayer aspect-fit to the view
|
layoutMetalLayer() // keep the stage-2 sublayer aspect-fit to the view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override func setFrameSize(_ newSize: NSSize) {
|
||||||
|
super.setFrameSize(newSize)
|
||||||
|
// `layout()` isn't guaranteed on a manual-frame (no-Auto-Layout) live resize, so the
|
||||||
|
// stage-2 metal sublayer's drawableSize could stay at the old size while the view grows —
|
||||||
|
// the compositor then upscales a too-small drawable and the video turns blocky. Resize the
|
||||||
|
// drawable here too so it always tracks the window's pixel size (no stale upscale).
|
||||||
|
layoutMetalLayer()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Capture state machine
|
// MARK: - Capture state machine
|
||||||
|
|
||||||
/// Clicking into the video engages capture; that click is local (engagement), so
|
/// Clicking into the video engages capture; that click is local (engagement), so
|
||||||
@@ -549,10 +558,17 @@ public final class StreamLayerView: NSView {
|
|||||||
cursorVisible = false
|
cursorVisible = false
|
||||||
_ = connection.resolvedCompositor // (was: Auto → gamescope; kept to document intent)
|
_ = connection.resolvedCompositor // (was: Auto → gamescope; kept to document intent)
|
||||||
|
|
||||||
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
// Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
|
||||||
// (`punktfunk.presenter == "stage2"`) takes explicit VTDecompressionSession decode + a
|
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
|
||||||
// CAMetalLayer/display-link present; it falls back here if Metal can't be set up.
|
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-1 is
|
||||||
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2",
|
// reachable only via the DEBUG presenter toggle; release always takes stage-2 (the stage-1
|
||||||
|
// pump below stays the automatic fallback if Metal is missing).
|
||||||
|
#if DEBUG
|
||||||
|
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1"
|
||||||
|
#else
|
||||||
|
let forceStage1 = false
|
||||||
|
#endif
|
||||||
|
if !forceStage1,
|
||||||
let meter = presentMeter,
|
let meter = presentMeter,
|
||||||
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
||||||
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -593,9 +609,11 @@ public final class StreamLayerView: NSView {
|
|||||||
targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp))
|
targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode,
|
/// Position the metal sublayer aspect-fit in the view (the host streams at the client's native
|
||||||
/// so this is usually the full bounds; it letterboxes a resized window). drawableSize is the
|
/// mode, so this is usually the full bounds; it letterboxes a resized window). Only the layer
|
||||||
/// layer's pixel size — the fullscreen-triangle shader scales the decoded texture to fill it.
|
/// FRAME is set here — the presenter sizes the drawable to the decoded frame and the layer's
|
||||||
|
/// contentsGravity (.resizeAspect) scales it to this frame via the system compositor, so a
|
||||||
|
/// resized window rescales through the system's filter (matching stage-1) instead of the shader.
|
||||||
private func layoutMetalLayer() {
|
private func layoutMetalLayer() {
|
||||||
guard let metalLayer, let connection else { return }
|
guard let metalLayer, let connection else { return }
|
||||||
let mode = connection.currentMode()
|
let mode = connection.currentMode()
|
||||||
@@ -604,14 +622,12 @@ public final class StreamLayerView: NSView {
|
|||||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||||
insideRect: bounds)
|
insideRect: bounds)
|
||||||
: bounds
|
: bounds
|
||||||
let scale = window?.backingScaleFactor ?? 1
|
|
||||||
// No implicit resize animation; refresh contentsScale on a retina↔non-retina move.
|
// No implicit resize animation; refresh contentsScale on a retina↔non-retina move.
|
||||||
CATransaction.begin()
|
CATransaction.begin()
|
||||||
CATransaction.setDisableActions(true)
|
CATransaction.setDisableActions(true)
|
||||||
metalLayer.contentsScale = scale
|
metalLayer.contentsScale = window?.backingScaleFactor ?? 1
|
||||||
metalLayer.frame = fit
|
metalLayer.frame = fit
|
||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewDidChangeBackingProperties() {
|
public override func viewDidChangeBackingProperties() {
|
||||||
|
|||||||
@@ -136,6 +136,13 @@ public final class StreamViewController: UIViewController {
|
|||||||
|
|
||||||
public override func loadView() {
|
public override func loadView() {
|
||||||
view = StreamLayerUIView()
|
view = StreamLayerUIView()
|
||||||
|
// Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g.
|
||||||
|
// moving to an external display at a different scale) — the iOS analogue of macOS's
|
||||||
|
// viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it
|
||||||
|
// doesn't capture self (no retain cycle with the registration).
|
||||||
|
registerForTraitChanges([UITraitDisplayScale.self]) { (vc: StreamViewController, _) in
|
||||||
|
vc.layoutMetalLayer()
|
||||||
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// Hide the iPadOS cursor while it hovers the video: the host renders its own
|
// Hide the iPadOS cursor while it hovers the video: the host renders its own
|
||||||
// cursor from our deltas, so the local one only diverges from it. This hides the
|
// cursor from our deltas, so the local one only diverges from it. This hides the
|
||||||
@@ -219,10 +226,17 @@ public final class StreamViewController: UIViewController {
|
|||||||
inputCapture = capture
|
inputCapture = capture
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
// Presenter choice — stage-2 is the DEFAULT (VTDecompressionSession decode + a
|
||||||
// (`punktfunk.presenter == "stage2"`) takes VTDecompressionSession decode + a
|
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder, where
|
||||||
// CAMetalLayer/display-link present; falls back here if Metal can't be set up.
|
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference frame with no
|
||||||
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2",
|
// way to recover. Stage-1 is reachable only via the DEBUG presenter toggle; release always
|
||||||
|
// takes stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||||
|
#if DEBUG
|
||||||
|
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1"
|
||||||
|
#else
|
||||||
|
let forceStage1 = false
|
||||||
|
#endif
|
||||||
|
if !forceStage1,
|
||||||
let meter = presentMeter,
|
let meter = presentMeter,
|
||||||
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
||||||
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -300,8 +314,8 @@ public final class StreamViewController: UIViewController {
|
|||||||
onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)?
|
onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)?
|
||||||
) {
|
) {
|
||||||
let metal = pipeline.layer
|
let metal = pipeline.layer
|
||||||
metal.contentsScale = streamView.contentScaleFactor
|
|
||||||
// Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base.
|
// Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base.
|
||||||
|
// (contentsScale + frame + drawableSize are all set by layoutMetalLayer() just below.)
|
||||||
streamView.layer.addSublayer(metal)
|
streamView.layer.addSublayer(metal)
|
||||||
metalLayer = metal
|
metalLayer = metal
|
||||||
stage2 = pipeline
|
stage2 = pipeline
|
||||||
@@ -325,9 +339,20 @@ public final class StreamViewController: UIViewController {
|
|||||||
layoutMetalLayer()
|
layoutMetalLayer()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode,
|
/// The display scale to render the metal drawable at. `traitCollection.displayScale` is the
|
||||||
/// so this is usually the full bounds). drawableSize is the layer's pixel size; the shader's
|
/// canonical render scale and is reliable once the controller is in the hierarchy;
|
||||||
/// fullscreen triangle scales the decoded texture to fill it.
|
/// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which
|
||||||
|
/// would size the drawable at point resolution → a pixelated, upscaled mess. Falls back to the
|
||||||
|
/// main screen scale if the trait is still unspecified.
|
||||||
|
private var renderScale: CGFloat {
|
||||||
|
let s = traitCollection.displayScale
|
||||||
|
return s > 0 ? s : UIScreen.main.scale
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position the metal sublayer aspect-fit in the view (the host streams at the client's native
|
||||||
|
/// mode, so this is usually the full bounds). Only the layer FRAME is set here — the presenter
|
||||||
|
/// sizes the drawable to the decoded frame and the layer's contentsGravity (.resizeAspect)
|
||||||
|
/// scales it to this frame via the system compositor (matching stage-1's videoGravity).
|
||||||
private func layoutMetalLayer() {
|
private func layoutMetalLayer() {
|
||||||
guard let metalLayer, let connection else { return }
|
guard let metalLayer, let connection else { return }
|
||||||
let mode = connection.currentMode()
|
let mode = connection.currentMode()
|
||||||
@@ -337,13 +362,11 @@ public final class StreamViewController: UIViewController {
|
|||||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||||
insideRect: bounds)
|
insideRect: bounds)
|
||||||
: bounds
|
: bounds
|
||||||
let scale = streamView.contentScaleFactor
|
|
||||||
CATransaction.begin()
|
CATransaction.begin()
|
||||||
CATransaction.setDisableActions(true) // don't animate the resize
|
CATransaction.setDisableActions(true) // don't animate the resize
|
||||||
metalLayer.contentsScale = scale
|
metalLayer.contentsScale = renderScale
|
||||||
metalLayer.frame = fit
|
metalLayer.frame = fit
|
||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func teardownStage2() {
|
private func teardownStage2() {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
#if canImport(Metal)
|
||||||
|
import Metal
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
final class MetalPresenterTests: XCTestCase {
|
||||||
|
/// `MetalVideoPresenter.init?()` compiles the runtime Metal shaders (the BT.709/BT.2020 YUV→RGB
|
||||||
|
/// fragment shaders plus the Catmull-Rom luma sampler). A `nil` result on a GPU-equipped host
|
||||||
|
/// means a shader failed to compile — this catches a malformed shader before it silently
|
||||||
|
/// degrades stage-2 to a stage-1 fallback on device.
|
||||||
|
func testPresenterInitCompilesShaders() throws {
|
||||||
|
guard MTLCreateSystemDefaultDevice() != nil else {
|
||||||
|
throw XCTSkip("no Metal device available in this environment")
|
||||||
|
}
|
||||||
|
XCTAssertNotNil(
|
||||||
|
MetalVideoPresenter(),
|
||||||
|
"stage-2 Metal shaders failed to compile (presenter init returned nil)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
+36
-1
@@ -45,8 +45,9 @@ Gaming Mode automatically.
|
|||||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. |
|
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. |
|
||||||
| `src/backend.ts` | Typed `callable` bridges to `main.py`. |
|
| `src/backend.ts` | Typed `callable` bridges to `main.py`. |
|
||||||
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut targets (so the window is focusable). |
|
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut targets (so the window is focusable). |
|
||||||
| `main.py` | Backend: `discover` / `pair` / `runner_info` / `get_settings` / `set_settings` / `kill_stream`. |
|
| `main.py` | Backend: `discover` / `pair` / `runner_info` / `get_settings` / `set_settings` / `kill_stream` / `check_update`. |
|
||||||
| `plugin.json` | Decky plugin manifest. |
|
| `plugin.json` | Decky plugin manifest. |
|
||||||
|
| `update.json` | CI-baked `{channel, manifest}` — where `check_update()` polls (absent on dev builds). |
|
||||||
| `decky.pyi` | Type stub for the injected `decky` module (vendored from the template). |
|
| `decky.pyi` | Type stub for the injected `decky` module (vendored from the template). |
|
||||||
|
|
||||||
### Discovery (`discover()`)
|
### Discovery (`discover()`)
|
||||||
@@ -140,6 +141,40 @@ shows up in the Quick Access Menu.
|
|||||||
> [`../../packaging/flatpak/README.md`](../../packaging/flatpak/README.md)) — install that on
|
> [`../../packaging/flatpak/README.md`](../../packaging/flatpak/README.md)) — install that on
|
||||||
> the Deck too, or the panel's Connect surfaces a `client-not-found` error.
|
> the Deck too, or the panel's Connect surfaces a `client-not-found` error.
|
||||||
|
|
||||||
|
## Updating (self-update, no store)
|
||||||
|
|
||||||
|
The plugin updates itself without the official Decky store. CI (`decky.yml`) publishes a tiny
|
||||||
|
per-channel `manifest.json` next to the zip in the Gitea registry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"version":"0.3.123","artifact":".../punktfunk-decky/0.3.123/punktfunk.zip","sha256":"…"}
|
||||||
|
```
|
||||||
|
|
||||||
|
and bakes an `update.json` (`{channel, manifest}`) into the plugin so it knows which channel it was
|
||||||
|
installed from. The backend `check_update()` reads the **installed** version from `package.json` —
|
||||||
|
the value Decky itself reports (it does **not** read `plugin.json`) — fetches the channel manifest,
|
||||||
|
and compares. When a newer build exists the frontend shows an **Update to vX** button that drives
|
||||||
|
Decky Loader's own install RPC:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
window.DeckyBackend.callable("utilities/install_plugin")(artifact, "punktfunk", version, hash, /*UPDATE=*/2)
|
||||||
|
```
|
||||||
|
|
||||||
|
The loader (root) downloads the immutable per-version zip, **SHA-256-verifies** it against `hash`,
|
||||||
|
replaces `~/homebrew/plugins/punktfunk`, and hot-reloads — the unprivileged backend never writes the
|
||||||
|
root-owned plugins dir itself. `window.DeckyBackend` / `utilities/install_plugin` are loader
|
||||||
|
internals (not `@decky/api`), so every access is guarded; missing them, the button falls back to a
|
||||||
|
toast pointing at **Install Plugin from URL**.
|
||||||
|
|
||||||
|
> CI stamps a **plain numeric** semver per channel (`0.3.<run>` canary, `X.Y.Z` stable) into
|
||||||
|
> `package.json`. Decky's `compare-versions` orders pre-release identifiers lexically (so `ci10 < ci9`)
|
||||||
|
> — a `-ciN` suffix would mis-detect updates.
|
||||||
|
|
||||||
|
**Optional — native Updates tab:** Decky's store is single-source (a custom store URL *replaces* the
|
||||||
|
official catalog), so punktfunk doesn't ship one by default. A user who wants the native update badge
|
||||||
|
can point Decky → Settings → **Custom store** at a punktfunk-only store JSON — not recommended if you
|
||||||
|
use other plugins, since it hides the official catalog.
|
||||||
|
|
||||||
## Limitations / next steps
|
## Limitations / next steps
|
||||||
|
|
||||||
- **Needs on-Deck validation in Gaming Mode**: the Steam-shortcut launch (`AddShortcut` /
|
- **Needs on-Deck validation in Gaming Mode**: the Steam-shortcut launch (`AddShortcut` /
|
||||||
|
|||||||
@@ -31,4 +31,6 @@ fi
|
|||||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
|
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
|
# 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).
|
# Gaming Mode reclaims focus automatically (no manual refocus needed).
|
||||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST"
|
# --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).
|
||||||
|
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --fullscreen
|
||||||
|
|||||||
+141
-4
@@ -17,6 +17,8 @@ The backend's jobs are the things Steam can't do:
|
|||||||
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
|
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
|
||||||
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
|
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
|
||||||
* **kill_stream()** — force-stop a wedged stream (``flatpak kill``).
|
* **kill_stream()** — force-stop a wedged stream (``flatpak kill``).
|
||||||
|
* **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
|
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the host
|
||||||
advert in ``crates/punktfunk-host/src/discovery.rs``.
|
advert in ``crates/punktfunk-host/src/discovery.rs``.
|
||||||
@@ -26,7 +28,10 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import ssl
|
||||||
import stat
|
import stat
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import decky
|
import decky
|
||||||
@@ -37,22 +42,99 @@ APP_ID = "io.unom.Punktfunk"
|
|||||||
# Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host).
|
# Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host).
|
||||||
SERVICE_TYPE = "_punktfunk._udp"
|
SERVICE_TYPE = "_punktfunk._udp"
|
||||||
|
|
||||||
# The flatpak client persists identity / known-hosts / settings under HOME/.config/punktfunk;
|
# The flatpak client persists identity / known-hosts / settings under HOME/.config/punktfunk.
|
||||||
# inside the flatpak sandbox HOME is ~/.var/app/<APP_ID>, so the real on-disk location is this.
|
# The sandbox HOME resolves to the REAL user home (== DECKY_USER_HOME), NOT the per-app
|
||||||
# The backend writes settings here so the (sandboxed) client reads them.
|
# ~/.var/app/<APP_ID> dir — verified on-device (`flatpak run … sh -c 'echo $HOME'` prints
|
||||||
|
# /home/deck, and the manifest's `--filesystem=~/.config/punktfunk` grants exactly that path;
|
||||||
|
# we also pass HOME=DECKY_USER_HOME into `flatpak run`, see _flatpak_env). Pointing here is what
|
||||||
|
# lets plugin settings actually reach the client AND lets us read the client's known-hosts to
|
||||||
|
# tell whether THIS device is already paired with a given host.
|
||||||
def _client_config_dir() -> Path:
|
def _client_config_dir() -> Path:
|
||||||
return Path(decky.DECKY_USER_HOME) / ".var" / "app" / APP_ID / ".config" / "punktfunk"
|
return Path(decky.DECKY_USER_HOME) / ".config" / "punktfunk"
|
||||||
|
|
||||||
|
|
||||||
def _settings_path() -> Path:
|
def _settings_path() -> Path:
|
||||||
return _client_config_dir() / "client-gtk-settings.json"
|
return _client_config_dir() / "client-gtk-settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _paired_fingerprints() -> set[str]:
|
||||||
|
"""Host cert fingerprints (lowercase hex) this client has PIN-paired, from the client's
|
||||||
|
known-hosts store. Keyed by fingerprint so it survives a host changing IP address."""
|
||||||
|
try:
|
||||||
|
data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return set()
|
||||||
|
hosts = data.get("hosts", []) if isinstance(data, dict) else []
|
||||||
|
return {
|
||||||
|
h["fp_hex"].lower()
|
||||||
|
for h in hosts
|
||||||
|
if isinstance(h, dict) and h.get("paired") and isinstance(h.get("fp_hex"), str)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _runner_path() -> str:
|
def _runner_path() -> str:
|
||||||
"""Absolute path to the launch wrapper shipped with the plugin (bin/punktfunkrun.sh)."""
|
"""Absolute path to the launch wrapper shipped with the plugin (bin/punktfunkrun.sh)."""
|
||||||
return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh")
|
return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
# can't offer updates. Instead the backend polls a tiny per-channel ``manifest.json`` the
|
||||||
|
# CI publishes next to the zip, compares it to the installed version, and the frontend
|
||||||
|
# offers a one-tap update that drives Decky's own (root, privileged) install RPC. The
|
||||||
|
# channel + manifest URL are baked into ``update.json`` by CI (.gitea/workflows/decky.yml);
|
||||||
|
# a dev/sideload build has no ``update.json`` and update checks are simply disabled.
|
||||||
|
_UPDATE_TTL_S = 1800.0 # cache a successful check for 30 min (the QAM remounts often)
|
||||||
|
_update_cache: dict = {"at": 0.0, "data": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _update_config() -> dict:
|
||||||
|
"""The CI-baked ``{channel, manifest}`` next to the plugin (absent on dev builds)."""
|
||||||
|
try:
|
||||||
|
return json.loads((Path(decky.DECKY_PLUGIN_DIR) / "update.json").read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _installed_version() -> str:
|
||||||
|
"""The version Decky itself reports for this plugin — it reads ``package.json`` (NOT
|
||||||
|
plugin.json), so the CI stamps the build version there."""
|
||||||
|
try:
|
||||||
|
pkg = json.loads((Path(decky.DECKY_PLUGIN_DIR) / "package.json").read_text())
|
||||||
|
return str(pkg.get("version", "0.0.0"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _semver_tuple(v: str) -> tuple[int, int, int]:
|
||||||
|
"""A tolerant (major, minor, patch) tuple for ``>`` comparison. We control the version
|
||||||
|
format (plain numeric ``X.Y.Z`` on both channels), so leading-int-per-component is
|
||||||
|
enough; any pre-release suffix is dropped before comparing."""
|
||||||
|
parts: list[int] = []
|
||||||
|
for comp in str(v).split("-", 1)[0].split(".")[:3]:
|
||||||
|
digits = ""
|
||||||
|
for ch in comp:
|
||||||
|
if ch.isdigit():
|
||||||
|
digits += ch
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
parts.append(int(digits) if digits else 0)
|
||||||
|
while len(parts) < 3:
|
||||||
|
parts.append(0)
|
||||||
|
return (parts[0], parts[1], parts[2])
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
return json.loads(resp.read().decode("utf-8", errors="replace"))
|
||||||
|
|
||||||
|
|
||||||
def _flatpak() -> str | None:
|
def _flatpak() -> str | None:
|
||||||
return shutil.which("flatpak") or (
|
return shutil.which("flatpak") or (
|
||||||
"/usr/bin/flatpak" if Path("/usr/bin/flatpak").exists() else None
|
"/usr/bin/flatpak" if Path("/usr/bin/flatpak").exists() else None
|
||||||
@@ -179,6 +261,13 @@ class Plugin:
|
|||||||
if stderr:
|
if stderr:
|
||||||
decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace"))
|
decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace"))
|
||||||
hosts = _parse_avahi_browse(stdout.decode(errors="replace"))
|
hosts = _parse_avahi_browse(stdout.decode(errors="replace"))
|
||||||
|
# Mark which hosts THIS device has already paired (by cert fingerprint), so the UI can
|
||||||
|
# show "Stream" instead of "Pair" — the mDNS `pair` field is the host's policy, not our
|
||||||
|
# per-device pairing state.
|
||||||
|
paired = _paired_fingerprints()
|
||||||
|
for h in hosts:
|
||||||
|
fp = h.get("fp") or ""
|
||||||
|
h["paired"] = bool(fp) and fp.lower() in paired
|
||||||
decky.logger.info("discovered %d punktfunk host(s)", len(hosts))
|
decky.logger.info("discovered %d punktfunk host(s)", len(hosts))
|
||||||
return hosts
|
return hosts
|
||||||
|
|
||||||
@@ -279,6 +368,54 @@ class Plugin:
|
|||||||
return {"ok": False}
|
return {"ok": False}
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
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``.
|
||||||
|
"""
|
||||||
|
current = _installed_version()
|
||||||
|
cfg = _update_config()
|
||||||
|
result = {
|
||||||
|
"current": current,
|
||||||
|
"latest": current,
|
||||||
|
"artifact": "",
|
||||||
|
"hash": "",
|
||||||
|
"channel": str(cfg.get("channel", "")),
|
||||||
|
"update_available": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
result["error"] = "fetch-failed"
|
||||||
|
return result # transient — don't cache, retry next open
|
||||||
|
|
||||||
|
latest = str(manifest.get("version", current))
|
||||||
|
result["latest"] = latest
|
||||||
|
result["artifact"] = str(manifest.get("artifact", ""))
|
||||||
|
result["hash"] = str(manifest.get("sha256", ""))
|
||||||
|
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"])
|
||||||
|
_update_cache["at"] = now
|
||||||
|
_update_cache["data"] = result
|
||||||
|
return result
|
||||||
|
|
||||||
# ---- Decky lifecycle ----
|
# ---- Decky lifecycle ----
|
||||||
|
|
||||||
async def _main(self):
|
async def _main(self):
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ export interface Host {
|
|||||||
name: string;
|
name: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
pair: string; // "required" | "optional"
|
pair: string; // "required" | "optional" — the HOST's policy
|
||||||
fp: string;
|
fp: string;
|
||||||
|
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PairResult {
|
export interface PairResult {
|
||||||
@@ -32,6 +33,16 @@ export interface StreamSettings {
|
|||||||
mic_enabled: boolean;
|
mic_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
current: string; // installed version (package.json)
|
||||||
|
latest: string; // newest 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;
|
||||||
|
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
|
||||||
|
}
|
||||||
|
|
||||||
export const discover = callable<[], Host[]>("discover");
|
export const discover = callable<[], Host[]>("discover");
|
||||||
export const pair = callable<
|
export const pair = callable<
|
||||||
[host: string, port: number, pin: string, name: string],
|
[host: string, port: number, pin: string, name: string],
|
||||||
@@ -43,3 +54,4 @@ export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>
|
|||||||
"set_settings",
|
"set_settings",
|
||||||
);
|
);
|
||||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||||
|
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||||
|
|||||||
+283
-43
@@ -10,12 +10,22 @@ import {
|
|||||||
PanelSectionRow,
|
PanelSectionRow,
|
||||||
SliderField,
|
SliderField,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
Tabs,
|
||||||
ToggleField,
|
ToggleField,
|
||||||
showModal,
|
showModal,
|
||||||
staticClasses,
|
staticClasses,
|
||||||
} from "@decky/ui";
|
} from "@decky/ui";
|
||||||
import { definePlugin, routerHook, toaster } from "@decky/api";
|
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||||
import { FC, useCallback, useEffect, useState } from "react";
|
import {
|
||||||
|
Component,
|
||||||
|
CSSProperties,
|
||||||
|
ErrorInfo,
|
||||||
|
FC,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
FaTv,
|
FaTv,
|
||||||
FaSyncAlt,
|
FaSyncAlt,
|
||||||
@@ -23,19 +33,130 @@ import {
|
|||||||
FaLockOpen,
|
FaLockOpen,
|
||||||
FaPlay,
|
FaPlay,
|
||||||
FaArrowLeft,
|
FaArrowLeft,
|
||||||
|
FaDownload,
|
||||||
} from "react-icons/fa";
|
} from "react-icons/fa";
|
||||||
import {
|
import {
|
||||||
discover,
|
discover,
|
||||||
getSettings,
|
getSettings,
|
||||||
pair,
|
pair,
|
||||||
setSettings,
|
setSettings,
|
||||||
|
checkUpdate,
|
||||||
Host,
|
Host,
|
||||||
StreamSettings,
|
StreamSettings,
|
||||||
|
UpdateInfo,
|
||||||
} from "./backend";
|
} from "./backend";
|
||||||
import { launchStream } from "./steam";
|
import { launchStream } from "./steam";
|
||||||
|
|
||||||
const ROUTE = "/punktfunk";
|
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;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------------------
|
||||||
|
// 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.
|
// Discovery hook — shared by the QAM panel and the full page.
|
||||||
// ----------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------
|
||||||
@@ -173,7 +294,13 @@ const RESOLUTIONS: [number, number, string][] = [
|
|||||||
[2560, 1440, "2560 × 1440"],
|
[2560, 1440, "2560 × 1440"],
|
||||||
];
|
];
|
||||||
const REFRESH = [0, 30, 60, 90, 120];
|
const REFRESH = [0, 30, 60, 90, 120];
|
||||||
const GAMEPADS = ["auto", "xbox360", "dualsense"];
|
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 SettingsSection: FC = () => {
|
||||||
const [s, setS] = useState<StreamSettings | null>(null);
|
const [s, setS] = useState<StreamSettings | null>(null);
|
||||||
@@ -234,14 +361,17 @@ const SettingsSection: FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Field label="Gamepad type" childrenContainerWidth="max">
|
<Field label="Gamepad type" childrenContainerWidth="max">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
rgOptions={GAMEPADS.map((g) => ({
|
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||||
data: g,
|
|
||||||
label: g === "auto" ? "Automatic" : g === "xbox360" ? "Xbox 360" : "DualSense",
|
|
||||||
}))}
|
|
||||||
selectedOption={s.gamepad}
|
selectedOption={s.gamepad}
|
||||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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
|
<ToggleField
|
||||||
label="Stream microphone"
|
label="Stream microphone"
|
||||||
checked={s.mic_enabled}
|
checked={s.mic_enabled}
|
||||||
@@ -255,20 +385,24 @@ const SettingsSection: FC = () => {
|
|||||||
// One host row on the full page.
|
// One host row on the full page.
|
||||||
// ----------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------
|
||||||
const HostRow: FC<{ host: Host }> = ({ host }) => {
|
const HostRow: FC<{ host: Host }> = ({ host }) => {
|
||||||
const pairRequired = host.pair === "required";
|
// 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 (
|
return (
|
||||||
<Field
|
<Field
|
||||||
label={
|
label={
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||||
{pairRequired ? <FaLock /> : <FaLockOpen />}
|
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||||||
{host.name}
|
{host.name}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
description={`${host.host}:${host.port}${pairRequired ? " · pairing required" : ""}`}
|
description={`${host.host}:${host.port}${
|
||||||
|
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
|
||||||
|
}`}
|
||||||
childrenContainerWidth="max"
|
childrenContainerWidth="max"
|
||||||
>
|
>
|
||||||
<Focusable style={{ display: "flex", gap: "0.5em" }}>
|
<Focusable style={{ display: "flex", gap: "0.5em" }}>
|
||||||
{pairRequired && (
|
{needsPair && (
|
||||||
<DialogButton
|
<DialogButton
|
||||||
style={{ minWidth: "5em" }}
|
style={{ minWidth: "5em" }}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -288,52 +422,129 @@ const HostRow: FC<{ host: Host }> = ({ host }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------
|
||||||
// The fullscreen page (registered as the /punktfunk route).
|
// 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 PunktfunkPage: FC = () => {
|
||||||
const { hosts, scanning, refresh } = useHosts();
|
const { hosts, scanning, refresh } = useHosts();
|
||||||
|
const update = useUpdate();
|
||||||
|
const [tab, setTab] = useState("hosts");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: "40px",
|
marginTop: "40px",
|
||||||
height: "calc(100% - 40px)",
|
height: "calc(100% - 40px)",
|
||||||
overflowY: "auto",
|
display: "flex",
|
||||||
padding: "0 2.5em 2.5em",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Focusable style={{ display: "flex", alignItems: "center", gap: "1em", marginBottom: "1em" }}>
|
<Focusable
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "1em",
|
||||||
|
padding: "0 2.5em",
|
||||||
|
marginBottom: "0.4em",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogButton
|
<DialogButton
|
||||||
style={{ width: "3em", minWidth: "3em" }}
|
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
||||||
onClick={() => Navigation.NavigateBack()}
|
onClick={() => Navigation.NavigateBack()}
|
||||||
>
|
>
|
||||||
<FaArrowLeft />
|
<FaArrowLeft />
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
<div className={staticClasses.Title} style={{ flex: 1 }}>
|
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||||||
punktfunk
|
punktfunk
|
||||||
</div>
|
</div>
|
||||||
<DialogButton style={{ width: "10em" }} disabled={scanning} onClick={refresh}>
|
{update?.update_available && (
|
||||||
{scanning ? (
|
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
||||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||||
) : (
|
Update v{update.latest}
|
||||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
</DialogButton>
|
||||||
)}
|
)}
|
||||||
{scanning ? "Scanning…" : "Refresh"}
|
|
||||||
</DialogButton>
|
|
||||||
</Focusable>
|
</Focusable>
|
||||||
|
|
||||||
<div style={{ fontSize: "1.1em", fontWeight: "bold", margin: "0.5em 0" }}>Hosts</div>
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
{hosts.length === 0 && !scanning && (
|
<Tabs
|
||||||
<Field focusable={false}>No hosts discovered on the LAN.</Field>
|
activeTab={tab}
|
||||||
)}
|
onShowTab={(id: string) => setTab(id)}
|
||||||
{hosts.map((h) => (
|
autoFocusContents
|
||||||
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} />
|
tabs={[
|
||||||
))}
|
{
|
||||||
|
id: "hosts",
|
||||||
<div style={{ fontSize: "1.1em", fontWeight: "bold", margin: "1.5em 0 0.5em" }}>
|
title: "Hosts",
|
||||||
Stream settings
|
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings",
|
||||||
|
title: "Settings",
|
||||||
|
content: <SettingsTab />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SettingsSection />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -343,9 +554,25 @@ const PunktfunkPage: FC = () => {
|
|||||||
// ----------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------
|
||||||
const QamPanel: FC = () => {
|
const QamPanel: FC = () => {
|
||||||
const { hosts, scanning, refresh } = useHosts();
|
const { hosts, scanning, refresh } = useHosts();
|
||||||
|
const update = useUpdate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{update?.update_available && (
|
||||||
|
<PanelSection title="Update">
|
||||||
|
<PanelSectionRow>
|
||||||
|
<ButtonItem
|
||||||
|
layout="below"
|
||||||
|
onClick={() => applyUpdate(update)}
|
||||||
|
label={`v${update.current} → v${update.latest}`}
|
||||||
|
>
|
||||||
|
<FaDownload style={{ marginRight: "0.5em" }} />
|
||||||
|
Update punktfunk
|
||||||
|
</ButtonItem>
|
||||||
|
</PanelSectionRow>
|
||||||
|
</PanelSection>
|
||||||
|
)}
|
||||||
|
|
||||||
<PanelSection title="punktfunk">
|
<PanelSection title="punktfunk">
|
||||||
<PanelSectionRow>
|
<PanelSectionRow>
|
||||||
<ButtonItem
|
<ButtonItem
|
||||||
@@ -378,25 +605,25 @@ const QamPanel: FC = () => {
|
|||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
)}
|
)}
|
||||||
{hosts.map((h) => {
|
{hosts.map((h) => {
|
||||||
const pairRequired = h.pair === "required";
|
const needsPair = h.pair === "required" && !h.paired;
|
||||||
return (
|
return (
|
||||||
<PanelSectionRow key={h.fp || `${h.host}:${h.port}`}>
|
<PanelSectionRow key={h.fp || `${h.host}:${h.port}`}>
|
||||||
<ButtonItem
|
<ButtonItem
|
||||||
layout="below"
|
layout="below"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
pairRequired
|
needsPair
|
||||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||||
: startStream(h)
|
: startStream(h)
|
||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||||
{pairRequired ? <FaLock /> : <FaLockOpen />}
|
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||||||
{h.name}
|
{h.name}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
description={`${h.host}:${h.port}`}
|
description={`${h.host}:${h.port}${h.paired ? " · paired" : ""}`}
|
||||||
>
|
>
|
||||||
{pairRequired ? "Pair & Stream" : "Stream"}
|
{needsPair ? "Pair & Stream" : "Stream"}
|
||||||
</ButtonItem>
|
</ButtonItem>
|
||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
);
|
);
|
||||||
@@ -406,12 +633,25 @@ const QamPanel: FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Full page behind the boundary — registered as the /punktfunk route.
|
||||||
|
const PunktfunkRoute: FC = () => (
|
||||||
|
<PluginErrorBoundary>
|
||||||
|
<PunktfunkPage />
|
||||||
|
</PluginErrorBoundary>
|
||||||
|
);
|
||||||
|
|
||||||
export default definePlugin(() => {
|
export default definePlugin(() => {
|
||||||
routerHook.addRoute(ROUTE, PunktfunkPage, { exact: true });
|
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
||||||
return {
|
return {
|
||||||
name: "punktfunk",
|
name: "punktfunk",
|
||||||
titleView: <div className={staticClasses.Title}>punktfunk</div>,
|
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw
|
||||||
content: <QamPanel />,
|
// at plugin-load time (an error boundary only catches render-time, not load-time, errors).
|
||||||
|
titleView: <div className={staticClasses?.Title}>punktfunk</div>,
|
||||||
|
content: (
|
||||||
|
<PluginErrorBoundary>
|
||||||
|
<QamPanel />
|
||||||
|
</PluginErrorBoundary>
|
||||||
|
),
|
||||||
icon: <FaTv />,
|
icon: <FaTv />,
|
||||||
onDismount() {
|
onDismount() {
|
||||||
routerHook.removeRoute(ROUTE);
|
routerHook.removeRoute(ROUTE);
|
||||||
|
|||||||
@@ -24,12 +24,31 @@ declare const SteamClient: {
|
|||||||
SetShortcutExe(appId: number, exe: string): void;
|
SetShortcutExe(appId: number, exe: string): void;
|
||||||
SetShortcutStartDir(appId: number, dir: string): void;
|
SetShortcutStartDir(appId: number, dir: string): void;
|
||||||
SetAppLaunchOptions(appId: number, options: string): void;
|
SetAppLaunchOptions(appId: number, options: string): void;
|
||||||
SetAppHidden(appId: number, hidden: boolean): void;
|
|
||||||
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
|
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
|
||||||
TerminateApp(gameId: string, _b: boolean): 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.
|
||||||
|
declare const collectionStore:
|
||||||
|
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
function hideShortcut(appId: number): void {
|
||||||
|
const attempt = () => {
|
||||||
|
try {
|
||||||
|
collectionStore?.SetAppsAsHidden?.([appId], true);
|
||||||
|
} catch {
|
||||||
|
/* overview not registered yet, or the API changed — cosmetic, ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
attempt(); // succeeds immediately for an already-registered (reused) shortcut
|
||||||
|
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
|
||||||
|
}
|
||||||
|
|
||||||
const SHORTCUT_NAME = "punktfunk";
|
const SHORTCUT_NAME = "punktfunk";
|
||||||
|
|
||||||
// The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the
|
// The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the
|
||||||
@@ -88,17 +107,41 @@ async function ensureShortcut(): Promise<number> {
|
|||||||
);
|
);
|
||||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||||
// Hide it from the library — it's an implementation detail, launched programmatically.
|
// Hide it from the library — it's an implementation detail, launched programmatically.
|
||||||
SteamClient.Apps.SetAppHidden(appId, true);
|
// Best-effort + deferred (see hideShortcut); never let it block the launch.
|
||||||
|
hideShortcut(appId);
|
||||||
rememberAppId(appId);
|
rememberAppId(appId);
|
||||||
return appId;
|
return appId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort: turn Steam Input OFF for our shortcut so SDL's HIDAPI Steam Deck driver can open the
|
||||||
|
* Deck's controls (paddles · trackpads · gyro) directly. There is no confirmed-stable SteamClient
|
||||||
|
* API for this, so it is feature-detected and MUST never block or throw into the launch — the manual
|
||||||
|
* toggle (game page → ⚙ → Controller Settings → Steam Input Off, surfaced in the plugin Settings) is
|
||||||
|
* the documented source of truth. No-op when the optional API is absent.
|
||||||
|
*/
|
||||||
|
function disableSteamInputForShortcut(appId: number): void {
|
||||||
|
try {
|
||||||
|
const input = (
|
||||||
|
SteamClient as unknown as {
|
||||||
|
Input?: { SetSteamInputEnabledForApp?: (appId: number, enabled: boolean) => void };
|
||||||
|
}
|
||||||
|
).Input;
|
||||||
|
input?.SetSteamInputEnabledForApp?.(appId, false);
|
||||||
|
} catch {
|
||||||
|
/* a controller tweak must never break the launch */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch a stream to `host:port` fullscreen in Gaming Mode. Encodes the target into the
|
* 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.
|
* shortcut's launch options (so one generic shortcut serves every host), then RunGame.
|
||||||
*/
|
*/
|
||||||
export async function launchStream(host: string, port: number): Promise<void> {
|
export async function launchStream(host: string, port: number): Promise<void> {
|
||||||
const appId = await ensureShortcut();
|
const appId = 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;
|
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||||
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment.
|
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment.
|
||||||
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`);
|
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`);
|
||||||
|
|||||||
+253
-8
@@ -22,6 +22,8 @@ struct App {
|
|||||||
gamepad: crate::gamepad::GamepadService,
|
gamepad: crate::gamepad::GamepadService,
|
||||||
/// One session at a time — ignore connects while one is starting/running.
|
/// One session at a time — ignore connects while one is starting/running.
|
||||||
busy: std::cell::Cell<bool>,
|
busy: std::cell::Cell<bool>,
|
||||||
|
/// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts.
|
||||||
|
fullscreen: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -41,7 +43,13 @@ pub fn run() -> glib::ExitCode {
|
|||||||
if let Some(pin) = arg_value("--pair") {
|
if let Some(pin) = arg_value("--pair") {
|
||||||
return headless_pair(&pin);
|
return headless_pair(&pin);
|
||||||
}
|
}
|
||||||
let app = adw::Application::builder().application_id(APP_ID).build();
|
let mut builder = adw::Application::builder().application_id(APP_ID);
|
||||||
|
// Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps each
|
||||||
|
// launch its own primary instance instead of forwarding to a still-registered name.
|
||||||
|
if shot_scene().is_some() {
|
||||||
|
builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE);
|
||||||
|
}
|
||||||
|
let app = builder.build();
|
||||||
app.connect_activate(build_ui);
|
app.connect_activate(build_ui);
|
||||||
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
||||||
// keeps GApplication from rejecting unknown options.
|
// keeps GApplication from rejecting unknown options.
|
||||||
@@ -56,6 +64,20 @@ fn arg_value(flag: &str) -> Option<String> {
|
|||||||
.filter(|v| !v.starts_with("--"))
|
.filter(|v| !v.starts_with("--"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if argv contains `flag` (a valueless switch).
|
||||||
|
fn arg_flag(flag: &str) -> bool {
|
||||||
|
std::env::args().any(|a| a == flag)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the stream fullscreen with no window chrome — the Steam Deck / Gaming-Mode launch path.
|
||||||
|
/// The Decky wrapper passes `--fullscreen`; we also honor the Deck/gamescope env as a fallback
|
||||||
|
/// so a manual launch under Gaming Mode does the right thing too.
|
||||||
|
fn fullscreen_mode() -> bool {
|
||||||
|
arg_flag("--fullscreen")
|
||||||
|
|| std::env::var_os("SteamDeck").is_some()
|
||||||
|
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the
|
/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the
|
||||||
/// known-hosts store as paired, so a later `--connect` connects silently. Same identity
|
/// known-hosts store as paired, so a later `--connect` connects silently. Same identity
|
||||||
/// store the streaming path uses (same binary), so pairing here makes the stream work.
|
/// store the streaming path uses (same binary), so pairing here makes the stream work.
|
||||||
@@ -161,6 +183,7 @@ fn build_ui(gtk_app: &adw::Application) {
|
|||||||
identity,
|
identity,
|
||||||
gamepad: crate::gamepad::GamepadService::start(),
|
gamepad: crate::gamepad::GamepadService::start(),
|
||||||
busy: std::cell::Cell::new(false),
|
busy: std::cell::Cell::new(false),
|
||||||
|
fullscreen: fullscreen_mode(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let hosts_page = crate::ui_hosts::new(
|
let hosts_page = crate::ui_hosts::new(
|
||||||
@@ -182,11 +205,65 @@ fn build_ui(gtk_app: &adw::Application) {
|
|||||||
nav.add(&hosts_page);
|
nav.add(&hosts_page);
|
||||||
window.present();
|
window.present();
|
||||||
|
|
||||||
|
// CI screenshot mode: render one scripted, host-free scene and signal readiness
|
||||||
|
// (clients/linux/tools/screenshots.sh). Mutually exclusive with a real connect.
|
||||||
|
if let Some(scene) = shot_scene() {
|
||||||
|
run_shot(app, &scene);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(req) = cli_connect_request() {
|
if let Some(req) = cli_connect_request() {
|
||||||
initiate_connect(app, req);
|
initiate_connect(app, req);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots.
|
||||||
|
fn shot_scene() -> Option<String> {
|
||||||
|
std::env::var("PUNKTFUNK_SHOT_SCENE")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render one mock-populated, host-free scene over the already-presented window, then print
|
||||||
|
/// `PF_SHOT_READY` once it has had a moment to map + settle so the driver knows when to capture.
|
||||||
|
/// No `NativeClient` or session is created. The stream scene is deliberately absent — its page
|
||||||
|
/// requires a live connector (`ui_stream::new` takes an `Arc<NativeClient>`).
|
||||||
|
fn run_shot(app: Rc<App>, scene: &str) {
|
||||||
|
// A plausible host for the trust/pair dialogs (fp_hex is 64 hex chars, like a real SHA-256).
|
||||||
|
let mock_req = || ConnectRequest {
|
||||||
|
name: "Living Room PC".to_string(),
|
||||||
|
addr: "192.168.1.42".to_string(),
|
||||||
|
port: 9777,
|
||||||
|
fp_hex: Some(
|
||||||
|
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(),
|
||||||
|
),
|
||||||
|
pair_optional: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
match scene {
|
||||||
|
// The saved-hosts grid reads ~/.config/punktfunk/client-known-hosts.json, which the
|
||||||
|
// driver seeds — so the already-shown hosts page is the scene; nothing to do here.
|
||||||
|
"hosts" | "02-hosts" => {}
|
||||||
|
"settings" | "03-settings" => {
|
||||||
|
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad);
|
||||||
|
}
|
||||||
|
"trust" | "04-trust" => tofu_dialog(app.clone(), mock_req()),
|
||||||
|
"pair" | "05-pair" => pin_dialog(app.clone(), mock_req()),
|
||||||
|
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let settle_ms = std::env::var("PUNKTFUNK_SHOT_SETTLE_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(900);
|
||||||
|
let scene = scene.to_string();
|
||||||
|
glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || {
|
||||||
|
use std::io::Write as _;
|
||||||
|
println!("PF_SHOT_READY scene={scene}");
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// The trust gate in front of every connect. The host is the policy authority (it
|
/// The trust gate in front of every connect. The host is the policy authority (it
|
||||||
/// advertises `pair=optional` only when it accepts unpaired clients); the client renders
|
/// advertises `pair=optional` only when it accepts unpaired clients); the client renders
|
||||||
/// its trust UI from that:
|
/// its trust UI from that:
|
||||||
@@ -218,19 +295,21 @@ fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
|
|||||||
// Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN.
|
// Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN.
|
||||||
tofu_dialog(app, req);
|
tofu_dialog(app, req);
|
||||||
} else {
|
} else {
|
||||||
// Rule 3b: pair=required or unknown policy — PIN pairing is mandatory.
|
// Rule 3b: pair=required or unknown policy — offer no-PIN delegated approval
|
||||||
pin_dialog(app, req);
|
// (request access → approve in the console) or the PIN ceremony.
|
||||||
|
approval_dialog(app, req);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Manual entry (no advertised fingerprint). A known address connects silently
|
// Manual entry (no advertised fingerprint). A known address connects silently
|
||||||
// on its stored pin (rule 1); an unknown one must pair — never silent TOFU.
|
// on its stored pin (rule 1); an unknown one must pair — request access (approve in
|
||||||
|
// the console) or use a PIN; never silent TOFU.
|
||||||
match known
|
match known
|
||||||
.find_by_addr(&req.addr, req.port)
|
.find_by_addr(&req.addr, req.port)
|
||||||
.and_then(|k| crate::trust::parse_hex32(&k.fp_hex))
|
.and_then(|k| crate::trust::parse_hex32(&k.fp_hex))
|
||||||
{
|
{
|
||||||
Some(pin) => start_session(app, req, Some(pin)),
|
Some(pin) => start_session(app, req, Some(pin)),
|
||||||
None => pin_dialog(app, req), // rule 3b
|
None => approval_dialog(app, req), // rule 3b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,6 +420,83 @@ fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
|||||||
dialog.present(Some(&parent));
|
dialog.present(Some(&parent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fresh host that requires pairing: offer the two ways in. "Request access" is the no-PIN
|
||||||
|
/// path — connect and wait for the operator to click Approve in the host's console/web UI
|
||||||
|
/// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony.
|
||||||
|
fn approval_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||||
|
let dialog = adw::AlertDialog::new(
|
||||||
|
Some("Pairing Required"),
|
||||||
|
Some(&format!(
|
||||||
|
"{} requires pairing.\n\nRequest access and approve this device in the host's console \
|
||||||
|
(or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.",
|
||||||
|
req.name
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
dialog.add_responses(&[
|
||||||
|
("cancel", "Cancel"),
|
||||||
|
("pin", "Use a PIN instead…"),
|
||||||
|
("request", "Request Access"),
|
||||||
|
]);
|
||||||
|
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("request"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let parent = app.window.clone();
|
||||||
|
dialog.connect_response(None, move |_, response| match response {
|
||||||
|
"request" => request_access(app.clone(), req.clone()),
|
||||||
|
"pin" => pin_dialog(app.clone(), req.clone()),
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
dialog.present(Some(&parent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
||||||
|
/// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On
|
||||||
|
/// approval the same connection is admitted (no reconnect) and the host is saved as paired.
|
||||||
|
fn request_access(app: Rc<App>, req: ConnectRequest) {
|
||||||
|
// Pin the advertised certificate for a discovered host (defence against a host impostor while
|
||||||
|
// we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||||
|
let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
|
||||||
|
let cancel = Rc::new(std::cell::Cell::new(false));
|
||||||
|
|
||||||
|
let waiting = adw::AlertDialog::new(
|
||||||
|
Some("Waiting for Approval"),
|
||||||
|
Some(&format!(
|
||||||
|
"Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \
|
||||||
|
connects automatically once you approve it.",
|
||||||
|
glib::host_name(),
|
||||||
|
req.name
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||||
|
waiting.set_close_response("cancel");
|
||||||
|
{
|
||||||
|
let app = app.clone();
|
||||||
|
let cancel = cancel.clone();
|
||||||
|
waiting.connect_response(Some("cancel"), move |_, _| {
|
||||||
|
// Return the UI immediately; the in-flight connect is left to time out and is torn
|
||||||
|
// down silently by the event loop (see StartOpts::cancel).
|
||||||
|
cancel.set(true);
|
||||||
|
app.busy.set(false);
|
||||||
|
app.toast("Cancelled — the request may still be pending on the host.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
waiting.present(Some(&app.window));
|
||||||
|
|
||||||
|
start_session_with(
|
||||||
|
app,
|
||||||
|
req,
|
||||||
|
pin,
|
||||||
|
StartOpts {
|
||||||
|
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
||||||
|
// approval still lands on this connection rather than timing the client out first.
|
||||||
|
connect_timeout: std::time::Duration::from_secs(185),
|
||||||
|
persist_paired: true,
|
||||||
|
waiting: Some(waiting),
|
||||||
|
cancel: Some(cancel),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
|
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
|
||||||
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
|
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
|
||||||
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
|
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
|
||||||
@@ -375,6 +531,7 @@ fn speed_test(app: Rc<App>, req: ConnectRequest) {
|
|||||||
GamepadPref::Auto,
|
GamepadPref::Auto,
|
||||||
0, // bitrate_kbps (host default)
|
0, // bitrate_kbps (host default)
|
||||||
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
||||||
|
2, // audio_channels: speed-test probe, stereo
|
||||||
None, // launch: speed-test probe connect, no game
|
None, // launch: speed-test probe connect, no game
|
||||||
pin,
|
pin,
|
||||||
Some(identity),
|
Some(identity),
|
||||||
@@ -443,11 +600,19 @@ fn resolve_mode(app: &App) -> punktfunk_core::config::Mode {
|
|||||||
refresh_hz: s.refresh_hz,
|
refresh_hz: s.refresh_hz,
|
||||||
};
|
};
|
||||||
if mode.width == 0 || mode.refresh_hz == 0 {
|
if mode.width == 0 || mode.refresh_hz == 0 {
|
||||||
|
// Prefer the monitor the window is on; fall back to the display's first monitor. On a
|
||||||
|
// `--connect` launch the window may not be mapped yet when this runs, and without the
|
||||||
|
// fallback we'd drop to the 1920×1080 floor below — wrong on the Deck (1280×800).
|
||||||
let monitor = app
|
let monitor = app
|
||||||
.window
|
.window
|
||||||
.surface()
|
.surface()
|
||||||
.zip(gdk::Display::default())
|
.zip(gdk::Display::default())
|
||||||
.and_then(|(surf, d)| d.monitor_at_surface(&surf));
|
.and_then(|(surf, d)| d.monitor_at_surface(&surf))
|
||||||
|
.or_else(|| {
|
||||||
|
gdk::Display::default()
|
||||||
|
.and_then(|d| d.monitors().item(0))
|
||||||
|
.and_then(|o| o.downcast::<gdk::Monitor>().ok())
|
||||||
|
});
|
||||||
if let Some(m) = monitor {
|
if let Some(m) = monitor {
|
||||||
let geo = m.geometry();
|
let geo = m.geometry();
|
||||||
let scale = m.scale_factor().max(1);
|
let scale = m.scale_factor().max(1);
|
||||||
@@ -470,7 +635,42 @@ fn resolve_mode(app: &App) -> punktfunk_core::config::Mode {
|
|||||||
mode
|
mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tunables for a session start that differ between the normal connect and the "request access"
|
||||||
|
/// (delegated-approval) flow. `Default` is the normal connect.
|
||||||
|
struct StartOpts {
|
||||||
|
/// Handshake budget. The request-access flow uses a long one because the host PARKS the
|
||||||
|
/// connection until the operator clicks Approve (see the host's `PENDING_APPROVAL_WAIT`).
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
/// Persist the host as *paired* on a successful connect. Set for request-access, where the
|
||||||
|
/// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU
|
||||||
|
/// persists the host *unpaired* (pinned, but not PIN/approval-verified).
|
||||||
|
persist_paired: bool,
|
||||||
|
/// A "waiting for approval" dialog to dismiss on the first session event (request-access only).
|
||||||
|
waiting: Option<adw::AlertDialog>,
|
||||||
|
/// Set by the waiting dialog's Cancel button. `NativeClient::connect` is a blocking call with
|
||||||
|
/// no abort, so Cancel returns the UI immediately (clears busy, closes the dialog) and leaves
|
||||||
|
/// the in-flight connect to time out; when it finally resolves, the event loop sees this flag
|
||||||
|
/// and tears down silently (drops the connector → closes the connection) without touching the
|
||||||
|
/// UI a new session may already own.
|
||||||
|
cancel: Option<Rc<std::cell::Cell<bool>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StartOpts {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
connect_timeout: std::time::Duration::from_secs(15),
|
||||||
|
persist_paired: false,
|
||||||
|
waiting: None,
|
||||||
|
cancel: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||||
|
start_session_with(app, req, pin, StartOpts::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_session_with(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>, opts: StartOpts) {
|
||||||
if app.busy.replace(true) {
|
if app.busy.replace(true) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -488,12 +688,17 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
},
|
},
|
||||||
bitrate_kbps: s.bitrate_kbps,
|
bitrate_kbps: s.bitrate_kbps,
|
||||||
mic_enabled: s.mic_enabled,
|
mic_enabled: s.mic_enabled,
|
||||||
|
audio_channels: s.audio_channels,
|
||||||
pin,
|
pin,
|
||||||
identity: app.identity.clone(),
|
identity: app.identity.clone(),
|
||||||
|
connect_timeout: opts.connect_timeout,
|
||||||
};
|
};
|
||||||
let inhibit = s.inhibit_shortcuts;
|
let inhibit = s.inhibit_shortcuts;
|
||||||
drop(s);
|
drop(s);
|
||||||
let tofu = pin.is_none();
|
let tofu = pin.is_none();
|
||||||
|
let persist_paired = opts.persist_paired;
|
||||||
|
let mut waiting = opts.waiting;
|
||||||
|
let cancel = opts.cancel;
|
||||||
|
|
||||||
let mut handle = crate::session::start(params);
|
let mut handle = crate::session::start(params);
|
||||||
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
|
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
|
||||||
@@ -501,14 +706,41 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
let mut frames = Some(frames);
|
let mut frames = Some(frames);
|
||||||
let mut page: Option<crate::ui_stream::StreamPage> = None;
|
let mut page: Option<crate::ui_stream::StreamPage> = None;
|
||||||
while let Ok(event) = handle.events.recv().await {
|
while let Ok(event) = handle.events.recv().await {
|
||||||
|
// A cancelled request-access connect resolved late: tear down silently. Don't touch
|
||||||
|
// app.busy — Cancel already cleared it, and a fresh session may now own it.
|
||||||
|
if cancel.as_ref().is_some_and(|c| c.get()) {
|
||||||
|
if let Some(w) = waiting.take() {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
match event {
|
match event {
|
||||||
SessionEvent::Connected {
|
SessionEvent::Connected {
|
||||||
connector,
|
connector,
|
||||||
mode,
|
mode,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
} => {
|
} => {
|
||||||
// A TOFU connect just observed the real fingerprint — pin it from now on.
|
// Dismiss the "waiting for approval" dialog (request-access flow), if any.
|
||||||
if tofu {
|
if let Some(w) = waiting.take() {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
|
if persist_paired {
|
||||||
|
// Request-access: the operator approved this device, so record the host as
|
||||||
|
// a trusted PAIRED host (pinning the fingerprint we observed) — future
|
||||||
|
// connects are then silent (rule 1), exactly like after a PIN ceremony.
|
||||||
|
let fp_hex = crate::trust::hex(&fingerprint);
|
||||||
|
let mut known = KnownHosts::load();
|
||||||
|
known.upsert(KnownHost {
|
||||||
|
name: req.name.clone(),
|
||||||
|
addr: req.addr.clone(),
|
||||||
|
port: req.port,
|
||||||
|
fp_hex,
|
||||||
|
paired: true,
|
||||||
|
});
|
||||||
|
let _ = known.save();
|
||||||
|
app.toast("Approved — connecting…");
|
||||||
|
} else if tofu {
|
||||||
|
// A TOFU connect just observed the real fingerprint — pin it from now on.
|
||||||
let fp_hex = crate::trust::hex(&fingerprint);
|
let fp_hex = crate::trust::hex(&fingerprint);
|
||||||
let mut known = KnownHosts::load();
|
let mut known = KnownHosts::load();
|
||||||
known.upsert(KnownHost {
|
known.upsert(KnownHost {
|
||||||
@@ -535,11 +767,18 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
connector,
|
connector,
|
||||||
frames.take().expect("Connected delivered once"),
|
frames.take().expect("Connected delivered once"),
|
||||||
app.gamepad.escape_events(),
|
app.gamepad.escape_events(),
|
||||||
|
app.gamepad.disconnect_events(),
|
||||||
handle.stop.clone(),
|
handle.stop.clone(),
|
||||||
inhibit,
|
inhibit,
|
||||||
&title,
|
&title,
|
||||||
);
|
);
|
||||||
app.nav.push(&p.page);
|
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 app.fullscreen {
|
||||||
|
app.window.fullscreen();
|
||||||
|
}
|
||||||
page = Some(p);
|
page = Some(p);
|
||||||
}
|
}
|
||||||
SessionEvent::Stats(s) => {
|
SessionEvent::Stats(s) => {
|
||||||
@@ -551,6 +790,9 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
msg,
|
msg,
|
||||||
trust_rejected,
|
trust_rejected,
|
||||||
} => {
|
} => {
|
||||||
|
if let Some(w) = waiting.take() {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
tracing::warn!(%msg, trust_rejected, "connect failed");
|
tracing::warn!(%msg, trust_rejected, "connect failed");
|
||||||
app.busy.set(false);
|
app.busy.set(false);
|
||||||
// A pinned connect rejected on trust grounds means the host's cert no
|
// A pinned connect rejected on trust grounds means the host's cert no
|
||||||
@@ -565,6 +807,9 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
SessionEvent::Ended(err) => {
|
SessionEvent::Ended(err) => {
|
||||||
|
if let Some(w) = waiting.take() {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
app.gamepad.detach();
|
app.gamepad.detach();
|
||||||
app.nav.pop_to_tag("hosts");
|
app.nav.pop_to_tag("hosts");
|
||||||
if let Some(e) = err {
|
if let Some(e) = err {
|
||||||
|
|||||||
+21
-10
@@ -27,16 +27,17 @@ pub struct AudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AudioPlayer {
|
impl AudioPlayer {
|
||||||
/// Spawn the PipeWire playback thread. Failure (no PipeWire in the session) is
|
/// Spawn the PipeWire playback thread for `channels` (2/6/8, canonical wire order
|
||||||
/// survivable — the caller streams video-only.
|
/// FL FR FC LFE RL RR SL SR). Failure (no PipeWire in the session) is survivable — the
|
||||||
pub fn spawn() -> Result<AudioPlayer> {
|
/// caller streams video-only.
|
||||||
|
pub fn spawn(channels: u32) -> Result<AudioPlayer> {
|
||||||
// 64 × 5 ms = 320 ms of slack between the pump and the PipeWire loop.
|
// 64 × 5 ms = 320 ms of slack between the pump and the PipeWire loop.
|
||||||
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
||||||
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
||||||
let thread = std::thread::Builder::new()
|
let thread = std::thread::Builder::new()
|
||||||
.name("punktfunk-audio".into())
|
.name("punktfunk-audio".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = pw_thread(pcm_rx, quit_rx) {
|
if let Err(e) = pw_thread(pcm_rx, quit_rx, channels as usize) {
|
||||||
tracing::warn!(error = %e, "audio playback thread ended");
|
tracing::warn!(error = %e, "audio playback thread ended");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -48,8 +49,8 @@ impl AudioPlayer {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue one interleaved-stereo f32 chunk. Drops the chunk if the PipeWire side is
|
/// Queue one interleaved f32 chunk (in the session's channel layout). Drops the chunk if the
|
||||||
/// wedged (the renderer conceals the gap; never block the session pump).
|
/// PipeWire side is wedged (the renderer conceals the gap; never block the session pump).
|
||||||
pub fn push(&self, pcm: Vec<f32>) {
|
pub fn push(&self, pcm: Vec<f32>) {
|
||||||
if let Err(TrySendError::Disconnected(_)) = self.pcm_tx.try_send(pcm) {
|
if let Err(TrySendError::Disconnected(_)) = self.pcm_tx.try_send(pcm) {
|
||||||
// Thread already dead — Drop will reap it; nothing to do per-chunk.
|
// Thread already dead — Drop will reap it; nothing to do per-chunk.
|
||||||
@@ -71,11 +72,14 @@ struct PlayerData {
|
|||||||
rx: Receiver<Vec<f32>>,
|
rx: Receiver<Vec<f32>>,
|
||||||
ring: VecDeque<f32>,
|
ring: VecDeque<f32>,
|
||||||
primed: bool,
|
primed: bool,
|
||||||
|
/// Interleaved channel count this stream was opened with (2/6/8).
|
||||||
|
channels: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pw_thread(
|
fn pw_thread(
|
||||||
pcm_rx: Receiver<Vec<f32>>,
|
pcm_rx: Receiver<Vec<f32>>,
|
||||||
quit_rx: pipewire::channel::Receiver<Terminate>,
|
quit_rx: pipewire::channel::Receiver<Terminate>,
|
||||||
|
channels: usize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use pipewire as pw;
|
use pipewire as pw;
|
||||||
use pw::{properties::properties, spa};
|
use pw::{properties::properties, spa};
|
||||||
@@ -115,6 +119,7 @@ fn pw_thread(
|
|||||||
rx: pcm_rx,
|
rx: pcm_rx,
|
||||||
ring: VecDeque::new(),
|
ring: VecDeque::new(),
|
||||||
primed: false,
|
primed: false,
|
||||||
|
channels,
|
||||||
};
|
};
|
||||||
|
|
||||||
let _listener = stream
|
let _listener = stream
|
||||||
@@ -130,19 +135,19 @@ fn pw_thread(
|
|||||||
while let Ok(chunk) = ud.rx.try_recv() {
|
while let Ok(chunk) = ud.rx.try_recv() {
|
||||||
ud.ring.extend(chunk);
|
ud.ring.extend(chunk);
|
||||||
}
|
}
|
||||||
let stride = 4 * CHANNELS; // F32LE interleaved
|
let stride = 4 * ud.channels; // F32LE interleaved
|
||||||
let datas = buffer.datas_mut();
|
let datas = buffer.datas_mut();
|
||||||
if datas.is_empty() {
|
if datas.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let data = &mut datas[0];
|
let data = &mut datas[0];
|
||||||
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
|
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
|
||||||
let want = want_frames * CHANNELS;
|
let want = want_frames * ud.channels;
|
||||||
|
|
||||||
// Adaptive jitter buffer (same shape as the host's virtual mic): prime to
|
// Adaptive jitter buffer (same shape as the host's virtual mic): prime to
|
||||||
// ~3 quanta, cap at ~1 quantum of slack beyond that, re-prime after a
|
// ~3 quanta, cap at ~1 quantum of slack beyond that, re-prime after a
|
||||||
// genuine drain.
|
// genuine drain.
|
||||||
let target = (3 * want).clamp(720 * CHANNELS, 9600 * CHANNELS);
|
let target = (3 * want).clamp(720 * ud.channels, 9600 * ud.channels);
|
||||||
while ud.ring.len() > target.max(want) + want {
|
while ud.ring.len() > target.max(want) + want {
|
||||||
ud.ring.pop_front();
|
ud.ring.pop_front();
|
||||||
}
|
}
|
||||||
@@ -182,7 +187,13 @@ fn pw_thread(
|
|||||||
let mut info = AudioInfoRaw::new();
|
let mut info = AudioInfoRaw::new();
|
||||||
info.set_format(AudioFormat::F32LE);
|
info.set_format(AudioFormat::F32LE);
|
||||||
info.set_rate(SAMPLE_RATE);
|
info.set_rate(SAMPLE_RATE);
|
||||||
info.set_channels(CHANNELS as u32);
|
info.set_channels(channels as u32);
|
||||||
|
// Channel positions in canonical wire order (FL FR FC LFE RL RR SL SR) so PipeWire routes each
|
||||||
|
// slot to the matching speaker (and downmixes when the sink has fewer). Identity, no permute.
|
||||||
|
let order = punktfunk_core::audio::spa_positions(channels as u8);
|
||||||
|
let mut positions = [0u32; 64];
|
||||||
|
positions[..order.len()].copy_from_slice(order);
|
||||||
|
info.set_position(positions);
|
||||||
let obj = pw::spa::pod::Object {
|
let obj = pw::spa::pod::Object {
|
||||||
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||||
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
|
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
|
||||||
|
|||||||
+186
-32
@@ -18,7 +18,7 @@ use punktfunk_core::quic::{HidOutput, RichInput};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
|
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
|
||||||
/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands
|
/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands
|
||||||
@@ -33,8 +33,15 @@ const G: f32 = 9.80665;
|
|||||||
/// is the only way out. Four simultaneous buttons that no game uses as a deliberate
|
/// is the only way out. Four simultaneous buttons that no game uses as a deliberate
|
||||||
/// combo, so it can't be triggered by normal play. Still forwarded to the host (the user
|
/// combo, so it can't be triggered by normal play. Still forwarded to the host (the user
|
||||||
/// is leaving anyway); we only also raise the escape signal.
|
/// is leaving anyway); we only also raise the escape signal.
|
||||||
|
///
|
||||||
|
/// **Escalation:** a quick press leaves fullscreen / releases capture; *holding* the same
|
||||||
|
/// chord for [`DISCONNECT_HOLD`] ends the session. Deliberately NOT the Steam / QAM buttons —
|
||||||
|
/// those are the marquee pass-through controls that now reach the host's game-mode UI.
|
||||||
const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK];
|
const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK];
|
||||||
|
|
||||||
|
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
||||||
|
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PadInfo {
|
pub struct PadInfo {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
@@ -58,6 +65,7 @@ impl PadInfo {
|
|||||||
GamepadPref::DualSense => "DualSense",
|
GamepadPref::DualSense => "DualSense",
|
||||||
GamepadPref::DualShock4 => "DualShock 4",
|
GamepadPref::DualShock4 => "DualShock 4",
|
||||||
GamepadPref::XboxOne => "Xbox One",
|
GamepadPref::XboxOne => "Xbox One",
|
||||||
|
GamepadPref::SteamDeck => "Steam Deck",
|
||||||
_ => "",
|
_ => "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +97,9 @@ pub struct GamepadService {
|
|||||||
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
|
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
|
||||||
/// fullscreen + release capture.
|
/// fullscreen + release capture.
|
||||||
escape_rx: async_channel::Receiver<()>,
|
escape_rx: async_channel::Receiver<()>,
|
||||||
|
/// 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<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GamepadService {
|
impl GamepadService {
|
||||||
@@ -98,11 +109,12 @@ impl GamepadService {
|
|||||||
let pinned = Arc::new(Mutex::new(None));
|
let pinned = Arc::new(Mutex::new(None));
|
||||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||||
let (escape_tx, escape_rx) = async_channel::unbounded();
|
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 (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
||||||
if let Err(e) = std::thread::Builder::new()
|
if let Err(e) = std::thread::Builder::new()
|
||||||
.name("punktfunk-gamepad".into())
|
.name("punktfunk-gamepad".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx) {
|
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx, &disconnect_tx) {
|
||||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -115,6 +127,7 @@ impl GamepadService {
|
|||||||
pinned,
|
pinned,
|
||||||
ctl,
|
ctl,
|
||||||
escape_rx,
|
escape_rx,
|
||||||
|
disconnect_rx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +137,12 @@ impl GamepadService {
|
|||||||
self.escape_rx.clone()
|
self.escape_rx.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A receiver that yields one `()` when the escape chord is held past [`DISCONNECT_HOLD`]
|
||||||
|
/// (controller disconnect). A fresh clone per call; the stream page spawns a future on it.
|
||||||
|
pub fn disconnect_events(&self) -> async_channel::Receiver<()> {
|
||||||
|
self.disconnect_rx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn pads(&self) -> Vec<PadInfo> {
|
pub fn pads(&self) -> Vec<PadInfo> {
|
||||||
self.pads.lock().unwrap().clone()
|
self.pads.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
@@ -188,6 +207,13 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
|
|||||||
Button::DPadLeft => wire::BTN_DPAD_LEFT,
|
Button::DPadLeft => wire::BTN_DPAD_LEFT,
|
||||||
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
||||||
Button::Touchpad => wire::BTN_TOUCHPAD,
|
Button::Touchpad => wire::BTN_TOUCHPAD,
|
||||||
|
// Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1–P4) + the misc/Share button.
|
||||||
|
// PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`).
|
||||||
|
Button::RightPaddle1 => wire::BTN_PADDLE1,
|
||||||
|
Button::LeftPaddle1 => wire::BTN_PADDLE2,
|
||||||
|
Button::RightPaddle2 => wire::BTN_PADDLE3,
|
||||||
|
Button::LeftPaddle2 => wire::BTN_PADDLE4,
|
||||||
|
Button::Misc1 => wire::BTN_MISC1,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -259,11 +285,22 @@ struct Worker {
|
|||||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||||
last_axis: [i32; 6],
|
last_axis: [i32; 6],
|
||||||
held_buttons: Vec<u32>,
|
held_buttons: Vec<u32>,
|
||||||
|
/// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad
|
||||||
|
/// switch / detach so a contact held at that moment doesn't stick. surface 0 = the legacy single
|
||||||
|
/// touchpad, 1/2 = a Steam left/right pad.
|
||||||
|
held_touches: std::collections::HashSet<(u8, u8)>,
|
||||||
last_accel: [i16; 3],
|
last_accel: [i16; 3],
|
||||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||||
escape_tx: async_channel::Sender<()>,
|
escape_tx: async_channel::Sender<()>,
|
||||||
|
/// Raises the UI disconnect signal when the escape chord is held past [`DISCONNECT_HOLD`].
|
||||||
|
disconnect_tx: async_channel::Sender<()>,
|
||||||
/// The escape chord is fully held — latched so it fires once, not every poll.
|
/// The escape chord is fully held — latched so it fires once, not every poll.
|
||||||
chord_armed: bool,
|
chord_armed: bool,
|
||||||
|
/// When the escape chord became fully held (drives the hold-to-disconnect escalation); `None`
|
||||||
|
/// when the chord is broken.
|
||||||
|
chord_since: Option<Instant>,
|
||||||
|
/// The disconnect signal already fired for the current hold — latched so it fires once.
|
||||||
|
disconnect_fired: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Worker {
|
impl Worker {
|
||||||
@@ -275,13 +312,22 @@ impl Worker {
|
|||||||
|
|
||||||
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||||
let pad = self.opened.get(&id)?;
|
let pad = self.opened.get(&id)?;
|
||||||
|
let mut pref = pref_for_type(
|
||||||
|
self.subsystem
|
||||||
|
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||||
|
);
|
||||||
|
// 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))
|
||||||
|
{
|
||||||
|
pref = GamepadPref::SteamDeck;
|
||||||
|
}
|
||||||
Some(PadInfo {
|
Some(PadInfo {
|
||||||
id,
|
id,
|
||||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||||
pref: pref_for_type(
|
pref,
|
||||||
self.subsystem
|
|
||||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,32 +343,90 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
*v = i32::MIN;
|
*v = i32::MIN;
|
||||||
}
|
}
|
||||||
|
// Lift any touchpad contact the host still believes is down (surface 0 = legacy pad).
|
||||||
|
for (surface, finger) in self.held_touches.drain() {
|
||||||
|
let rich = if surface == 0 {
|
||||||
|
RichInput::Touchpad {
|
||||||
|
pad: 0,
|
||||||
|
finger,
|
||||||
|
active: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RichInput::TouchpadEx {
|
||||||
|
pad: 0,
|
||||||
|
surface,
|
||||||
|
finger,
|
||||||
|
touch: false,
|
||||||
|
click: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
pressure: 0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = c.send_rich_input(rich);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.held_buttons.clear();
|
self.held_buttons.clear();
|
||||||
self.last_axis = [i32::MIN; 6];
|
self.last_axis = [i32::MIN; 6];
|
||||||
|
self.held_touches.clear();
|
||||||
}
|
}
|
||||||
|
// A held chord doesn't survive a flush (detach / pad-switch) — clear its latches too.
|
||||||
|
self.reset_chord();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it
|
/// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it
|
||||||
/// fires once per press). Called after each button-down updates `held_buttons`.
|
/// fires once per press) and start the hold-to-disconnect timer. Called after each
|
||||||
|
/// button-down updates `held_buttons`.
|
||||||
fn maybe_fire_escape(&mut self) {
|
fn maybe_fire_escape(&mut self) {
|
||||||
if self.chord_armed {
|
if self.chord_armed {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
||||||
self.chord_armed = true;
|
self.chord_armed = true;
|
||||||
|
self.chord_since = Some(Instant::now());
|
||||||
let _ = self.escape_tx.try_send(());
|
let _ = self.escape_tx.try_send(());
|
||||||
tracing::info!("gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen");
|
tracing::info!(
|
||||||
|
"gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen (hold to disconnect)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire the disconnect signal once the escape chord has been continuously held past
|
||||||
|
/// [`DISCONNECT_HOLD`]. Polled from the main loop so the hold completes without new events.
|
||||||
|
fn maybe_fire_disconnect(&mut self) {
|
||||||
|
if self.disconnect_fired {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(since) = self.chord_since {
|
||||||
|
if since.elapsed() >= DISCONNECT_HOLD {
|
||||||
|
self.disconnect_fired = true;
|
||||||
|
let _ = self.disconnect_tx.try_send(());
|
||||||
|
tracing::info!("gamepad escape chord held — disconnecting");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-arm once the chord is broken (any of its buttons released).
|
/// Re-arm once the chord is broken (any of its buttons released).
|
||||||
fn rearm_escape(&mut self) {
|
fn rearm_escape(&mut self) {
|
||||||
if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
||||||
self.chord_armed = false;
|
self.reset_chord();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear the escape/disconnect chord latches. Called at every session boundary
|
||||||
|
/// ([`flush_held`](Self::flush_held) on detach/pad-switch + on attach): the hold-to-disconnect
|
||||||
|
/// path *always* ends the session while the chord is still physically held, so the matching
|
||||||
|
/// button-up events arrive after detach (dropped by the `attached` guard) and `rearm_escape`
|
||||||
|
/// never runs — without this the latched state would leak into the next session and either
|
||||||
|
/// swallow its first chord press or fire a stale disconnect on connect.
|
||||||
|
fn reset_chord(&mut self) {
|
||||||
|
self.chord_armed = false;
|
||||||
|
self.chord_since = None;
|
||||||
|
self.disconnect_fired = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
||||||
fn set_sensors(&mut self, enabled: bool) {
|
fn set_sensors(&mut self, enabled: bool) {
|
||||||
let Some(id) = self.active_id() else { return };
|
let Some(id) = self.active_id() else { return };
|
||||||
@@ -335,6 +439,56 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam
|
||||||
|
/// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and
|
||||||
|
/// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned).
|
||||||
|
fn forward_touch(
|
||||||
|
&mut self,
|
||||||
|
which: u32,
|
||||||
|
touchpad: u32,
|
||||||
|
finger: u8,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
active: bool,
|
||||||
|
) {
|
||||||
|
let Some(c) = self.attached.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let multi = self
|
||||||
|
.opened
|
||||||
|
.get(&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 };
|
||||||
|
let rich = if multi {
|
||||||
|
RichInput::TouchpadEx {
|
||||||
|
pad: 0,
|
||||||
|
surface,
|
||||||
|
finger,
|
||||||
|
touch: active,
|
||||||
|
click: false,
|
||||||
|
x: (cx * 65535.0 - 32768.0) as i16,
|
||||||
|
y: (cy * 65535.0 - 32768.0) as i16,
|
||||||
|
pressure: 0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RichInput::Touchpad {
|
||||||
|
pad: 0,
|
||||||
|
finger,
|
||||||
|
active,
|
||||||
|
x: (cx * 65535.0) as u16,
|
||||||
|
y: (cy * 65535.0) as u16,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = c.send_rich_input(rich);
|
||||||
|
if active {
|
||||||
|
self.held_touches.insert((surface, finger));
|
||||||
|
} else {
|
||||||
|
self.held_touches.remove(&(surface, finger));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
@@ -344,11 +498,18 @@ fn run(
|
|||||||
pinned_out: &Mutex<Option<u32>>,
|
pinned_out: &Mutex<Option<u32>>,
|
||||||
ctl: &Receiver<Ctl>,
|
ctl: &Receiver<Ctl>,
|
||||||
escape_tx: &async_channel::Sender<()>,
|
escape_tx: &async_channel::Sender<()>,
|
||||||
|
disconnect_tx: &async_channel::Sender<()>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
||||||
// own thread.
|
// own thread.
|
||||||
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "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");
|
||||||
let sdl = sdl3::init().map_err(|e| e.to_string())?;
|
let sdl = sdl3::init().map_err(|e| e.to_string())?;
|
||||||
let subsystem = sdl.gamepad().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())?;
|
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||||
@@ -361,9 +522,13 @@ fn run(
|
|||||||
attached: None,
|
attached: None,
|
||||||
last_axis: [i32::MIN; 6],
|
last_axis: [i32::MIN; 6],
|
||||||
held_buttons: Vec::new(),
|
held_buttons: Vec::new(),
|
||||||
|
held_touches: std::collections::HashSet::new(),
|
||||||
last_accel: [0; 3],
|
last_accel: [0; 3],
|
||||||
escape_tx: escape_tx.clone(),
|
escape_tx: escape_tx.clone(),
|
||||||
|
disconnect_tx: disconnect_tx.clone(),
|
||||||
chord_armed: false,
|
chord_armed: false,
|
||||||
|
chord_since: None,
|
||||||
|
disconnect_fired: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let publish = |w: &Worker| {
|
let publish = |w: &Worker| {
|
||||||
@@ -381,6 +546,7 @@ fn run(
|
|||||||
Ok(Ctl::Attach(c)) => {
|
Ok(Ctl::Attach(c)) => {
|
||||||
w.attached = Some(c);
|
w.attached = Some(c);
|
||||||
w.last_axis = [i32::MIN; 6];
|
w.last_axis = [i32::MIN; 6];
|
||||||
|
w.reset_chord(); // every session starts un-latched (Attach doesn't flush)
|
||||||
w.set_sensors(true);
|
w.set_sensors(true);
|
||||||
}
|
}
|
||||||
Ok(Ctl::Detach) => {
|
Ok(Ctl::Detach) => {
|
||||||
@@ -474,9 +640,11 @@ fn run(
|
|||||||
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// DualSense touchpad → the rich-input plane, normalized 0..=65535.
|
// Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
|
||||||
|
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
|
||||||
Event::ControllerTouchpadDown {
|
Event::ControllerTouchpadDown {
|
||||||
which,
|
which,
|
||||||
|
touchpad,
|
||||||
finger,
|
finger,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@@ -484,41 +652,23 @@ fn run(
|
|||||||
}
|
}
|
||||||
| Event::ControllerTouchpadMotion {
|
| Event::ControllerTouchpadMotion {
|
||||||
which,
|
which,
|
||||||
|
touchpad,
|
||||||
finger,
|
finger,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
..
|
..
|
||||||
} if active == Some(which) && w.attached.is_some() => {
|
} if active == Some(which) && w.attached.is_some() => {
|
||||||
let _ = w
|
w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
|
||||||
.attached
|
|
||||||
.as_ref()
|
|
||||||
.unwrap()
|
|
||||||
.send_rich_input(RichInput::Touchpad {
|
|
||||||
pad: 0,
|
|
||||||
finger: finger as u8,
|
|
||||||
active: true,
|
|
||||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
|
||||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Event::ControllerTouchpadUp {
|
Event::ControllerTouchpadUp {
|
||||||
which,
|
which,
|
||||||
|
touchpad,
|
||||||
finger,
|
finger,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
..
|
..
|
||||||
} if active == Some(which) && w.attached.is_some() => {
|
} if active == Some(which) && w.attached.is_some() => {
|
||||||
let _ = w
|
w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
|
||||||
.attached
|
|
||||||
.as_ref()
|
|
||||||
.unwrap()
|
|
||||||
.send_rich_input(RichInput::Touchpad {
|
|
||||||
pad: 0,
|
|
||||||
finger: finger as u8,
|
|
||||||
active: false,
|
|
||||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
|
||||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// Motion: accel events update the cache; each gyro event ships a sample
|
// Motion: accel events update the cache; each gyro event ships a sample
|
||||||
// (the DualSense reports both at ~250 Hz). Scale convention shared with
|
// (the DualSense reports both at ~250 Hz). Scale convention shared with
|
||||||
@@ -559,6 +709,10 @@ fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escalate a held escape chord to a disconnect (polled — the hold completes with no
|
||||||
|
// new button events; the chord itself is only detected while a session is attached).
|
||||||
|
w.maybe_fire_disconnect();
|
||||||
|
|
||||||
// Feedback planes (this thread is their single consumer). The host re-sends
|
// Feedback planes (this thread is their single consumer). The host re-sends
|
||||||
// rumble state periodically, so a generous duration with refresh-on-update is
|
// rumble state periodically, so a generous duration with refresh-on-update is
|
||||||
// safe — a dropped stop heals within ~500 ms.
|
// safe — a dropped stop heals within ~500 ms.
|
||||||
|
|||||||
@@ -20,11 +20,18 @@ pub struct SessionParams {
|
|||||||
pub compositor: CompositorPref,
|
pub compositor: CompositorPref,
|
||||||
pub gamepad: GamepadPref,
|
pub gamepad: GamepadPref,
|
||||||
pub bitrate_kbps: u32,
|
pub bitrate_kbps: u32,
|
||||||
|
/// Requested audio channel count (2/6/8); the host echoes the resolved value.
|
||||||
|
pub audio_channels: u8,
|
||||||
/// Stream the default microphone to the host's virtual mic source.
|
/// Stream the default microphone to the host's virtual mic source.
|
||||||
pub mic_enabled: bool,
|
pub mic_enabled: bool,
|
||||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||||
pub pin: Option<[u8; 32]>,
|
pub pin: Option<[u8; 32]>,
|
||||||
pub identity: (String, String),
|
pub identity: (String, String),
|
||||||
|
/// How long to wait for the handshake. The normal path uses a short budget; the
|
||||||
|
/// "request access" (delegated-approval) path uses a long one, because the host PARKS the
|
||||||
|
/// connection until the operator clicks Approve in its console (so this must exceed the
|
||||||
|
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
|
||||||
|
pub connect_timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default)]
|
#[derive(Clone, Copy, Default)]
|
||||||
@@ -83,6 +90,42 @@ fn now_ns() -> u64 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opus decoder for the audio plane: a plain stereo decoder (the validated path) or a multistream
|
||||||
|
/// decoder for 5.1/7.1, both behind one `decode_float`. Built from the host-RESOLVED channel count
|
||||||
|
/// via the shared layout table.
|
||||||
|
enum AudioDec {
|
||||||
|
Stereo(opus::Decoder),
|
||||||
|
Surround(opus::MSDecoder),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioDec {
|
||||||
|
fn new(channels: u8) -> Result<AudioDec, opus::Error> {
|
||||||
|
if channels == 2 {
|
||||||
|
Ok(AudioDec::Stereo(opus::Decoder::new(
|
||||||
|
48_000,
|
||||||
|
opus::Channels::Stereo,
|
||||||
|
)?))
|
||||||
|
} else {
|
||||||
|
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||||
|
Ok(AudioDec::Surround(opus::MSDecoder::new(
|
||||||
|
48_000, l.streams, l.coupled, l.mapping,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_float(
|
||||||
|
&mut self,
|
||||||
|
input: &[u8],
|
||||||
|
out: &mut [f32],
|
||||||
|
fec: bool,
|
||||||
|
) -> Result<usize, opus::Error> {
|
||||||
|
match self {
|
||||||
|
AudioDec::Stereo(d) => d.decode_float(input, out, fec),
|
||||||
|
AudioDec::Surround(d) => d.decode_float(input, out, fec),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn pump(
|
fn pump(
|
||||||
params: SessionParams,
|
params: SessionParams,
|
||||||
ev_tx: async_channel::Sender<SessionEvent>,
|
ev_tx: async_channel::Sender<SessionEvent>,
|
||||||
@@ -96,11 +139,12 @@ fn pump(
|
|||||||
params.compositor,
|
params.compositor,
|
||||||
params.gamepad,
|
params.gamepad,
|
||||||
params.bitrate_kbps,
|
params.bitrate_kbps,
|
||||||
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
||||||
|
params.audio_channels,
|
||||||
None, // launch: the Linux client has no library picker yet
|
None, // launch: the Linux client has no library picker yet
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
Duration::from_secs(15),
|
params.connect_timeout,
|
||||||
) {
|
) {
|
||||||
Ok(c) => Arc::new(c),
|
Ok(c) => Arc::new(c),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -134,11 +178,14 @@ fn pump(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Audio is best-effort: a session without it still streams. Gamepads are the
|
// Audio is best-effort: a session without it still streams. Gamepads are the
|
||||||
// app-lifetime service's job (the UI attaches it on Connected).
|
// app-lifetime service's job (the UI attaches it on Connected). Build the decoder + playback
|
||||||
let player = audio::AudioPlayer::spawn()
|
// from the host-RESOLVED channel count (never the request), so an older/clamping host that
|
||||||
|
// resolves stereo is decoded as stereo.
|
||||||
|
let channels = connector.audio_channels;
|
||||||
|
let player = audio::AudioPlayer::spawn(channels as u32)
|
||||||
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
|
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
|
||||||
.ok();
|
.ok();
|
||||||
let mut opus_dec = opus::Decoder::new(48_000, opus::Channels::Stereo)
|
let mut opus_dec = AudioDec::new(channels)
|
||||||
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
|
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
|
||||||
.ok();
|
.ok();
|
||||||
let _mic = params
|
let _mic = params
|
||||||
@@ -157,8 +204,8 @@ fn pump(
|
|||||||
let mut bytes_n = 0u64;
|
let mut bytes_n = 0u64;
|
||||||
let mut decode_us_sum = 0u64;
|
let mut decode_us_sum = 0u64;
|
||||||
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
|
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
|
||||||
let mut pcm = vec![0f32; 5760 * 2]; // decode scratch: max Opus frame (120 ms stereo)
|
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
|
||||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
||||||
let mut last_dropped = connector.frames_dropped();
|
let mut last_dropped = connector.frames_dropped();
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
let mut last_kf_req: Option<Instant> = None;
|
||||||
|
|
||||||
@@ -221,7 +268,8 @@ fn pump(
|
|||||||
while let Ok(pkt) = connector.next_audio(Duration::ZERO) {
|
while let Ok(pkt) = connector.next_audio(Duration::ZERO) {
|
||||||
if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) {
|
if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) {
|
||||||
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||||
Ok(samples) => player.push(pcm[..samples * 2].to_vec()),
|
// `samples` is per-channel; the interleaved frame is `samples * channels`.
|
||||||
|
Ok(samples) => player.push(pcm[..samples * channels as usize].to_vec()),
|
||||||
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ impl KnownHosts {
|
|||||||
self.hosts.iter().find(|h| h.addr == addr && h.port == port)
|
self.hosts.iter().find(|h| h.addr == addr && h.port == port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forget the entry with this fingerprint. Returns true if one was removed (the user
|
||||||
|
/// will have to pair/trust again to reconnect).
|
||||||
|
pub fn remove_by_fp(&mut self, fp_hex: &str) -> bool {
|
||||||
|
let before = self.hosts.len();
|
||||||
|
self.hosts.retain(|h| h.fp_hex != fp_hex);
|
||||||
|
self.hosts.len() != before
|
||||||
|
}
|
||||||
|
|
||||||
/// Insert or refresh an entry, keyed by fingerprint. `paired` only ever upgrades
|
/// Insert or refresh an entry, keyed by fingerprint. `paired` only ever upgrades
|
||||||
/// (a later TOFU connect must not demote a PIN-paired host).
|
/// (a later TOFU connect must not demote a PIN-paired host).
|
||||||
pub fn upsert(&mut self, entry: KnownHost) {
|
pub fn upsert(&mut self, entry: KnownHost) {
|
||||||
@@ -124,6 +132,9 @@ pub struct Settings {
|
|||||||
pub inhibit_shortcuts: bool,
|
pub inhibit_shortcuts: bool,
|
||||||
/// Stream the default microphone to the host's virtual mic source.
|
/// Stream the default microphone to the host's virtual mic source.
|
||||||
pub mic_enabled: bool,
|
pub mic_enabled: bool,
|
||||||
|
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||||
|
/// can capture; the resolved count drives the decoder + playback layout.
|
||||||
|
pub audio_channels: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
@@ -137,6 +148,7 @@ impl Default for Settings {
|
|||||||
compositor: "auto".into(),
|
compositor: "auto".into(),
|
||||||
inhibit_shortcuts: true,
|
inhibit_shortcuts: true,
|
||||||
mic_enabled: false,
|
mic_enabled: false,
|
||||||
|
audio_channels: 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,52 @@ pub fn new(
|
|||||||
// pinned connect; TOFU eligibility is irrelevant.
|
// pinned connect; TOFU eligibility is irrelevant.
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
};
|
};
|
||||||
|
// Forget this host (drops the pinned fingerprint — a later connect re-pairs).
|
||||||
|
// Confirmed first, since it's destructive and a misclick on the Deck is easy.
|
||||||
|
let remove_btn = gtk::Button::from_icon_name("user-trash-symbolic");
|
||||||
|
remove_btn.set_tooltip_text(Some("Remove saved host"));
|
||||||
|
remove_btn.set_valign(gtk::Align::Center);
|
||||||
|
remove_btn.add_css_class("flat");
|
||||||
|
{
|
||||||
|
let fp = k.fp_hex.clone();
|
||||||
|
let name = k.name.clone();
|
||||||
|
let saved_list = saved_list.clone();
|
||||||
|
let saved_label = saved_label.clone();
|
||||||
|
let row = row.clone();
|
||||||
|
remove_btn.connect_clicked(move |_| {
|
||||||
|
let dialog = adw::AlertDialog::new(
|
||||||
|
Some("Remove saved host?"),
|
||||||
|
Some(&format!(
|
||||||
|
"Forget “{name}”? You'll need to pair (or trust) it again to reconnect."
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
dialog.add_responses(&[("cancel", "Cancel"), ("remove", "Remove")]);
|
||||||
|
dialog.set_response_appearance(
|
||||||
|
"remove",
|
||||||
|
adw::ResponseAppearance::Destructive,
|
||||||
|
);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
{
|
||||||
|
// Scoped clones for the response handler so `row` survives for present().
|
||||||
|
let fp = fp.clone();
|
||||||
|
let saved_list = saved_list.clone();
|
||||||
|
let saved_label = saved_label.clone();
|
||||||
|
let row = row.clone();
|
||||||
|
dialog.connect_response(Some("remove"), move |_, _| {
|
||||||
|
let mut known = KnownHosts::load();
|
||||||
|
known.remove_by_fp(&fp);
|
||||||
|
let _ = known.save();
|
||||||
|
saved_list.remove(&row);
|
||||||
|
let empty = known.hosts.is_empty();
|
||||||
|
saved_list.set_visible(!empty);
|
||||||
|
saved_label.set_visible(!empty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dialog.present(Some(&row));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
row.add_suffix(&remove_btn);
|
||||||
let speed_btn = gtk::Button::from_icon_name("network-transmit-receive-symbolic");
|
let speed_btn = gtk::Button::from_icon_name("network-transmit-receive-symbolic");
|
||||||
speed_btn.set_tooltip_text(Some("Test network speed"));
|
speed_btn.set_tooltip_text(Some("Test network speed"));
|
||||||
speed_btn.set_valign(gtk::Align::Center);
|
speed_btn.set_valign(gtk::Align::Center);
|
||||||
|
|||||||
@@ -19,6 +19,49 @@ 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"];
|
||||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||||
|
|
||||||
|
/// 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",
|
||||||
|
"================================ MIT ================================\n\n",
|
||||||
|
include_str!("../../../LICENSE-MIT"),
|
||||||
|
"\n\n=============================== Apache-2.0 ===============================\n\n",
|
||||||
|
include_str!("../../../LICENSE-APACHE"),
|
||||||
|
);
|
||||||
|
/// Third-party software notices for the linked Rust crates (generated by
|
||||||
|
/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog).
|
||||||
|
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
|
||||||
|
|
||||||
|
/// Show the About dialog (app license + the third-party-software Legal section).
|
||||||
|
fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||||||
|
let about = adw::AboutDialog::builder()
|
||||||
|
.application_name("punktfunk")
|
||||||
|
.developer_name("unom")
|
||||||
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
|
.website("https://git.unom.io/unom/punktfunk")
|
||||||
|
.license_type(gtk::License::Custom)
|
||||||
|
.license(APP_LICENSE)
|
||||||
|
.build();
|
||||||
|
// The native (FFmpeg/GTK/PipeWire/SDL3) components are dynamically linked under their own
|
||||||
|
// (LGPL/Zlib/MIT) licenses; the Rust crate notices are the substantive attribution set.
|
||||||
|
about.add_legal_section(
|
||||||
|
"Third-party software (Rust crates)",
|
||||||
|
None,
|
||||||
|
gtk::License::Custom,
|
||||||
|
Some(THIRD_PARTY_NOTICES),
|
||||||
|
);
|
||||||
|
about.add_legal_section(
|
||||||
|
"Third-party software (system libraries)",
|
||||||
|
None,
|
||||||
|
gtk::License::Custom,
|
||||||
|
Some(
|
||||||
|
"This application dynamically links system libraries under their own licenses, \
|
||||||
|
including FFmpeg (LGPL v2.1+), GTK 4 and libadwaita (LGPL v2.1+), PipeWire (MIT), \
|
||||||
|
and SDL 3 (Zlib). Their full license texts are available from each project.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
about.present(Some(parent));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn show(
|
pub fn show(
|
||||||
parent: &impl IsA<gtk::Widget>,
|
parent: &impl IsA<gtk::Widget>,
|
||||||
settings: Rc<RefCell<Settings>>,
|
settings: Rc<RefCell<Settings>>,
|
||||||
@@ -140,15 +183,39 @@ pub fn show(
|
|||||||
input.add(&inhibit_row);
|
input.add(&inhibit_row);
|
||||||
|
|
||||||
let audio = adw::PreferencesGroup::builder().title("Audio").build();
|
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 mic_row = adw::SwitchRow::builder()
|
let mic_row = adw::SwitchRow::builder()
|
||||||
.title("Stream microphone")
|
.title("Stream microphone")
|
||||||
.subtitle("Send the default input device to the host's virtual microphone")
|
.subtitle("Send the default input device to the host's virtual microphone")
|
||||||
.build();
|
.build();
|
||||||
audio.add(&mic_row);
|
audio.add(&mic_row);
|
||||||
|
|
||||||
|
let about = adw::PreferencesGroup::builder().title("About").build();
|
||||||
|
let licenses_row = adw::ActionRow::builder()
|
||||||
|
.title("Third-party licenses")
|
||||||
|
.subtitle("Open-source software used by punktfunk")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
licenses_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
{
|
||||||
|
let about_parent: gtk::Widget = parent.clone().upcast();
|
||||||
|
licenses_row.connect_activated(move |_| show_about(&about_parent));
|
||||||
|
}
|
||||||
|
about.add(&licenses_row);
|
||||||
|
|
||||||
page.add(&stream);
|
page.add(&stream);
|
||||||
page.add(&input);
|
page.add(&input);
|
||||||
page.add(&audio);
|
page.add(&audio);
|
||||||
|
page.add(&about);
|
||||||
|
|
||||||
// Seed from the current settings.
|
// Seed from the current settings.
|
||||||
{
|
{
|
||||||
@@ -170,6 +237,11 @@ pub fn show(
|
|||||||
compositor_row.set_selected(comp_i as u32);
|
compositor_row.set_selected(comp_i as u32);
|
||||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||||
mic_row.set_active(s.mic_enabled);
|
mic_row.set_active(s.mic_enabled);
|
||||||
|
surround_row.set_selected(match s.audio_channels {
|
||||||
|
6 => 1,
|
||||||
|
8 => 2,
|
||||||
|
_ => 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let dialog = adw::PreferencesDialog::new();
|
let dialog = adw::PreferencesDialog::new();
|
||||||
@@ -186,6 +258,11 @@ pub fn show(
|
|||||||
.to_string();
|
.to_string();
|
||||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||||
s.mic_enabled = mic_row.is_active();
|
s.mic_enabled = mic_row.is_active();
|
||||||
|
s.audio_channels = match surround_row.selected() {
|
||||||
|
1 => 6,
|
||||||
|
2 => 8,
|
||||||
|
_ => 2,
|
||||||
|
};
|
||||||
s.save();
|
s.save();
|
||||||
});
|
});
|
||||||
dialog.present(Some(parent));
|
dialog.present(Some(parent));
|
||||||
|
|||||||
@@ -124,12 +124,13 @@ impl Capture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
window: &adw::ApplicationWindow,
|
window: &adw::ApplicationWindow,
|
||||||
connector: Arc<NativeClient>,
|
connector: Arc<NativeClient>,
|
||||||
frames: async_channel::Receiver<DecodedFrame>,
|
frames: async_channel::Receiver<DecodedFrame>,
|
||||||
escape_rx: async_channel::Receiver<()>,
|
escape_rx: async_channel::Receiver<()>,
|
||||||
|
disconnect_rx: async_channel::Receiver<()>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
inhibit_shortcuts: bool,
|
inhibit_shortcuts: bool,
|
||||||
title: &str,
|
title: &str,
|
||||||
@@ -152,7 +153,7 @@ pub fn new(
|
|||||||
stats_label.set_margin_top(12);
|
stats_label.set_margin_top(12);
|
||||||
|
|
||||||
let hint = gtk::Label::new(Some(
|
let hint = gtk::Label::new(Some(
|
||||||
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases",
|
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects",
|
||||||
));
|
));
|
||||||
hint.add_css_class("osd");
|
hint.add_css_class("osd");
|
||||||
hint.set_halign(gtk::Align::Center);
|
hint.set_halign(gtk::Align::Center);
|
||||||
@@ -163,7 +164,9 @@ pub fn new(
|
|||||||
// Flashed when entering fullscreen — the only exit affordances once the header bar is
|
// Flashed when entering fullscreen — the only exit affordances once the header bar is
|
||||||
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the
|
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the
|
||||||
// only way out on a Steam Deck).
|
// only way out on a Steam Deck).
|
||||||
let fs_hint = gtk::Label::new(Some("F11 · L1 + R1 + Start + Select — exit fullscreen"));
|
let fs_hint = gtk::Label::new(Some(
|
||||||
|
"F11 · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)",
|
||||||
|
));
|
||||||
fs_hint.add_css_class("osd");
|
fs_hint.add_css_class("osd");
|
||||||
fs_hint.set_halign(gtk::Align::Center);
|
fs_hint.set_halign(gtk::Align::Center);
|
||||||
fs_hint.set_valign(gtk::Align::Start);
|
fs_hint.set_valign(gtk::Align::Start);
|
||||||
@@ -297,6 +300,7 @@ pub fn new(
|
|||||||
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||||
let cap = capture.clone();
|
let cap = capture.clone();
|
||||||
let window_k = window.clone();
|
let window_k = window.clone();
|
||||||
|
let stop_kb = stop.clone();
|
||||||
key.connect_key_pressed(move |_, keyval, keycode, state| {
|
key.connect_key_pressed(move |_, keyval, keycode, state| {
|
||||||
let chord = gdk::ModifierType::CONTROL_MASK
|
let chord = gdk::ModifierType::CONTROL_MASK
|
||||||
| gdk::ModifierType::ALT_MASK
|
| gdk::ModifierType::ALT_MASK
|
||||||
@@ -309,6 +313,13 @@ pub fn new(
|
|||||||
}
|
}
|
||||||
return glib::Propagation::Stop;
|
return glib::Propagation::Stop;
|
||||||
}
|
}
|
||||||
|
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
|
||||||
|
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit.
|
||||||
|
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
|
||||||
|
cap.release();
|
||||||
|
stop_kb.store(true, Ordering::SeqCst);
|
||||||
|
return glib::Propagation::Stop;
|
||||||
|
}
|
||||||
if keyval == gdk::Key::F11 {
|
if keyval == gdk::Key::F11 {
|
||||||
if window_k.is_fullscreen() {
|
if window_k.is_fullscreen() {
|
||||||
window_k.unfullscreen();
|
window_k.unfullscreen();
|
||||||
@@ -442,6 +453,24 @@ pub fn new(
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Controller disconnect (escape chord held past the hold threshold) → end the session, the
|
||||||
|
// controller equivalent of Ctrl+Alt+Shift+D. Setting `stop` ends the session pump, which pops
|
||||||
|
// this page (and fires `hidden` below). One-shot — the session is going away.
|
||||||
|
let disconnect_future = {
|
||||||
|
let window = window.clone();
|
||||||
|
let cap = capture.clone();
|
||||||
|
let stop_d = stop.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
if disconnect_rx.recv().await.is_ok() {
|
||||||
|
cap.release();
|
||||||
|
if window.is_fullscreen() {
|
||||||
|
window.unfullscreen();
|
||||||
|
}
|
||||||
|
stop_d.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// The page's `hidden` fires once navigation away completes (back button, pop on
|
// The page's `hidden` fires once navigation away completes (back button, pop on
|
||||||
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
|
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
|
||||||
{
|
{
|
||||||
@@ -449,6 +478,7 @@ pub fn new(
|
|||||||
let stop_h = stop.clone();
|
let stop_h = stop.clone();
|
||||||
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
||||||
let escape_future = RefCell::new(Some(escape_future));
|
let escape_future = RefCell::new(Some(escape_future));
|
||||||
|
let disconnect_future = RefCell::new(Some(disconnect_future));
|
||||||
page.connect_hidden(move |_| {
|
page.connect_hidden(move |_| {
|
||||||
tracing::debug!("stream page hidden — ending session");
|
tracing::debug!("stream page hidden — ending session");
|
||||||
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
||||||
@@ -458,6 +488,9 @@ pub fn new(
|
|||||||
if let Some(f) = escape_future.borrow_mut().take() {
|
if let Some(f) = escape_future.borrow_mut().take() {
|
||||||
f.abort();
|
f.abort();
|
||||||
}
|
}
|
||||||
|
if let Some(f) = disconnect_future.borrow_mut().take() {
|
||||||
|
f.abort();
|
||||||
|
}
|
||||||
if window.is_fullscreen() {
|
if window.is_fullscreen() {
|
||||||
window.unfullscreen();
|
window.unfullscreen();
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+123
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Capture host-free UI screenshots of the native Linux client under a virtual X
|
||||||
|
# display. Mirrors the iOS harness (clients/apple/tools/screenshots.sh): one app
|
||||||
|
# launch per scene (PUNKTFUNK_SHOT_SCENE), the app renders a mock-populated REAL
|
||||||
|
# view and prints `PF_SHOT_READY`, then we grab the X root window. No host, GPU, or
|
||||||
|
# live stream — only the chrome scenes (the stream page needs a live connector).
|
||||||
|
#
|
||||||
|
# cargo build --release -p punktfunk-client-linux
|
||||||
|
# bash clients/linux/tools/screenshots.sh # → clients/linux/screenshots/<scene>.png
|
||||||
|
# bash clients/linux/tools/screenshots.sh hosts pair # a subset
|
||||||
|
#
|
||||||
|
# Env knobs: BIN (client binary), OUT (output dir), GEOMETRY (Xvfb WxHxDepth),
|
||||||
|
# SETTLE (extra seconds after PF_SHOT_READY), SHOT_DISPLAY (X display), GSK_RENDERER
|
||||||
|
# (gl|ngl|cairo — gl/llvmpipe by default for full libadwaita fidelity).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # clients/linux
|
||||||
|
BIN="${BIN:-$here/../../target/release/punktfunk-client}"
|
||||||
|
OUT="${OUT:-$here/screenshots}"
|
||||||
|
# The client window maps at its 1100x720 default; with no WM under Xvfb it lands at the
|
||||||
|
# top-left, so keep the root just larger so the full window (incl. its CSD shadow) is
|
||||||
|
# captured by a root grab with only a thin margin to crop.
|
||||||
|
GEOMETRY="${GEOMETRY:-1280x800x24}"
|
||||||
|
SETTLE="${SETTLE:-1.2}"
|
||||||
|
SHOT_DISPLAY="${SHOT_DISPLAY:-:99}"
|
||||||
|
|
||||||
|
if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair); fi
|
||||||
|
|
||||||
|
[ -x "$BIN" ] || {
|
||||||
|
echo "client binary not found: $BIN (build it first: cargo build --release -p punktfunk-client-linux)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Isolated scratch HOME: the client generates its identity here on first run, and the
|
||||||
|
# saved-hosts grid is read from client-known-hosts.json, so seed mock hosts for the
|
||||||
|
# `hosts` scene (the dialogs/settings build their own mock state in-app).
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
export HOME="$WORK"
|
||||||
|
mkdir -p "$HOME/.config/punktfunk"
|
||||||
|
cat >"$HOME/.config/punktfunk/client-known-hosts.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"hosts": [
|
||||||
|
{ "name": "Living Room PC", "addr": "192.168.1.42", "port": 9777,
|
||||||
|
"fp_hex": "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||||
|
"paired": true },
|
||||||
|
{ "name": "Office", "addr": "192.168.1.50", "port": 9777,
|
||||||
|
"fp_hex": "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
|
||||||
|
"paired": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
# Software-rendered X session — no GPU/Wayland. GL/llvmpipe runs the real NGL renderer
|
||||||
|
# (cairo is documented-incomplete for 3D-transformed content / libadwaita transitions).
|
||||||
|
unset WAYLAND_DISPLAY
|
||||||
|
export DISPLAY="$SHOT_DISPLAY"
|
||||||
|
export GDK_BACKEND=x11
|
||||||
|
export LIBGL_ALWAYS_SOFTWARE=1
|
||||||
|
export GALLIUM_DRIVER="${GALLIUM_DRIVER:-llvmpipe}"
|
||||||
|
export GSK_RENDERER="${GSK_RENDERER:-gl}"
|
||||||
|
|
||||||
|
Xvfb "$SHOT_DISPLAY" -screen 0 "$GEOMETRY" -nolisten tcp >"$WORK/xvfb.log" 2>&1 &
|
||||||
|
XVFB_PID=$!
|
||||||
|
cleanup() {
|
||||||
|
kill "$XVFB_PID" 2>/dev/null || true
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Wait for the display to accept connections.
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
if command -v xdpyinfo >/dev/null 2>&1; then
|
||||||
|
xdpyinfo -display "$SHOT_DISPLAY" >/dev/null 2>&1 && break
|
||||||
|
else
|
||||||
|
[ -e "/tmp/.X11-unix/X${SHOT_DISPLAY#:}" ] && break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
capture() {
|
||||||
|
local out="$1"
|
||||||
|
if command -v import >/dev/null 2>&1; then
|
||||||
|
import -silent -window root "$out"
|
||||||
|
elif command -v scrot >/dev/null 2>&1; then
|
||||||
|
scrot -o "$out"
|
||||||
|
else
|
||||||
|
echo "no screenshot tool — install imagemagick or scrot" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
rc=0
|
||||||
|
for scene in "${SCENES[@]}"; do
|
||||||
|
: >"$WORK/log"
|
||||||
|
PUNKTFUNK_SHOT_SCENE="$scene" "$BIN" >"$WORK/log" 2>&1 &
|
||||||
|
pid=$!
|
||||||
|
ready=0
|
||||||
|
for _ in $(seq 1 200); do # up to ~20s
|
||||||
|
if grep -q "PF_SHOT_READY" "$WORK/log"; then
|
||||||
|
ready=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then break; fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
if [ "$ready" = 1 ]; then
|
||||||
|
sleep "$SETTLE"
|
||||||
|
if capture "$OUT/$scene.png"; then
|
||||||
|
echo "✓ $scene → $OUT/$scene.png"
|
||||||
|
else
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✗ $scene: client never signalled PF_SHOT_READY" >&2
|
||||||
|
sed 's/^/ /' "$WORK/log" >&2 || true
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
wait "$pid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
exit "$rc"
|
||||||
@@ -18,8 +18,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
# LAN host discovery (`--discover`): browse the native `_punktfunk._udp` mDNS service the host
|
# LAN host discovery (`--discover`): browse the native `_punktfunk._udp` mDNS service the host
|
||||||
# advertises (same crate/version the host advertises with).
|
# advertises (same crate/version the host advertises with).
|
||||||
mdns-sd = "0.20"
|
mdns-sd = "0.20"
|
||||||
|
# Opus: multistream DECODE of the host's audio plane (the surround validator) + `--mic-test`'s
|
||||||
# Linux-only: --mic-test's Opus encoder (libopus). The mic UPLINK itself is portable —
|
# encoder. libopus is already in the graph via `punktfunk-core`'s quic feature; this exposes the
|
||||||
# only this synthetic-tone test rig needs the encoder.
|
# name directly. Cross-platform (cmake-vendored), so the probe builds + validates everywhere.
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
|
||||||
opus = "0.3"
|
opus = "0.3"
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ struct Args {
|
|||||||
gamepad: GamepadPref,
|
gamepad: GamepadPref,
|
||||||
/// `--bitrate KBPS` — request this encoder bitrate (kilobits/s); 0 = host default.
|
/// `--bitrate KBPS` — request this encoder bitrate (kilobits/s); 0 = host default.
|
||||||
bitrate_kbps: u32,
|
bitrate_kbps: u32,
|
||||||
|
/// `--audio-channels N` — request stereo (2), 5.1 (6) or 7.1 (8) audio; default 2. The probe
|
||||||
|
/// multistream-decodes the host's frames and asserts the per-channel sample count, so it's the
|
||||||
|
/// headless validator for the surround encode path.
|
||||||
|
audio_channels: u8,
|
||||||
/// `--launch ID` — ask the host to launch a library title in this session (a store-qualified
|
/// `--launch ID` — ask the host to launch a library title in this session (a store-qualified
|
||||||
/// id from the host's `GET /api/v1/library`, e.g. `steam:570`). Host resolves it; `None` = none.
|
/// id from the host's `GET /api/v1/library`, e.g. `steam:570`). Host resolves it; `None` = none.
|
||||||
launch: Option<String>,
|
launch: Option<String>,
|
||||||
@@ -201,6 +205,11 @@ fn parse_args() -> Args {
|
|||||||
compositor,
|
compositor,
|
||||||
gamepad,
|
gamepad,
|
||||||
bitrate_kbps: get("--bitrate").and_then(|s| s.parse().ok()).unwrap_or(0),
|
bitrate_kbps: get("--bitrate").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||||
|
audio_channels: punktfunk_core::audio::normalize_channels(
|
||||||
|
get("--audio-channels")
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(2),
|
||||||
|
),
|
||||||
launch: get("--launch").map(str::to_string),
|
launch: get("--launch").map(str::to_string),
|
||||||
speed_test: get("--speed-test").and_then(|s| {
|
speed_test: get("--speed-test").and_then(|s| {
|
||||||
let (kbps, ms) = s.split_once(':')?;
|
let (kbps, ms) = s.split_once(':')?;
|
||||||
@@ -385,13 +394,23 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
// `--launch ID` — host resolves it against its own library and runs it this session.
|
// `--launch ID` — host resolves it against its own library and runs it this session.
|
||||||
launch: args.launch.clone(),
|
launch: args.launch.clone(),
|
||||||
// This headless tool just dumps the bitstream (no decode), so it can always claim
|
// This headless tool just dumps the bitstream (no decode), so it can always claim
|
||||||
// 10-bit support. Gated by env so latency runs stay on the 8-bit baseline:
|
// 10-bit / 4:4:4 support. Gated by env so latency runs stay on the 8-bit 4:2:0 baseline:
|
||||||
// PUNKTFUNK_CLIENT_10BIT=1 advertises VIDEO_CAP_10BIT to exercise the host Main10 path.
|
// PUNKTFUNK_CLIENT_10BIT=1 advertises VIDEO_CAP_10BIT (host Main10 path);
|
||||||
video_caps: if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
|
// PUNKTFUNK_CLIENT_444=1 advertises VIDEO_CAP_444 (host HEVC 4:4:4 path) — verify the
|
||||||
punktfunk_core::quic::VIDEO_CAP_10BIT
|
// resulting chroma with `ffprobe` on the `--out` .h265.
|
||||||
} else {
|
video_caps: {
|
||||||
0
|
let mut caps = 0u8;
|
||||||
|
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
|
||||||
|
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
|
||||||
|
}
|
||||||
|
if std::env::var_os("PUNKTFUNK_CLIENT_444").is_some() {
|
||||||
|
caps |= punktfunk_core::quic::VIDEO_CAP_444;
|
||||||
|
}
|
||||||
|
caps
|
||||||
},
|
},
|
||||||
|
// `--audio-channels` (default stereo); the probe multistream-decodes + validates the
|
||||||
|
// host's frames to exercise the surround encode path headlessly.
|
||||||
|
audio_channels: args.audio_channels,
|
||||||
}
|
}
|
||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
@@ -408,6 +427,8 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
bit_depth = welcome.bit_depth,
|
bit_depth = welcome.bit_depth,
|
||||||
color = ?welcome.color,
|
color = ?welcome.color,
|
||||||
hdr = welcome.color.is_hdr(),
|
hdr = welcome.color.is_hdr(),
|
||||||
|
chroma_444 = welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
|
||||||
|
chroma_format_idc = welcome.chroma_format,
|
||||||
"session offer"
|
"session offer"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -830,13 +851,37 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
hidout_pkts.clone(),
|
hidout_pkts.clone(),
|
||||||
);
|
);
|
||||||
let conn2 = conn.clone();
|
let conn2 = conn.clone();
|
||||||
|
// Build a multistream decoder for the host-RESOLVED layout so the probe actually decodes
|
||||||
|
// the surround stream (not just counts bytes) — the headless validator for the encode path.
|
||||||
|
let audio_channels = welcome.audio_channels;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
use std::sync::atomic::Ordering::Relaxed;
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
let mut hdr_logged = false;
|
let mut hdr_logged = false;
|
||||||
|
let layout = punktfunk_core::audio::layout_for(audio_channels, false);
|
||||||
|
let mut audio_dec =
|
||||||
|
opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping).ok();
|
||||||
|
let mut pcm = vec![0f32; 5760 * audio_channels as usize];
|
||||||
|
let mut audio_decoded_logged = false;
|
||||||
while let Ok(d) = conn2.read_datagram().await {
|
while let Ok(d) = conn2.read_datagram().await {
|
||||||
if let Some((_, _, opus)) = punktfunk_core::quic::decode_audio_datagram(&d) {
|
if let Some((_, _, opus)) = punktfunk_core::quic::decode_audio_datagram(&d) {
|
||||||
a.fetch_add(1, Relaxed);
|
a.fetch_add(1, Relaxed);
|
||||||
ab.fetch_add(opus.len() as u64, Relaxed);
|
ab.fetch_add(opus.len() as u64, Relaxed);
|
||||||
|
// Decode + validate: the per-channel sample count must be a legal Opus frame
|
||||||
|
// size; log the first success so a loopback test can assert surround decoded.
|
||||||
|
if let Some(dec) = audio_dec.as_mut() {
|
||||||
|
match dec.decode_float(opus, &mut pcm, false) {
|
||||||
|
Ok(samples) if !audio_decoded_logged => {
|
||||||
|
audio_decoded_logged = true;
|
||||||
|
tracing::info!(
|
||||||
|
channels = audio_channels,
|
||||||
|
samples_per_channel = samples,
|
||||||
|
"audio decoded (Opus multistream)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => tracing::debug!(error = %e, "probe audio decode"),
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() {
|
} else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() {
|
||||||
r.fetch_add(1, Relaxed);
|
r.fetch_add(1, Relaxed);
|
||||||
} else if let Some(meta) = punktfunk_core::quic::decode_hdr_meta_datagram(&d) {
|
} else if let Some(meta) = punktfunk_core::quic::decode_hdr_meta_datagram(&d) {
|
||||||
|
|||||||
@@ -76,11 +76,29 @@ foreach ($f in $required) {
|
|||||||
Copy-Item $src (Join-Path $layout $f) -Force
|
Copy-Item $src (Join-Path $layout $f) -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
# FFmpeg runtime DLLs (the exe link-imports the decode set; copy them all — small and correct)
|
# FFmpeg runtime DLLs (the exe link-imports the decode set; copy them all — small and correct).
|
||||||
|
# These are unmodified BtbN *lgpl-shared* builds, linked dynamically (replaceable DLLs) — FFmpeg is
|
||||||
|
# used under the LGPL v2.1+; the license text + notice ship in licenses\ below.
|
||||||
$ff = Get-ChildItem -Path $FfmpegBin -Filter *.dll -ErrorAction SilentlyContinue
|
$ff = Get-ChildItem -Path $FfmpegBin -Filter *.dll -ErrorAction SilentlyContinue
|
||||||
if (-not $ff) { throw "no FFmpeg DLLs in $FfmpegBin" }
|
if (-not $ff) { throw "no FFmpeg DLLs in $FfmpegBin" }
|
||||||
$ff | ForEach-Object { Copy-Item $_.FullName (Join-Path $layout $_.Name) -Force }
|
$ff | ForEach-Object { Copy-Item $_.FullName (Join-Path $layout $_.Name) -Force }
|
||||||
|
|
||||||
|
# license/attribution payload (MSIX has no installer EULA page, so ship them as files): FFmpeg's LGPL
|
||||||
|
# notice + license text, the project's own MIT/Apache texts, and the generated third-party notices.
|
||||||
|
$licDir = Join-Path $layout 'licenses'
|
||||||
|
New-Item -ItemType Directory -Force -Path $licDir | Out-Null
|
||||||
|
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
|
||||||
|
Copy-Item (Join-Path $repoRoot 'packaging\windows\licenses\FFmpeg-LGPL-NOTICE.txt') $licDir -Force -ErrorAction SilentlyContinue
|
||||||
|
foreach ($n in @('THIRD-PARTY-NOTICES.txt', 'LICENSE-MIT', 'LICENSE-APACHE')) {
|
||||||
|
$p = Join-Path $repoRoot $n
|
||||||
|
if (Test-Path $p) { Copy-Item $p $licDir -Force }
|
||||||
|
}
|
||||||
|
$ffRoot = Split-Path $FfmpegBin -Parent
|
||||||
|
foreach ($lic in @('LICENSE.txt', 'LICENSE', 'COPYING.LGPLv2.1', 'COPYING.LGPLv3', 'COPYING.txt')) {
|
||||||
|
$p = Join-Path $ffRoot $lic
|
||||||
|
if (Test-Path $p) { Copy-Item $p $licDir -Force }
|
||||||
|
}
|
||||||
|
|
||||||
# tile/store assets
|
# tile/store assets
|
||||||
Copy-Item (Join-Path $assets '*') (Join-Path $layout 'Assets') -Force
|
Copy-Item (Join-Path $assets '*') (Join-Path $layout 'Assets') -Force
|
||||||
|
|
||||||
|
|||||||
+333
-17
@@ -20,7 +20,9 @@ use crate::video::{DecodedFrame, DecoderPref};
|
|||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
use windows_reactor::*;
|
use windows_reactor::*;
|
||||||
|
|
||||||
const RESOLUTIONS: &[(u32, u32)] = &[
|
const RESOLUTIONS: &[(u32, u32)] = &[
|
||||||
@@ -39,13 +41,31 @@ const DECODERS: &[(&str, &str)] = &[
|
|||||||
];
|
];
|
||||||
/// Bitrate presets in Mb/s; `0` = host default.
|
/// Bitrate presets in Mb/s; `0` = host default.
|
||||||
const BITRATES_MBPS: &[u32] = &[0, 10, 20, 30, 50, 80, 150];
|
const BITRATES_MBPS: &[u32] = &[0, 10, 20, 30, 50, 80, 150];
|
||||||
|
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
||||||
|
/// capture; the resolved count drives the decoder + WASAPI render layout.
|
||||||
|
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];
|
||||||
|
|
||||||
|
/// punktfunk's own license (MIT OR Apache-2.0), shown on the Licenses screen.
|
||||||
|
const APP_LICENSE: &str = concat!(
|
||||||
|
include_str!("../../../LICENSE-MIT"),
|
||||||
|
"\n\n================================ Apache-2.0 ================================\n\n",
|
||||||
|
include_str!("../../../LICENSE-APACHE"),
|
||||||
|
);
|
||||||
|
/// Third-party software notices for the linked Rust crates (generated by
|
||||||
|
/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/).
|
||||||
|
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
enum Screen {
|
enum Screen {
|
||||||
Hosts,
|
Hosts,
|
||||||
Connecting,
|
Connecting,
|
||||||
|
/// The no-PIN "request access" wait: an identified connect is in flight, parked by the host
|
||||||
|
/// until the operator approves this device in its console. Cancelable.
|
||||||
|
RequestAccess,
|
||||||
Stream,
|
Stream,
|
||||||
Settings,
|
Settings,
|
||||||
|
/// Open-source / third-party license notices (reached from Settings).
|
||||||
|
Licenses,
|
||||||
Pair,
|
Pair,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +149,11 @@ struct Shared {
|
|||||||
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
|
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
|
||||||
/// by the stream page's HUD poll thread to drive the overlay.
|
/// by the stream page's HUD poll thread to drive the overlay.
|
||||||
stats: Mutex<Stats>,
|
stats: Mutex<Stats>,
|
||||||
|
/// Cancel flag for the in-flight "request access" connect. A FRESH flag is installed per
|
||||||
|
/// request: the waiting screen's Cancel button reads it back from here and sets it, and that
|
||||||
|
/// request's event loop (which captured the same `Arc` at spawn) then tears down silently when
|
||||||
|
/// the parked connect finally resolves. `None` outside a request-access flow.
|
||||||
|
cancel: Mutex<Option<Arc<AtomicBool>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppCtx {
|
pub struct AppCtx {
|
||||||
@@ -373,8 +398,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
.vertical_alignment(VerticalAlignment::Center)
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// request_access_page (like settings_page/Connecting) uses no hooks, so calling it inline
|
||||||
|
// is sound — it only wires a Cancel button to the shared cancel flag + navigation.
|
||||||
|
Screen::RequestAccess => request_access_page(ctx, &set_screen),
|
||||||
// settings_page uses no hooks (it never touches `cx`), so calling it inline is sound.
|
// settings_page uses no hooks (it never touches `cx`), so calling it inline is sound.
|
||||||
Screen::Settings => settings_page(ctx, &set_screen),
|
Screen::Settings => settings_page(ctx, &set_screen),
|
||||||
|
// licenses_page is a static text screen (no hooks), so inline is sound.
|
||||||
|
Screen::Licenses => licenses_page(&set_screen),
|
||||||
Screen::Pair => component(pair_page, svc),
|
Screen::Pair => component(pair_page, svc),
|
||||||
Screen::Stream => component(stream_page, StreamProps { svc, stats }),
|
Screen::Stream => component(stream_page, StreamProps { svc, stats }),
|
||||||
}
|
}
|
||||||
@@ -566,12 +596,61 @@ fn initiate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tunables that differ between the normal connect and the no-PIN "request access" flow.
|
||||||
|
/// `Default` is the normal connect: short handshake budget, persist *unpaired* on TOFU, and the
|
||||||
|
/// plain "Connecting" screen.
|
||||||
|
struct ConnectOpts {
|
||||||
|
/// Handshake budget. Request-access uses a long one because the host PARKS the connection
|
||||||
|
/// until the operator clicks Approve in its console (see the host's `PENDING_APPROVAL_WAIT`).
|
||||||
|
connect_timeout: Duration,
|
||||||
|
/// Persist the host as *paired* on a successful connect. Set for request-access, where the
|
||||||
|
/// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU
|
||||||
|
/// persists the host *unpaired* (pinned, but not PIN/approval-verified).
|
||||||
|
persist_paired: bool,
|
||||||
|
/// Show the cancelable "waiting for approval" screen instead of "Connecting" (request-access).
|
||||||
|
awaiting_approval: bool,
|
||||||
|
/// Set by the waiting screen's Cancel button. `NativeClient::connect` is blocking with no
|
||||||
|
/// abort, so Cancel returns the UI immediately and leaves the parked connect to resolve/time
|
||||||
|
/// out; this request's event loop then sees the flag and tears down silently (drops the
|
||||||
|
/// connector → closes the connection) without touching a screen a new session may already own.
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConnectOpts {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
connect_timeout: Duration::from_secs(15),
|
||||||
|
persist_paired: false,
|
||||||
|
awaiting_approval: false,
|
||||||
|
cancel: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn connect(
|
fn connect(
|
||||||
ctx: &Arc<AppCtx>,
|
ctx: &Arc<AppCtx>,
|
||||||
target: &Target,
|
target: &Target,
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
set_screen: &AsyncSetState<Screen>,
|
set_screen: &AsyncSetState<Screen>,
|
||||||
set_status: &AsyncSetState<String>,
|
set_status: &AsyncSetState<String>,
|
||||||
|
) {
|
||||||
|
connect_with(
|
||||||
|
ctx,
|
||||||
|
target,
|
||||||
|
pin,
|
||||||
|
set_screen,
|
||||||
|
set_status,
|
||||||
|
ConnectOpts::default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect_with(
|
||||||
|
ctx: &Arc<AppCtx>,
|
||||||
|
target: &Target,
|
||||||
|
pin: Option<[u8; 32]>,
|
||||||
|
set_screen: &AsyncSetState<Screen>,
|
||||||
|
set_status: &AsyncSetState<String>,
|
||||||
|
opts: ConnectOpts,
|
||||||
) {
|
) {
|
||||||
let s = ctx.settings.lock().unwrap().clone();
|
let s = ctx.settings.lock().unwrap().clone();
|
||||||
let mode = if s.width != 0 && s.refresh_hz != 0 {
|
let mode = if s.width != 0 && s.refresh_hz != 0 {
|
||||||
@@ -598,34 +677,60 @@ fn connect(
|
|||||||
compositor: CompositorPref::Auto,
|
compositor: CompositorPref::Auto,
|
||||||
gamepad: gamepad_pref,
|
gamepad: gamepad_pref,
|
||||||
bitrate_kbps: s.bitrate_kbps,
|
bitrate_kbps: s.bitrate_kbps,
|
||||||
|
audio_channels: s.audio_channels,
|
||||||
mic_enabled: s.mic_enabled,
|
mic_enabled: s.mic_enabled,
|
||||||
hdr_enabled: s.hdr_enabled,
|
hdr_enabled: s.hdr_enabled,
|
||||||
decoder: DecoderPref::from_name(&s.decoder),
|
decoder: DecoderPref::from_name(&s.decoder),
|
||||||
pin,
|
pin,
|
||||||
identity: ctx.identity.clone(),
|
identity: ctx.identity.clone(),
|
||||||
|
connect_timeout: opts.connect_timeout,
|
||||||
});
|
});
|
||||||
set_status.call(String::new());
|
set_status.call(String::new());
|
||||||
set_screen.call(Screen::Connecting);
|
set_screen.call(if opts.awaiting_approval {
|
||||||
|
Screen::RequestAccess
|
||||||
|
} else {
|
||||||
|
Screen::Connecting
|
||||||
|
});
|
||||||
|
|
||||||
let tofu = pin.is_none();
|
let tofu = pin.is_none();
|
||||||
|
let persist_paired = opts.persist_paired;
|
||||||
|
let cancel = opts.cancel;
|
||||||
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
||||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||||
let target = target.clone();
|
let target = target.clone();
|
||||||
std::thread::spawn(move || loop {
|
std::thread::spawn(move || loop {
|
||||||
match handle.events.recv_blocking() {
|
let event = match handle.events.recv_blocking() {
|
||||||
Ok(SessionEvent::Connected {
|
Ok(e) => e,
|
||||||
|
Err(_) => {
|
||||||
|
gamepad.detach();
|
||||||
|
ss.call(Screen::Hosts);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// A cancelled request-access connect that resolved late (the host approved or the park
|
||||||
|
// timed out after the user walked away): tear down silently. Cancel already returned the
|
||||||
|
// UI to the host list; dropping `event` (and with it any connector) closes the connection
|
||||||
|
// without popping a stream or a stray error over the screen a new session may own.
|
||||||
|
if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match event {
|
||||||
|
SessionEvent::Connected {
|
||||||
connector,
|
connector,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
..
|
..
|
||||||
}) => {
|
} => {
|
||||||
if tofu {
|
if persist_paired || tofu {
|
||||||
|
// Request-access: the operator approved this device, so record the host as a
|
||||||
|
// trusted PAIRED host — future connects are then silent (rule 1), exactly like
|
||||||
|
// after a PIN ceremony. A plain TOFU connect persists it *unpaired* (pinned).
|
||||||
let mut k = KnownHosts::load();
|
let mut k = KnownHosts::load();
|
||||||
k.upsert(KnownHost {
|
k.upsert(KnownHost {
|
||||||
name: target.name.clone(),
|
name: target.name.clone(),
|
||||||
addr: target.addr.clone(),
|
addr: target.addr.clone(),
|
||||||
port: target.port,
|
port: target.port,
|
||||||
fp_hex: trust::hex(&fingerprint),
|
fp_hex: trust::hex(&fingerprint),
|
||||||
paired: false,
|
paired: persist_paired,
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
}
|
}
|
||||||
@@ -634,10 +739,10 @@ fn connect(
|
|||||||
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
|
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
|
||||||
ss.call(Screen::Stream);
|
ss.call(Screen::Stream);
|
||||||
}
|
}
|
||||||
Ok(SessionEvent::Failed {
|
SessionEvent::Failed {
|
||||||
msg,
|
msg,
|
||||||
trust_rejected,
|
trust_rejected,
|
||||||
}) => {
|
} => {
|
||||||
st.call(msg);
|
st.call(msg);
|
||||||
gamepad.detach();
|
gamepad.detach();
|
||||||
if trust_rejected {
|
if trust_rejected {
|
||||||
@@ -649,22 +754,100 @@ fn connect(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(SessionEvent::Ended(err)) => {
|
SessionEvent::Ended(err) => {
|
||||||
st.call(err.unwrap_or_else(|| "Session ended".into()));
|
st.call(err.unwrap_or_else(|| "Session ended".into()));
|
||||||
gamepad.detach();
|
gamepad.detach();
|
||||||
ss.call(Screen::Hosts);
|
ss.call(Screen::Hosts);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(SessionEvent::Stats(s)) => *shared.stats.lock().unwrap() = s,
|
SessionEvent::Stats(s) => *shared.stats.lock().unwrap() = s,
|
||||||
Err(_) => {
|
|
||||||
gamepad.detach();
|
|
||||||
ss.call(Screen::Hosts);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
||||||
|
/// operator approves this device in its console (or web UI), showing a cancelable "waiting"
|
||||||
|
/// screen meanwhile. On approval the SAME connection is admitted (no reconnect) and the host is
|
||||||
|
/// saved as paired, so later connects are silent.
|
||||||
|
fn request_access(
|
||||||
|
ctx: &Arc<AppCtx>,
|
||||||
|
target: &Target,
|
||||||
|
set_screen: &AsyncSetState<Screen>,
|
||||||
|
set_status: &AsyncSetState<String>,
|
||||||
|
) {
|
||||||
|
// Pin the advertised certificate for a discovered host (defence against a host impostor while
|
||||||
|
// we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||||
|
let pin = target.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||||
|
// A fresh cancel flag per request, installed where the waiting screen's Cancel button can read
|
||||||
|
// it back; this request's event loop captures the same `Arc` (via ConnectOpts) below.
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
*ctx.shared.cancel.lock().unwrap() = Some(cancel.clone());
|
||||||
|
connect_with(
|
||||||
|
ctx,
|
||||||
|
target,
|
||||||
|
pin,
|
||||||
|
set_screen,
|
||||||
|
set_status,
|
||||||
|
ConnectOpts {
|
||||||
|
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
||||||
|
// approval still lands on this connection rather than timing the client out first.
|
||||||
|
connect_timeout: Duration::from_secs(185),
|
||||||
|
persist_paired: true,
|
||||||
|
awaiting_approval: true,
|
||||||
|
cancel: Some(cancel),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cancelable "waiting for approval" screen (request-access flow): a spinner + guidance while
|
||||||
|
/// the identified connect sits parked on the host, plus a Cancel that returns to the host list and
|
||||||
|
/// trips the shared cancel flag so the parked connect tears down silently if it resolves after the
|
||||||
|
/// user has walked away. Mirrors the inline `Connecting` screen; uses no hooks.
|
||||||
|
fn request_access_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
|
||||||
|
let target_name = ctx.shared.target.lock().unwrap().name.clone();
|
||||||
|
let headline = if target_name.is_empty() {
|
||||||
|
"Waiting for approval\u{2026}".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Waiting for {target_name} to approve\u{2026}")
|
||||||
|
};
|
||||||
|
let cancel_btn = {
|
||||||
|
let (ctx, ss) = (ctx.clone(), set_screen.clone());
|
||||||
|
button("Cancel")
|
||||||
|
.icon(SymbolGlyph::Cancel)
|
||||||
|
.on_click(move || {
|
||||||
|
// Return the UI immediately; the parked connect is blocking with no abort, so trip
|
||||||
|
// the flag this request's event loop captured — it then tears down silently when
|
||||||
|
// the connect finally resolves (see ConnectOpts::cancel).
|
||||||
|
if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() {
|
||||||
|
c.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
ss.call(Screen::Hosts);
|
||||||
|
})
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center)
|
||||||
|
};
|
||||||
|
vstack((
|
||||||
|
ProgressRing::indeterminate()
|
||||||
|
.width(48.0)
|
||||||
|
.height(48.0)
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center),
|
||||||
|
text_block(headline)
|
||||||
|
.font_size(18.0)
|
||||||
|
.semibold()
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center),
|
||||||
|
text_block(
|
||||||
|
"Approve this device in the host's console or web UI \u{2014} it connects automatically \
|
||||||
|
once you approve it. No PIN needed.",
|
||||||
|
)
|
||||||
|
.foreground(ThemeRef::SecondaryText)
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center),
|
||||||
|
cancel_btn,
|
||||||
|
))
|
||||||
|
.spacing(16.0)
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||||
let ctx = &props.ctx;
|
let ctx = &props.ctx;
|
||||||
let set_screen = &props.set_screen;
|
let set_screen = &props.set_screen;
|
||||||
@@ -724,6 +907,20 @@ fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
|||||||
.icon(SymbolGlyph::Cancel)
|
.icon(SymbolGlyph::Cancel)
|
||||||
.on_click(move || ss.call(Screen::Hosts))
|
.on_click(move || ss.call(Screen::Hosts))
|
||||||
};
|
};
|
||||||
|
// The no-PIN alternative offered alongside the PIN ceremony: open an identified connect that
|
||||||
|
// the host parks until the operator approves this device in its console (delegated approval).
|
||||||
|
let request_btn = {
|
||||||
|
let (ctx2, ss, st, target2) = (
|
||||||
|
ctx.clone(),
|
||||||
|
set_screen.clone(),
|
||||||
|
set_status.clone(),
|
||||||
|
target.clone(),
|
||||||
|
);
|
||||||
|
button("Request access without a PIN")
|
||||||
|
.icon(SymbolGlyph::Send)
|
||||||
|
.on_click(move || request_access(&ctx2, &target2, &ss, &st))
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Stretch)
|
||||||
|
};
|
||||||
|
|
||||||
let content = card(vstack((
|
let content = card(vstack((
|
||||||
grid((
|
grid((
|
||||||
@@ -756,6 +953,13 @@ fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
|||||||
.font_size(28.0)
|
.font_size(28.0)
|
||||||
.on_changed(move |s| set_code.call(s)),
|
.on_changed(move |s| set_code.call(s)),
|
||||||
hstack((pair_btn, cancel_btn)).spacing(8.0),
|
hstack((pair_btn, cancel_btn)).spacing(8.0),
|
||||||
|
text_block(
|
||||||
|
"Don\u{2019}t have a PIN? Request access instead and approve this device on the host \
|
||||||
|
(its console or web UI) \u{2014} no PIN needed.",
|
||||||
|
)
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
request_btn,
|
||||||
))
|
))
|
||||||
.spacing(16.0))
|
.spacing(16.0))
|
||||||
.max_width(480.0)
|
.max_width(480.0)
|
||||||
@@ -886,6 +1090,23 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
s.save();
|
s.save();
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
let ac_i = AUDIO_CHANNELS
|
||||||
|
.iter()
|
||||||
|
.position(|&(v, _)| v == s.audio_channels)
|
||||||
|
.unwrap_or(0) as i32;
|
||||||
|
let ac_names: Vec<String> = AUDIO_CHANNELS.iter().map(|&(_, l)| l.to_string()).collect();
|
||||||
|
let channels_combo = {
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
ComboBox::new(ac_names)
|
||||||
|
.header("Audio channels")
|
||||||
|
.selected_index(ac_i)
|
||||||
|
.on_selection_changed(move |i: i32| {
|
||||||
|
let (v, _) = AUDIO_CHANNELS[(i.max(0) as usize).min(AUDIO_CHANNELS.len() - 1)];
|
||||||
|
let mut s = ctx.settings.lock().unwrap();
|
||||||
|
s.audio_channels = v;
|
||||||
|
s.save();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let header = grid((
|
let header = grid((
|
||||||
text_block("Settings")
|
text_block("Settings")
|
||||||
@@ -934,8 +1155,32 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
.spacing(10.0),
|
.spacing(10.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
let audio_card =
|
let audio_card = card(
|
||||||
card(vstack((text_block("Audio").font_size(15.0).semibold(), mic_toggle)).spacing(10.0));
|
vstack((
|
||||||
|
text_block("Audio").font_size(15.0).semibold(),
|
||||||
|
text_block("Request stereo or surround — the host downmixes if its output has fewer.")
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
channels_combo,
|
||||||
|
mic_toggle,
|
||||||
|
))
|
||||||
|
.spacing(10.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let licenses_button = {
|
||||||
|
let ss = set_screen.clone();
|
||||||
|
button("Third-party licenses").on_click(move || ss.call(Screen::Licenses))
|
||||||
|
};
|
||||||
|
let about_card = card(
|
||||||
|
vstack((
|
||||||
|
text_block("About").font_size(15.0).semibold(),
|
||||||
|
text_block("punktfunk is licensed under MIT OR Apache-2.0.")
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
licenses_button,
|
||||||
|
))
|
||||||
|
.spacing(10.0),
|
||||||
|
);
|
||||||
|
|
||||||
page(vec![
|
page(vec![
|
||||||
header.into(),
|
header.into(),
|
||||||
@@ -945,6 +1190,77 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
video_card.into(),
|
video_card.into(),
|
||||||
section("AUDIO"),
|
section("AUDIO"),
|
||||||
audio_card.into(),
|
audio_card.into(),
|
||||||
|
section("ABOUT"),
|
||||||
|
about_card.into(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static screen: the app's own license + the third-party software notices (reached from Settings).
|
||||||
|
fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
||||||
|
let header = grid((
|
||||||
|
text_block("Third-party licenses")
|
||||||
|
.font_size(30.0)
|
||||||
|
.bold()
|
||||||
|
.grid_column(0)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
|
button("Back")
|
||||||
|
.accent()
|
||||||
|
.icon(SymbolGlyph::Back)
|
||||||
|
.on_click({
|
||||||
|
let ss = set_screen.clone();
|
||||||
|
move || ss.call(Screen::Settings)
|
||||||
|
})
|
||||||
|
.grid_column(1)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
|
))
|
||||||
|
.columns([GridLength::Star(1.0), GridLength::Auto])
|
||||||
|
.margin(edges(0.0, 0.0, 0.0, 6.0));
|
||||||
|
|
||||||
|
let app_card = card(
|
||||||
|
vstack((
|
||||||
|
text_block("punktfunk").font_size(15.0).semibold(),
|
||||||
|
text_block("Licensed under MIT OR Apache-2.0, at your option.")
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
text_block(APP_LICENSE)
|
||||||
|
.font_size(11.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(8.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let natives_card = card(
|
||||||
|
vstack((
|
||||||
|
text_block("Bundled components").font_size(15.0).semibold(),
|
||||||
|
text_block(
|
||||||
|
"FFmpeg is bundled under the LGPL v2.1+ (dynamically linked, replaceable DLLs); its \
|
||||||
|
license and notice ship in the installed licenses\\ folder. SDL 3 (Zlib) and the \
|
||||||
|
Windows App SDK (Microsoft) are also linked.",
|
||||||
|
)
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(8.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let notices_card = card(
|
||||||
|
vstack((
|
||||||
|
text_block("Rust crates").font_size(15.0).semibold(),
|
||||||
|
text_block(THIRD_PARTY_NOTICES)
|
||||||
|
.font_size(11.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(8.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
page(vec![
|
||||||
|
header.into(),
|
||||||
|
section("PUNKTFUNK"),
|
||||||
|
app_card.into(),
|
||||||
|
section("BUNDLED"),
|
||||||
|
natives_card.into(),
|
||||||
|
section("OPEN SOURCE"),
|
||||||
|
notices_card.into(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ use std::time::Duration;
|
|||||||
use wasapi::{DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat};
|
use wasapi::{DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat};
|
||||||
|
|
||||||
const SAMPLE_RATE: usize = 48_000;
|
const SAMPLE_RATE: usize = 48_000;
|
||||||
|
/// The microphone uplink stays stereo (the host's virtual mic is stereo). The render path is
|
||||||
|
/// multichannel — its channel count + block align are runtime, driven by the host-resolved layout.
|
||||||
const CHANNELS: usize = 2;
|
const CHANNELS: usize = 2;
|
||||||
/// 48 kHz stereo f32: 2 channels * 4 bytes = 8 bytes per frame.
|
|
||||||
const BLOCK_ALIGN: usize = CHANNELS * 4;
|
|
||||||
/// Mic frames are 20 ms (960 samples/channel) — any size ≤ 120 ms is fine host-side.
|
/// Mic frames are 20 ms (960 samples/channel) — any size ≤ 120 ms is fine host-side.
|
||||||
const MIC_FRAME: usize = 960;
|
const MIC_FRAME: usize = 960;
|
||||||
|
|
||||||
@@ -34,9 +34,10 @@ pub struct AudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AudioPlayer {
|
impl AudioPlayer {
|
||||||
/// Spawn the WASAPI render thread. Failure (no render endpoint on this box) is
|
/// Spawn the WASAPI render thread for `channels` (2/6/8, canonical wire order
|
||||||
/// survivable — the caller streams video-only.
|
/// FL FR FC LFE RL RR SL SR). Failure (no render endpoint on this box) is survivable — the
|
||||||
pub fn spawn() -> Result<AudioPlayer> {
|
/// caller streams video-only.
|
||||||
|
pub fn spawn(channels: u8) -> Result<AudioPlayer> {
|
||||||
// 64 × 5 ms = 320 ms of slack between the pump and the WASAPI loop.
|
// 64 × 5 ms = 320 ms of slack between the pump and the WASAPI loop.
|
||||||
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
@@ -45,14 +46,14 @@ impl AudioPlayer {
|
|||||||
let thread = std::thread::Builder::new()
|
let thread = std::thread::Builder::new()
|
||||||
.name("punktfunk-audio".into())
|
.name("punktfunk-audio".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = render_thread(pcm_rx, stop_t, ready_tx) {
|
if let Err(e) = render_thread(pcm_rx, stop_t, ready_tx, channels) {
|
||||||
tracing::warn!(error = format!("{e:#}"), "audio playback thread ended");
|
tracing::warn!(error = format!("{e:#}"), "audio playback thread ended");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.context("spawn audio thread")?;
|
.context("spawn audio thread")?;
|
||||||
match ready_rx.recv_timeout(Duration::from_secs(3)) {
|
match ready_rx.recv_timeout(Duration::from_secs(3)) {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
tracing::info!("WASAPI render: 48 kHz stereo f32 (default endpoint)");
|
tracing::info!(channels, "WASAPI render: 48 kHz f32 (default endpoint)");
|
||||||
Ok(AudioPlayer {
|
Ok(AudioPlayer {
|
||||||
pcm_tx,
|
pcm_tx,
|
||||||
stop,
|
stop,
|
||||||
@@ -66,8 +67,8 @@ impl AudioPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue one interleaved-stereo f32 chunk. Drops the chunk if the WASAPI side is wedged
|
/// Queue one interleaved f32 chunk (in the session's channel layout). Drops the chunk if the
|
||||||
/// (the renderer conceals the gap; never block the session pump).
|
/// WASAPI side is wedged (the renderer conceals the gap; never block the session pump).
|
||||||
pub fn push(&self, pcm: Vec<f32>) {
|
pub fn push(&self, pcm: Vec<f32>) {
|
||||||
if let Err(TrySendError::Disconnected(_)) = self.pcm_tx.try_send(pcm) {
|
if let Err(TrySendError::Disconnected(_)) = self.pcm_tx.try_send(pcm) {
|
||||||
// Thread already dead — Drop will reap it; nothing to do per-chunk.
|
// Thread already dead — Drop will reap it; nothing to do per-chunk.
|
||||||
@@ -88,6 +89,7 @@ fn render_thread(
|
|||||||
pcm_rx: Receiver<Vec<f32>>,
|
pcm_rx: Receiver<Vec<f32>>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
ready: SyncSender<Result<()>>,
|
ready: SyncSender<Result<()>>,
|
||||||
|
channels: u8,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if let Err(e) = wasapi::initialize_mta()
|
if let Err(e) = wasapi::initialize_mta()
|
||||||
.ok()
|
.ok()
|
||||||
@@ -97,12 +99,26 @@ fn render_thread(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let res = (|| -> Result<()> {
|
let res = (|| -> Result<()> {
|
||||||
|
// F32LE interleaved: channels × 4 bytes/sample. Stereo (channels == 2) is byte-identical
|
||||||
|
// to the old fixed path (mask 0x3, block align 8).
|
||||||
|
let block_align = channels as usize * 4;
|
||||||
let device = DeviceEnumerator::new()
|
let device = DeviceEnumerator::new()
|
||||||
.context("DeviceEnumerator")?
|
.context("DeviceEnumerator")?
|
||||||
.get_default_device(&Direction::Render)
|
.get_default_device(&Direction::Render)
|
||||||
.context("default render endpoint")?;
|
.context("default render endpoint")?;
|
||||||
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||||
let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE, CHANNELS, None);
|
// The explicit dwChannelMask is the wire order (FL FR FC LFE RL RR SL SR); 5.1 = 0x3F,
|
||||||
|
// 7.1 = 0x63F. WASAPI delivers channels in ascending mask-bit order, which equals the wire
|
||||||
|
// order, so the render mapping is the identity — no permute. `autoconvert` (below) lets the
|
||||||
|
// audio engine downmix when the endpoint has fewer speakers.
|
||||||
|
let desired = WaveFormat::new(
|
||||||
|
32,
|
||||||
|
32,
|
||||||
|
&SampleType::Float,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
channels as usize,
|
||||||
|
Some(punktfunk_core::audio::wasapi_channel_mask(channels)),
|
||||||
|
);
|
||||||
let (default_period, _min_period) =
|
let (default_period, _min_period) =
|
||||||
audio_client.get_device_period().context("device period")?;
|
audio_client.get_device_period().context("device period")?;
|
||||||
let mode = StreamMode::EventsShared {
|
let mode = StreamMode::EventsShared {
|
||||||
@@ -139,10 +155,10 @@ fn render_thread(
|
|||||||
if avail_frames == 0 {
|
if avail_frames == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let want_bytes = avail_frames * BLOCK_ALIGN;
|
let want_bytes = avail_frames * block_align;
|
||||||
|
|
||||||
// Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain.
|
// Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain.
|
||||||
let target = (3 * want_bytes).clamp(720 * BLOCK_ALIGN, 9600 * BLOCK_ALIGN);
|
let target = (3 * want_bytes).clamp(720 * block_align, 9600 * block_align);
|
||||||
while ring.len() > target.max(want_bytes) + want_bytes {
|
while ring.len() > target.max(want_bytes) + want_bytes {
|
||||||
ring.pop_front();
|
ring.pop_front();
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-27
@@ -169,6 +169,13 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
|
|||||||
Button::DPadLeft => wire::BTN_DPAD_LEFT,
|
Button::DPadLeft => wire::BTN_DPAD_LEFT,
|
||||||
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
||||||
Button::Touchpad => wire::BTN_TOUCHPAD,
|
Button::Touchpad => wire::BTN_TOUCHPAD,
|
||||||
|
// Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1–P4) + the misc/Share button.
|
||||||
|
// PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`).
|
||||||
|
Button::RightPaddle1 => wire::BTN_PADDLE1,
|
||||||
|
Button::LeftPaddle1 => wire::BTN_PADDLE2,
|
||||||
|
Button::RightPaddle2 => wire::BTN_PADDLE3,
|
||||||
|
Button::LeftPaddle2 => wire::BTN_PADDLE4,
|
||||||
|
Button::Misc1 => wire::BTN_MISC1,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -240,6 +247,9 @@ struct Worker {
|
|||||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||||
last_axis: [i32; 6],
|
last_axis: [i32; 6],
|
||||||
held_buttons: Vec<u32>,
|
held_buttons: Vec<u32>,
|
||||||
|
/// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad
|
||||||
|
/// switch / detach. surface 0 = the legacy single touchpad, 1/2 = a Steam left/right pad.
|
||||||
|
held_touches: std::collections::HashSet<(u8, u8)>,
|
||||||
last_accel: [i16; 3],
|
last_accel: [i16; 3],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,13 +262,21 @@ impl Worker {
|
|||||||
|
|
||||||
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||||
let pad = self.opened.get(&id)?;
|
let pad = self.opened.get(&id)?;
|
||||||
|
let mut pref = pref_for_type(
|
||||||
|
self.subsystem
|
||||||
|
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||||
|
);
|
||||||
|
// No SDL type for the Steam Deck / Steam Controller — detect Valve by VID/PID (Deck 0x1205,
|
||||||
|
// SC wired 0x1102, SC dongle 0x1142) so the host builds the virtual hid-steam pad.
|
||||||
|
if pad.vendor_id() == Some(0x28DE)
|
||||||
|
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
|
||||||
|
{
|
||||||
|
pref = GamepadPref::SteamDeck;
|
||||||
|
}
|
||||||
Some(PadInfo {
|
Some(PadInfo {
|
||||||
id,
|
id,
|
||||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||||
pref: pref_for_type(
|
pref,
|
||||||
self.subsystem
|
|
||||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,9 +292,33 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
*v = i32::MIN;
|
*v = i32::MIN;
|
||||||
}
|
}
|
||||||
|
for (surface, finger) in self.held_touches.drain() {
|
||||||
|
let rich = if surface == 0 {
|
||||||
|
RichInput::Touchpad {
|
||||||
|
pad: 0,
|
||||||
|
finger,
|
||||||
|
active: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RichInput::TouchpadEx {
|
||||||
|
pad: 0,
|
||||||
|
surface,
|
||||||
|
finger,
|
||||||
|
touch: false,
|
||||||
|
click: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
pressure: 0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = c.send_rich_input(rich);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.held_buttons.clear();
|
self.held_buttons.clear();
|
||||||
self.last_axis = [i32::MIN; 6];
|
self.last_axis = [i32::MIN; 6];
|
||||||
|
self.held_touches.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +334,56 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam
|
||||||
|
/// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and
|
||||||
|
/// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned).
|
||||||
|
fn forward_touch(
|
||||||
|
&mut self,
|
||||||
|
which: u32,
|
||||||
|
touchpad: u32,
|
||||||
|
finger: u8,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
active: bool,
|
||||||
|
) {
|
||||||
|
let Some(c) = self.attached.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let multi = self
|
||||||
|
.opened
|
||||||
|
.get(&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 };
|
||||||
|
let rich = if multi {
|
||||||
|
RichInput::TouchpadEx {
|
||||||
|
pad: 0,
|
||||||
|
surface,
|
||||||
|
finger,
|
||||||
|
touch: active,
|
||||||
|
click: false,
|
||||||
|
x: (cx * 65535.0 - 32768.0) as i16,
|
||||||
|
y: (cy * 65535.0 - 32768.0) as i16,
|
||||||
|
pressure: 0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RichInput::Touchpad {
|
||||||
|
pad: 0,
|
||||||
|
finger,
|
||||||
|
active,
|
||||||
|
x: (cx * 65535.0) as u16,
|
||||||
|
y: (cy * 65535.0) as u16,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = c.send_rich_input(rich);
|
||||||
|
if active {
|
||||||
|
self.held_touches.insert((surface, finger));
|
||||||
|
} else {
|
||||||
|
self.held_touches.remove(&(surface, finger));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
@@ -305,6 +397,10 @@ fn run(
|
|||||||
// thread.
|
// thread.
|
||||||
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "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.
|
||||||
|
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
|
||||||
|
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
|
||||||
let sdl = sdl3::init().map_err(|e| e.to_string())?;
|
let sdl = sdl3::init().map_err(|e| e.to_string())?;
|
||||||
let subsystem = sdl.gamepad().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())?;
|
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||||
@@ -317,6 +413,7 @@ fn run(
|
|||||||
attached: None,
|
attached: None,
|
||||||
last_axis: [i32::MIN; 6],
|
last_axis: [i32::MIN; 6],
|
||||||
held_buttons: Vec::new(),
|
held_buttons: Vec::new(),
|
||||||
|
held_touches: std::collections::HashSet::new(),
|
||||||
last_accel: [0; 3],
|
last_accel: [0; 3],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -426,9 +523,11 @@ fn run(
|
|||||||
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// DualSense touchpad → the rich-input plane, normalized 0..=65535.
|
// Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
|
||||||
|
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
|
||||||
Event::ControllerTouchpadDown {
|
Event::ControllerTouchpadDown {
|
||||||
which,
|
which,
|
||||||
|
touchpad,
|
||||||
finger,
|
finger,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@@ -436,41 +535,23 @@ fn run(
|
|||||||
}
|
}
|
||||||
| Event::ControllerTouchpadMotion {
|
| Event::ControllerTouchpadMotion {
|
||||||
which,
|
which,
|
||||||
|
touchpad,
|
||||||
finger,
|
finger,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
..
|
..
|
||||||
} if active == Some(which) && w.attached.is_some() => {
|
} if active == Some(which) && w.attached.is_some() => {
|
||||||
let _ = w
|
w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
|
||||||
.attached
|
|
||||||
.as_ref()
|
|
||||||
.unwrap()
|
|
||||||
.send_rich_input(RichInput::Touchpad {
|
|
||||||
pad: 0,
|
|
||||||
finger: finger as u8,
|
|
||||||
active: true,
|
|
||||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
|
||||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Event::ControllerTouchpadUp {
|
Event::ControllerTouchpadUp {
|
||||||
which,
|
which,
|
||||||
|
touchpad,
|
||||||
finger,
|
finger,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
..
|
..
|
||||||
} if active == Some(which) && w.attached.is_some() => {
|
} if active == Some(which) && w.attached.is_some() => {
|
||||||
let _ = w
|
w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
|
||||||
.attached
|
|
||||||
.as_ref()
|
|
||||||
.unwrap()
|
|
||||||
.send_rich_input(RichInput::Touchpad {
|
|
||||||
pad: 0,
|
|
||||||
finger: finger as u8,
|
|
||||||
active: false,
|
|
||||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
|
||||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// Motion: accel events update the cache; each gyro event ships a sample (the
|
// Motion: accel events update the cache; each gyro event ships a sample (the
|
||||||
// DualSense reports both at ~250 Hz). Scale convention shared with the other
|
// DualSense reports both at ~250 Hz). Scale convention shared with the other
|
||||||
|
|||||||
@@ -177,11 +177,16 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
|||||||
compositor: CompositorPref::Auto,
|
compositor: CompositorPref::Auto,
|
||||||
gamepad: GamepadPref::Auto,
|
gamepad: GamepadPref::Auto,
|
||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
|
// Headless CLI path (test/scripting) — stereo baseline; the GUI sources this from settings.
|
||||||
|
audio_channels: 2,
|
||||||
mic_enabled: flag("--mic"),
|
mic_enabled: flag("--mic"),
|
||||||
hdr_enabled: !flag("--no-hdr"),
|
hdr_enabled: !flag("--no-hdr"),
|
||||||
decoder,
|
decoder,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
|
// Headless CLI uses the normal (short) handshake budget; the long request-access wait is a
|
||||||
|
// GUI-only flow.
|
||||||
|
connect_timeout: Duration::from_secs(15),
|
||||||
});
|
});
|
||||||
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(60);
|
let deadline = Instant::now() + Duration::from_secs(60);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ pub struct SessionParams {
|
|||||||
pub compositor: CompositorPref,
|
pub compositor: CompositorPref,
|
||||||
pub gamepad: GamepadPref,
|
pub gamepad: GamepadPref,
|
||||||
pub bitrate_kbps: u32,
|
pub bitrate_kbps: u32,
|
||||||
|
/// Requested audio channel count (2/6/8); the host echoes the resolved value.
|
||||||
|
pub audio_channels: u8,
|
||||||
/// Stream the default microphone to the host's virtual mic source.
|
/// Stream the default microphone to the host's virtual mic source.
|
||||||
pub mic_enabled: bool,
|
pub mic_enabled: bool,
|
||||||
/// Advertise 10-bit + HDR10 so the host may upgrade HDR content to a Main10/PQ stream.
|
/// Advertise 10-bit + HDR10 so the host may upgrade HDR content to a Main10/PQ stream.
|
||||||
@@ -32,6 +34,11 @@ pub struct SessionParams {
|
|||||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||||
pub pin: Option<[u8; 32]>,
|
pub pin: Option<[u8; 32]>,
|
||||||
pub identity: (String, String),
|
pub identity: (String, String),
|
||||||
|
/// How long to wait for the handshake. The normal path uses a short budget; the
|
||||||
|
/// "request access" (delegated-approval) path uses a long one, because the host PARKS the
|
||||||
|
/// connection until the operator clicks Approve in its console (so this must exceed the
|
||||||
|
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
|
||||||
|
pub connect_timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default, PartialEq)]
|
#[derive(Clone, Copy, Default, PartialEq)]
|
||||||
@@ -94,6 +101,42 @@ fn now_ns() -> u64 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opus decoder for the audio plane: a plain stereo decoder (the validated path) or a multistream
|
||||||
|
/// decoder for 5.1/7.1, both behind one `decode_float`. Built from the host-RESOLVED channel count
|
||||||
|
/// via the shared layout table.
|
||||||
|
enum AudioDec {
|
||||||
|
Stereo(opus::Decoder),
|
||||||
|
Surround(opus::MSDecoder),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioDec {
|
||||||
|
fn new(channels: u8) -> Result<AudioDec, opus::Error> {
|
||||||
|
if channels == 2 {
|
||||||
|
Ok(AudioDec::Stereo(opus::Decoder::new(
|
||||||
|
48_000,
|
||||||
|
opus::Channels::Stereo,
|
||||||
|
)?))
|
||||||
|
} else {
|
||||||
|
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||||
|
Ok(AudioDec::Surround(opus::MSDecoder::new(
|
||||||
|
48_000, l.streams, l.coupled, l.mapping,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_float(
|
||||||
|
&mut self,
|
||||||
|
input: &[u8],
|
||||||
|
out: &mut [f32],
|
||||||
|
fec: bool,
|
||||||
|
) -> Result<usize, opus::Error> {
|
||||||
|
match self {
|
||||||
|
AudioDec::Stereo(d) => d.decode_float(input, out, fec),
|
||||||
|
AudioDec::Surround(d) => d.decode_float(input, out, fec),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn pump(
|
fn pump(
|
||||||
params: SessionParams,
|
params: SessionParams,
|
||||||
ev_tx: async_channel::Sender<SessionEvent>,
|
ev_tx: async_channel::Sender<SessionEvent>,
|
||||||
@@ -122,10 +165,11 @@ fn pump(
|
|||||||
}
|
}
|
||||||
0
|
0
|
||||||
},
|
},
|
||||||
|
params.audio_channels,
|
||||||
None, // launch: the Windows client has no library picker yet
|
None, // launch: the Windows client has no library picker yet
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
Duration::from_secs(15),
|
params.connect_timeout,
|
||||||
) {
|
) {
|
||||||
Ok(c) => Arc::new(c),
|
Ok(c) => Arc::new(c),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -161,11 +205,14 @@ fn pump(
|
|||||||
let mut hardware = decoder.is_hardware();
|
let mut hardware = decoder.is_hardware();
|
||||||
let mut hdr = false;
|
let mut hdr = false;
|
||||||
// Audio is best-effort: a session without it still streams. Gamepads are the
|
// Audio is best-effort: a session without it still streams. Gamepads are the
|
||||||
// app-lifetime service's job (the UI attaches it on Connected).
|
// app-lifetime service's job (the UI attaches it on Connected). Build the decoder + playback
|
||||||
let player = audio::AudioPlayer::spawn()
|
// from the host-RESOLVED channel count (never the request), so an older/clamping host that
|
||||||
|
// resolves stereo is decoded as stereo.
|
||||||
|
let channels = connector.audio_channels;
|
||||||
|
let player = audio::AudioPlayer::spawn(channels)
|
||||||
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
|
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
|
||||||
.ok();
|
.ok();
|
||||||
let mut opus_dec = opus::Decoder::new(48_000, opus::Channels::Stereo)
|
let mut opus_dec = AudioDec::new(channels)
|
||||||
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
|
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
|
||||||
.ok();
|
.ok();
|
||||||
let _mic = params
|
let _mic = params
|
||||||
@@ -184,8 +231,8 @@ fn pump(
|
|||||||
let mut bytes_n = 0u64;
|
let mut bytes_n = 0u64;
|
||||||
let mut decode_us_sum = 0u64;
|
let mut decode_us_sum = 0u64;
|
||||||
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
|
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
|
||||||
let mut pcm = vec![0f32; 5760 * 2]; // decode scratch: max Opus frame (120 ms stereo)
|
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
|
||||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
||||||
let mut last_dropped = connector.frames_dropped();
|
let mut last_dropped = connector.frames_dropped();
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
let mut last_kf_req: Option<Instant> = None;
|
||||||
|
|
||||||
@@ -253,7 +300,8 @@ fn pump(
|
|||||||
while let Ok(pkt) = connector.next_audio(Duration::ZERO) {
|
while let Ok(pkt) = connector.next_audio(Duration::ZERO) {
|
||||||
if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) {
|
if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) {
|
||||||
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||||
Ok(samples) => player.push(pcm[..samples * 2].to_vec()),
|
// `samples` is per-channel; the interleaved frame is `samples * channels`.
|
||||||
|
Ok(samples) => player.push(pcm[..samples * channels as usize].to_vec()),
|
||||||
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ pub struct Settings {
|
|||||||
pub inhibit_shortcuts: bool,
|
pub inhibit_shortcuts: bool,
|
||||||
/// Stream the default microphone to the host's virtual mic source.
|
/// Stream the default microphone to the host's virtual mic source.
|
||||||
pub mic_enabled: bool,
|
pub mic_enabled: bool,
|
||||||
|
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||||
|
/// can capture; the resolved count drives the decoder + WASAPI render layout.
|
||||||
|
pub audio_channels: u8,
|
||||||
/// Advertise 10-bit + HDR10 so the host upgrades HDR content to a Main10/PQ stream (the client
|
/// Advertise 10-bit + HDR10 so the host upgrades HDR content to a Main10/PQ stream (the client
|
||||||
/// presents it on a 10-bit ST.2084 swapchain). No effect on SDR content.
|
/// presents it on a 10-bit ST.2084 swapchain). No effect on SDR content.
|
||||||
pub hdr_enabled: bool,
|
pub hdr_enabled: bool,
|
||||||
@@ -148,6 +151,7 @@ impl Default for Settings {
|
|||||||
compositor: "auto".into(),
|
compositor: "auto".into(),
|
||||||
inhibit_shortcuts: true,
|
inhibit_shortcuts: true,
|
||||||
mic_enabled: false,
|
mic_enabled: false,
|
||||||
|
audio_channels: 2,
|
||||||
hdr_enabled: true,
|
hdr_enabled: true,
|
||||||
decoder: "auto".into(),
|
decoder: "auto".into(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ crate-type = ["lib", "cdylib", "staticlib"]
|
|||||||
default = []
|
default = []
|
||||||
# Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here,
|
# Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here,
|
||||||
# never on the per-frame hot path. Off by default so the core stays runtime-free.
|
# never on the per-frame hot path. Off by default so the core stays runtime-free.
|
||||||
quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac", "dep:spake2"]
|
quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac", "dep:spake2", "dep:opus"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
|
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
|
||||||
@@ -51,6 +51,12 @@ sha2 = { version = "0.10", optional = true }
|
|||||||
hmac = { version = "0.12", optional = true }
|
hmac = { version = "0.12", optional = true }
|
||||||
spake2 = { version = "0.4", optional = true }
|
spake2 = { version = "0.4", optional = true }
|
||||||
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] }
|
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] }
|
||||||
|
# In-core Opus (multistream) DECODE for the C-ABI `punktfunk_connection_next_audio_pcm` path —
|
||||||
|
# used by embedders without a multistream-capable Opus decoder (Apple's AudioToolbox is
|
||||||
|
# stereo-only). The Rust clients link `opus` themselves and decode the raw `next_audio` frames,
|
||||||
|
# so this only matters when the connection API (quic) is built. Same libopus the host vendors;
|
||||||
|
# cargo unifies the build. Multistream API: `opus::MSDecoder` (lib.rs:1187).
|
||||||
|
opus = { version = "0.3", optional = true }
|
||||||
|
|
||||||
# `libc` for batched UDP syscalls: `sendmmsg`/`recvmmsg` on Linux (the 1 Gbps+ lever) and the
|
# `libc` for batched UDP syscalls: `sendmmsg`/`recvmmsg` on Linux (the 1 Gbps+ lever) and the
|
||||||
# `recv(MSG_DONTWAIT)` drain on the other unix (Apple/BSD) targets, which have no `recvmmsg`
|
# `recv(MSG_DONTWAIT)` drain on the other unix (Apple/BSD) targets, which have no `recvmmsg`
|
||||||
|
|||||||
@@ -467,6 +467,23 @@ pub struct PunktfunkConnection {
|
|||||||
last: std::sync::Mutex<Option<crate::session::Frame>>,
|
last: std::sync::Mutex<Option<crate::session::Frame>>,
|
||||||
/// Same, for `punktfunk_connection_next_audio` (independent of the video slot).
|
/// Same, for `punktfunk_connection_next_audio` (independent of the video slot).
|
||||||
last_audio: std::sync::Mutex<Option<crate::client::AudioPacket>>,
|
last_audio: std::sync::Mutex<Option<crate::client::AudioPacket>>,
|
||||||
|
/// Decode-in-core state for `punktfunk_connection_next_audio_pcm` (Apple / any embedder
|
||||||
|
/// without a multistream Opus decoder). The decoder is built lazily from the negotiated
|
||||||
|
/// `inner.audio_channels`; `pcm` is a fixed-capacity reusable buffer the returned pointer
|
||||||
|
/// borrows until the next PCM call (same contract as `last_audio`).
|
||||||
|
audio_pcm: std::sync::Mutex<AudioPcmState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lazily-initialized in-core Opus decode state. A coupled-1-stream multistream decoder is
|
||||||
|
/// equivalent to a plain stereo decoder, so one [`opus::MSDecoder`] handles 2/6/8 channels.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AudioPcmState {
|
||||||
|
decoder: Option<opus::MSDecoder>,
|
||||||
|
/// Interleaved f32 PCM, wire channel order. Pre-sized to the largest legal Opus frame
|
||||||
|
/// (120 ms @ 48 kHz = 5760 samples/ch) × 8 channels so decode never reallocates (which would
|
||||||
|
/// dangle the pointer handed to the embedder).
|
||||||
|
pcm: Vec<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
|
/// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
|
||||||
@@ -475,6 +492,10 @@ pub const PUNKTFUNK_HIDOUT_LED: u8 = 1;
|
|||||||
pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2;
|
pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2;
|
||||||
/// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
|
/// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
|
||||||
pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
||||||
|
/// `PunktfunkHidOutput::kind` — a trackpad haptic pulse (Steam Controller voice-coils). `which` =
|
||||||
|
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
|
||||||
|
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||||
|
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
|
||||||
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||||
|
|
||||||
@@ -542,6 +563,23 @@ impl PunktfunkHidOutput {
|
|||||||
out.effect[..n].copy_from_slice(&effect[..n]);
|
out.effect[..n].copy_from_slice(&effect[..n]);
|
||||||
out.effect_len = n as u8;
|
out.effect_len = n as u8;
|
||||||
}
|
}
|
||||||
|
HidOutput::TrackpadHaptic {
|
||||||
|
pad,
|
||||||
|
side,
|
||||||
|
amplitude,
|
||||||
|
period,
|
||||||
|
count,
|
||||||
|
} => {
|
||||||
|
// No new struct (PunktfunkHidOutput has no size guard): pack into the existing
|
||||||
|
// `which` (side) + `effect[0..6]` (amplitude/period/count LE), `effect_len = 6`.
|
||||||
|
out.kind = PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC;
|
||||||
|
out.pad = *pad;
|
||||||
|
out.which = *side;
|
||||||
|
out.effect[0..2].copy_from_slice(&litude.to_le_bytes());
|
||||||
|
out.effect[2..4].copy_from_slice(&period.to_le_bytes());
|
||||||
|
out.effect[4..6].copy_from_slice(&count.to_le_bytes());
|
||||||
|
out.effect_len = 6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -601,6 +639,11 @@ impl PunktfunkHdrMeta {
|
|||||||
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
|
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
|
||||||
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
||||||
pub const PUNKTFUNK_RICH_MOTION: u8 = 2;
|
pub const PUNKTFUNK_RICH_MOTION: u8 = 2;
|
||||||
|
/// `RichInput::TouchpadEx` kind on the wire — an extended trackpad contact that identifies the
|
||||||
|
/// surface (0 single / 1 Steam-left / 2 Steam-right) and carries click + pressure. The host decodes
|
||||||
|
/// it today; *sending* it from a C client needs the size-prefixed `PunktfunkRichInputEx` +
|
||||||
|
/// `punktfunk_connection_send_rich_input2` (added with client capture).
|
||||||
|
pub const PUNKTFUNK_RICH_TOUCHPAD_EX: u8 = 3;
|
||||||
|
|
||||||
/// One rich client→host input for the host's virtual DualSense
|
/// One rich client→host input for the host's virtual DualSense
|
||||||
/// ([`punktfunk_connection_send_rich_input`]): a touchpad contact or a motion sample. Set `kind`
|
/// ([`punktfunk_connection_send_rich_input`]): a touchpad contact or a motion sample. Set `kind`
|
||||||
@@ -649,6 +692,77 @@ impl PunktfunkRichInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forward-compatible superset of [`PunktfunkRichInput`] that can also express the rich Steam
|
||||||
|
/// surfaces: a *second* trackpad (`surface`), a distinct `click` vs touch, signed coordinates, and
|
||||||
|
/// pressure. Sent via [`punktfunk_connection_send_rich_input2`] — the only way a C client can emit a
|
||||||
|
/// `TouchpadEx`. The caller MUST set `struct_size = sizeof(PunktfunkRichInputEx)` (the ABI-skew
|
||||||
|
/// guard, like [`PunktfunkConfig`]); the legacy [`PunktfunkRichInput`] +
|
||||||
|
/// [`punktfunk_connection_send_rich_input`] stay byte-for-byte for existing callers.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct PunktfunkRichInputEx {
|
||||||
|
/// MUST equal `sizeof(PunktfunkRichInputEx)`.
|
||||||
|
pub struct_size: u32,
|
||||||
|
/// One of `PUNKTFUNK_RICH_*` (`TOUCHPAD` / `MOTION` / `TOUCHPAD_EX`).
|
||||||
|
pub kind: u8,
|
||||||
|
/// Gamepad index.
|
||||||
|
pub pad: u8,
|
||||||
|
/// Touchpad/TouchpadEx: contact id.
|
||||||
|
pub finger: u8,
|
||||||
|
/// Touchpad/TouchpadEx: 1 = finger down / touching, 0 = lifted.
|
||||||
|
pub active: u8,
|
||||||
|
/// TouchpadEx: which surface — 0 = single/DualSense, 1 = Steam left pad, 2 = Steam right pad.
|
||||||
|
pub surface: u8,
|
||||||
|
/// TouchpadEx: 1 = the pad is physically clicked (depressed), distinct from a touch contact.
|
||||||
|
pub click: u8,
|
||||||
|
/// Reserved for alignment; set to 0.
|
||||||
|
pub _reserved: [u8; 2],
|
||||||
|
/// TouchpadEx: x coordinate — **signed**, centred at 0 (the real Steam report convention). For a
|
||||||
|
/// legacy `TOUCHPAD` kind sent through this struct, store the unsigned `0..=65535` value's bits.
|
||||||
|
pub x: i16,
|
||||||
|
/// TouchpadEx: y coordinate — signed, centred at 0.
|
||||||
|
pub y: i16,
|
||||||
|
/// TouchpadEx: contact pressure (`0` if the surface has no force sensor).
|
||||||
|
pub pressure: u16,
|
||||||
|
/// Motion: gyro (pitch, yaw, roll), raw signed-16.
|
||||||
|
pub gyro: [i16; 3],
|
||||||
|
/// Motion: accelerometer (x, y, z), raw signed-16.
|
||||||
|
pub accel: [i16; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
impl PunktfunkRichInputEx {
|
||||||
|
fn to_rich(self) -> Option<crate::quic::RichInput> {
|
||||||
|
use crate::quic::RichInput;
|
||||||
|
match self.kind {
|
||||||
|
PUNKTFUNK_RICH_TOUCHPAD_EX => Some(RichInput::TouchpadEx {
|
||||||
|
pad: self.pad,
|
||||||
|
surface: self.surface,
|
||||||
|
finger: self.finger,
|
||||||
|
touch: self.active != 0,
|
||||||
|
click: self.click != 0,
|
||||||
|
x: self.x,
|
||||||
|
y: self.y,
|
||||||
|
pressure: self.pressure,
|
||||||
|
}),
|
||||||
|
PUNKTFUNK_RICH_MOTION => Some(RichInput::Motion {
|
||||||
|
pad: self.pad,
|
||||||
|
gyro: self.gyro,
|
||||||
|
accel: self.accel,
|
||||||
|
}),
|
||||||
|
PUNKTFUNK_RICH_TOUCHPAD => Some(RichInput::Touchpad {
|
||||||
|
pad: self.pad,
|
||||||
|
finger: self.finger,
|
||||||
|
active: self.active != 0,
|
||||||
|
x: self.x as u16,
|
||||||
|
y: self.y as u16,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Read an optional NUL-terminated UTF-8 string parameter; `Err` = invalid pointer/UTF-8.
|
/// Read an optional NUL-terminated UTF-8 string parameter; `Err` = invalid pointer/UTF-8.
|
||||||
#[cfg(feature = "quic")]
|
#[cfg(feature = "quic")]
|
||||||
unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result<Option<&'a str>, ()> {
|
unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result<Option<&'a str>, ()> {
|
||||||
@@ -697,6 +811,22 @@ pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
|
|||||||
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
||||||
/// hosts); otherwise the host falls back to X-Box 360.
|
/// hosts); otherwise the host falls back to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
||||||
|
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro,
|
||||||
|
/// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands.
|
||||||
|
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
|
||||||
|
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the
|
||||||
|
/// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when
|
||||||
|
/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
|
||||||
|
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
||||||
|
|
||||||
|
/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
|
||||||
|
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's
|
||||||
|
/// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
|
||||||
|
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE1: u32 = 0x0001_0000;
|
||||||
|
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE2: u32 = 0x0002_0000;
|
||||||
|
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE3: u32 = 0x0004_0000;
|
||||||
|
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE4: u32 = 0x0008_0000;
|
||||||
|
pub const PUNKTFUNK_GAMEPAD_BTN_MISC1: u32 = 0x0020_0000;
|
||||||
|
|
||||||
/// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
|
/// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
|
||||||
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
|
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
|
||||||
@@ -708,22 +838,45 @@ pub const PUNKTFUNK_VIDEO_CAP_10BIT: u8 = 0x01;
|
|||||||
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can present
|
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can present
|
||||||
/// BT.2020 PQ HDR10 (implies 10-bit). (Mirrors `quic::VIDEO_CAP_HDR`.)
|
/// BT.2020 PQ HDR10 (implies 10-bit). (Mirrors `quic::VIDEO_CAP_HDR`.)
|
||||||
pub const PUNKTFUNK_VIDEO_CAP_HDR: u8 = 0x02;
|
pub const PUNKTFUNK_VIDEO_CAP_HDR: u8 = 0x02;
|
||||||
|
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can decode a
|
||||||
|
/// full-chroma 4:4:4 HEVC stream (Range Extensions). The host emits 4:4:4 only when this is set,
|
||||||
|
/// the host opted in, the codec is HEVC, and the GPU supports it — else the stream stays 4:2:0 and
|
||||||
|
/// [`punktfunk_connection_chroma_format`] reports the real value. (Mirrors `quic::VIDEO_CAP_444`.)
|
||||||
|
pub const PUNKTFUNK_VIDEO_CAP_444: u8 = 0x04;
|
||||||
|
|
||||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||||
#[cfg(feature = "quic")]
|
#[cfg(feature = "quic")]
|
||||||
const _: () = {
|
const _: () = {
|
||||||
assert!(PUNKTFUNK_VIDEO_CAP_10BIT == crate::quic::VIDEO_CAP_10BIT);
|
assert!(PUNKTFUNK_VIDEO_CAP_10BIT == crate::quic::VIDEO_CAP_10BIT);
|
||||||
assert!(PUNKTFUNK_VIDEO_CAP_HDR == crate::quic::VIDEO_CAP_HDR);
|
assert!(PUNKTFUNK_VIDEO_CAP_HDR == crate::quic::VIDEO_CAP_HDR);
|
||||||
|
assert!(PUNKTFUNK_VIDEO_CAP_444 == crate::quic::VIDEO_CAP_444);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||||
const _: () = {
|
const _: () = {
|
||||||
use crate::config::GamepadPref;
|
use crate::config::GamepadPref;
|
||||||
|
use crate::input::gamepad as g;
|
||||||
assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
|
assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
|
||||||
assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.to_u8() as u32);
|
assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.to_u8() as u32);
|
||||||
assert!(PUNKTFUNK_GAMEPAD_DUALSENSE == GamepadPref::DualSense.to_u8() as u32);
|
assert!(PUNKTFUNK_GAMEPAD_DUALSENSE == GamepadPref::DualSense.to_u8() as u32);
|
||||||
assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.to_u8() as u32);
|
assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.to_u8() as u32);
|
||||||
assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32);
|
assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32);
|
||||||
|
assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER == GamepadPref::SteamController.to_u8() as u32);
|
||||||
|
assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32);
|
||||||
|
// Extended button bits mirror the wire `input::gamepad` constants.
|
||||||
|
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
|
||||||
|
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
|
||||||
|
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE3 == g::BTN_PADDLE3);
|
||||||
|
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE4 == g::BTN_PADDLE4);
|
||||||
|
assert!(PUNKTFUNK_GAMEPAD_BTN_MISC1 == g::BTN_MISC1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The additive M3 kinds (TouchpadEx / TrackpadHaptic) must never grow the legacy ABI structs —
|
||||||
|
// they have no `struct_size` guard, so a layout change would corrupt old-built callers' buffers.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
const _: () = {
|
||||||
|
assert!(core::mem::size_of::<PunktfunkRichInput>() == 20);
|
||||||
|
assert!(core::mem::size_of::<PunktfunkHidOutput>() == 19);
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's
|
/// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's
|
||||||
@@ -980,6 +1133,58 @@ pub unsafe extern "C" fn punktfunk_connect_ex5(
|
|||||||
client_cert_pem: *const std::os::raw::c_char,
|
client_cert_pem: *const std::os::raw::c_char,
|
||||||
client_key_pem: *const std::os::raw::c_char,
|
client_key_pem: *const std::os::raw::c_char,
|
||||||
timeout_ms: u32,
|
timeout_ms: u32,
|
||||||
|
) -> *mut PunktfunkConnection {
|
||||||
|
// Delegate to the surround-aware variant requesting stereo (the pre-surround behaviour).
|
||||||
|
unsafe {
|
||||||
|
punktfunk_connect_ex6(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
refresh_hz,
|
||||||
|
compositor,
|
||||||
|
gamepad,
|
||||||
|
bitrate_kbps,
|
||||||
|
video_caps,
|
||||||
|
2, // audio_channels = stereo
|
||||||
|
launch_id,
|
||||||
|
pin_sha256,
|
||||||
|
observed_sha256_out,
|
||||||
|
client_cert_pem,
|
||||||
|
client_key_pem,
|
||||||
|
timeout_ms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`punktfunk_connect_ex5`], but additionally requests the audio channel count:
|
||||||
|
/// `2` (stereo, the default behaviour of every earlier variant), `6` (5.1) or `8` (7.1). The host
|
||||||
|
/// clamps the request to what it can actually capture and echoes the resolved count via
|
||||||
|
/// [`punktfunk_connection_audio_channels`]; the `0xC9` audio frames are Opus-(multi)stream encoded
|
||||||
|
/// for that layout. A client that wants surround calls this; everything else inherits stereo.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Same as [`punktfunk_connect`].
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connect_ex6(
|
||||||
|
host: *const std::os::raw::c_char,
|
||||||
|
port: u16,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
refresh_hz: u32,
|
||||||
|
compositor: u32,
|
||||||
|
gamepad: u32,
|
||||||
|
bitrate_kbps: u32,
|
||||||
|
video_caps: u8,
|
||||||
|
audio_channels: u8,
|
||||||
|
launch_id: *const std::os::raw::c_char,
|
||||||
|
pin_sha256: *const u8,
|
||||||
|
observed_sha256_out: *mut u8,
|
||||||
|
client_cert_pem: *const std::os::raw::c_char,
|
||||||
|
client_key_pem: *const std::os::raw::c_char,
|
||||||
|
timeout_ms: u32,
|
||||||
) -> *mut PunktfunkConnection {
|
) -> *mut PunktfunkConnection {
|
||||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||||
if host.is_null() {
|
if host.is_null() {
|
||||||
@@ -1029,6 +1234,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex5(
|
|||||||
gamepad,
|
gamepad,
|
||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
video_caps,
|
video_caps,
|
||||||
|
crate::audio::normalize_channels(audio_channels),
|
||||||
launch,
|
launch,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
@@ -1045,6 +1251,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex5(
|
|||||||
inner: c,
|
inner: c,
|
||||||
last: std::sync::Mutex::new(None),
|
last: std::sync::Mutex::new(None),
|
||||||
last_audio: std::sync::Mutex::new(None),
|
last_audio: std::sync::Mutex::new(None),
|
||||||
|
audio_pcm: std::sync::Mutex::new(AudioPcmState::default()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
Err(_) => std::ptr::null_mut(),
|
Err(_) => std::ptr::null_mut(),
|
||||||
@@ -1250,6 +1457,121 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the audio channel count the host resolved for this session (from its Welcome): `2`
|
||||||
|
/// (stereo), `6` (5.1) or `8` (7.1). `*out` is filled when non-NULL. The `0xC9` Opus frames are
|
||||||
|
/// (multistream-)encoded for this layout; an embedder decoding raw frames itself must build its
|
||||||
|
/// decoder from THIS value (see [`crate::audio::layout_for`]) — or use
|
||||||
|
/// [`punktfunk_connection_next_audio_pcm`], which decodes in-core. Available immediately after a
|
||||||
|
/// successful connect (it doesn't change without a reconfigure).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_audio_channels(
|
||||||
|
c: *mut PunktfunkConnection,
|
||||||
|
out: *mut u8,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
if !out.is_null() {
|
||||||
|
// SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
|
||||||
|
unsafe { *out = c.inner.audio_channels };
|
||||||
|
}
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One decoded audio frame from [`punktfunk_connection_next_audio_pcm`]: interleaved 32-bit
|
||||||
|
/// float PCM at 48 kHz, in the canonical wire channel order `FL FR FC LFE RL RR SL SR` (the
|
||||||
|
/// first `channels` of it). `samples` points at `frame_count * channels` floats and borrows
|
||||||
|
/// connection memory **until the next PCM call** on this handle.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct PunktfunkAudioPcm {
|
||||||
|
/// Interleaved f32 samples (wire channel order), `frame_count * channels` long.
|
||||||
|
pub samples: *const f32,
|
||||||
|
/// Samples per channel in this frame.
|
||||||
|
pub frame_count: u32,
|
||||||
|
/// Channel count (2/6/8) — the negotiated [`punktfunk_connection_audio_channels`].
|
||||||
|
pub channels: u8,
|
||||||
|
/// Source packet sequence number.
|
||||||
|
pub seq: u32,
|
||||||
|
/// Capture presentation timestamp (ns).
|
||||||
|
pub pts_ns: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the next audio frame and **decode it in-core** to interleaved f32 PCM — for embedders
|
||||||
|
/// without a multistream-capable Opus decoder (e.g. Apple, whose AudioToolbox Opus path is
|
||||||
|
/// stereo-only). The decoder is built once from the negotiated channel count and handles 2/6/8
|
||||||
|
/// channels (a 1-coupled-stream multistream decoder is exactly a stereo decoder). Same
|
||||||
|
/// timeout/closed semantics as [`punktfunk_connection_next_audio`]; `out->samples` borrows
|
||||||
|
/// connection memory until the next PCM call on this handle. Use EITHER this or
|
||||||
|
/// [`punktfunk_connection_next_audio`] on a given connection, from one dedicated audio thread —
|
||||||
|
/// not both (they share the underlying queue).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
||||||
|
c: *mut PunktfunkConnection,
|
||||||
|
out: *mut PunktfunkAudioPcm,
|
||||||
|
timeout_ms: u32,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
if out.is_null() {
|
||||||
|
return PunktfunkStatus::NullPointer;
|
||||||
|
}
|
||||||
|
let channels = crate::audio::normalize_channels(c.inner.audio_channels);
|
||||||
|
let pkt = match c
|
||||||
|
.inner
|
||||||
|
.next_audio(std::time::Duration::from_millis(timeout_ms as u64))
|
||||||
|
{
|
||||||
|
Ok(pkt) => pkt,
|
||||||
|
Err(e) => return e.status(),
|
||||||
|
};
|
||||||
|
let mut state = c.audio_pcm.lock().unwrap();
|
||||||
|
if state.decoder.is_none() {
|
||||||
|
let layout = crate::audio::layout_for(channels, false);
|
||||||
|
match opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping) {
|
||||||
|
Ok(d) => {
|
||||||
|
// Largest legal Opus frame is 120 ms = 5760 samples/ch.
|
||||||
|
state.pcm = vec![0f32; 5760 * channels as usize];
|
||||||
|
state.decoder = Some(d);
|
||||||
|
}
|
||||||
|
Err(_) => return PunktfunkStatus::Unsupported,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let AudioPcmState { decoder, pcm } = &mut *state;
|
||||||
|
let dec = decoder.as_mut().unwrap();
|
||||||
|
// `decode_float` divides the output buffer length by the channel count to get the
|
||||||
|
// per-channel capacity; an empty payload requests packet-loss concealment.
|
||||||
|
match dec.decode_float(&pkt.data, pcm, false) {
|
||||||
|
Ok(frame_count) => {
|
||||||
|
unsafe {
|
||||||
|
*out = PunktfunkAudioPcm {
|
||||||
|
samples: pcm.as_ptr(),
|
||||||
|
frame_count: frame_count as u32,
|
||||||
|
channels,
|
||||||
|
seq: pkt.seq,
|
||||||
|
pts_ns: pkt.pts_ns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
}
|
||||||
|
Err(_) => PunktfunkStatus::BadPacket,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||||
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||||
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
|
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
|
||||||
@@ -1414,6 +1736,33 @@ pub unsafe extern "C" fn punktfunk_connection_color_info(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the session's resolved chroma subsampling (from the host's Welcome) as the HEVC
|
||||||
|
/// `chroma_format_idc`: `1` = 4:2:0 (the default every pre-4:4:4 host produced), `3` = full-chroma
|
||||||
|
/// 4:4:4. `*out` is filled when non-NULL. The in-band SPS is authoritative; this lets the embedder
|
||||||
|
/// pre-size its decoder / pick a 4:4:4 pixel format up front. Available immediately after a
|
||||||
|
/// successful connect (it doesn't change without a reconfigure).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_chroma_format(
|
||||||
|
c: *mut PunktfunkConnection,
|
||||||
|
out: *mut u8,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
if !out.is_null() {
|
||||||
|
// SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
|
||||||
|
unsafe { *out = c.inner.chroma_format };
|
||||||
|
}
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
@@ -1508,6 +1857,43 @@ pub unsafe extern "C" fn punktfunk_connection_send_rich_input(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a rich client→host input via the forward-compatible [`PunktfunkRichInputEx`] — the only way
|
||||||
|
/// a C client can emit a `TouchpadEx` (a second trackpad / signed coords / pressure). Set
|
||||||
|
/// `rich->struct_size = sizeof(PunktfunkRichInputEx)`; a smaller (older-layout) value is rejected.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle; `rich` is null or points to at least its declared
|
||||||
|
/// `struct_size` bytes.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_send_rich_input2(
|
||||||
|
c: *mut PunktfunkConnection,
|
||||||
|
rich: *const PunktfunkRichInputEx,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
if rich.is_null() {
|
||||||
|
return PunktfunkStatus::NullPointer;
|
||||||
|
}
|
||||||
|
// Read only the 4-byte size prefix first to bound the subsequent full read (the
|
||||||
|
// `PunktfunkConfig` ABI-skew precedent).
|
||||||
|
let declared = unsafe { std::ptr::addr_of!((*rich).struct_size).read_unaligned() } as usize;
|
||||||
|
if declared < std::mem::size_of::<PunktfunkRichInputEx>() {
|
||||||
|
return PunktfunkStatus::InvalidArg;
|
||||||
|
}
|
||||||
|
match unsafe { *rich }.to_rich() {
|
||||||
|
Some(r) => match c.inner.send_rich_input(r) {
|
||||||
|
Ok(()) => PunktfunkStatus::Ok,
|
||||||
|
Err(e) => e.status(),
|
||||||
|
},
|
||||||
|
None => PunktfunkStatus::InvalidArg,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The currently active session mode — the Welcome's, until an accepted
|
/// The currently active session mode — the Welcome's, until an accepted
|
||||||
/// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
|
/// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
//! Shared audio layout: the single source of truth for Opus (multi)stream surround across the
|
||||||
|
//! host, the GameStream compatibility path, and every client decoder.
|
||||||
|
//!
|
||||||
|
//! **Canonical wire channel order** is `FL FR FC LFE RL RR SL SR` (the GameStream/Moonlight
|
||||||
|
//! order, and the PipeWire/PulseAudio default map for 6/8 channels). Every host capturer
|
||||||
|
//! delivers PCM in this order and every client decodes into it, so the Opus multistream
|
||||||
|
//! `mapping` is the **identity** (`[0, 1, …, channels-1]`) on both ends — punktfunk owns the
|
||||||
|
//! encoder and every decoder, so the GFE-style pre-rotation Moonlight needs over SDP
|
||||||
|
//! (`gamestream::audio::surround_params`) is a GameStream-only concern and never touches the
|
||||||
|
//! native `punktfunk/1` path.
|
||||||
|
//!
|
||||||
|
//! Channel counts the protocol negotiates: `2` (stereo), `6` (5.1) and `8` (7.1). Anything
|
||||||
|
//! else clamps to stereo ([`normalize_channels`]).
|
||||||
|
|
||||||
|
/// Canonical wire channel positions; the index is the channel's slot in the interleaved PCM
|
||||||
|
/// frame. A count of N uses positions `0..N` (always a prefix of this 8-channel order).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum WirePos {
|
||||||
|
FrontLeft = 0,
|
||||||
|
FrontRight = 1,
|
||||||
|
FrontCenter = 2,
|
||||||
|
Lfe = 3,
|
||||||
|
RearLeft = 4,
|
||||||
|
RearRight = 5,
|
||||||
|
SideLeft = 6,
|
||||||
|
SideRight = 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full 8-channel wire order; the N-channel order is its first N entries.
|
||||||
|
pub const WIRE_ORDER_8: [WirePos; 8] = {
|
||||||
|
use WirePos::*;
|
||||||
|
[
|
||||||
|
FrontLeft,
|
||||||
|
FrontRight,
|
||||||
|
FrontCenter,
|
||||||
|
Lfe,
|
||||||
|
RearLeft,
|
||||||
|
RearRight,
|
||||||
|
SideLeft,
|
||||||
|
SideRight,
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One Opus (multi)stream layout. `mapping` is the libopus multistream mapping we encode AND
|
||||||
|
/// decode with — identity, since punktfunk owns both ends. `streams`/`coupled` give the
|
||||||
|
/// normal-quality coupling (FL,FR)+(FC,LFE) [+(RL,RR) on 7.1] with the remaining channels as
|
||||||
|
/// mono streams; high quality is one mono stream per channel. Bitrates match Sunshine's
|
||||||
|
/// per-config values (stereo keeps punktfunk's live-validated 128 kbps).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct OpusLayout {
|
||||||
|
/// Interleaved channel count (2, 6 or 8).
|
||||||
|
pub channels: u8,
|
||||||
|
/// Number of Opus streams in the multistream packet.
|
||||||
|
pub streams: u8,
|
||||||
|
/// How many of those streams are coupled (stereo) pairs.
|
||||||
|
pub coupled: u8,
|
||||||
|
/// libopus multistream channel mapping — identity `[0, 1, …, channels-1]`.
|
||||||
|
pub mapping: &'static [u8],
|
||||||
|
/// Target Opus bitrate in bits/sec (hard CBR; constant packet size, which GameStream's
|
||||||
|
/// audio FEC relies on).
|
||||||
|
pub bitrate: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stereo: a plain coupled pair. The 128 kbps live-validated config.
|
||||||
|
pub const LAYOUT_STEREO: OpusLayout = OpusLayout {
|
||||||
|
channels: 2,
|
||||||
|
streams: 1,
|
||||||
|
coupled: 1,
|
||||||
|
mapping: &[0, 1],
|
||||||
|
bitrate: 128_000,
|
||||||
|
};
|
||||||
|
/// 5.1 normal quality: (FL,FR)+(FC,LFE) coupled, RL+RR mono.
|
||||||
|
pub const LAYOUT_51: OpusLayout = OpusLayout {
|
||||||
|
channels: 6,
|
||||||
|
streams: 4,
|
||||||
|
coupled: 2,
|
||||||
|
mapping: &[0, 1, 2, 3, 4, 5],
|
||||||
|
bitrate: 256_000,
|
||||||
|
};
|
||||||
|
/// 5.1 high quality: one mono stream per channel.
|
||||||
|
pub const LAYOUT_51_HQ: OpusLayout = OpusLayout {
|
||||||
|
channels: 6,
|
||||||
|
streams: 6,
|
||||||
|
coupled: 0,
|
||||||
|
mapping: &[0, 1, 2, 3, 4, 5],
|
||||||
|
bitrate: 1_536_000,
|
||||||
|
};
|
||||||
|
/// 7.1 normal quality: (FL,FR)+(FC,LFE)+(RL,RR) coupled, SL+SR mono.
|
||||||
|
pub const LAYOUT_71: OpusLayout = OpusLayout {
|
||||||
|
channels: 8,
|
||||||
|
streams: 5,
|
||||||
|
coupled: 3,
|
||||||
|
mapping: &[0, 1, 2, 3, 4, 5, 6, 7],
|
||||||
|
bitrate: 450_000,
|
||||||
|
};
|
||||||
|
/// 7.1 high quality: one mono stream per channel.
|
||||||
|
pub const LAYOUT_71_HQ: OpusLayout = OpusLayout {
|
||||||
|
channels: 8,
|
||||||
|
streams: 8,
|
||||||
|
coupled: 0,
|
||||||
|
mapping: &[0, 1, 2, 3, 4, 5, 6, 7],
|
||||||
|
bitrate: 2_048_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Pick the layout for a negotiated channel count. Unknown counts fall back to stereo (clients
|
||||||
|
/// only ever request 2/6/8). `high_quality` selects the uncoupled high-bitrate config.
|
||||||
|
pub fn layout_for(channels: u8, high_quality: bool) -> &'static OpusLayout {
|
||||||
|
match (channels, high_quality) {
|
||||||
|
(6, false) => &LAYOUT_51,
|
||||||
|
(6, true) => &LAYOUT_51_HQ,
|
||||||
|
(8, false) => &LAYOUT_71,
|
||||||
|
(8, true) => &LAYOUT_71_HQ,
|
||||||
|
_ => &LAYOUT_STEREO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp an arbitrary (wire / requested) channel count to one the protocol negotiates. `0`,
|
||||||
|
/// absent, or any unsupported value becomes stereo.
|
||||||
|
pub fn normalize_channels(requested: u8) -> u8 {
|
||||||
|
match requested {
|
||||||
|
6 => 6,
|
||||||
|
8 => 8,
|
||||||
|
_ => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
|
||||||
|
|
||||||
|
/// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout.
|
||||||
|
///
|
||||||
|
/// NB 7.1 == `0x63F` (FL FR FC LFE **BL BR SL SR**), NOT `0xFF` — `0xFF` selects the
|
||||||
|
/// front-of-center pair FLC/FRC, the wrong speakers. WASAPI delivers channels in ascending
|
||||||
|
/// mask-bit order, which equals the wire order, so the decoded PCM needs no permutation.
|
||||||
|
pub const fn wasapi_channel_mask(channels: u8) -> u32 {
|
||||||
|
const FL: u32 = 0x1;
|
||||||
|
const FR: u32 = 0x2;
|
||||||
|
const FC: u32 = 0x4;
|
||||||
|
const LFE: u32 = 0x8;
|
||||||
|
const BL: u32 = 0x10; // back left (wire RL)
|
||||||
|
const BR: u32 = 0x20; // back right (wire RR)
|
||||||
|
const SL: u32 = 0x200; // side left
|
||||||
|
const SR: u32 = 0x400; // side right
|
||||||
|
match channels {
|
||||||
|
6 => FL | FR | FC | LFE | BL | BR, // 0x3F
|
||||||
|
8 => FL | FR | FC | LFE | BL | BR | SL | SR, // 0x63F
|
||||||
|
_ => FL | FR, // 0x3 (stereo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PipeWire / SPA `enum spa_audio_channel` positions in wire order — identical to the host
|
||||||
|
/// capture side (`punktfunk-host` `audio::linux::spa_positions`): FL=3 FR=4 FC=5 LFE=6 SL=7
|
||||||
|
/// SR=8 RL=12 RR=13. Identity routing: the client sets these on its playback node so PipeWire
|
||||||
|
/// maps each wire slot to the matching speaker (and downmixes when the sink has fewer).
|
||||||
|
pub fn spa_positions(channels: u8) -> &'static [u32] {
|
||||||
|
const STEREO: [u32; 2] = [3, 4]; // FL FR
|
||||||
|
const C51: [u32; 6] = [3, 4, 5, 6, 12, 13]; // FL FR FC LFE RL RR
|
||||||
|
const C71: [u32; 8] = [3, 4, 5, 6, 12, 13, 7, 8]; // FL FR FC LFE RL RR SL SR
|
||||||
|
match channels {
|
||||||
|
6 => &C51,
|
||||||
|
8 => &C71,
|
||||||
|
_ => &STEREO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_table_is_consistent() {
|
||||||
|
for l in [
|
||||||
|
&LAYOUT_STEREO,
|
||||||
|
&LAYOUT_51,
|
||||||
|
&LAYOUT_51_HQ,
|
||||||
|
&LAYOUT_71,
|
||||||
|
&LAYOUT_71_HQ,
|
||||||
|
] {
|
||||||
|
// Mapping is identity and exactly `channels` entries long.
|
||||||
|
assert_eq!(l.mapping.len(), l.channels as usize);
|
||||||
|
for (i, &m) in l.mapping.iter().enumerate() {
|
||||||
|
assert_eq!(m as usize, i, "mapping must be identity for {l:?}");
|
||||||
|
}
|
||||||
|
// libopus invariant: total channels == coupled*2 + (streams - coupled).
|
||||||
|
assert_eq!(
|
||||||
|
l.coupled * 2 + (l.streams - l.coupled),
|
||||||
|
l.channels,
|
||||||
|
"stream/coupled accounting for {l:?}"
|
||||||
|
);
|
||||||
|
assert!(l.coupled <= l.streams);
|
||||||
|
assert!(l.bitrate > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_for_picks_expected() {
|
||||||
|
assert_eq!(layout_for(2, false), &LAYOUT_STEREO);
|
||||||
|
assert_eq!(layout_for(6, false), &LAYOUT_51);
|
||||||
|
assert_eq!(layout_for(6, true), &LAYOUT_51_HQ);
|
||||||
|
assert_eq!(layout_for(8, false), &LAYOUT_71);
|
||||||
|
assert_eq!(layout_for(8, true), &LAYOUT_71_HQ);
|
||||||
|
// Unknown / 0 → stereo.
|
||||||
|
assert_eq!(layout_for(0, false), &LAYOUT_STEREO);
|
||||||
|
assert_eq!(layout_for(3, false), &LAYOUT_STEREO);
|
||||||
|
assert_eq!(layout_for(7, true), &LAYOUT_STEREO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_clamps_to_negotiable() {
|
||||||
|
assert_eq!(normalize_channels(2), 2);
|
||||||
|
assert_eq!(normalize_channels(6), 6);
|
||||||
|
assert_eq!(normalize_channels(8), 8);
|
||||||
|
for bad in [0u8, 1, 3, 4, 5, 7, 9, 255] {
|
||||||
|
assert_eq!(normalize_channels(bad), 2, "{bad} must clamp to stereo");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wasapi_masks_are_correct() {
|
||||||
|
assert_eq!(wasapi_channel_mask(2), 0x3);
|
||||||
|
assert_eq!(wasapi_channel_mask(6), 0x3F);
|
||||||
|
assert_eq!(wasapi_channel_mask(8), 0x63F); // NOT 0xFF
|
||||||
|
// Bit count must equal the channel count.
|
||||||
|
assert_eq!(wasapi_channel_mask(2).count_ones(), 2);
|
||||||
|
assert_eq!(wasapi_channel_mask(6).count_ones(), 6);
|
||||||
|
assert_eq!(wasapi_channel_mask(8).count_ones(), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spa_positions_match_wire_order() {
|
||||||
|
assert_eq!(spa_positions(2), &[3, 4]);
|
||||||
|
assert_eq!(spa_positions(6), &[3, 4, 5, 6, 12, 13]);
|
||||||
|
assert_eq!(spa_positions(8), &[3, 4, 5, 6, 12, 13, 7, 8]);
|
||||||
|
assert_eq!(spa_positions(2).len(), 2);
|
||||||
|
assert_eq!(spa_positions(6).len(), 6);
|
||||||
|
assert_eq!(spa_positions(8).len(), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real-libopus proof that the shared layout round-trips with channel identity: a tone fed
|
||||||
|
/// into wire channel N (host `opus::MSEncoder`) comes back out on channel N (client
|
||||||
|
/// `opus::MSDecoder`), for stereo / 5.1 / 7.1. This is the single guarantee the whole
|
||||||
|
/// feature rests on — encoder layout == decoder layout == identity mapping — so if a layout
|
||||||
|
/// constant is ever wrong, this fails. Gated on `quic` (where `opus` is a dependency).
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[test]
|
||||||
|
fn multistream_layout_roundtrips_with_channel_identity() {
|
||||||
|
const SR: u32 = 48_000;
|
||||||
|
const SAMPLES: usize = 240; // 5 ms @ 48 kHz
|
||||||
|
for &channels in &[2u8, 6, 8] {
|
||||||
|
let l = layout_for(channels, false);
|
||||||
|
let ch = l.channels as usize;
|
||||||
|
let mut enc = opus::MSEncoder::new(
|
||||||
|
SR,
|
||||||
|
l.streams,
|
||||||
|
l.coupled,
|
||||||
|
l.mapping,
|
||||||
|
opus::Application::LowDelay,
|
||||||
|
)
|
||||||
|
.expect("MSEncoder");
|
||||||
|
enc.set_bitrate(opus::Bitrate::Bits(l.bitrate)).unwrap();
|
||||||
|
enc.set_vbr(false).unwrap();
|
||||||
|
let mut dec =
|
||||||
|
opus::MSDecoder::new(SR, l.streams, l.coupled, l.mapping).expect("MSDecoder");
|
||||||
|
|
||||||
|
for tone_ch in 0..ch {
|
||||||
|
let mut out = vec![0u8; 4000];
|
||||||
|
let mut energy = vec![0f64; ch];
|
||||||
|
// A few frames to clear the codec startup transient before measuring.
|
||||||
|
for f in 0..8 {
|
||||||
|
let mut frame = vec![0f32; SAMPLES * ch];
|
||||||
|
for t in 0..SAMPLES {
|
||||||
|
let phase = (f * SAMPLES + t) as f32 * 440.0 * 2.0 * std::f32::consts::PI
|
||||||
|
/ SR as f32;
|
||||||
|
frame[t * ch + tone_ch] = 0.5 * phase.sin();
|
||||||
|
}
|
||||||
|
let n = enc.encode_float(&frame, &mut out).unwrap();
|
||||||
|
let mut decoded = vec![0f32; SAMPLES * ch];
|
||||||
|
let got = dec.decode_float(&out[..n], &mut decoded, false).unwrap();
|
||||||
|
assert_eq!(got, SAMPLES, "{channels}ch frame size");
|
||||||
|
if f >= 4 {
|
||||||
|
for t in 0..SAMPLES {
|
||||||
|
for (c, e) in energy.iter_mut().enumerate() {
|
||||||
|
*e += (decoded[t * ch + c] as f64).powi(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let loudest = (0..ch)
|
||||||
|
.max_by(|&a, &b| energy[a].total_cmp(&energy[b]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
loudest, tone_ch,
|
||||||
|
"{channels}ch: tone in channel {tone_ch} must come out on {tone_ch} (energies {energy:?})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user