Compare commits
260 Commits
4afdb18cc4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c04e77293 | |||
| 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 | |||
| a4833e4780 | |||
| 4e79e6cdad | |||
| f74bc4a3f1 | |||
| 8e18d01af5 | |||
| 3477cbe7ce | |||
| 5a2e07e865 | |||
| 6e949b6748 | |||
| 8ae161fe61 | |||
| 3a89ee8cd7 | |||
| dac0fee4e3 | |||
| 125a51d81d | |||
| 7b99b41ede | |||
| 9ea2c17419 | |||
| a9cca82fb8 | |||
| 7ab0661ddc | |||
| 92e68024f1 | |||
| 64abce6daa | |||
| bdfab8e0d5 | |||
| 8e87e617df | |||
| 5bf787eb2b | |||
| 0a6c9d8852 | |||
| 0eedfb3c1f | |||
| f6490f4c28 | |||
| d01a8fd17a | |||
| 3e7c9bd059 | |||
| 7aa787a789 | |||
| 3514702d8c | |||
| 327a5fa828 | |||
| 9777ed7fb3 | |||
| ba68a98873 | |||
| 22359f5dc8 | |||
| 7e9023faad | |||
| 5acc12d9e9 | |||
| aed0bf0c2a | |||
| b65745284e | |||
| 8ca695eb4c | |||
| 61c02e695e | |||
| 203ad8069d | |||
| 5f8c6b6147 | |||
| cd3368fc71 | |||
| bd05bc8c30 | |||
| 658564353c | |||
| 6b3cbce120 | |||
| 739fa74e68 | |||
| c87ca577a3 | |||
| e68b7330ae | |||
| e5c2b4e7f5 | |||
| 7ad3a57e68 | |||
| 22bef1fd0a | |||
| bf577044f1 | |||
| 4c95ba72a3 | |||
| 011607ec10 | |||
| 803573b4ec | |||
| 00cf51d610 | |||
| 84a3b95f17 | |||
| 8cde8621ce | |||
| 0bf3984614 | |||
| 75ee53d1dd | |||
| 0255a8289c | |||
| 6bed5d9e8e | |||
| 48202a0f89 | |||
| bf57aa4000 | |||
| 0ccd0fe676 | |||
| e1ca2e4d3c | |||
| e119aa50e9 | |||
| 683c81be03 | |||
| fe61597d92 | |||
| d9b8b88a42 | |||
| 15202011c1 | |||
| 05e87e6ab0 | |||
| 38c68c33e5 | |||
| a0427cd2a3 | |||
| a4c85af155 | |||
| 9ba90d4b77 | |||
| 5358ef9fee | |||
| 0a63154293 | |||
| e5057f6cc1 | |||
| a3eefc2374 | |||
| cd591514ad | |||
| a2bd0cd77c | |||
| 48f980ebb1 | |||
| 1cd87066d7 | |||
| 789ad49bc4 | |||
| c87bfe0e7b | |||
| f98ab07dd6 | |||
| dbab1f98ba | |||
| 5d279f8886 | |||
| e60cda3939 | |||
| d638a93e04 | |||
| a755d6eab7 | |||
| b0d28380b5 | |||
| ed583650a6 | |||
| e5c9ee8327 | |||
| 0a7ae5ef09 | |||
| 95dcef3515 | |||
| 0badc17d87 | |||
| a11b0dd3c7 | |||
| 3b21d8ecf8 | |||
| 83d3d6384a | |||
| 6399d2817d | |||
| e2f004589c | |||
| 590ceaa850 | |||
| d8a453f6ca | |||
| 024e709191 | |||
| 94e82df9f3 | |||
| bbc891e50a | |||
| 3e535f1de4 | |||
| c94a81d523 | |||
| df32060655 | |||
| 55899bf73f | |||
| 725e596d2b | |||
| d17aeefd1c | |||
| 1b0a13c25e | |||
| 3d3dd3627c | |||
| ad27174027 | |||
| d0d31b1040 | |||
| 4f10f3439d | |||
| 788e4acbb5 | |||
| d7a9fbf0b6 | |||
| f652617f30 | |||
| ae803b24d5 | |||
| 3fbabc854c | |||
| 8c4e7b07bf | |||
| 6d8c7a5185 | |||
| 2f7847ce9b | |||
| c6a818e985 | |||
| f34e956818 | |||
| 04e52b0c22 | |||
| 2df3c0f2b4 | |||
| 60df3c9c52 | |||
| 9fd19b90a9 | |||
| 6975691f7d | |||
| f896f70bb8 | |||
| b24c10a723 | |||
| 1682b83b3f | |||
| 838cac4f69 | |||
| 4f62643c82 | |||
| c91e7a0e38 | |||
| bed4711096 | |||
| 5d3cb5e63f | |||
| d3e4ea0118 | |||
| 43144203fa | |||
| d8a7d6f3a2 | |||
| 8a04db9844 | |||
| 0b663cefb6 | |||
| e2c9bfd3d9 | |||
| c5dab484df | |||
| e27abc065e | |||
| d39da4bc06 | |||
| 095540efc2 | |||
| de232ec2f7 | |||
| e4e34fdb48 | |||
| 3ec462c2ea | |||
| 58f4dccc02 | |||
| 32879f45bf | |||
| b54f781524 | |||
| 5e106c51cf | |||
| d2746bd65a | |||
| 9b840151e4 | |||
| a12c6e0ba4 | |||
| b0c82333d2 | |||
| f208f3d92e | |||
| 51de8ccbdb | |||
| 118752c136 | |||
| 9af8e9a7d9 | |||
| e466814ef8 | |||
| 95c6ceb072 | |||
| e919fa6a2e | |||
| 6db3525e29 | |||
| 6a501f484a | |||
| 72eeedc4da | |||
| fde438a1ed | |||
| 01dc0b616c | |||
| 4a73102d48 | |||
| aa159df33f | |||
| 983adc5347 | |||
| 78c16e5136 | |||
| 0205c7b8d6 | |||
| 3e6c9f6060 | |||
| b3811ff72e | |||
| b6b0b6c29e | |||
| 527c2f677e | |||
| f3555d5eb5 | |||
| 75d5a6d7fb | |||
| 1fe4161d4d | |||
| 54b75c9be4 | |||
| 3c55ec37fa | |||
| 551012bb43 | |||
| 3526517eb1 | |||
| 22a9ce4229 | |||
| 450bcf1e7b | |||
| a2a6b858f7 | |||
| f85d51b9f9 | |||
| 516efcc3a3 | |||
| f37a304fba |
@@ -0,0 +1,37 @@
|
|||||||
|
# cargo-audit configuration — consumed by `.gitea/workflows/audit.yml` (`cargo audit`).
|
||||||
|
#
|
||||||
|
# Silence only advisories that are KNOWN-UNFIXABLE and either not applicable to how we use the crate
|
||||||
|
# or an accepted, documented risk. Keep this list TIGHT and justify every entry — an ignore here
|
||||||
|
# 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
|
||||||
|
# `unmaintained` warnings (audiopus_sys via opus, paste via utoipa-axum). Both are transitive, at
|
||||||
|
# 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]
|
||||||
|
ignore = [
|
||||||
|
# rsa "Marvin Attack" (RUSTSEC-2023-0071): a timing side-channel in the rsa crate's variable-time
|
||||||
|
# modular exponentiation of the SECRET exponent. IMPORTANT — this affects the RSA private-key op in
|
||||||
|
# general, INCLUDING signing (m^d mod n), which the host DOES perform (gamestream/pairing.rs
|
||||||
|
# `signing_key.sign(&serversecret)`). It is NOT, as an earlier version of this note wrongly claimed,
|
||||||
|
# limited to decryption — so "the vulnerable path isn't exercised" is false; signing exercises it.
|
||||||
|
# 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",
|
||||||
|
]
|
||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
# Root build context is used only by web/Dockerfile, which needs web/ and
|
# Root build context is used only by web/Dockerfile, which needs web/ and
|
||||||
# docs/api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
|
# api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
|
||||||
# out of the context upload.
|
# out of the context upload.
|
||||||
*
|
*
|
||||||
!web
|
!web
|
||||||
!docs/api/openapi.json
|
!api/openapi.json
|
||||||
web/node_modules
|
web/node_modules
|
||||||
web/.output
|
web/.output
|
||||||
web/dist
|
web/dist
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -12,6 +12,10 @@ name: android
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
# Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed
|
||||||
|
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
|
||||||
|
# push is canary (Play `internal`).
|
||||||
|
tags: ['v*']
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -69,11 +73,24 @@ jobs:
|
|||||||
VERSION_CODE: ${{ github.run_number }}
|
VERSION_CODE: ${{ github.run_number }}
|
||||||
run: ./gradlew :app:assembleDebug --stacktrace
|
run: ./gradlew :app:assembleDebug --stacktrace
|
||||||
|
|
||||||
|
# Single source of the version name + the Play track for the release steps below. versionCode
|
||||||
|
# stays github.run_number (monotonic across both tracks; Play rejects a regressed code).
|
||||||
|
- name: Version + channel
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||||
|
run: |
|
||||||
|
case "$GITHUB_REF" in
|
||||||
|
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
||||||
|
*) VN="0.3.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||||
|
esac
|
||||||
|
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||||
|
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||||
|
echo "android version $VN -> Play track '$TRACK'"
|
||||||
|
|
||||||
- name: Build Release (signed AAB + universal APK)
|
- name: Build Release (signed AAB + universal APK)
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||||
working-directory: clients/android
|
working-directory: clients/android
|
||||||
env:
|
env:
|
||||||
VERSION_CODE: ${{ github.run_number }}
|
VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV)
|
||||||
RELEASE_KEYSTORE_FILE: "../release.jks"
|
RELEASE_KEYSTORE_FILE: "../release.jks"
|
||||||
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
|
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
|
||||||
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
||||||
@@ -85,33 +102,52 @@ jobs:
|
|||||||
|
|
||||||
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
||||||
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
|
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
|
||||||
- name: Publish AAB + APK to Gitea generic registry
|
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
# to the unified Gitea Release.
|
||||||
|
- name: Publish to generic registry + attach to Gitea release
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.unom.io
|
REGISTRY: git.unom.io
|
||||||
OWNER: unom
|
OWNER: unom
|
||||||
PKG: punktfunk-android
|
PKG: punktfunk-android
|
||||||
VERSION: ${{ github.run_number }}
|
VERSION: ${{ github.run_number }}
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
AAB=clients/android/app/build/outputs/bundle/release/app-release.aab
|
AAB=clients/android/app/build/outputs/bundle/release/app-release.aab
|
||||||
APK=clients/android/app/build/outputs/apk/release/app-release.apk
|
APK=clients/android/app/build/outputs/apk/release/app-release.apk
|
||||||
base="https://$REGISTRY/api/packages/$OWNER/generic/$PKG/$VERSION"
|
base="https://$REGISTRY/api/packages/$OWNER/generic/$PKG"
|
||||||
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$AAB" "$base/punktfunk-android-r$VERSION.aab"
|
# 1) immutable, run-number-versioned store (sideload + provenance)
|
||||||
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/punktfunk-android-r$VERSION.apk"
|
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$AAB" "$base/$VERSION/punktfunk-android-r$VERSION.aab"
|
||||||
echo "Published artifacts (versionCode=$VERSION):"
|
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$VERSION/punktfunk-android-r$VERSION.apk"
|
||||||
echo " $base/punktfunk-android-r$VERSION.aab"
|
echo "published store version $VERSION (versionCode)"
|
||||||
echo " $base/punktfunk-android-r$VERSION.apk"
|
# 2) channel alias for a predictable sideload URL: stable -> latest/, canary -> canary/
|
||||||
|
case "$GITHUB_REF" in refs/tags/v*) ALIAS=latest ;; *) ALIAS=canary ;; esac
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$REGISTRY_TOKEN" -X DELETE "$base/$ALIAS/punktfunk-android.apk" || true
|
||||||
|
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$ALIAS/punktfunk-android.apk"
|
||||||
|
echo "sideload alias: $base/$ALIAS/punktfunk-android.apk"
|
||||||
|
# 3) on a real release, attach the .aab + .apk to the unified Gitea Release (X.Y.Z names)
|
||||||
|
case "$GITHUB_REF" in
|
||||||
|
refs/tags/v*)
|
||||||
|
. scripts/ci/gitea-release.sh
|
||||||
|
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||||
|
upsert_asset "$RID" "$AAB" "punktfunk-${VERSION_NAME}.aab"
|
||||||
|
upsert_asset "$RID" "$APK" "punktfunk-${VERSION_NAME}.apk"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
|
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
|
||||||
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
|
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
|
||||||
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
|
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
|
||||||
- name: Upload to Google Play (Internal Testing)
|
# Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
# promotion to production in the Play console.
|
||||||
|
- name: Upload to Google Play
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||||
env:
|
env:
|
||||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||||
run: |
|
run: |
|
||||||
|
echo "uploading to Play track '$PLAY_TRACK'"
|
||||||
python3 clients/android/ci/play-upload.py \
|
python3 clients/android/ci/play-upload.py \
|
||||||
--package io.unom.punktfunk \
|
--package io.unom.punktfunk \
|
||||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||||
--track internal --status completed
|
--track "$PLAY_TRACK" --status completed
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into
|
# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into
|
||||||
# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent
|
# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent
|
||||||
# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST.
|
# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST.
|
||||||
|
#
|
||||||
|
# A second job (`screenshots`) captures the App Store Connect screenshots of the REAL UI
|
||||||
|
# (mac window + iOS/iPad/tvOS Simulators, see clients/apple/tools/screenshots.sh) and attaches
|
||||||
|
# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated
|
||||||
|
# from the build/test job and best-effort, so a capture gap never reds the core signal.
|
||||||
name: apple
|
name: apple
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -27,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
|
||||||
|
|
||||||
@@ -37,3 +61,71 @@ jobs:
|
|||||||
- name: Test (unit + real-codec round trip; remote tests self-skip)
|
- name: Test (unit + real-codec round trip; remote tests self-skip)
|
||||||
working-directory: clients/apple
|
working-directory: clients/apple
|
||||||
run: swift test
|
run: swift test
|
||||||
|
|
||||||
|
# App Store screenshots of the real UI, zipped and attached to the run as a build artifact.
|
||||||
|
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
|
||||||
|
# first, and is a separate job so a capture hiccup can never red the core signal.
|
||||||
|
#
|
||||||
|
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13"), captured on the Simulator
|
||||||
|
# (`simctl io screenshot`, no Screen Recording grant needed). macOS and tvOS are deliberately
|
||||||
|
# NOT in CI: the self-hosted runner is headless (no window-server session), so the mac window
|
||||||
|
# capture can't run there; tvOS needs the Tier-3 build-std slice. Generate those two locally on
|
||||||
|
# a GUI Mac with `clients/apple/tools/screenshots.sh macos tvos`.
|
||||||
|
screenshots:
|
||||||
|
needs: swift
|
||||||
|
if: gitea.event_name != 'pull_request'
|
||||||
|
runs-on: macos-arm64
|
||||||
|
timeout-minutes: 75
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Rust toolchain + iOS Simulator targets
|
||||||
|
run: |
|
||||||
|
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
|
| sh -s -- -y --no-modify-path --profile minimal
|
||||||
|
fi
|
||||||
|
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||||
|
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||||
|
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||||
|
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)
|
||||||
|
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||||
|
|
||||||
|
- name: Capture screenshots (iPhone 6.9" + iPad 13"; auto-creates the Simulators)
|
||||||
|
working-directory: clients/apple
|
||||||
|
env:
|
||||||
|
SETTLE: "8" # Simulators settle slower than a local run
|
||||||
|
run: |
|
||||||
|
# Independent invocations: one platform failing skips it, not the other.
|
||||||
|
bash tools/screenshots.sh ios || echo "::warning::iOS (iPhone 6.9\") screenshots skipped"
|
||||||
|
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
|
||||||
|
echo "Produced:"; ls -la screenshots || true
|
||||||
|
|
||||||
|
- name: Upload screenshots (zip artifact)
|
||||||
|
if: always()
|
||||||
|
# v3, not v4: Gitea's artifact backend identifies as GHES, which @actions/artifact v2+
|
||||||
|
# (upload-artifact@v4) refuses. v3 uses the older API Gitea supports; download is still a zip.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: punktfunk-appstore-screenshots
|
||||||
|
path: clients/apple/screenshots
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 30
|
||||||
|
|||||||
+32
-14
@@ -13,16 +13,16 @@ name: deb
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
# HOST-scoped tags only. The Apple client uses `v*` (release.yml); those must NOT trigger a
|
# Single project version: a `vX.Y.Z` tag is THE release for every platform (see
|
||||||
# host publish — a `v0.1.1` client tag previously shipped a host package versioned 0.1.1 that
|
# docs-site channels.md). The old version-shadow (a client tag shipping a host package
|
||||||
# outranked every rolling build (the version-shadow). Host releases use `host-v*`.
|
# that outranked rolling builds) is now structurally impossible — main publishes to the
|
||||||
tags: ['host-v*']
|
# `canary` apt distribution, tags to `stable`, so the two never share a version line.
|
||||||
|
tags: ['v*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.unom.io
|
REGISTRY: git.unom.io
|
||||||
OWNER: unom
|
OWNER: unom
|
||||||
DISTRIBUTION: stable
|
|
||||||
COMPONENT: main
|
COMPONENT: main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -34,19 +34,22 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Version
|
- name: Version + channel
|
||||||
# host-vX.Y.Z tag -> X.Y.Z (a real host release). A main push -> 0.2.0~ciN.g<sha>: the '~'
|
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
|
||||||
# sorts it BELOW the eventual 0.2.0 tag, it climbs monotonically by run number, AND it sits
|
# A main push -> 0.3.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||||
# ABOVE the stray 0.1.1, so `apt upgrade` truly moves boxes forward. Computed BEFORE the
|
# below the eventual 0.3.0 tag, it climbs monotonically by run number, and the canary base
|
||||||
# build so it's stamped into the binary (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
# stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves
|
||||||
|
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
||||||
|
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
||||||
run: |
|
run: |
|
||||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/host-v*) V="${GITHUB_REF_NAME#host-v}" ;;
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||||
*) V="0.2.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
*) V="0.3.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||||
esac
|
esac
|
||||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||||
echo "package version $V"
|
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||||
|
echo "package version $V -> apt distribution '$DIST'"
|
||||||
|
|
||||||
# dpkg-shlibdeps (Depends resolution) + dpkg-deb live in dpkg-dev. The client's link
|
# dpkg-shlibdeps (Depends resolution) + dpkg-deb live in dpkg-dev. The client's link
|
||||||
# deps are also baked into the rust-ci image, but this job runs against the image
|
# deps are also baked into the rust-ci image, but this job runs against the image
|
||||||
@@ -55,7 +58,8 @@ jobs:
|
|||||||
- name: dpkg-dev + client link deps
|
- name: dpkg-dev + client link deps
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends dpkg-dev \
|
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
|
||||||
|
apt-get install -y --no-install-recommends dpkg-dev python3 \
|
||||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
||||||
|
|
||||||
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
||||||
@@ -124,3 +128,17 @@ jobs:
|
|||||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||||
done
|
done
|
||||||
echo "published to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
echo "published to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||||
|
|
||||||
|
# On a real release, also attach the .debs to the unified Gitea Release so they're on the
|
||||||
|
# downloads page next to every other platform's artifact (canary builds live in the apt
|
||||||
|
# `canary` distribution above — no release page for those).
|
||||||
|
- name: Attach .debs to the Gitea release (stable tags only)
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
. scripts/ci/gitea-release.sh
|
||||||
|
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||||
|
for DEB in dist/*.deb; do
|
||||||
|
upsert_asset "$RID" "$DEB"
|
||||||
|
done
|
||||||
|
|||||||
+55
-31
@@ -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,19 +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
|
- name: Version + channel + stamp
|
||||||
# Tag v1.2.3 -> 1.2.3; main push -> 0.0.1-ciN.g<sha>. Used only for the registry
|
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.<run>
|
||||||
# version path + the zip name (the plugin.json version is the source of truth Decky
|
# (`canary/` alias). Decky reads a plugin's INSTALLED version from package.json (NOT
|
||||||
# reads after install).
|
# 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}" ;;
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;;
|
||||||
*) V="0.0.1-ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
*) 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 "decky version $V"
|
echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
|
||||||
|
echo "BASE=$BASE" >> "$GITHUB_ENV"
|
||||||
|
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 }}
|
||||||
@@ -88,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 }}
|
||||||
@@ -98,33 +122,33 @@ 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) Stable `latest/punktfunk.zip` — this is the link to paste into Decky's
|
# 2) Channel alias (stable release -> latest/, canary main build -> canary/) — the
|
||||||
# "install from URL". The generic registry rejects re-uploading an existing
|
# zip is the "install from URL" link; manifest.json is what the installed plugin
|
||||||
# version/file (409), so delete the prior `latest` 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/latest/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/latest/punktfunk.zip"
|
"$BASE/$ALIAS/punktfunk.zip"
|
||||||
echo "install-from-URL link: $BASE/latest/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 "update manifest: $BASE/$ALIAS/manifest.json"
|
||||||
|
|
||||||
- name: Attach zip to the Gitea release (tags only)
|
- name: Attach zip to the Gitea release (stable tags only)
|
||||||
if: startsWith(gitea.ref, 'refs/tags/')
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
working-directory: ${{ gitea.workspace }}
|
working-directory: ${{ gitea.workspace }}
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
. scripts/ci/gitea-release.sh
|
||||||
ID=$(curl -sf -X POST "$API/releases" \
|
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||||
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
upsert_asset "$RID" "$RUNNER_TEMP/punktfunk.zip" "punktfunk-${VERSION}.zip"
|
||||||
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
|
|
||||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|
|
||||||
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
|
|
||||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
|
||||||
curl -sf -X POST "$API/releases/$ID/assets?name=punktfunk-${VERSION}.zip" \
|
|
||||||
-H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@$RUNNER_TEMP/punktfunk.zip" >/dev/null
|
|
||||||
echo "attached punktfunk-${VERSION}.zip to release $GITHUB_REF_NAME"
|
|
||||||
|
|||||||
@@ -58,16 +58,21 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
|
# On a release tag, also tag the image vX.Y.Z so a release pins reproducible web/docs images.
|
||||||
|
EXTRA=""
|
||||||
|
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
|
||||||
docker build --pull ${{ matrix.buildargs }} \
|
docker build --pull ${{ matrix.buildargs }} \
|
||||||
-f "${{ matrix.dockerfile }}" \
|
-f "${{ matrix.dockerfile }}" \
|
||||||
-t "$REGISTRY/$OWNER/${{ matrix.image }}:latest" \
|
-t "$REGISTRY/$OWNER/${{ matrix.image }}:latest" \
|
||||||
-t "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" \
|
-t "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" \
|
||||||
|
$EXTRA \
|
||||||
"${{ matrix.context }}"
|
"${{ matrix.context }}"
|
||||||
|
|
||||||
- name: Push
|
- name: Push
|
||||||
run: |
|
run: |
|
||||||
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}"
|
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}"
|
||||||
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest"
|
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest"
|
||||||
|
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
|
||||||
|
|
||||||
# Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to
|
# Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to
|
||||||
# (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set
|
# (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
||||||
# docs/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
# design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
||||||
paths:
|
paths:
|
||||||
- 'clients/linux/**'
|
- 'clients/linux/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
@@ -71,19 +71,23 @@ jobs:
|
|||||||
https://dl.flathub.org/repo/flathub.flatpakrepo
|
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||||
git config --global --add safe.directory "$PWD"
|
git config --global --add safe.directory "$PWD"
|
||||||
|
|
||||||
- name: Version
|
- name: Version + channel
|
||||||
# Tag v1.2.3 -> 1.2.3; a main push -> 0.0.1-ciN.g<sha> (sorts before a real release,
|
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
|
||||||
# increases by run number — newest main build always wins). The generic registry
|
# 0.3.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo
|
||||||
# version string allows letters/dots/hyphens.
|
# (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
|
||||||
|
# on a stable box never jumps to a canary build. The generic-registry version string allows
|
||||||
|
# letters/dots/hyphens.
|
||||||
run: |
|
run: |
|
||||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
|
||||||
*) V="0.0.1-ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
*) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
||||||
esac
|
esac
|
||||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||||
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
|
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
|
||||||
echo "flatpak version $V"
|
echo "FLATPAK_BRANCH=$BRANCH" >> "$GITHUB_ENV"
|
||||||
|
echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
|
||||||
|
echo "flatpak version $V -> branch '$BRANCH' alias '$ALIAS'"
|
||||||
|
|
||||||
- name: Generate offline cargo sources
|
- name: Generate offline cargo sources
|
||||||
# flatpak builds with no network; vendor every crate from Cargo.lock into
|
# flatpak builds with no network; vendor every crate from Cargo.lock into
|
||||||
@@ -108,19 +112,20 @@ jobs:
|
|||||||
# runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus
|
# runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus
|
||||||
# the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the
|
# the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the
|
||||||
# container-safe path (no FUSE).
|
# container-safe path (no FUSE).
|
||||||
# --default-branch=stable pins the ref to app/io.unom.Punktfunk/x86_64/stable so the
|
# --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch>
|
||||||
# hosted .flatpakref (Branch=stable) matches deterministically (manifest sets no branch).
|
# (canary or stable) so the matching hosted .flatpakref resolves deterministically
|
||||||
|
# (manifest sets no branch).
|
||||||
flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||||
--default-branch=stable \
|
--default-branch="$FLATPAK_BRANCH" \
|
||||||
--install-deps-from=flathub \
|
--install-deps-from=flathub \
|
||||||
--repo="$PWD/repo" \
|
--repo="$PWD/repo" \
|
||||||
"$PWD/build-dir" "$MANIFEST"
|
"$PWD/build-dir" "$MANIFEST"
|
||||||
|
|
||||||
- name: Export single-file bundle
|
- name: Export single-file bundle
|
||||||
run: |
|
run: |
|
||||||
# Branch must be passed explicitly now that the repo ref is `stable` (--default-branch
|
# Branch must be passed explicitly (matches --default-branch above); build-bundle
|
||||||
# above); build-bundle otherwise defaults to `master` and errors "Refspec … not found".
|
# otherwise defaults to `master` and errors "Refspec … not found".
|
||||||
flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID" stable
|
flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID" "$FLATPAK_BRANCH"
|
||||||
ls -lh "$BUNDLE"
|
ls -lh "$BUNDLE"
|
||||||
|
|
||||||
- name: Publish to the Gitea generic registry
|
- name: Publish to the Gitea generic registry
|
||||||
@@ -132,14 +137,14 @@ jobs:
|
|||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
||||||
"$BASE/$VERSION/$BUNDLE"
|
"$BASE/$VERSION/$BUNDLE"
|
||||||
echo "published $BASE/$VERSION/$BUNDLE"
|
echo "published $BASE/$VERSION/$BUNDLE"
|
||||||
# 2) Stable `latest/punktfunk-client.flatpak` alias for the Decky fallback + scripts.
|
# 2) Channel alias (stable release -> latest/, canary main build -> canary/) for the
|
||||||
# The generic registry rejects re-uploading an existing version/file (409), so
|
# Decky fallback + scripts. The generic registry rejects re-uploading an existing
|
||||||
# delete the prior `latest` file first (ignore 404 on the first ever run).
|
# version/file (409), so delete the prior alias file first (ignore 404 on run #1).
|
||||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||||
"$BASE/latest/punktfunk-client.flatpak" || true
|
"$BASE/$ALIAS/punktfunk-client.flatpak" || true
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
||||||
"$BASE/latest/punktfunk-client.flatpak"
|
"$BASE/$ALIAS/punktfunk-client.flatpak"
|
||||||
echo "published $BASE/latest/punktfunk-client.flatpak"
|
echo "published $BASE/$ALIAS/punktfunk-client.flatpak"
|
||||||
|
|
||||||
# Sign the OSTree repo flatpak-builder already produced and publish it to flatpak.unom.io on
|
# Sign the OSTree repo flatpak-builder already produced and publish it to flatpak.unom.io on
|
||||||
# unom-1, so users get `flatpak update` (the single-file bundle above has no remote). Mirrors
|
# unom-1, so users get `flatpak update` (the single-file bundle above has no remote). Mirrors
|
||||||
@@ -165,7 +170,7 @@ jobs:
|
|||||||
# build-sign signs the COMMIT objects; build-update-repo signs the SUMMARY. Both are
|
# build-sign signs the COMMIT objects; build-update-repo signs the SUMMARY. Both are
|
||||||
# required — clients with gpg-verify=true verify the commit, so summary-only signing
|
# required — clients with gpg-verify=true verify the commit, so summary-only signing
|
||||||
# fails the pull with "GPG verification enabled, but no signatures found".
|
# fails the pull with "GPG verification enabled, but no signatures found".
|
||||||
flatpak build-sign "$PWD/repo" "$APP_ID" stable \
|
flatpak build-sign "$PWD/repo" "$APP_ID" "$FLATPAK_BRANCH" \
|
||||||
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME"
|
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME"
|
||||||
flatpak build-update-repo --generate-static-deltas \
|
flatpak build-update-repo --generate-static-deltas \
|
||||||
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" "$PWD/repo"
|
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" "$PWD/repo"
|
||||||
@@ -180,23 +185,33 @@ jobs:
|
|||||||
Comment=unom Flatpak applications
|
Comment=unom Flatpak applications
|
||||||
GPGKey=$GPGKEY
|
GPGKey=$GPGKEY
|
||||||
EOF
|
EOF
|
||||||
cat > "site/${APP_ID}.flatpakref" <<EOF
|
# Two refs, one per channel — both regenerated every run and rsync'd without --delete, so
|
||||||
|
# the server always offers both (the stable ref only resolves once a release has built the
|
||||||
|
# `stable` branch). A box installs ONE; `flatpak update` then tracks that channel's branch.
|
||||||
|
write_ref() { # <filename> <branch> <title>
|
||||||
|
cat > "site/$1" <<EOF
|
||||||
[Flatpak Ref]
|
[Flatpak Ref]
|
||||||
Name=$APP_ID
|
Name=$APP_ID
|
||||||
Branch=stable
|
Branch=$2
|
||||||
Url=$REPO_URL/repo/
|
Url=$REPO_URL/repo/
|
||||||
Title=Punktfunk
|
Title=$3
|
||||||
Homepage=https://punktfunk.unom.io
|
Homepage=https://punktfunk.unom.io
|
||||||
IsRuntime=false
|
IsRuntime=false
|
||||||
GPGKey=$GPGKEY
|
GPGKey=$GPGKEY
|
||||||
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||||
EOF
|
EOF
|
||||||
|
}
|
||||||
|
write_ref "${APP_ID}.flatpakref" stable "Punktfunk"
|
||||||
|
write_ref "${APP_ID}.Canary.flatpakref" canary "Punktfunk (Canary)"
|
||||||
cat > site/index.html <<EOF
|
cat > site/index.html <<EOF
|
||||||
<!doctype html><meta charset=utf-8><title>unom flatpak repo</title>
|
<!doctype html><meta charset=utf-8><title>unom flatpak repo</title>
|
||||||
<h1>unom Flatpak repository</h1>
|
<h1>unom Flatpak repository</h1>
|
||||||
<p>Install the Punktfunk Linux client (auto-adds Flathub for the GNOME runtime, then tracks updates):</p>
|
<p>Install the Punktfunk Linux client (auto-adds Flathub for the GNOME runtime, then tracks updates).</p>
|
||||||
|
<p><b>Stable</b> (recommended — only moves on releases):</p>
|
||||||
<pre>flatpak install --user $REPO_URL/${APP_ID}.flatpakref
|
<pre>flatpak install --user $REPO_URL/${APP_ID}.flatpakref
|
||||||
flatpak run $APP_ID</pre>
|
flatpak run $APP_ID</pre>
|
||||||
|
<p><b>Canary</b> (latest main build, unstable):</p>
|
||||||
|
<pre>flatpak install --user $REPO_URL/${APP_ID}.Canary.flatpakref</pre>
|
||||||
<p>Or add the whole remote: <code>flatpak remote-add --user --if-not-exists unom $REPO_URL/unom.flatpakrepo</code></p>
|
<p>Or add the whole remote: <code>flatpak remote-add --user --if-not-exists unom $REPO_URL/unom.flatpakrepo</code></p>
|
||||||
EOF
|
EOF
|
||||||
# 3) Ship to unom-1 and (re)start the static server. rsync WITHOUT --delete keeps old
|
# 3) Ship to unom-1 and (re)start the static server. rsync WITHOUT --delete keeps old
|
||||||
@@ -207,24 +222,16 @@ jobs:
|
|||||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||||
$SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
|
$SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
|
||||||
rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
|
rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
|
||||||
rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
|
rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
|
||||||
rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
|
rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
|
||||||
$SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
$SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
||||||
echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
|
echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
|
||||||
|
|
||||||
- name: Attach bundle to the Gitea release (tags only)
|
- name: Attach bundle to the Gitea release (stable tags only)
|
||||||
if: startsWith(gitea.ref, 'refs/tags/')
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
. scripts/ci/gitea-release.sh
|
||||||
ID=$(curl -sf -X POST "$API/releases" \
|
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||||
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
upsert_asset "$RID" "$BUNDLE"
|
||||||
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
|
|
||||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|
|
||||||
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
|
|
||||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
|
||||||
curl -sf -X POST "$API/releases/$ID/assets?name=$BUNDLE" \
|
|
||||||
-H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@$BUNDLE" >/dev/null
|
|
||||||
echo "attached $BUNDLE to release $GITHUB_REF_NAME"
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -46,6 +46,19 @@ name: release
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
# Canary: a relevant main push uploads the iOS + macOS + tvOS builds to TestFlight (Apple's
|
||||||
|
# own canary channel) — no notarized DMG (that's stable-only; see the per-step gates).
|
||||||
|
# Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are
|
||||||
|
# continue-on-error until the App Store Connect record exists, so this no-ops until then.
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'clients/apple/**'
|
||||||
|
- 'crates/punktfunk-core/**'
|
||||||
|
- 'scripts/build-xcframework.sh'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- '.gitea/workflows/release.yml'
|
||||||
|
# Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release
|
||||||
|
# + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store.
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -87,8 +100,8 @@ jobs:
|
|||||||
- name: Version from tag
|
- name: Version from tag
|
||||||
run: |
|
run: |
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
||||||
*) V="0.0.${GITHUB_RUN_NUMBER}" ;;
|
*) V="0.3.0" ;; # canary marketing version; the build number disambiguates
|
||||||
esac
|
esac
|
||||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||||
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
||||||
@@ -105,7 +118,27 @@ 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
|
||||||
|
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||||
|
# same track as iOS/macOS (the nightly toolchain is installed unconditionally above).
|
||||||
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
|
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
|
||||||
|
|
||||||
- name: Stage App Store Connect API key
|
- name: Stage App Store Connect API key
|
||||||
@@ -116,6 +149,9 @@ jobs:
|
|||||||
chmod 600 "$RUNNER_TEMP/asc.p8"
|
chmod 600 "$RUNNER_TEMP/asc.p8"
|
||||||
|
|
||||||
- name: macOS — archive, codesign Developer ID, notarize, DMG
|
- name: macOS — archive, codesign Developer ID, notarize, DMG
|
||||||
|
# Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not
|
||||||
|
# relevant to TestFlight testers (the canary channel). Skipped on canary main pushes.
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
run: |
|
run: |
|
||||||
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
|
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
|
||||||
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
|
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
|
||||||
@@ -154,23 +190,14 @@ jobs:
|
|||||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
|
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
|
||||||
echo "DMG=$DMG" >> "$GITHUB_ENV"
|
echo "DMG=$DMG" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Attach DMG to Gitea release
|
- name: Attach DMG to the Gitea release (stable tags only)
|
||||||
if: startsWith(gitea.ref, 'refs/tags/')
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
. scripts/ci/gitea-release.sh
|
||||||
# Create the release (409 -> already exists, fetch it instead).
|
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||||
ID=$(curl -sf -X POST "$API/releases" \
|
upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg"
|
||||||
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
|
||||||
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
|
|
||||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|
|
||||||
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
|
|
||||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
|
||||||
curl -sf -X POST "$API/releases/$ID/assets?name=Punktfunk-$VERSION.dmg" \
|
|
||||||
-H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@$DMG" >/dev/null
|
|
||||||
echo "attached Punktfunk-$VERSION.dmg to release $GITHUB_REF_NAME"
|
|
||||||
|
|
||||||
- name: macOS App Store — archive + upload to TestFlight
|
- name: macOS App Store — archive + upload to TestFlight
|
||||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||||
@@ -180,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"
|
||||||
@@ -191,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">
|
||||||
@@ -225,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">
|
||||||
@@ -278,38 +306,31 @@ jobs:
|
|||||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||||
|
|
||||||
- name: tvOS — archive + upload to TestFlight
|
- name: tvOS — archive + upload to TestFlight
|
||||||
|
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
|
||||||
|
# on every apple push (above), so this matches the iOS step's gate exactly.
|
||||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||||
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
|
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
|
||||||
# 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">
|
||||||
|
|||||||
+32
-13
@@ -13,9 +13,10 @@ name: rpm
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
# HOST-scoped tags only — the Apple client's `v*` tags (release.yml) must NOT publish a host
|
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm
|
||||||
# RPM (a `v0.1.1` client tag previously shipped a host 0.1.1 that shadowed every rolling build).
|
# groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old
|
||||||
tags: ['host-v*']
|
# version-shadow (a release outranking rolling builds in one group) is structurally gone.
|
||||||
|
tags: ['v*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -66,20 +67,22 @@ jobs:
|
|||||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||||
restore-keys: cargo-home-
|
restore-keys: cargo-home-
|
||||||
|
|
||||||
- name: Version
|
- name: Version + channel
|
||||||
# host-vX.Y.Z tag -> X.Y.Z-1 (a real host release); main push -> 0.2.0-0.ciN.g<sha>, whose
|
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.3.0-0.ciN.g<sha>
|
||||||
# "0." release sorts BELOW the eventual 0.2.0-1 yet climbs by run number AND outranks the
|
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.3.0-1 yet
|
||||||
# stray 0.1.1, so `rpm-ostree upgrade` truly moves to the newest build. The spec %build
|
# climbs by run number. The canary base stays one minor ahead of the latest stable so a
|
||||||
# stamps PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
# stable->canary box re-point still moves forward. The spec %build stamps
|
||||||
|
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
||||||
run: |
|
run: |
|
||||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/host-v*) V="${GITHUB_REF_NAME#host-v}"; R="1" ;;
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
|
||||||
*) V="0.2.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
*) V="0.3.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
||||||
esac
|
esac
|
||||||
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
|
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
|
||||||
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
|
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
|
||||||
echo "rpm $V-$R"
|
echo "GROUP=$GROUP" >> "$GITHUB_ENV"
|
||||||
|
echo "rpm $V-$R -> group '$GROUP'"
|
||||||
|
|
||||||
- name: Build RPM
|
- name: Build RPM
|
||||||
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below
|
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below
|
||||||
@@ -101,6 +104,22 @@ jobs:
|
|||||||
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
|
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
|
||||||
echo "uploading $rpm"
|
echo "uploading $rpm"
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
||||||
"https://$REGISTRY/api/packages/$OWNER/rpm/${{ matrix.group }}/upload"
|
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
||||||
|
done
|
||||||
|
echo "published to $OWNER/rpm/$GROUP"
|
||||||
|
|
||||||
|
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
|
||||||
|
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
|
||||||
|
# both on the release; canary builds live in the `*-canary` rpm groups (no release page).
|
||||||
|
- name: Attach .rpms to the Gitea release (stable tags only)
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
. scripts/ci/gitea-release.sh
|
||||||
|
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||||
|
for rpm in dist/*.rpm; do
|
||||||
|
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
|
||||||
|
base="$(basename "$rpm" .rpm)"
|
||||||
|
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
|
||||||
done
|
done
|
||||||
echo "published to $OWNER/rpm/${{ matrix.group }}"
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# One-shot provisioning of the WDK + cargo-wdk onto the persistent self-hosted windows-amd64 runner, so
|
||||||
|
# the all-Rust UMDF drivers can build there (design/windows-host-rewrite.md, M0). The runner has the base
|
||||||
|
# Windows SDK + MSVC + LLVM + Rust but NOT the WDK (no km/wdf/iddcx headers) or cargo-wdk.
|
||||||
|
#
|
||||||
|
# Dispatch manually (workflow_dispatch). Idempotent: re-running is a near no-op once provisioned. The
|
||||||
|
# install persists on the runner (real box, not an ephemeral container), so this runs once, not per build.
|
||||||
|
name: windows-drivers-provision
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'scripts/ci/provision-windows-wdk.ps1'
|
||||||
|
- '.gitea/workflows/windows-drivers-provision.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
provision:
|
||||||
|
runs-on: windows-amd64
|
||||||
|
timeout-minutes: 60
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: pwsh
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install WDK + cargo-wdk on the runner
|
||||||
|
run: ./scripts/ci/provision-windows-wdk.ps1
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Windows driver workspace CI — runs on the self-hosted Windows runner (home-windows-1, host mode;
|
||||||
|
# label windows-amd64). Part of the Windows-host rewrite (design/windows-host-rewrite.md, M0).
|
||||||
|
#
|
||||||
|
# Stage 1 (this file): PROBE the runner's driver toolchain (WDK / EWDK / cargo-make / LLVM / the
|
||||||
|
# inf2cat/stampinf/devgen/signtool tools) so we know what's provisioned BEFORE writing driver code,
|
||||||
|
# and build+test the owned ABI crate (pf-driver-proto) on MSVC to prove it compiles cross-OS and the
|
||||||
|
# CI wiring works. The runner has no RTX GPU — that's fine: builds, the IddCx bindgen/link, the
|
||||||
|
# /INTEGRITYCHECK self-sign-load, and (later) IDD-push frame flow on the basic display do not need one;
|
||||||
|
# only live NVENC encode does, which defers to the RTX box.
|
||||||
|
#
|
||||||
|
# shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the
|
||||||
|
# first GITHUB_ENV line — see windows.yml).
|
||||||
|
name: windows-drivers
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- '.gitea/workflows/windows-drivers.yml'
|
||||||
|
- 'crates/pf-driver-proto/**'
|
||||||
|
- 'packaging/windows/drivers/**'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.gitea/workflows/windows-drivers.yml'
|
||||||
|
- 'crates/pf-driver-proto/**'
|
||||||
|
- 'packaging/windows/drivers/**'
|
||||||
|
|
||||||
|
# Driver builds need the WDK on the runner (provision once via windows-drivers-provision.yml).
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
probe-and-proto:
|
||||||
|
runs-on: windows-amd64
|
||||||
|
timeout-minutes: 30
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: pwsh
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Probe driver toolchain (informational — never fails the job)
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
function head($t) { Write-Host ""; Write-Host "===== $t =====" }
|
||||||
|
|
||||||
|
head "Windows Kits roots"
|
||||||
|
$kits = @('C:\Program Files (x86)\Windows Kits\10', 'C:\Program Files\Windows Kits\10')
|
||||||
|
foreach ($k in $kits) { if (Test-Path $k) { Write-Host "found: $k" } }
|
||||||
|
|
||||||
|
head "SDK Include versions (um vs km — km => WDK present)"
|
||||||
|
foreach ($k in $kits) {
|
||||||
|
$inc = Join-Path $k 'Include'
|
||||||
|
if (Test-Path $inc) {
|
||||||
|
Get-ChildItem $inc -Directory | ForEach-Object {
|
||||||
|
$hasUm = Test-Path (Join-Path $_.FullName 'um')
|
||||||
|
$hasKm = Test-Path (Join-Path $_.FullName 'km')
|
||||||
|
$wdf = Test-Path (Join-Path $_.FullName 'km\wdf\umdf\2.31')
|
||||||
|
$iddcx = (Get-ChildItem (Join-Path $_.FullName 'um\iddcx') -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) -join ','
|
||||||
|
Write-Host ("{0,-16} um={1,-5} km={2,-5} wdf2.31={3,-5} iddcx=[{4}]" -f $_.Name, $hasUm, $hasKm, $wdf, $iddcx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
head "Driver tooling (inf2cat / stampinf / signtool / devgen / InfVerif)"
|
||||||
|
foreach ($tool in 'inf2cat.exe','stampinf.exe','signtool.exe','devgen.exe','InfVerif.exe','makecat.exe') {
|
||||||
|
$hits = @()
|
||||||
|
foreach ($k in $kits) {
|
||||||
|
$hits += Get-ChildItem -Path $k -Filter $tool -Recurse -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 -ExpandProperty FullName
|
||||||
|
}
|
||||||
|
$hits = $hits | Where-Object { $_ } | Select-Object -First 1
|
||||||
|
Write-Host ("{0,-14} -> {1}" -f $tool, ($(if ($hits) { $hits } else { 'NOT FOUND' })))
|
||||||
|
}
|
||||||
|
|
||||||
|
head "EWDK"
|
||||||
|
Write-Host ("EWDKROOT = " + ($env:EWDKROOT ?? '<unset>'))
|
||||||
|
|
||||||
|
head "LLVM / clang (bindgen 0.72 builds on the runner default clang)"
|
||||||
|
Write-Host ("LIBCLANG_PATH = " + ($env:LIBCLANG_PATH ?? '<unset>'))
|
||||||
|
$clang = Get-Command clang -ErrorAction SilentlyContinue
|
||||||
|
if ($clang) { & clang --version } else { Write-Host "clang: NOT on PATH" }
|
||||||
|
|
||||||
|
head "cargo-make (the gamepad drivers' build driver)"
|
||||||
|
$cm = & cargo make --version 2>&1; Write-Host $cm
|
||||||
|
|
||||||
|
head "Rust + targets"
|
||||||
|
& rustc -V; & cargo -V
|
||||||
|
Write-Host "installed targets:"; & rustup target list --installed
|
||||||
|
|
||||||
|
head "Env knobs the WDK build cares about"
|
||||||
|
Write-Host ("Version_Number = " + ($env:Version_Number ?? '<unset>'))
|
||||||
|
Write-Host ("CARGO_HOME = " + ($env:CARGO_HOME ?? '<unset>'))
|
||||||
|
Write-Host ("CARGO_TARGET_DIR (daemon) = " + ($env:CARGO_TARGET_DIR ?? '<unset>'))
|
||||||
|
|
||||||
|
- name: Build + test pf-driver-proto (MSVC)
|
||||||
|
run: |
|
||||||
|
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml).
|
||||||
|
$env:CARGO_TARGET_DIR = "C:\t\drv"
|
||||||
|
cargo build -p pf-driver-proto
|
||||||
|
cargo test -p pf-driver-proto
|
||||||
|
cargo clippy -p pf-driver-proto --all-targets -- -D warnings
|
||||||
|
cargo fmt -p pf-driver-proto -- --check
|
||||||
|
|
||||||
|
# Build the UMDF driver workspace (wdk-probe) on windows-drivers-rs: proves wdk-sys bindgen/link works
|
||||||
|
# on the runner's WDK + LLVM, that pf-driver-proto path-deps into a driver, and exposes the produced
|
||||||
|
# DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question.
|
||||||
|
driver-build:
|
||||||
|
runs-on: windows-amd64
|
||||||
|
timeout-minutes: 45
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: pwsh
|
||||||
|
# In-tree target dir on purpose: wdk-build's find_top_level_cargo_manifest() walks UP from OUT_DIR
|
||||||
|
# to the first ancestor with a Cargo.lock, so a relocated CARGO_TARGET_DIR (C:\t\…) hides the
|
||||||
|
# workspace lock and it panics. The driver deps have no deep CMake-from-source crates, so the
|
||||||
|
# default in-tree target stays well under MAX_PATH (unlike the SDL3/audiopus client build).
|
||||||
|
working-directory: packaging/windows/drivers
|
||||||
|
env:
|
||||||
|
# wdk-build otherwise picks 10.0.28000.0 (no km/crt) and bindgen fails — pin the WDK SDK version.
|
||||||
|
Version_Number: '10.0.26100.0'
|
||||||
|
# No LIBCLANG_PATH pin: the vendored bindgen 0.72 builds clean on the runner's default clang 22
|
||||||
|
# (the shipping pack proves it). A 0.71-era layout-test overflow once needed LLVM 21; the 0.72 bump
|
||||||
|
# retired that — see design/windows-build-and-packaging.md.
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Ensure WDK + cargo-wdk (idempotent self-provision)
|
||||||
|
# Run the provisioning script here too so driver-build is self-sufficient and never races a
|
||||||
|
# separate provision run on the single runner. Path is relative to the job working-directory
|
||||||
|
# (packaging/windows/drivers). Near-noop once the toolchain is present.
|
||||||
|
run: ../../../scripts/ci/provision-windows-wdk.ps1
|
||||||
|
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay)
|
||||||
|
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
|
||||||
|
# pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve
|
||||||
|
# against IddCxStub end-to-end (M1 step 2 gate).
|
||||||
|
run: cargo build -v
|
||||||
|
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||||
|
run: |
|
||||||
|
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||||
|
$dll = "target\x86_64-pc-windows-msvc\debug\pf_vdisplay.dll"
|
||||||
|
if (-not (Test-Path $dll)) { throw "pf_vdisplay.dll not produced at $dll" }
|
||||||
|
$b = [IO.File]::ReadAllBytes($dll)
|
||||||
|
$pe = [BitConverter]::ToInt32($b, 0x3c)
|
||||||
|
$dllchar = [BitConverter]::ToUInt16($b, $pe + 0x5e) # OptionalHeader.DllCharacteristics
|
||||||
|
Write-Host ("pf_vdisplay.dll built OK ({0:N0} bytes)" -f (Get-Item $dll).Length)
|
||||||
|
Write-Host ("BEFORE: DllCharacteristics = 0x{0:X4}; FORCE_INTEGRITY = {1}" -f $dllchar, (($dllchar -band 0x0080) -ne 0))
|
||||||
|
- name: Clear FORCE_INTEGRITY (self-signed-load fix) + verify
|
||||||
|
# wdk-build sets /INTEGRITYCHECK unconditionally -> a self-signed driver won't load. Clear the PE
|
||||||
|
# bit deterministically (the reusable packaging step; signing/.cat happen later for real drivers).
|
||||||
|
run: ../clear-force-integrity.ps1 -Path target\x86_64-pc-windows-msvc\debug\pf_vdisplay.dll
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
||||||
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
||||||
# SudoVDA virtual-display driver) from one signed setup.exe. Runs on the self-hosted Windows runner
|
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
|
||||||
|
# bun) from one signed setup.exe. Runs on the self-hosted Windows runner
|
||||||
# (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml.
|
# (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml.
|
||||||
#
|
#
|
||||||
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that
|
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that
|
||||||
@@ -11,18 +12,22 @@
|
|||||||
#
|
#
|
||||||
# Registry (public reads, unom org): https://git.unom.io/unom/-/packages (generic group)
|
# Registry (public reads, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||||
#
|
#
|
||||||
# Versioning (free-form; not MSIX's 4-part rule):
|
# Versioning (free-form; not MSIX's 4-part rule) — single project version:
|
||||||
# host-win-vX.Y.Z tag -> X.Y.Z (a real host release; own tag namespace, off host-v*/win-v*/v*
|
# vX.Y.Z tag -> X.Y.Z (THE release; published + stable `latest/` alias + attached to the
|
||||||
# to avoid the version-shadow bug class — see deb.yml).
|
# unified Gitea Release).
|
||||||
# main push / dispatch -> 0.2.<run_number> (rolling; climbs monotonically by run number).
|
# main push / dispatch -> 0.3.<run_number> (canary; `canary/` alias; climbs by run number).
|
||||||
#
|
#
|
||||||
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
|
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
|
||||||
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
||||||
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
||||||
#
|
#
|
||||||
# NVENC: the host builds with --features nvenc; the only link need is nvencodeapi.lib, synthesised
|
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
||||||
# from a 2-export .def with llvm-dlltool (no GPU/SDK at build time). The resulting exe is NVIDIA-only
|
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export
|
||||||
# by design — CI never launches it, so no GPU is needed here.
|
# .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 lgpl-shared
|
||||||
|
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
||||||
|
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
||||||
|
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
|
||||||
name: windows-host
|
name: windows-host
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -32,11 +37,12 @@ on:
|
|||||||
- 'crates/punktfunk-host/**'
|
- 'crates/punktfunk-host/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
- 'packaging/windows/**'
|
- 'packaging/windows/**'
|
||||||
- 'scripts/windows/host.env.example'
|
- 'scripts/windows/**'
|
||||||
|
- 'web/**'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- '.gitea/workflows/windows-host.yml'
|
- '.gitea/workflows/windows-host.yml'
|
||||||
tags: ['host-win-v*']
|
tags: ['v*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -51,6 +57,22 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Locale-safety gate (installer-run scripts must be ASCII)
|
||||||
|
shell: pwsh
|
||||||
|
# The installer runs these via powershell.exe (Windows PowerShell 5.1) and cmd.exe on the END
|
||||||
|
# USER's box. PS 5.1 reads a BOM-less script in the active ANSI codepage, so on a non-UTF-8 locale
|
||||||
|
# (e.g. German Windows-1252) a stray em-dash mis-decodes into a curly quote and the script aborts
|
||||||
|
# with "unterminated string" - exactly how the pf-vdisplay driver install silently failed in the
|
||||||
|
# field. Keep every installer-run script pure ASCII (matches install-gamepad-drivers.ps1).
|
||||||
|
run: |
|
||||||
|
$bad = Get-ChildItem packaging/windows/*.ps1, scripts/windows/*.ps1, scripts/windows/*.cmd -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { [IO.File]::ReadAllText($_.FullName) -match '[^\x00-\x7F]' }
|
||||||
|
if ($bad) {
|
||||||
|
$bad.FullName | ForEach-Object { Write-Output "::error::non-ASCII in installer-run script: $_" }
|
||||||
|
throw "installer-run scripts must be pure ASCII (PS 5.1 mis-parses them on non-UTF-8 locales)"
|
||||||
|
}
|
||||||
|
Write-Output "installer-run scripts are ASCII-clean"
|
||||||
|
|
||||||
- name: Configure + version
|
- name: Configure + version
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
@@ -59,10 +81,17 @@ 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
|
||||||
$v = if ($env:GITHUB_REF -like 'refs/tags/host-win-v*') {
|
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
|
||||||
$env:GITHUB_REF_NAME -replace '^host-win-v', ''
|
# 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
|
||||||
|
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
||||||
|
if (-not $env:FFMPEG_DIR) {
|
||||||
|
"FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
}
|
||||||
|
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||||
|
$env:GITHUB_REF_NAME -replace '^v', ''
|
||||||
} else {
|
} else {
|
||||||
"0.2.$($env:GITHUB_RUN_NUMBER)"
|
"0.3.$($env:GITHUB_RUN_NUMBER)"
|
||||||
}
|
}
|
||||||
"HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
@@ -74,14 +103,27 @@ jobs:
|
|||||||
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
|
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
|
||||||
"PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
|
||||||
- name: Build (release, nvenc)
|
- name: Build (release, nvenc + amf-qsv)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo build --release -p punktfunk-host --features nvenc
|
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
|
||||||
|
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
|
||||||
|
|
||||||
- name: Clippy (host, Windows)
|
- name: Clippy (host, Windows)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
||||||
run: cargo clippy -p punktfunk-host --features nvenc -- -D warnings
|
run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings
|
||||||
|
|
||||||
|
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||||
|
shell: pwsh
|
||||||
|
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
|
||||||
|
# like Doom use HDR on the virtual display). Lint here so a regression fails CI instead of
|
||||||
|
# silently shipping the host without the layer (pack-host-installer.ps1 builds it non-fatally).
|
||||||
|
# Windows-only FFI (user32 + the vk_layer loader glue) → can't be linted on the Linux CI.
|
||||||
|
run: |
|
||||||
|
Push-Location packaging/windows/pf-vkhdr-layer
|
||||||
|
cargo fmt --check; if ($LASTEXITCODE) { throw "pf-vkhdr-layer rustfmt" }
|
||||||
|
cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" }
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
- name: Ensure Inno Setup
|
- name: Ensure Inno Setup
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -91,6 +133,59 @@ jobs:
|
|||||||
choco install innosetup -y --no-progress
|
choco install innosetup -y --no-progress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- name: Fetch portable bun runtime (build tool + bundled to run the console)
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
# ONE pinned bun, used both to BUILD the console and shipped in the installer to RUN it. The
|
||||||
|
# .output is self-contained (Nitro noExternals — deps bundled + tree-shaken, no node_modules),
|
||||||
|
# so the installer ships just bun + a ~75-file .output instead of node + a node_modules forest.
|
||||||
|
$ver = 'bun-v1.3.14'
|
||||||
|
$url = "https://github.com/oven-sh/bun/releases/download/$ver/bun-windows-x64.zip"
|
||||||
|
New-Item -ItemType Directory -Force -Path C:\t | Out-Null
|
||||||
|
$zip = 'C:\t\bun.zip'; $dst = 'C:\t\bundist'
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile $zip
|
||||||
|
if (Test-Path $dst) { Remove-Item $dst -Recurse -Force }
|
||||||
|
Expand-Archive -Path $zip -DestinationPath $dst -Force
|
||||||
|
$bun = (Get-ChildItem -Path $dst -Recurse -Filter bun.exe | Select-Object -First 1).FullName
|
||||||
|
if (-not $bun) { throw "bun.exe not found in $url" }
|
||||||
|
"BUN_EXE=$bun" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
& $bun --version
|
||||||
|
|
||||||
|
- name: Build + smoke-boot web console (bun)
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
# PAT with read access to the unom org packages — the @unom npm registry needs auth to BUILD.
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
# The bun fetched above builds the Nitro server AND runs it. noExternals (vite.config) makes the
|
||||||
|
# output self-contained, so there's no .output/server install — the installer ships bun + the
|
||||||
|
# ~75-file .output. The runner is SYSTEM with no ~/.npmrc, so supply the private @unom token in
|
||||||
|
# the SYSTEM home .npmrc to BUILD (kept OUT of the shipped bundle — web\.npmrc has only the
|
||||||
|
# registry mapping, and nothing copies it into .output).
|
||||||
|
run: |
|
||||||
|
$bun = $env:BUN_EXE
|
||||||
|
if ($env:REGISTRY_TOKEN) {
|
||||||
|
$rc = Join-Path $env:USERPROFILE '.npmrc'
|
||||||
|
Add-Content -Path $rc -Value '@unom:registry=https://git.unom.io/api/packages/unom/npm/'
|
||||||
|
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
|
||||||
|
}
|
||||||
|
Push-Location web
|
||||||
|
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
||||||
|
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
|
||||||
|
if (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet) {
|
||||||
|
throw "web build is a bun bundle (Bun.serve) - need the node-server preset"
|
||||||
|
}
|
||||||
|
Pop-Location
|
||||||
|
# Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login.
|
||||||
|
$env:PORT = '3009'; $env:HOST = '127.0.0.1'; $env:PUNKTFUNK_UI_PASSWORD = 'ci'
|
||||||
|
$server = (Resolve-Path 'web\.output\server\index.mjs').Path
|
||||||
|
$p = Start-Process -FilePath $bun -ArgumentList $server -PassThru -WindowStyle Hidden
|
||||||
|
Start-Sleep -Seconds 4
|
||||||
|
try { $code = (Invoke-WebRequest -Uri 'http://127.0.0.1:3009/login' -UseBasicParsing -TimeoutSec 10).StatusCode } catch { $code = 0 }
|
||||||
|
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
Write-Output "web console smoke (bun): /login -> $code"
|
||||||
|
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
||||||
|
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
|
||||||
- name: Pack + sign installer
|
- name: Pack + sign installer
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
@@ -116,13 +211,25 @@ jobs:
|
|||||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||||
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
|
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
|
||||||
foreach ($f in $files) { Publish-File $f "$base/$($env:HOST_VERSION)/$(Split-Path $f -Leaf)" }
|
foreach ($f in $files) { Publish-File $f "$base/$($env:HOST_VERSION)/$(Split-Path $f -Leaf)" }
|
||||||
# On a tagged release, also refresh the stable `latest/` alias (delete-then-reupload, like
|
# Refresh the channel alias (delete-then-reupload, like flatpak.yml/decky.yml) for a
|
||||||
# flatpak.yml/decky.yml) so there's a predictable download URL.
|
# predictable download URL: stable release -> `latest/`, canary main build -> `canary/`.
|
||||||
if ($env:GITHUB_REF -like 'refs/tags/host-win-v*') {
|
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||||
$aliases = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
|
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
|
||||||
foreach ($f in $files) {
|
foreach ($f in $files) {
|
||||||
$alias = $aliases[$f]; if (-not $alias) { continue }
|
$an = $aliasNames[$f]; if (-not $an) { continue }
|
||||||
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/latest/$alias" 2>$null
|
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
|
||||||
Publish-File $f "$base/latest/$alias"
|
Publish-File $f "$base/$alias/$an"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# On a real release, also attach the signed installer (+ its .cer) to the unified Gitea Release.
|
||||||
|
- name: Attach host installer to the Gitea release (stable tags only)
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
. scripts/ci/gitea-release.ps1
|
||||||
|
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||||
|
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
|
||||||
|
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,12 @@
|
|||||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||||
# Packaging internals: clients/windows/packaging/README.md.
|
# Packaging internals: clients/windows/packaging/README.md.
|
||||||
#
|
#
|
||||||
# Versioning — MSIX requires a strictly 4-part numeric version (no ~/- suffixes), so:
|
# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so:
|
||||||
# win-vX.Y.Z tag -> X.Y.Z.0 (a real Windows-client release; `win-v*` is its own tag namespace,
|
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
|
||||||
# kept off the host's `host-v*` and the Apple `v*` to avoid the
|
# Published to the generic registry + the stable `latest/` alias + attached to the
|
||||||
# version-shadow class of bug — see deb.yml).
|
# unified Gitea Release alongside every other platform's artifact.
|
||||||
# main push / dispatch -> 0.2.<run_number>.0 (rolling; climbs monotonically by run number).
|
# main push / dispatch -> 0.3.<run_number>.0 (canary; climbs monotonically by run number).
|
||||||
|
# Published to the generic registry + the `canary/` alias.
|
||||||
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||||
#
|
#
|
||||||
# Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets
|
# Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets
|
||||||
@@ -34,7 +35,7 @@ on:
|
|||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- '.gitea/workflows/windows-msix.yml'
|
- '.gitea/workflows/windows-msix.yml'
|
||||||
tags: ['win-v*']
|
tags: ['v*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -72,10 +73,11 @@ jobs:
|
|||||||
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }}
|
||||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/win-v*') {
|
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||||
($env:GITHUB_REF_NAME -replace '^win-v', '').Split('.')
|
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
|
||||||
|
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
|
||||||
} else {
|
} else {
|
||||||
@('0', '2', $env:GITHUB_RUN_NUMBER)
|
@('0', '3', $env:GITHUB_RUN_NUMBER)
|
||||||
}
|
}
|
||||||
while ($parts.Count -lt 4) { $parts += '0' }
|
while ($parts.Count -lt 4) { $parts += '0' }
|
||||||
$v = ($parts[0..3] -join '.')
|
$v = ($parts[0..3] -join '.')
|
||||||
@@ -101,11 +103,43 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
$PSNativeCommandUseErrorActionPreference = $false
|
||||||
|
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
|
||||||
|
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
|
||||||
|
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||||
|
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
|
||||||
|
$aliasNames = @{
|
||||||
|
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
|
||||||
|
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
|
||||||
|
}
|
||||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||||
|
function Put($f, $url) {
|
||||||
|
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" }
|
||||||
|
Write-Output "published $url"
|
||||||
|
}
|
||||||
foreach ($f in $files) {
|
foreach ($f in $files) {
|
||||||
$name = Split-Path $f -Leaf
|
$name = Split-Path $f -Leaf
|
||||||
$url = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)/$($env:MSIX_VERSION)/$name"
|
# 1) immutable, versioned path
|
||||||
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
|
Put $f "$base/$($env:MSIX_VERSION)/$name"
|
||||||
Write-Output "published $name -> $url"
|
# 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file)
|
||||||
|
$an = $aliasNames["$f"]
|
||||||
|
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
|
||||||
|
Put $f "$base/$alias/$an"
|
||||||
|
}
|
||||||
|
|
||||||
|
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
|
||||||
|
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
|
||||||
|
# the race, and x64/arm64 filenames differ so the assets don't collide.
|
||||||
|
- name: Attach MSIX to the Gitea release (stable tags only)
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
. scripts/ci/gitea-release.ps1
|
||||||
|
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||||
|
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||||
|
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ dist/
|
|||||||
clients/apple/.build/
|
clients/apple/.build/
|
||||||
clients/apple/PunktfunkCore.xcframework/
|
clients/apple/PunktfunkCore.xcframework/
|
||||||
clients/apple/.swiftpm/
|
clients/apple/.swiftpm/
|
||||||
|
# Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact)
|
||||||
|
clients/apple/screenshots/
|
||||||
|
clients/linux/screenshots/
|
||||||
# Xcode per-user state
|
# Xcode per-user state
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core
|
Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core
|
||||||
(`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design:
|
(`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design:
|
||||||
[`docs/implementation-plan.md`](docs/implementation-plan.md). Status table: `README.md`.
|
[`design/implementation-plan.md`](design/implementation-plan.md). Status table: `README.md`.
|
||||||
|
|
||||||
## Where the work stands
|
## Where the work stands
|
||||||
|
|
||||||
@@ -27,7 +27,15 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS
|
Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS
|
||||||
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
|
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
|
||||||
back-channel; validated live — pad created/destroyed with the session). Management REST API +
|
back-channel; validated live — pad created/destroyed with the session). Management REST API +
|
||||||
checked-in OpenAPI doc (`mgmt.rs`).
|
checked-in OpenAPI doc (`mgmt.rs`). **Web-console performance capture** (`stats_recorder.rs`,
|
||||||
|
design: [`design/stats-capture-plan.md`](design/stats-capture-plan.md)): the operator arms stats
|
||||||
|
recording from the web console, plays, stops, and reviews the run as graphs (per-stage latency
|
||||||
|
breakdown · fps new/repeat · goodput · loss/FEC). A shared `Arc<StatsRecorder>` ring (the hot-path
|
||||||
|
gate is a runtime `AtomicBool`, replacing the startup-only `PUNKTFUNK_PERF`) is fed by **both** the
|
||||||
|
native `virtual_stream` and the GameStream encode loop at their existing ~2 s/~1 s aggregation
|
||||||
|
boundary, and finished captures are saved as on-disk recordings
|
||||||
|
(`~/.config/punktfunk/captures/*.json`) browsable/exportable from the console's **Performance** page
|
||||||
|
(recharts). Endpoints `/api/v1/stats/*` (bearer-only). *Implemented; not yet on-glass validated.*
|
||||||
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
|
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
|
||||||
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
|
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
|
||||||
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
||||||
@@ -47,7 +55,7 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
(no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores
|
(no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores
|
||||||
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
|
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
|
||||||
default; `--allow-tofu`/`--open` accept unpaired clients).
|
default; `--allow-tofu`/`--open` accept unpaired clients).
|
||||||
**LAN auto-discovery**: both `serve --native` and `punktfunk1-host` advertise the native service over
|
**LAN auto-discovery**: both `serve` and `punktfunk1-host` advertise the native service over
|
||||||
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
|
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
|
||||||
pin)/`pair`(required|optional)/`id`; `punktfunk-probe --discover` lists hosts, Apple clients
|
pin)/`pair`(required|optional)/`id`; `punktfunk-probe --discover` lists hosts, Apple clients
|
||||||
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
|
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
|
||||||
@@ -65,18 +73,55 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
|
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
|
||||||
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
|
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
|
||||||
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
|
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
|
||||||
env > uinput Xbox 360; DualSense (UHID) only on Linux hosts.
|
env > uinput Xbox 360. Backends: **Xbox 360** (uinput / ViGEm), **Xbox One/Series** (the same
|
||||||
- **Windows host: implemented and shipping (NVIDIA-only, x64-only).** `#[cfg(windows)]` backends
|
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
|
||||||
|
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
|
||||||
|
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
|
||||||
|
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
|
||||||
|
LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows
|
||||||
|
(UMDF minidriver)** backend — `inject/dualsense_windows.rs` + `inject/dualshock4_windows.rs`, one
|
||||||
|
driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4
|
||||||
|
reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays
|
||||||
|
Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller
|
||||||
|
(DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad
|
||||||
|
dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver
|
||||||
|
(`packaging/windows/xusb-driver/`, `inject/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB`
|
||||||
|
and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState`
|
||||||
|
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
|
||||||
|
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are bundled + pnputil-installed
|
||||||
|
by the Inno Setup installer (`packaging/windows/gamepad-drivers/` + `install-gamepad-drivers.ps1`).
|
||||||
|
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
|
||||||
|
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
|
||||||
|
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
|
||||||
|
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
|
||||||
|
the remaining piece.)
|
||||||
|
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
|
||||||
behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA**
|
behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA**
|
||||||
virtual display per session (`vdisplay/sudovda.rs`), NVENC encode (`--features nvenc`), SendInput +
|
virtual display per session (`vdisplay/sudovda.rs`), GPU encode (NVENC `--features nvenc`; AMD/Intel
|
||||||
**ViGEm** gamepads (`inject/gamepad_windows.rs`), WASAPI loopback + virtual mic (`audio/wasapi_*`).
|
`--features amf-qsv`), SendInput + **ViGEm** gamepads (`inject/gamepad_windows.rs`), WASAPI loopback
|
||||||
Ships as a **signed Inno Setup installer** that registers a `LocalSystem` SCM service launching into
|
+ virtual mic (`audio/wasapi_*`). Ships as a **signed Inno Setup installer** that registers a
|
||||||
the interactive session for secure-desktop (UAC/lock-screen) capture (`service.rs`), bundles the
|
`LocalSystem` SCM service launching into the interactive session for secure-desktop (UAC/lock-screen)
|
||||||
SudoVDA driver, and is published by `windows-host.yml`. **HDR (10-bit)**: WGC captures the HDR
|
capture (`service.rs`), bundles the SudoVDA driver + the FFmpeg DLLs, and is published by
|
||||||
desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), NVENC forces HEVC Main10 + BT.2020 PQ,
|
`windows-host.yml`. **Encoder is GPU-aware** (`encode.rs` `open_video` + `windows_resolved_backend`):
|
||||||
the client auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`;
|
`PUNKTFUNK_ENCODER=auto` (the host.env default) detects the DXGI adapter vendor → **NVENC** (NVIDIA,
|
||||||
**Windows host only** (the Linux host stays 8-bit, blocked upstream). Newer/less battle-tested than
|
direct SDK, `encode/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec
|
||||||
the Linux host; no AMD/Intel/software encode path. Packaging: `packaging/windows/`.
|
(`encode/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`,
|
||||||
|
system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a
|
||||||
|
system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is
|
||||||
|
probed per-GPU on AMF/QSV (`windows_codec_support` → `serverinfo`, AV1 gated). **HDR (10-bit)**: WGC
|
||||||
|
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
|
||||||
|
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
|
||||||
|
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
|
||||||
|
host only** (the Linux host stays 8-bit, blocked upstream). **Vulkan-game HDR over the virtual
|
||||||
|
display**: NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an IddCx
|
||||||
|
indirect display (so Vulkan games — Doom: The Dark Ages, id Tech, etc. — say "device does not support
|
||||||
|
HDR"), even though the ICD happily *accepts + presents* a forced HDR swapchain there. A tiny always-on
|
||||||
|
Vulkan **implicit layer** (`packaging/windows/pf-vkhdr-layer/`, `VK_LAYER_PUNKTFUNK_hdr_inject`)
|
||||||
|
injects the `HDR10_ST2084`/scRGB surface formats into `vkGetPhysicalDeviceSurfaceFormats[2]KHR`,
|
||||||
|
self-gated on the display's actual advanced-color state (no-op on SDR / real monitors); bundled +
|
||||||
|
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
|
||||||
|
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
|
||||||
|
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
|
||||||
|
|
||||||
## What's left
|
## What's left
|
||||||
|
|
||||||
@@ -99,11 +144,25 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
|
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
|
||||||
includes the pairing ceremony + `--require-pairing` gate),
|
includes the pairing ceremony + `--require-pairing` gate),
|
||||||
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
||||||
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter**
|
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter is now the DEFAULT**
|
||||||
(`VTDecompressionSession` + `CAMetalLayer`) is built and live-validated on glass behind the opt-in
|
(stage-1 is the Metal-unavailable / DEBUG fallback): explicit `VTDecompressionSession` decode →
|
||||||
`punktfunk.presenter` flag (~11 ms p50 capture→present), to become the default after a few
|
`CAMetalLayer`, presented from the hosting view's **main-runloop `CADisplayLink`** (`renderTick` pops
|
||||||
resolution/HDR checks. Next: make stage 2 the default, glass-to-glass numbers via
|
the newest ready frame per vsync; macOS `displaySyncEnabled = false` is the real fullscreen-judder fix,
|
||||||
`tools/latency-probe`, iOS/iPadOS/tvOS variants.
|
~11 ms p50). *(An off-main `CAMetalDisplayLink` and an off-main blocking-render present thread were
|
||||||
|
both tried and reverted — both measured slower on macOS and iPad.)* **HDR fixed**
|
||||||
|
(`design/apple-stage2-presenter.md`): the "too bright" bug was a missing reference-white anchor — the
|
||||||
|
fix keeps the PQ-passthrough shader and adds `CAEDRMetadata.hdr10(…, opticalOutputScale: 203)` +
|
||||||
|
`wantsExtendedDynamicRangeContent` on the layer (on all platforms; the old `#if os(macOS)` guard left
|
||||||
|
iOS/tvOS EDR half-engaged), routing the 0xCE mastering metadata to the layer (via `setHdrMeta`) instead
|
||||||
|
of a never-composited source buffer. **Mid-session SDR↔HDR** is handled: `render` reconciles the layer
|
||||||
|
per-frame from the decoded `frame.isHDR` (per-mode pixel format `bgra8`/`rgba16Float`), so a game
|
||||||
|
entering HDR mid-stream just reconfigures (last 0xCE grade cached + re-applied; pump drains 0xCE
|
||||||
|
unconditionally). **4:4:4 added**: decode format is a 2×2 `(chroma, HDR)` matrix
|
||||||
|
(`420v/x420/444v/x444`, all biplanar so the shaders are unchanged), advertised (`VIDEO_CAP_444`) only
|
||||||
|
behind a **hardware-required `VTDecompressionSession` probe** (`Stage444Probe`, validated on M3) with a
|
||||||
|
Settings opt-out + a bounded pump backstop for an undecodable 4:4:4 session. *HDR brightness + 4:4:4
|
||||||
|
still need on-glass validation (Windows-HDR / `PUNKTFUNK_444` host).* Next: glass-to-glass numbers via
|
||||||
|
`tools/latency-probe`.
|
||||||
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
|
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
|
||||||
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
||||||
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
||||||
@@ -111,7 +170,7 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
slice threads) → `GtkGraphicsOffload`-wrapped picture, PipeWire playback (mic-player
|
slice threads) → `GtkGraphicsOffload`-wrapped picture, PipeWire playback (mic-player
|
||||||
jitter ring inverted), SDL3 gamepad capture + rumble/lightbar feedback, keyboard via
|
jitter ring inverted), SDL3 gamepad capture + rumble/lightbar feedback, keyboard via
|
||||||
exact inverse of the host VK table, absolute mouse + 120-unit scroll. Validated live
|
exact inverse of the host VK table, absolute mouse + 120-unit scroll. Validated live
|
||||||
against `serve --native` on this box: 1080p60, steady 60 fps, capture→decoded p50
|
against `serve` on this box: 1080p60, steady 60 fps, capture→decoded p50
|
||||||
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
|
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
|
||||||
stage 1.5 (2026-06-12 evening)**: capture state machine (click-to-capture,
|
stage 1.5 (2026-06-12 evening)**: capture state machine (click-to-capture,
|
||||||
Ctrl+Alt+Shift+Q / focus-loss release, held-state flush), app-lifetime SDL gamepad
|
Ctrl+Alt+Shift+Q / focus-loss release, held-state flush), app-lifetime SDL gamepad
|
||||||
@@ -169,15 +228,18 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
|
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
|
||||||
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
|
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
|
||||||
Opus/Oboe audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
|
Opus/Oboe audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
|
||||||
(`feedback.rs`), `NsdManager` mDNS discovery, SPAKE2 PIN pairing + TOFU (Keystore identity +
|
(`feedback.rs`), **native `mdns-sd` mDNS discovery** (`discovery.rs`, polled over JNI — the same
|
||||||
|
browse the Linux/Windows clients use, replacing the flaky per-OEM `NsdManager`; Kotlin keeps only
|
||||||
|
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
|
||||||
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
|
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
|
||||||
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
|
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
|
||||||
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish.
|
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish.
|
||||||
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
||||||
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
||||||
at high res).
|
at high res).
|
||||||
3. **punktfunk/1 protocol growth.** **Done:** unified host (`serve --native` runs GameStream + the
|
3. **punktfunk/1 protocol growth.** **Done:** unified host (`serve --gamestream` runs GameStream + the
|
||||||
punktfunk/1 QUIC host in one process) with native pairing driven over the mgmt API /
|
punktfunk/1 QUIC host in one process; bare `serve` is the secure native-only default — GameStream is
|
||||||
|
opt-in, trusted-LAN only, security-review #5/#9) with native pairing driven over the mgmt API /
|
||||||
web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
|
web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
|
||||||
**Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
|
**Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
|
||||||
`pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts
|
`pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts
|
||||||
@@ -212,8 +274,8 @@ bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip
|
|||||||
```
|
```
|
||||||
|
|
||||||
Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h`
|
Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h`
|
||||||
(cbindgen from `punktfunk-core/src/abi.rs`) and `docs/api/openapi.json` (regenerate with
|
(cbindgen from `punktfunk-core/src/abi.rs`) and `api/openapi.json` (regenerate with
|
||||||
`cargo run -p punktfunk-host -- openapi > docs/api/openapi.json`; spec lives in `mgmt.rs`).
|
`cargo run -p punktfunk-host -- openapi > api/openapi.json`; spec lives in `mgmt.rs`).
|
||||||
|
|
||||||
CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the
|
CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the
|
||||||
workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site
|
workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site
|
||||||
@@ -234,15 +296,16 @@ crates/punktfunk-host/
|
|||||||
vdisplay/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
|
vdisplay/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
|
||||||
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
||||||
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
|
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
|
||||||
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs
|
encode/{nvenc,linux,vaapi,ffmpeg_win,sw}.rs per-GPU encoders (NVENC · Linux NVENC/CUDA · VAAPI · AMF/QSV · openh264)
|
||||||
|
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs
|
||||||
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
||||||
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
||||||
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
|
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
|
||||||
clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController)
|
clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController)
|
||||||
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
|
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
|
||||||
clients/decky/ Steam Deck Decky plugin
|
clients/decky/ Steam Deck Decky plugin
|
||||||
crates/punktfunk-host/src/{capture/dxgi,vdisplay/sudovda,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends
|
crates/punktfunk-host/src/{capture/dxgi,vdisplay/sudovda,encode/ffmpeg_win,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends
|
||||||
web/ TanStack web console over the mgmt API (status · devices · pairing)
|
web/ TanStack web console over the mgmt API (status · devices · pairing · performance graphs)
|
||||||
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
|
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
|
||||||
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
||||||
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
|
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
|
||||||
@@ -280,9 +343,9 @@ scanout → KWin `--drm` impossible; everything renders offscreen via `renderD12
|
|||||||
# launcher menu is EMPTY (no apps, no System Settings).
|
# launcher menu is EMPTY (no apps, no System Settings).
|
||||||
bash scripts/headless/run-headless-kde.sh 1920x1080
|
bash scripts/headless/run-headless-kde.sh 1920x1080
|
||||||
|
|
||||||
# host (shell 2):
|
# host (shell 2): bare `serve` is native-only (secure default); add --gamestream for Moonlight compat.
|
||||||
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_VIDEO_SOURCE=virtual \
|
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_VIDEO_SOURCE=virtual \
|
||||||
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve
|
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve --gamestream
|
||||||
|
|
||||||
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
|
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
|
||||||
# across sessions — bound it with --max-sessions):
|
# across sessions — bound it with --max-sessions):
|
||||||
@@ -296,7 +359,24 @@ or 8.x/libavcodec 62** — validated live on Ubuntu 26.04 (8) and Bazzite F43 (7
|
|||||||
FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKTFUNK_VIDEO_SOURCE=virtual|portal`,
|
FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKTFUNK_VIDEO_SOURCE=virtual|portal`,
|
||||||
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1`, `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`.
|
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),
|
||||||
|
`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
+537
-304
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -3,6 +3,8 @@ 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",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
"clients/windows",
|
"clients/windows",
|
||||||
@@ -10,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"
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
# punktfunk
|
<p align="center">
|
||||||
|
<img src="assets/punktfunk-logo.svg" alt="punktfunk" width="320" />
|
||||||
|
</p>
|
||||||
|
|
||||||
**Low-latency desktop and game streaming, Linux-first.** Run the host on a Linux machine — or a
|
<p align="center"><b>Low-latency desktop and game streaming with first-class Linux and Windows hosts.</b></p>
|
||||||
Windows PC — with an NVIDIA GPU, connect from a Mac, PC, phone, tablet, or TV, and stream your desktop
|
|
||||||
or games — each device at its **own native resolution and refresh rate**, over your local network.
|
Run the host on a Linux machine or a Windows PC, connect from a Mac, PC, phone, tablet, or TV, and
|
||||||
|
stream your desktop or games — each device at its **own native resolution and refresh rate**, over
|
||||||
|
your local network.
|
||||||
|
|
||||||
📖 **Documentation: [docs.punktfunk.unom.io](https://docs.punktfunk.unom.io)** — start with
|
📖 **Documentation: [docs.punktfunk.unom.io](https://docs.punktfunk.unom.io)** — start with
|
||||||
[How It Works](https://docs.punktfunk.unom.io/docs/how-it-works) or the
|
[How It Works](https://docs.punktfunk.unom.io/docs/how-it-works) or the
|
||||||
[Quick Start](https://docs.punktfunk.unom.io/docs/quickstart).
|
[Quick Start](https://docs.punktfunk.unom.io/docs/quickstart).
|
||||||
|
|
||||||
|
💬 **Community: [Discord](https://discord.gg/kaPNvzMuGU)** — chat, support, and **Android beta
|
||||||
|
access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
|
||||||
|
|
||||||
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
||||||
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
||||||
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
||||||
@@ -19,6 +26,11 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
|||||||
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display
|
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display
|
||||||
sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No
|
sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No
|
||||||
letterboxing, no scaling, no rearranging your real monitors.
|
letterboxing, no scaling, no rearranging your real monitors.
|
||||||
|
- **A real virtual display on Windows, too.** On Linux the host uses per-compositor virtual outputs;
|
||||||
|
on Windows you get the same on-the-fly virtual display — at the client's exact mode, no physical
|
||||||
|
monitor or dummy HDMI plug, even on the secure desktop (UAC / lock screen). It also has **its own
|
||||||
|
indirect display driver (IDD)** the host pushes finished frames straight into, rather than scraping
|
||||||
|
a screen — tight, push-based integration that's unusual for a Windows streaming host.
|
||||||
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
|
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
|
||||||
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
|
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
|
||||||
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on a LAN.
|
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on a LAN.
|
||||||
@@ -35,7 +47,7 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
|||||||
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
||||||
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
||||||
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
||||||
| **Windows host** (NVIDIA, x64) | 🟡 Implemented & shipping as a signed installer (DXGI capture · SudoVDA virtual display · NVENC · WASAPI · ViGEm); NVIDIA-only, newer than the Linux host |
|
| **Windows host** (x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
|
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
|
||||||
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
|
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
|
||||||
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, Oboe audio, controllers, discovery, pairing |
|
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, Oboe audio, controllers, discovery, pairing |
|
||||||
@@ -49,8 +61,10 @@ gamescope, Mutter, and Sway/wlroots backends), encoded with GPU **zero-copy** (d
|
|||||||
NVENC) up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a
|
NVENC) up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a
|
||||||
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→reassembled at 720p120), with
|
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→reassembled at 720p120), with
|
||||||
mid-stream mode renegotiation and a wall-clock skew handshake so latency stays valid across machines.
|
mid-stream mode renegotiation and a wall-clock skew handshake so latency stays valid across machines.
|
||||||
Both protocols run from **one process** (`punktfunk-host serve --native`) and are managed through a
|
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
|
||||||
REST API and web console. Builds against FFmpeg 7 or 8.
|
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
|
||||||
|
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
|
||||||
|
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
|
||||||
|
|
||||||
Full milestone status: **[docs.punktfunk.unom.io/docs/status](https://docs.punktfunk.unom.io/docs/status)** ·
|
Full milestone status: **[docs.punktfunk.unom.io/docs/status](https://docs.punktfunk.unom.io/docs/status)** ·
|
||||||
roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
||||||
@@ -59,17 +73,18 @@ roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
|||||||
|
|
||||||
Pick your platform and install from its package registry — the per-platform guide covers adding the
|
Pick your platform and install from its package registry — the per-platform guide covers adding the
|
||||||
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a
|
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a
|
||||||
Windows host (NVIDIA-only) also ships as a signed installer.
|
Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
||||||
|
|
||||||
| Platform | Install | Guide |
|
| Platform | Install | Guide |
|
||||||
|--------|---------|-------|
|
|--------|---------|-------|
|
||||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
|
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
|
||||||
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||||
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||||
| **Windows** (NVIDIA, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
| **Windows** (x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||||
|
|
||||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||||
After install, run `punktfunk-host serve --native` inside your desktop session, then pair from the web
|
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||||
|
add `--gamestream` on a trusted LAN if you also want stock Moonlight clients), then pair from the web
|
||||||
console. Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
|
console. Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
|
||||||
|
|
||||||
## Connect a client
|
## Connect a client
|
||||||
@@ -110,7 +125,7 @@ and the [docs site](https://docs.punktfunk.unom.io).
|
|||||||
```
|
```
|
||||||
crates/
|
crates/
|
||||||
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
|
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
|
||||||
punktfunk-host/ Linux host: virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
|
punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
|
||||||
clients/
|
clients/
|
||||||
apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController)
|
apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController)
|
||||||
linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3)
|
linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3)
|
||||||
@@ -121,7 +136,7 @@ clients/
|
|||||||
web/ web console (TanStack) over the management API — status · devices · pairing
|
web/ web console (TanStack) over the management API — status · devices · pairing
|
||||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
||||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||||
docs/ design notes & deep-dive plans
|
design/ design notes & deep-dive plans (index: design/README.md)
|
||||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||||
tools/ latency-probe · loss-harness (measurement)
|
tools/ latency-probe · loss-harness (measurement)
|
||||||
```
|
```
|
||||||
@@ -140,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"
|
||||||
+2237
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 579 298" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<style>
|
||||||
|
/* Theme-adaptive so the logo stays readable on both light and dark README
|
||||||
|
backgrounds: deep violet (the brand-mark palette) on light, the original
|
||||||
|
light violet on dark. Evaluated by the viewer's color scheme. */
|
||||||
|
.pf-wm { fill: #6c5bf3; }
|
||||||
|
.pf-back { fill: #a79ff8; }
|
||||||
|
.pf-deep { fill: #6c5bf3; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.pf-wm { fill: #cec9fb; }
|
||||||
|
.pf-back { fill: #f2f1fe; }
|
||||||
|
.pf-deep { fill: #8c7ef5; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="pf-wm" style="fill-rule:nonzero;" d="M21.144,176.635l0,102.687l31.253,0l0,-35.563l73.436,0l0,-23.555l-73.436,0l0,-19.398l77.285,0l0,-24.171l-108.537,0Z"/>
|
||||||
|
<path class="pf-wm" style="fill-rule:nonzero;" d="M136.148,176.635l0,47.264c0.154,16.627 0.154,16.627 0.308,20.014c0.77,15.087 2.463,21.4 7.544,26.634c7.698,8.16 20.014,10.315 59.272,10.315c23.863,0 34.178,-0.616 43.415,-2.463c11.7,-2.463 19.552,-10.623 21.246,-22.323c0.924,-7.236 1.078,-8.929 1.54,-32.176l0,-47.264l-31.253,0l0,47.264c0,2.155 -0.154,7.082 -0.308,10.623c-0.462,9.699 -1.232,12.47 -3.695,15.087c-3.387,3.695 -9.853,4.619 -31.407,4.619c-26.634,0 -32.638,-1.693 -34.332,-9.853c-0.77,-4.157 -0.77,-4.311 -1.078,-20.476l0,-47.264l-31.253,0Z"/>
|
||||||
|
<path class="pf-wm" style="fill-rule:nonzero;" d="M275.938,176.527l0,102.687l31.868,0l-0.77,-76.669l3.387,0l54.038,76.669l54.346,0l0,-102.687l-31.868,0l0.77,76.515l-3.233,0l-53.73,-76.515l-54.808,0Z"/>
|
||||||
|
<path class="pf-wm" style="fill-rule:nonzero;" d="M425.273,176.527l0,102.687l31.253,0l0,-39.258l17.089,0l46.032,39.258l47.418,0l-64.353,-52.344l59.426,-50.959l-47.88,0l-40.644,37.873l-17.089,0l0,-37.257l-31.253,0Z"/>
|
||||||
|
</g>
|
||||||
|
<path class="pf-back" style="fill-rule:nonzero;" d="M65.442,150.143c24.514,0 44.298,-19.784 44.298,-44.298c0,-24.514 -19.784,-44.298 -44.298,-44.298c-24.514,0 -44.298,19.784 -44.298,44.298c0,24.514 19.784,44.298 44.298,44.298Z"/>
|
||||||
|
<path class="pf-deep" style="fill-rule:nonzero;" d="M141.063,92.871c17.334,-17.334 17.334,-45.312 0,-62.647c-17.334,-17.334 -45.312,-17.334 -62.647,-0c-17.334,17.334 -17.334,45.312 0,62.647c17.334,17.334 45.312,17.334 62.647,-0Z"/>
|
||||||
|
<path style="fill:url(#_Linear1);" d="M121.228,104.359c-14.777,3.965 -31.187,0.136 -42.811,-11.488c-11.624,-11.624 -15.453,-28.034 -11.488,-42.811c14.777,-3.965 31.187,-0.136 42.811,11.488c11.624,11.624 15.453,28.034 11.488,42.811Z"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(31.323323,-31.323323,31.323323,31.323323,78.416832,92.870811)">
|
||||||
|
<stop offset="0" style="stop-color:#cec9fb;stop-opacity:0"/>
|
||||||
|
<stop offset="1" style="stop-color:#fcfcff;stop-opacity:1"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -11,8 +11,8 @@ machine, trust logic) instead of re-porting it into Kotlin.
|
|||||||
|
|
||||||
| Side | Owns |
|
| Side | Owns |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **Rust** (`clients/android/native` → `libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing |
|
| **Rust** (`clients/android/native` → `libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing, **mDNS discovery** (`mdns-sd`, the same browse the Linux/Windows clients use) |
|
||||||
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, `NsdManager` discovery, Keystore identity, permissions |
|
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, the Wi-Fi `MulticastLock` + permission UX, Keystore identity, permissions |
|
||||||
|
|
||||||
The single seam is `io.unom.punktfunk.kit.NativeBridge` ⇄ `Java_io_unom_punktfunk_kit_NativeBridge_*`.
|
The single seam is `io.unom.punktfunk.kit.NativeBridge` ⇄ `Java_io_unom_punktfunk_kit_NativeBridge_*`.
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ clients/android/native/ Rust cdylib (workspace member) — links punktf
|
|||||||
clients/android/ Gradle project (this dir)
|
clients/android/ Gradle project (this dir)
|
||||||
settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew
|
settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew
|
||||||
app/ :app — Compose UI: Connect / Settings / Stream screens (phone + TV)
|
app/ :app — Compose UI: Connect / Settings / Stream screens (phone + TV)
|
||||||
kit/ :kit — NativeBridge · discovery (NsdManager) · Gamepad · Keymap ·
|
kit/ :kit — NativeBridge · discovery (native mdns-sd, polled) · Gamepad · Keymap ·
|
||||||
security (Keystore identity + known-host store) · cargo-ndk build
|
security (Keystore identity + known-host store) · cargo-ndk build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -74,7 +74,8 @@ streaming experience:
|
|||||||
- **Audio** — Opus + Oboe playback with a jitter ring, plus mic uplink to the host.
|
- **Audio** — Opus + Oboe playback with a jitter ring, plus mic uplink to the host.
|
||||||
- **Input** — game controllers (buttons + axes) with rumble and HID feedback; D-pad /
|
- **Input** — game controllers (buttons + axes) with rumble and HID feedback; D-pad /
|
||||||
game-controller focus navigation for the couch (TV + phone).
|
game-controller focus navigation for the couch (TV + phone).
|
||||||
- **Discovery & trust** — `NsdManager` mDNS host list, SPAKE2 PIN pairing and TOFU, with a
|
- **Discovery & trust** — native `mdns-sd` mDNS host list (polled over JNI; the same browse the
|
||||||
|
Linux/Windows clients use, not `NsdManager`), SPAKE2 PIN pairing and TOFU, with a
|
||||||
Keystore-wrapped client identity and a known-host store.
|
Keystore-wrapped client identity and a known-host store.
|
||||||
- **UI** — Compose host list / settings / stream screens, Material You theming.
|
- **UI** — Compose host list / settings / stream screens, Material You theming.
|
||||||
- **Shipping** — built for `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing).
|
- **Shipping** — built for `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing).
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ android {
|
|||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
||||||
versionCode = vCode?.toInt() ?: 1
|
versionCode = vCode?.toInt() ?: 1
|
||||||
versionName = "0.0.2" // bumped for first Play Store release
|
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
|
||||||
|
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
|
||||||
|
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2"
|
||||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,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
|
||||||
@@ -97,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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
<!-- punktfunk/1 QUIC/UDP data plane. -->
|
<!-- punktfunk/1 QUIC/UDP data plane. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<!-- mDNS discovery of _punktfunk._udp on the LAN (NsdManager). -->
|
<!-- mDNS discovery of _punktfunk._udp on the LAN (native mdns-sd browse). Requested
|
||||||
|
opportunistically — raw multicast reception needs only the MulticastLock, not this. -->
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
<!-- Hold a MulticastLock while NsdManager discovery runs (OEM Wi-Fi power-save hedge). -->
|
<!-- HostDiscovery holds a MulticastLock while the native mDNS browse runs — raw multicast
|
||||||
|
reception needs it (also an OEM Wi-Fi power-save hedge). -->
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import io.unom.punktfunk.components.EmptyHostsState
|
import io.unom.punktfunk.components.EmptyHostsState
|
||||||
import io.unom.punktfunk.components.HostCard
|
import io.unom.punktfunk.components.HostCard
|
||||||
import io.unom.punktfunk.components.SectionLabel
|
import io.unom.punktfunk.components.SectionLabel
|
||||||
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||||
import io.unom.punktfunk.kit.discovery.HostDiscovery
|
import io.unom.punktfunk.kit.discovery.HostDiscovery
|
||||||
@@ -73,40 +74,64 @@ 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) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var host by remember { mutableStateOf("") }
|
var host by remember { mutableStateOf("") }
|
||||||
|
var hostName by remember { mutableStateOf("") }
|
||||||
var port by remember { mutableStateOf("9777") }
|
var port by remember { mutableStateOf("9777") }
|
||||||
var connecting by remember { mutableStateOf(false) }
|
var connecting by remember { mutableStateOf(false) }
|
||||||
var status by remember { mutableStateOf<String?>(null) }
|
var status by remember { mutableStateOf<String?>(null) }
|
||||||
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
||||||
val (w, h, hz) = settings.effectiveMode(context)
|
val (w, h, hz) = settings.effectiveMode(context)
|
||||||
|
|
||||||
// mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the
|
// mDNS discovery scoped to this screen, via the native mdns-sd browse (HostDiscovery) — its
|
||||||
// onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.)
|
// onChange fires on the main thread, so it can set Compose state directly. (Emulator SLIRP drops
|
||||||
// NsdManager discovery needs NEARBY_WIFI_DEVICES on Android 13+ (a runtime permission) — without
|
// multicast → empty; that's the network, not the API.) Raw multicast reception only needs the
|
||||||
// it discoverServices silently finds nothing. Request it once, then (re)start discovery on grant.
|
// Wi-Fi MulticastLock (HostDiscovery holds it), NOT NEARBY_WIFI_DEVICES — that gated the old
|
||||||
|
// NsdManager path. We still request NEARBY_WIFI_DEVICES opportunistically (some OEMs filter
|
||||||
|
// multicast without it; harmless where it isn't), but never block discovery on the grant — a
|
||||||
|
// denial used to leave discovery dead forever.
|
||||||
val discovery = remember { HostDiscovery(context) }
|
val discovery = remember { HostDiscovery(context) }
|
||||||
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
|
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
|
||||||
var nearbyGranted by remember { mutableStateOf(hasNearbyPermission(context)) }
|
|
||||||
val nearbyLauncher = rememberLauncherForActivityResult(
|
val nearbyLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { granted -> nearbyGranted = granted }
|
) { _ -> /* best-effort hint; discovery runs regardless of the result */ }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (!nearbyGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) {
|
||||||
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DisposableEffect(nearbyGranted) {
|
DisposableEffect(Unit) {
|
||||||
discovery.onChange = { discovered = it }
|
discovery.onChange = { discovered = it }
|
||||||
if (nearbyGranted) discovery.start()
|
discovery.start()
|
||||||
onDispose {
|
onDispose {
|
||||||
discovery.onChange = null
|
discovery.onChange = null
|
||||||
discovery.stop()
|
discovery.stop()
|
||||||
@@ -124,8 +149,18 @@ 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).
|
||||||
|
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||||
|
|
||||||
|
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
|
||||||
|
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
|
||||||
|
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
|
||||||
|
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
|
||||||
|
|
||||||
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
|
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
|
||||||
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
|
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
|
||||||
@@ -140,11 +175,19 @@ 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 the user enabled it AND this device's display can present it
|
||||||
|
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||||
|
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||||
|
// "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
|
||||||
|
// explicit choice is passed through unchanged.
|
||||||
|
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||||
val handle = withContext(Dispatchers.IO) {
|
val handle = withContext(Dispatchers.IO) {
|
||||||
NativeBridge.nativeConnect(
|
NativeBridge.nativeConnect(
|
||||||
targetHost, targetPort, w, h, hz,
|
targetHost, targetPort, w, h, hz,
|
||||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
id.certPem, id.privateKeyPem, pinHex ?: "",
|
||||||
settings.bitrateKbps, settings.compositor, settings.gamepad,
|
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||||
|
hdrEnabled, settings.audioChannels, CONNECT_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
connecting = false
|
connecting = false
|
||||||
@@ -163,14 +206,77 @@ 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
|
||||||
fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) {
|
// access (approve in the console) or by the SPAKE2 PIN ceremony.
|
||||||
|
fun connect(
|
||||||
|
targetHost: String,
|
||||||
|
targetPort: Int,
|
||||||
|
dh: DiscoveredHost? = null,
|
||||||
|
manualName: String? = null,
|
||||||
|
) {
|
||||||
val known = knownHostStore.get(targetHost, targetPort)
|
val known = knownHostStore.get(targetHost, targetPort)
|
||||||
val adv = dh?.fingerprint?.lowercase()
|
val adv = dh?.fingerprint?.lowercase()
|
||||||
val name = dh?.name ?: targetHost
|
// Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered
|
||||||
|
// mDNS name; else the name typed in the Add-host sheet; else the bare address.
|
||||||
|
val name = known?.name ?: dh?.name ?: manualName?.trim()?.takeIf { it.isNotEmpty() } ?: targetHost
|
||||||
when {
|
when {
|
||||||
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
|
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
|
||||||
known != null && (adv == null || adv == known.fpHex) ->
|
known != null && (adv == null || adv == known.fpHex) ->
|
||||||
@@ -182,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +358,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (savedHosts.isEmpty() && discovered.isEmpty()) {
|
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
EmptyHostsState()
|
EmptyHostsState()
|
||||||
}
|
}
|
||||||
@@ -272,16 +379,17 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
knownHostStore.remove(kh.address, kh.port)
|
knownHostStore.remove(kh.address, kh.port)
|
||||||
savedHosts = knownHostStore.all()
|
savedHosts = knownHostStore.all()
|
||||||
},
|
},
|
||||||
|
onRename = { renameTarget = kh },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (discovered.isNotEmpty()) {
|
if (discoveredUnsaved.isNotEmpty()) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
SectionLabel("Discovered on the network")
|
SectionLabel("Discovered on the network")
|
||||||
}
|
}
|
||||||
items(discovered, key = { "disc-${it.host}-${it.port}" }) { dh ->
|
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
|
||||||
HostCard(
|
HostCard(
|
||||||
name = dh.name,
|
name = dh.name,
|
||||||
address = "${dh.host}:${dh.port}",
|
address = "${dh.host}:${dh.port}",
|
||||||
@@ -293,9 +401,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active-discovery hint: when we're scanning but nothing's turned up yet, show it's
|
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
|
||||||
// working rather than looking idle/empty.
|
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
|
||||||
if (nearbyGranted && discovered.isEmpty()) {
|
// rather than looking idle/empty.
|
||||||
|
if (!connecting && discovered.isEmpty()) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||||
@@ -354,6 +463,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = hostName,
|
||||||
|
onValueChange = { hostName = it },
|
||||||
|
label = { Text("Name (optional)") },
|
||||||
|
placeholder = { Text("e.g. Living Room") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = host,
|
value = host,
|
||||||
onValueChange = { host = it },
|
onValueChange = { host = it },
|
||||||
@@ -361,7 +479,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = port,
|
value = port,
|
||||||
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
||||||
@@ -376,9 +494,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
val h = host.trim()
|
val h = host.trim()
|
||||||
val p = port.toIntOrNull() ?: 9777
|
val p = port.toIntOrNull() ?: 9777
|
||||||
|
val n = hostName
|
||||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
||||||
showManualSheet = false
|
showManualSheet = false
|
||||||
connect(h, p)
|
connect(h, p, manualName = n)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -433,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") }
|
||||||
@@ -498,10 +644,95 @@ 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
|
||||||
|
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
||||||
|
renameTarget?.let { kh ->
|
||||||
|
var newName by remember(kh) { mutableStateOf(kh.name) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { renameTarget = null },
|
||||||
|
title = { Text("Rename host") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newName,
|
||||||
|
onValueChange = { newName = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
placeholder = { Text(kh.address) },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = newName.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
knownHostStore.rename(kh.address, kh.port, newName.trim())
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
renameTarget = null
|
||||||
|
},
|
||||||
|
) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { renameTarget = null }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** NsdManager discovery needs NEARBY_WIFI_DEVICES on API 33+; below that it doesn't apply. */
|
/**
|
||||||
|
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
|
||||||
|
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
|
||||||
|
* the native core + MulticastLock) does not depend on it.
|
||||||
|
*/
|
||||||
fun hasNearbyPermission(context: Context): Boolean =
|
fun hasNearbyPermission(context: Context): Boolean =
|
||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
|
||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a saved host and a discovered advert are the same machine — matched by certificate
|
||||||
|
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
|
||||||
|
* Mirrors the Apple client's `StoredHost.matches`; de-dupes "Discovered" against "Saved hosts".
|
||||||
|
*/
|
||||||
|
private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
|
||||||
|
val advFp = dh.fingerprint?.lowercase()
|
||||||
|
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
|
||||||
|
return address == dh.host && port == dh.port
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.view.Display
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-tunable stream settings, persisted in `SharedPreferences`. A `0` resolution/refresh means
|
* User-tunable stream settings, persisted in `SharedPreferences`. A `0` resolution/refresh means
|
||||||
@@ -13,11 +14,27 @@ 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,
|
||||||
|
/**
|
||||||
|
* Touch input model. `true` (default) = trackpad: the cursor stays put on touch-down and moves
|
||||||
|
* by the finger's relative delta (swipe to nudge, lift and re-swipe to walk it across), tap to
|
||||||
|
* click where it is. `false` = direct pointing: the cursor jumps to the finger (the old behaviour).
|
||||||
|
*/
|
||||||
|
val trackpadMode: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
||||||
@@ -30,10 +47,13 @@ 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),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: Settings) {
|
fun save(s: Settings) {
|
||||||
@@ -42,10 +62,13 @@ 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)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +77,13 @@ 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +102,21 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
|||||||
return Triple(maxOf(w, h), minOf(w, h), hz)
|
return Triple(maxOf(w, h), minOf(w, h), hz)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this device's display can actually present HDR10, so we should advertise HDR to the
|
||||||
|
* host. On an SDR panel we advertise `0` instead — the host then sends a proper 8-bit BT.709 stream
|
||||||
|
* rather than BT.2020 PQ the panel would mis-tone-map (the washed-out/dark failure). Mirrors the
|
||||||
|
* capability gate the Apple/Windows clients apply.
|
||||||
|
*/
|
||||||
|
fun displaySupportsHdr(context: Context): Boolean {
|
||||||
|
val display = runCatching { context.display }.getOrNull() ?: return false
|
||||||
|
@Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31
|
||||||
|
val caps = display.hdrCapabilities ?: return false
|
||||||
|
return caps.supportedHdrTypes.any {
|
||||||
|
it == Display.HdrCapabilities.HDR_TYPE_HDR10 || it == Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
||||||
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
||||||
val native = nativeDisplayMode(context)
|
val native = nativeDisplayMode(context)
|
||||||
@@ -108,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",
|
||||||
@@ -126,9 +174,11 @@ val COMPOSITOR_OPTIONS = listOf(
|
|||||||
"gamescope",
|
"gamescope",
|
||||||
)
|
)
|
||||||
|
|
||||||
/** index = GamepadPref wire byte. */
|
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
|
||||||
val GAMEPAD_OPTIONS = listOf(
|
val GAMEPAD_OPTIONS = listOf(
|
||||||
"Automatic",
|
"Automatic",
|
||||||
"Xbox 360",
|
"Xbox 360",
|
||||||
"DualSense",
|
"DualSense",
|
||||||
|
"Xbox One",
|
||||||
|
"DualShock 4",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ 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.BorderStroke
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -16,14 +15,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedCard
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -33,7 +32,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -47,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)
|
||||||
@@ -59,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()
|
||||||
@@ -90,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") {
|
||||||
@@ -107,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",
|
||||||
@@ -122,6 +148,16 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsGroup("Pointer") {
|
||||||
|
ToggleRow(
|
||||||
|
title = "Trackpad mode",
|
||||||
|
subtitle = "Relative cursor like a laptop touchpad — swipe to nudge, tap to click. " +
|
||||||
|
"Off = the cursor jumps to your finger.",
|
||||||
|
checked = s.trackpadMode,
|
||||||
|
onCheckedChange = { on -> update(s.copy(trackpadMode = on)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
SettingsGroup("Overlay") {
|
SettingsGroup("Overlay") {
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
title = "Stats overlay",
|
title = "Stats overlay",
|
||||||
@@ -130,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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,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(
|
||||||
@@ -170,16 +240,11 @@ private fun ToggleRow(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */
|
||||||
* A labelled value that opens a menu on click. Uses a clickable [Surface] + [DropdownMenu] rather
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
* than `ExposedDropdownMenuBox` — that component's read-only text field traps D-pad / controller
|
|
||||||
* focus (directional keys never leave it), so you can't navigate past it on a TV. Calls [onSelect]
|
|
||||||
* on a pick. A primary-colour border marks D-pad focus.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun <T> SettingDropdown(
|
private fun <T> SettingDropdown(
|
||||||
label: String,
|
label: String,
|
||||||
@@ -188,35 +253,20 @@ private fun <T> SettingDropdown(
|
|||||||
onSelect: (T) -> Unit,
|
onSelect: (T) -> Unit,
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
var focused by remember { mutableStateOf(false) }
|
|
||||||
val selectedLabel = options.firstOrNull { it.first == selected }?.second
|
val selectedLabel = options.firstOrNull { it.first == selected }?.second
|
||||||
?: options.firstOrNull()?.second.orEmpty()
|
?: options.firstOrNull()?.second.orEmpty()
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||||
Surface(
|
OutlinedTextField(
|
||||||
onClick = { expanded = true },
|
value = selectedLabel,
|
||||||
shape = MaterialTheme.shapes.small,
|
onValueChange = {},
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
readOnly = true,
|
||||||
border = if (focused) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
label = { Text(label) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||||
.onFocusChanged { focused = it.isFocused },
|
.fillMaxWidth(),
|
||||||
) {
|
)
|
||||||
Row(
|
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text(selectedLabel, style = MaterialTheme.typography.bodyLarge)
|
|
||||||
}
|
|
||||||
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
|
||||||
options.forEach { (value, lbl) ->
|
options.forEach { (value, lbl) ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(lbl) },
|
text = { Text(lbl) },
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -26,7 +27,6 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.input.pointer.positionChange
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -42,8 +42,25 @@ import io.unom.punktfunk.kit.NativeBridge
|
|||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.hypot
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
// Touch-gesture tuning (px / ms). TAP_SLOP: movement under this still counts as a tap, not a drag.
|
||||||
|
// TAP_DRAG_MS: a new touch within this long after a tap starts a left-button drag. SCROLL_DIV: px of
|
||||||
|
// two-finger pan per wheel notch (smaller = faster scroll).
|
||||||
|
private const val TAP_SLOP = 12f
|
||||||
|
private const val TAP_DRAG_MS = 250L
|
||||||
|
private const val SCROLL_DIV = 4f
|
||||||
|
|
||||||
|
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
|
||||||
|
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
|
||||||
|
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
||||||
|
// px/ms, capped at ACCEL_MAX (so a fast swipe can't fling the cursor uncontrollably).
|
||||||
|
private const val POINTER_SENS = 1.3f
|
||||||
|
private const val ACCEL_GAIN = 0.6f
|
||||||
|
private const val ACCEL_SPEED_FLOOR = 0.3f
|
||||||
|
private const val ACCEL_MAX = 3.0f
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -62,8 +79,11 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// Live decode stats for the HUD. Poll once a second for the whole stream (cheap, and each call
|
// Live decode stats for the HUD. Poll once a second for the whole stream (cheap, and each call
|
||||||
// drains+resets the native window so it never grows unbounded even while the overlay is hidden);
|
// drains+resets the native window so it never grows unbounded even while the overlay is hidden);
|
||||||
// `showStats` only gates rendering. A 3-finger tap toggles it live; the default comes from Settings.
|
// `showStats` only gates rendering. A 3-finger tap toggles it live; the default comes from Settings.
|
||||||
|
val initialSettings = remember { SettingsStore(context).load() }
|
||||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||||
var showStats by remember { mutableStateOf(SettingsStore(context).load().statsHudEnabled) }
|
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
|
||||||
|
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||||
|
val trackpad = initialSettings.trackpadMode
|
||||||
LaunchedEffect(handle) {
|
LaunchedEffect(handle) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
@@ -83,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.
|
||||||
@@ -95,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)
|
||||||
@@ -139,41 +169,154 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
if (showStats) {
|
if (showStats) {
|
||||||
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
||||||
}
|
}
|
||||||
// Touch virtual-trackpad overlay: 1-finger drag → relative mouse move; tap → left click;
|
// Touch → mouse. Two models, chosen by the Trackpad-mode setting:
|
||||||
// 2-finger drag → scroll; 3-finger tap → toggle the stats HUD. (Physical-mouse pointer
|
// • trackpad (default): the cursor STAYS where it is on touch-down and moves by the finger's
|
||||||
// capture comes in a later increment.)
|
// relative delta (MouseMove) with mild pointer acceleration — swipe to nudge, lift and
|
||||||
|
// re-swipe to walk it across, tap to click where it is. This is what makes the cursor
|
||||||
|
// reachable on a small screen.
|
||||||
|
// • direct (opt-out): the cursor jumps to the finger and follows it (MouseMoveAbs,
|
||||||
|
// host-normalized against the overlay size), the old "direct pointing" behaviour.
|
||||||
|
// Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||||
|
// two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||||
|
// windows); three-finger tap = toggle the stats HUD.
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().pointerInput(handle) {
|
Modifier.fillMaxSize().pointerInput(handle, trackpad) {
|
||||||
|
var lastTapUp = 0L
|
||||||
|
var lastTapX = 0f
|
||||||
|
var lastTapY = 0f
|
||||||
|
fun moveAbs(x: Float, y: Float) {
|
||||||
|
val sw = size.width
|
||||||
|
val sh = size.height
|
||||||
|
if (sw <= 0 || sh <= 0) return
|
||||||
|
NativeBridge.nativeSendPointerAbs(
|
||||||
|
handle,
|
||||||
|
x.coerceIn(0f, (sw - 1).toFloat()).roundToInt(),
|
||||||
|
y.coerceIn(0f, (sh - 1).toFloat()).roundToInt(),
|
||||||
|
sw,
|
||||||
|
sh,
|
||||||
|
)
|
||||||
|
}
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val first = awaitFirstDown(requireUnconsumed = false)
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
val startX = down.position.x
|
||||||
|
val startY = down.position.y
|
||||||
|
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||||
|
// button for this whole gesture (laptop-trackpad convention).
|
||||||
|
val isDrag = down.uptimeMillis - lastTapUp < TAP_DRAG_MS &&
|
||||||
|
abs(startX - lastTapX) < TAP_SLOP && abs(startY - lastTapY) < TAP_SLOP
|
||||||
|
lastTapUp = 0L // consume the arming either way
|
||||||
|
// Direct mode jumps the cursor to the finger; trackpad mode leaves it put (the
|
||||||
|
// whole point — you nudge it with swipes instead).
|
||||||
|
if (!trackpad) moveAbs(startX, startY)
|
||||||
|
if (isDrag) NativeBridge.nativeSendPointerButton(handle, 1, true)
|
||||||
|
|
||||||
var moved = false
|
var moved = false
|
||||||
var maxFingers = 1
|
var maxFingers = 1
|
||||||
|
var scrolling = false
|
||||||
|
var prevCx = startX
|
||||||
|
var prevCy = startY
|
||||||
|
var upTime = down.uptimeMillis
|
||||||
|
// Trackpad relative-motion state: the tracked finger, its last position/time, and
|
||||||
|
// the sub-pixel remainder so a slow drag isn't lost to Int truncation.
|
||||||
|
var trackId = down.id
|
||||||
|
var prevX = startX
|
||||||
|
var prevY = startY
|
||||||
|
var prevT = down.uptimeMillis
|
||||||
|
var accX = 0f
|
||||||
|
var accY = 0f
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val ev = awaitPointerEvent()
|
val ev = awaitPointerEvent()
|
||||||
val fingers = ev.changes.count { it.pressed }
|
val pressed = ev.changes.filter { it.pressed }
|
||||||
if (fingers == 0) break
|
if (pressed.isEmpty()) {
|
||||||
if (fingers > maxFingers) maxFingers = fingers
|
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
|
||||||
val primary = ev.changes.firstOrNull { it.id == first.id } ?: ev.changes.first()
|
break
|
||||||
val d = primary.positionChange()
|
}
|
||||||
if (abs(d.x) > 0.5f || abs(d.y) > 0.5f) {
|
if (pressed.size > maxFingers) maxFingers = pressed.size
|
||||||
moved = true
|
|
||||||
if (fingers >= 2) {
|
if (pressed.size >= 2) {
|
||||||
// screen +y down → wire +up, so negate y. Coarse divisor; tune live.
|
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||||
val sy = (-d.y / 4f).toInt()
|
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||||
val sx = (d.x / 4f).toInt()
|
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||||
if (sy != 0) NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
if (!scrolling) {
|
||||||
if (sx != 0) NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
scrolling = true
|
||||||
|
prevCx = cx
|
||||||
|
prevCy = cy
|
||||||
|
}
|
||||||
|
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
|
||||||
|
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
|
||||||
|
if (sy != 0) {
|
||||||
|
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
||||||
|
prevCy = cy
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
if (sx != 0) {
|
||||||
|
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
||||||
|
prevCx = cx
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
} else if (!scrolling) {
|
||||||
|
// One finger (skipped once a gesture turned into a scroll, so dropping
|
||||||
|
// back to one finger doesn't jerk the cursor).
|
||||||
|
val p = pressed.firstOrNull { it.id == down.id } ?: pressed.first()
|
||||||
|
if (abs(p.position.x - startX) > TAP_SLOP ||
|
||||||
|
abs(p.position.y - startY) > TAP_SLOP
|
||||||
|
) {
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
if (trackpad) {
|
||||||
|
// Relative: move by the finger delta × (sensitivity × acceleration),
|
||||||
|
// carrying the sub-pixel remainder. Re-anchor (zero delta this frame)
|
||||||
|
// if the tracked finger changed, so lifting one of several fingers
|
||||||
|
// never jumps the cursor.
|
||||||
|
if (p.id != trackId) {
|
||||||
|
trackId = p.id
|
||||||
|
prevX = p.position.x
|
||||||
|
prevY = p.position.y
|
||||||
|
prevT = p.uptimeMillis
|
||||||
|
}
|
||||||
|
val dx = p.position.x - prevX
|
||||||
|
val dy = p.position.y - prevY
|
||||||
|
val dt = (p.uptimeMillis - prevT).coerceAtLeast(1L)
|
||||||
|
prevX = p.position.x
|
||||||
|
prevY = p.position.y
|
||||||
|
prevT = p.uptimeMillis
|
||||||
|
val speed = hypot(dx, dy) / dt // finger px per ms
|
||||||
|
val accel = (1f + ACCEL_GAIN * (speed - ACCEL_SPEED_FLOOR).coerceAtLeast(0f))
|
||||||
|
.coerceAtMost(ACCEL_MAX)
|
||||||
|
accX += dx * POINTER_SENS * accel
|
||||||
|
accY += dy * POINTER_SENS * accel
|
||||||
|
val outX = accX.toInt() // truncates toward zero → remainder kept w/ sign
|
||||||
|
val outY = accY.toInt()
|
||||||
|
if (outX != 0 || outY != 0) {
|
||||||
|
NativeBridge.nativeSendPointerMove(handle, outX, outY)
|
||||||
|
accX -= outX
|
||||||
|
accY -= outY
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
NativeBridge.nativeSendPointerMove(handle, d.x.toInt(), d.y.toInt())
|
moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ev.changes.forEach { it.consume() }
|
ev.changes.forEach { it.consume() }
|
||||||
}
|
}
|
||||||
if (!moved && maxFingers == 1) {
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, 1, true)
|
if (isDrag) {
|
||||||
NativeBridge.nativeSendPointerButton(handle, 1, false)
|
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
||||||
} else if (!moved && maxFingers >= 3) {
|
} else if (!moved) {
|
||||||
showStats = !showStats // quick in-stream HUD toggle
|
when {
|
||||||
|
maxFingers >= 3 -> showStats = !showStats // in-stream HUD toggle
|
||||||
|
maxFingers == 2 -> { // two-finger tap → right click
|
||||||
|
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
||||||
|
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
||||||
|
}
|
||||||
|
else -> { // tap → left click (at the cursor's current spot), arm tap-drag
|
||||||
|
NativeBridge.nativeSendPointerButton(handle, 1, true)
|
||||||
|
NativeBridge.nativeSendPointerButton(handle, 1, false)
|
||||||
|
lastTapUp = upTime
|
||||||
|
lastTapX = startX
|
||||||
|
lastTapY = startY
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -182,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()
|
||||||
@@ -206,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(
|
||||||
@@ -225,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),
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ fun SectionLabel(text: String) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
|
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
|
||||||
* saved hosts) an overflow menu with Forget. Tapping the card connects.
|
* saved hosts) an overflow menu with Rename / Forget. Tapping the card connects.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun HostCard(
|
fun HostCard(
|
||||||
@@ -59,6 +59,7 @@ fun HostCard(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onConnect: () -> Unit,
|
onConnect: () -> Unit,
|
||||||
onForget: (() -> Unit)?,
|
onForget: (() -> Unit)?,
|
||||||
|
onRename: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
||||||
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
|
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
|
||||||
@@ -106,7 +107,7 @@ fun HostCard(
|
|||||||
StatusPill(status)
|
StatusPill(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onForget != null) {
|
if (onForget != null || onRename != null) {
|
||||||
var menu by remember { mutableStateOf(false) }
|
var menu by remember { mutableStateOf(false) }
|
||||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||||
@@ -118,13 +119,24 @@ fun HostCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
DropdownMenuItem(
|
if (onRename != null) {
|
||||||
text = { Text("Forget") },
|
DropdownMenuItem(
|
||||||
onClick = {
|
text = { Text("Rename") },
|
||||||
menu = false
|
onClick = {
|
||||||
onForget()
|
menu = false
|
||||||
},
|
onRename()
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (onForget != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Forget") },
|
||||||
|
onClick = {
|
||||||
|
menu = false
|
||||||
|
onForget()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,83 @@ object Gamepad {
|
|||||||
const val AXIS_LT = 4
|
const val AXIS_LT = 4
|
||||||
const val AXIS_RT = 5
|
const val AXIS_RT = 5
|
||||||
|
|
||||||
|
// GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`.
|
||||||
|
const val PREF_AUTO = 0
|
||||||
|
const val PREF_XBOX360 = 1
|
||||||
|
const val PREF_DUALSENSE = 2
|
||||||
|
const val PREF_XBOXONE = 3
|
||||||
|
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.
|
||||||
|
private const val VID_SONY = 0x054C
|
||||||
|
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.
|
||||||
|
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||||
|
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
|
||||||
|
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||||
|
private val PID_XBOXONE = setOf(
|
||||||
|
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a connected controller's [GamepadPref] wire byte from its USB VID/PID, mirroring the
|
||||||
|
* Linux client's `pref_for_type` (SDL3 `GamepadType`) and the Apple client's GameController type
|
||||||
|
* auto-resolution. Android exposes no controller-type enum, so we match `getVendorId()` /
|
||||||
|
* `getProductId()`. Used only when the user picked "Automatic" — an explicit choice is honored as
|
||||||
|
* is. An unrecognized pad (or none) falls back to [PREF_XBOX360], the safe XInput default the
|
||||||
|
* host always supports. Never returns [PREF_AUTO] (the host would then decide) — once we have a
|
||||||
|
* physical pad we resolve it concretely, matching the other native clients.
|
||||||
|
*/
|
||||||
|
fun prefFor(dev: InputDevice?): Int {
|
||||||
|
if (dev == null) return PREF_XBOX360
|
||||||
|
val vid = dev.vendorId
|
||||||
|
val pid = dev.productId
|
||||||
|
return when {
|
||||||
|
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
||||||
|
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||||
|
fun firstPad(): InputDevice? {
|
||||||
|
for (id in InputDevice.getDeviceIds()) {
|
||||||
|
val d = InputDevice.getDevice(id) ?: continue
|
||||||
|
val s = d.sources
|
||||||
|
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||||
|
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||||
|
) {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A
|
||||||
|
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
|
||||||
|
* type from the first connected controller via [prefFor] (so the host gets the right pad even
|
||||||
|
* though Android can't tell it the controller type any other way).
|
||||||
|
*/
|
||||||
|
fun resolvePref(setting: Int): Int =
|
||||||
|
if (setting == PREF_AUTO) prefFor(firstPad()) else setting
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gamepad `KEYCODE_*` → BTN_* bit, or 0 if not a gamepad button we forward. A/B/X/Y are
|
* Gamepad `KEYCODE_*` → BTN_* bit, or 0 if not a gamepad button we forward. A/B/X/Y are
|
||||||
* positional (Xbox layout; Nintendo relabeling needs device-type detection, deferred).
|
* positional (Xbox layout; Nintendo relabeling needs device-type detection, deferred).
|
||||||
|
|||||||
@@ -81,8 +81,16 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
rumbleThread?.interrupt()
|
rumbleThread?.interrupt()
|
||||||
hidoutThread?.interrupt()
|
hidoutThread?.interrupt()
|
||||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||||
runCatching { rumbleThread?.join(200) }
|
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
||||||
runCatching { hidoutThread?.join(200) }
|
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
||||||
|
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
||||||
|
// would let a thread survive into the freed handle → use-after-free SIGSEGV (the
|
||||||
|
// back-while-streaming crash, on the one path the main-thread `closed` guard can't cover).
|
||||||
|
// Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms)
|
||||||
|
// and rendering is a quick best-effort binder call, so each thread observes running=false and
|
||||||
|
// exits within ~one timeout — the join returns promptly (well under any ANR threshold).
|
||||||
|
runCatching { rumbleThread?.join() }
|
||||||
|
runCatching { hidoutThread?.join() }
|
||||||
rumbleThread = null
|
rumbleThread = null
|
||||||
hidoutThread = null
|
hidoutThread = null
|
||||||
runCatching { lightsSession?.close() }
|
runCatching { lightsSession?.close() }
|
||||||
@@ -94,18 +102,7 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
|
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
|
||||||
private fun resolvePad(): InputDevice? {
|
private fun resolvePad(): InputDevice? = Gamepad.firstPad()
|
||||||
for (id in InputDevice.getDeviceIds()) {
|
|
||||||
val d = InputDevice.getDevice(id) ?: continue
|
|
||||||
val s = d.sources
|
|
||||||
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
|
||||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
|
||||||
) {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Rumble ----
|
// ---- Rumble ----
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -44,6 +46,9 @@ object NativeBridge {
|
|||||||
bitrateKbps: Int,
|
bitrateKbps: Int,
|
||||||
compositorPref: Int,
|
compositorPref: Int,
|
||||||
gamepadPref: Int,
|
gamepadPref: Int,
|
||||||
|
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. */
|
||||||
@@ -66,6 +71,27 @@ object NativeBridge {
|
|||||||
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
|
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
|
||||||
external fun nativeClose(handle: Long)
|
external fun nativeClose(handle: Long)
|
||||||
|
|
||||||
|
// ---- LAN discovery: mDNS browse of `_punktfunk._udp` in Rust (mdns-sd), polled by Kotlin ----
|
||||||
|
// Replaces NsdManager. The caller holds the Wi-Fi MulticastLock for the browse lifetime; raw
|
||||||
|
// multicast *reception* needs it. See io.unom.punktfunk.kit.discovery.HostDiscovery.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start browsing `_punktfunk._udp` on the LAN. Returns an opaque discovery handle, or `0` on
|
||||||
|
* failure. Pair with exactly one [nativeDiscoveryStop]. Cheap + non-blocking (spawns the mDNS
|
||||||
|
* daemon + a fold thread).
|
||||||
|
*/
|
||||||
|
external fun nativeDiscoveryStart(): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current resolved-host snapshot for [handle]: newline-joined records, each
|
||||||
|
* `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
|
||||||
|
* cheap (a lock + string build), safe to call on the main thread.
|
||||||
|
*/
|
||||||
|
external fun nativeDiscoveryPoll(handle: Long): String
|
||||||
|
|
||||||
|
/** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */
|
||||||
|
external fun nativeDiscoveryStop(handle: Long)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
||||||
@@ -77,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?
|
||||||
|
|
||||||
@@ -107,6 +136,13 @@ object NativeBridge {
|
|||||||
/** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */
|
/** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */
|
||||||
external fun nativeSendPointerMove(handle: Long, dx: Int, dy: Int)
|
external fun nativeSendPointerMove(handle: Long, dx: Int, dy: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute mouse position — the host moves the cursor to (x, y) in a [surfaceWidth]×[surfaceHeight]
|
||||||
|
* pixel space (it normalizes against that size and maps into the output region). Touch
|
||||||
|
* "direct pointing": the cursor jumps to the finger. Parity with the Apple client's absolute touch.
|
||||||
|
*/
|
||||||
|
external fun nativeSendPointerAbs(handle: Long, x: Int, y: Int, surfaceWidth: Int, surfaceHeight: Int)
|
||||||
|
|
||||||
/** One mouse-button transition. button: 1=left 2=middle 3=right 4=X1 5=X2. */
|
/** One mouse-button transition. button: 1=left 2=middle 3=right 4=X1 5=X2. */
|
||||||
external fun nativeSendPointerButton(handle: Long, button: Int, down: Boolean)
|
external fun nativeSendPointerButton(handle: Long, button: Int, down: Boolean)
|
||||||
|
|
||||||
|
|||||||
+88
-138
@@ -1,17 +1,13 @@
|
|||||||
package io.unom.punktfunk.kit.discovery
|
package io.unom.punktfunk.kit.discovery
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.nsd.NsdManager
|
|
||||||
import android.net.nsd.NsdServiceInfo
|
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Build
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
|
||||||
private const val TAG = "PunktfunkNsd"
|
private const val TAG = "PunktfunkMdns"
|
||||||
|
|
||||||
/** DNS-SD service type punktfunk hosts advertise (host: `_punktfunk._udp.local.`). */
|
|
||||||
const val PUNKTFUNK_SERVICE_TYPE = "_punktfunk._udp"
|
|
||||||
const val PUNKTFUNK_PROTO = "punktfunk/1"
|
|
||||||
|
|
||||||
/** One resolved host fit for the picker. [key] is the stable dedup id. */
|
/** One resolved host fit for the picker. [key] is the stable dedup id. */
|
||||||
data class DiscoveredHost(
|
data class DiscoveredHost(
|
||||||
@@ -23,165 +19,115 @@ data class DiscoveredHost(
|
|||||||
val pairingRequired: Boolean = false,
|
val pairingRequired: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Parsed TXT fields. Pure — unit-testable without Android (see ParseTxtTest). */
|
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||||
data class TxtFields(
|
private const val FIELD_SEP = '\u001F'
|
||||||
val proto: String?,
|
|
||||||
val fp: String?,
|
|
||||||
val pair: String?,
|
|
||||||
val id: String?,
|
|
||||||
) {
|
|
||||||
val pairingRequired: Boolean get() = pair == "required"
|
|
||||||
val isPunktfunk: Boolean get() = proto == PUNKTFUNK_PROTO
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure TXT parser. NSD hands TXT as a `Map<String, ByteArray?>` (a null/empty value = present-but-
|
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
|
||||||
* empty key). Decode UTF-8; missing keys are null, never an error.
|
* if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side
|
||||||
|
* already applied the protocol gate and address selection, so this is just field marshaling.
|
||||||
*/
|
*/
|
||||||
fun parseTxt(attrs: Map<String, ByteArray?>): TxtFields {
|
fun parseHostRecord(record: String): DiscoveredHost? {
|
||||||
fun s(k: String): String? = attrs[k]?.takeIf { it.isNotEmpty() }?.toString(Charsets.UTF_8)
|
val f = record.split(FIELD_SEP)
|
||||||
return TxtFields(proto = s("proto"), fp = s("fp"), pair = s("pair"), id = s("id"))
|
if (f.size < 6) return null
|
||||||
|
val addr = f[2]
|
||||||
|
val port = f[3].toIntOrNull() ?: return null
|
||||||
|
if (addr.isBlank() || port !in 1..65535) return null
|
||||||
|
return DiscoveredHost(
|
||||||
|
key = f[0].ifBlank { "$addr:$port" },
|
||||||
|
name = f[1].ifBlank { addr },
|
||||||
|
host = addr,
|
||||||
|
port = port,
|
||||||
|
fingerprint = f[4].ifBlank { null },
|
||||||
|
pairingRequired = f[5] == "required",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browses `_punktfunk._udp` via NsdManager, resolves each service (the reliable
|
* Browses `_punktfunk._udp` for punktfunk/1 hosts via the native `mdns-sd` core (the same browse the
|
||||||
* `registerServiceInfoCallback` path on API 34+, legacy `resolveService` on 31–33 where its TXT is
|
* Linux/Windows clients use), exposed over JNI — *not* `NsdManager`, whose per-OEM system daemon
|
||||||
* often empty), and pushes the live host set to [onChange] (invoked on the main thread).
|
* made discovery "mostly broken". [start] spins up the native browse and polls it ~1 Hz on the main
|
||||||
|
* thread, pushing the live host set to [onChange] (also on the main thread, only when it changes);
|
||||||
|
* [stop] tears it down.
|
||||||
*
|
*
|
||||||
* Lifecycle: [start] when the picker appears, [stop] when it leaves / on connect — holds a
|
* We hold a Wi-Fi [WifiManager.MulticastLock] for the browse lifetime — raw multicast *reception*
|
||||||
* MulticastLock while running (an OEM Wi-Fi power-save hedge). Note: the Android emulator's SLIRP
|
* needs it. (The Android emulator's SLIRP NAT drops multicast, so on the emulator discovery starts
|
||||||
* NAT drops multicast, so on the emulator discovery starts but never finds a LAN host.
|
* but never finds a LAN host — same as before; that's the network, not the API.)
|
||||||
*/
|
*/
|
||||||
class HostDiscovery(context: Context) {
|
class HostDiscovery(context: Context) {
|
||||||
private val appCtx = context.applicationContext
|
private val appCtx = context.applicationContext
|
||||||
private val nsd = appCtx.getSystemService(Context.NSD_SERVICE) as NsdManager
|
|
||||||
|
|
||||||
/** Invoked on the main thread whenever the resolved host set changes. */
|
/** Invoked on the main thread whenever the resolved host set changes. */
|
||||||
var onChange: ((List<DiscoveredHost>) -> Unit)? = null
|
var onChange: ((List<DiscoveredHost>) -> Unit)? = null
|
||||||
|
|
||||||
private val resolved = LinkedHashMap<String, DiscoveredHost>() // key -> host
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var multicastLock: WifiManager.MulticastLock? = null
|
private var multicastLock: WifiManager.MulticastLock? = null
|
||||||
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
private var nativeHandle = 0L
|
||||||
private val infoCallbacks = mutableListOf<NsdManager.ServiceInfoCallback>() // API 34+ registrations
|
|
||||||
private var running = false
|
private var running = false
|
||||||
|
private var last: List<DiscoveredHost> = emptyList()
|
||||||
|
|
||||||
@Synchronized
|
private val poll = object : Runnable {
|
||||||
fun start() {
|
override fun run() {
|
||||||
if (running) return
|
if (!running) return
|
||||||
running = true
|
val hosts = snapshot()
|
||||||
acquireMulticastLock()
|
if (hosts != last) {
|
||||||
val listener = makeDiscoveryListener()
|
last = hosts
|
||||||
discoveryListener = listener
|
onChange?.invoke(hosts)
|
||||||
runCatching {
|
}
|
||||||
nsd.discoverServices(PUNKTFUNK_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
|
handler.postDelayed(this, POLL_MS)
|
||||||
}.onFailure {
|
|
||||||
Log.e(TAG, "discoverServices failed", it)
|
|
||||||
stop()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
fun start() {
|
||||||
fun stop() {
|
if (running) return
|
||||||
if (!running) return
|
acquireMulticastLock()
|
||||||
running = false
|
val h = runCatching { NativeBridge.nativeDiscoveryStart() }
|
||||||
discoveryListener?.let { runCatching { nsd.stopServiceDiscovery(it) } }
|
.onFailure { Log.e(TAG, "nativeDiscoveryStart threw", it) }
|
||||||
discoveryListener = null
|
.getOrDefault(0L)
|
||||||
if (Build.VERSION.SDK_INT >= 34) {
|
if (h == 0L) {
|
||||||
for (cb in infoCallbacks) runCatching { nsd.unregisterServiceInfoCallback(cb) }
|
Log.e(TAG, "native mDNS discovery failed to start")
|
||||||
|
releaseMulticastLock()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
infoCallbacks.clear()
|
nativeHandle = h
|
||||||
|
running = true
|
||||||
|
last = emptyList()
|
||||||
|
handler.post(poll)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (!running && nativeHandle == 0L) return
|
||||||
|
running = false
|
||||||
|
handler.removeCallbacks(poll)
|
||||||
|
val h = nativeHandle
|
||||||
|
nativeHandle = 0L
|
||||||
|
if (h != 0L) runCatching { NativeBridge.nativeDiscoveryStop(h) }
|
||||||
|
.onFailure { Log.e(TAG, "nativeDiscoveryStop threw", it) }
|
||||||
releaseMulticastLock()
|
releaseMulticastLock()
|
||||||
resolved.clear()
|
last = emptyList()
|
||||||
onChange?.invoke(emptyList())
|
onChange?.invoke(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun publish() {
|
private fun snapshot(): List<DiscoveredHost> {
|
||||||
onChange?.invoke(resolved.values.sortedBy { it.name.lowercase() })
|
val h = nativeHandle
|
||||||
}
|
if (h == 0L) return emptyList()
|
||||||
|
// getOrNull (not getOrDefault): the JNI returns a platform String!, so a (near-impossible)
|
||||||
private fun makeDiscoveryListener() = object : NsdManager.DiscoveryListener {
|
// native null is a *success* value here — coalesce it so the main-thread poll can't NPE.
|
||||||
override fun onDiscoveryStarted(type: String) {
|
val blob = runCatching { NativeBridge.nativeDiscoveryPoll(h) }
|
||||||
Log.d(TAG, "discovery started: $type")
|
.onFailure { Log.e(TAG, "nativeDiscoveryPoll threw", it) }
|
||||||
}
|
.getOrNull() ?: ""
|
||||||
override fun onDiscoveryStopped(type: String) {
|
if (blob.isEmpty()) return emptyList()
|
||||||
Log.d(TAG, "discovery stopped: $type")
|
return blob.split('\n')
|
||||||
}
|
.filter { it.isNotBlank() }
|
||||||
override fun onStartDiscoveryFailed(type: String, code: Int) {
|
.mapNotNull { parseHostRecord(it) }
|
||||||
Log.e(TAG, "start discovery failed: $code")
|
.associateBy { it.key } // dedup by stable key (id, or addr:port)
|
||||||
runCatching { nsd.stopServiceDiscovery(this) }
|
.values
|
||||||
}
|
.sortedBy { it.name.lowercase() }
|
||||||
override fun onStopDiscoveryFailed(type: String, code: Int) {
|
|
||||||
Log.e(TAG, "stop discovery failed: $code")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceFound(info: NsdServiceInfo) {
|
|
||||||
Log.d(TAG, "found: ${info.serviceName}")
|
|
||||||
resolve(info)
|
|
||||||
}
|
|
||||||
override fun onServiceLost(info: NsdServiceInfo) {
|
|
||||||
Log.d(TAG, "lost: ${info.serviceName}")
|
|
||||||
// onServiceLost carries no TXT, so drop by the instance-name fallback key only.
|
|
||||||
if (resolved.remove(info.serviceName) != null) publish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolve(found: NsdServiceInfo) {
|
|
||||||
if (Build.VERSION.SDK_INT >= 34) resolveViaCallback(found) else resolveViaLegacy(found)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveViaCallback(found: NsdServiceInfo) {
|
|
||||||
val cb = object : NsdManager.ServiceInfoCallback {
|
|
||||||
override fun onServiceUpdated(info: NsdServiceInfo) = ingest(info)
|
|
||||||
override fun onServiceLost() {}
|
|
||||||
override fun onServiceInfoCallbackRegistrationFailed(code: Int) {
|
|
||||||
Log.e(TAG, "ServiceInfoCallback reg failed: $code")
|
|
||||||
}
|
|
||||||
override fun onServiceInfoCallbackUnregistered() {}
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
nsd.registerServiceInfoCallback(found, appCtx.mainExecutor, cb)
|
|
||||||
infoCallbacks.add(cb)
|
|
||||||
}.onFailure { Log.e(TAG, "registerServiceInfoCallback failed", it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveViaLegacy(found: NsdServiceInfo) {
|
|
||||||
// A ResolveListener can't be reused — allocate one per resolve. TXT may be empty pre-34.
|
|
||||||
val listener = object : NsdManager.ResolveListener {
|
|
||||||
override fun onServiceResolved(info: NsdServiceInfo) = ingest(info)
|
|
||||||
override fun onResolveFailed(info: NsdServiceInfo, code: Int) {
|
|
||||||
Log.e(TAG, "resolve failed: $code")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runCatching { nsd.resolveService(found, listener) }
|
|
||||||
.onFailure { Log.e(TAG, "resolveService failed", it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION") // info.host is deprecated at API 34 (replaced by hostAddresses)
|
|
||||||
private fun ingest(info: NsdServiceInfo) {
|
|
||||||
val txt = parseTxt(info.attributes)
|
|
||||||
// Reject an incompatible protocol IF the host advertised one; tolerate empty TXT (pre-34).
|
|
||||||
if (txt.proto != null && !txt.isPunktfunk) {
|
|
||||||
Log.d(TAG, "skip non-punktfunk proto=${txt.proto}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val ip = (if (Build.VERSION.SDK_INT >= 34) info.hostAddresses.firstOrNull() else info.host)
|
|
||||||
?.hostAddress ?: return
|
|
||||||
val key = txt.id?.takeIf { it.isNotBlank() } ?: info.serviceName
|
|
||||||
resolved[key] = DiscoveredHost(
|
|
||||||
key = key,
|
|
||||||
name = info.serviceName.removeSuffix("."),
|
|
||||||
host = ip,
|
|
||||||
port = info.port,
|
|
||||||
fingerprint = txt.fp,
|
|
||||||
pairingRequired = txt.pairingRequired,
|
|
||||||
)
|
|
||||||
Log.d(TAG, "resolved: ${resolved[key]}")
|
|
||||||
publish()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun acquireMulticastLock() {
|
private fun acquireMulticastLock() {
|
||||||
val wifi = appCtx.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
val wifi = appCtx.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||||
multicastLock = wifi.createMulticastLock("punktfunk-nsd").apply {
|
multicastLock = wifi.createMulticastLock("punktfunk-mdns").apply {
|
||||||
setReferenceCounted(true)
|
setReferenceCounted(true)
|
||||||
runCatching { acquire() }
|
runCatching { acquire() }
|
||||||
}
|
}
|
||||||
@@ -191,4 +137,8 @@ class HostDiscovery(context: Context) {
|
|||||||
multicastLock?.takeIf { it.isHeld }?.let { runCatching { it.release() } }
|
multicastLock?.takeIf { it.isHeld }?.let { runCatching { it.release() } }
|
||||||
multicastLock = null
|
multicastLock = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val POLL_MS = 1000L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ class KnownHostStore(context: Context) {
|
|||||||
prefs.edit().remove(key(address, port)).apply()
|
prefs.edit().remove(key(address, port)).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set a saved host's display name, keeping its pin + paired flag. No-op if not saved. */
|
||||||
|
fun rename(address: String, port: Int, newName: String) {
|
||||||
|
val h = get(address, port) ?: return
|
||||||
|
save(h.copy(name = newName))
|
||||||
|
}
|
||||||
|
|
||||||
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
||||||
fun all(): List<KnownHost> =
|
fun all(): List<KnownHost> =
|
||||||
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package io.unom.punktfunk.kit.discovery
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure JVM test of the native-record parser (`key␟name␟addr␟port␟fp␟pair`), the Kotlin half of the
|
||||||
|
* discovery JNI seam. No Android types. Run: `./gradlew :kit:testDebugUnitTest`.
|
||||||
|
*/
|
||||||
|
class ParseRecordTest {
|
||||||
|
private val s = '\u001F' // field separator (must match the Rust side, discovery.rs FIELD_SEP)
|
||||||
|
|
||||||
|
private fun rec(vararg f: String) = f.joinToString(s.toString())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesFullRecord() {
|
||||||
|
val fp = "a".repeat(64)
|
||||||
|
val h = parseHostRecord(rec("host-123", "home-worker-2", "192.168.1.70", "9777", fp, "required"))!!
|
||||||
|
assertEquals("host-123", h.key)
|
||||||
|
assertEquals("home-worker-2", h.name)
|
||||||
|
assertEquals("192.168.1.70", h.host)
|
||||||
|
assertEquals(9777, h.port)
|
||||||
|
assertEquals(fp, h.fingerprint)
|
||||||
|
assertTrue(h.pairingRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun optionalPairingAndEmptyFingerprint() {
|
||||||
|
val h = parseHostRecord(rec("id", "name", "10.0.0.5", "9777", "", "optional"))!!
|
||||||
|
assertNull(h.fingerprint)
|
||||||
|
assertEquals(false, h.pairingRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyKeyFallsBackToAddrPort() {
|
||||||
|
// Host advertised no `id` TXT → the native side leaves the key blank; we synthesize addr:port.
|
||||||
|
val h = parseHostRecord(rec("", "name", "10.0.0.5", "9777", "", "required"))!!
|
||||||
|
assertEquals("10.0.0.5:9777", h.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyNameFallsBackToAddr() {
|
||||||
|
val h = parseHostRecord(rec("k", "", "10.0.0.5", "9777", "", "optional"))!!
|
||||||
|
assertEquals("10.0.0.5", h.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsTooFewFields() {
|
||||||
|
assertNull(parseHostRecord("only${'\u001F'}three${'\u001F'}fields"))
|
||||||
|
assertNull(parseHostRecord(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsBadPortOrAddress() {
|
||||||
|
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "notaport", "", "required")))
|
||||||
|
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "0", "", "required")))
|
||||||
|
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "70000", "", "required")))
|
||||||
|
assertNull(parseHostRecord(rec("k", "n", "", "9777", "", "required")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package io.unom.punktfunk.kit.discovery
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/** Pure JVM test of the mDNS TXT parser (no Android types). Run: `./gradlew :kit:testDebugUnitTest`. */
|
|
||||||
class ParseTxtTest {
|
|
||||||
private fun b(s: String): ByteArray = s.toByteArray(Charsets.UTF_8)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parsesFullRecord() {
|
|
||||||
val fp = "a".repeat(64)
|
|
||||||
val t = parseTxt(
|
|
||||||
mapOf(
|
|
||||||
"proto" to b("punktfunk/1"),
|
|
||||||
"fp" to b(fp),
|
|
||||||
"pair" to b("required"),
|
|
||||||
"id" to b("host-123"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals("punktfunk/1", t.proto)
|
|
||||||
assertEquals(fp, t.fp)
|
|
||||||
assertEquals("host-123", t.id)
|
|
||||||
assertTrue(t.isPunktfunk)
|
|
||||||
assertTrue(t.pairingRequired)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun optionalPairingAndMissingKeys() {
|
|
||||||
val t = parseTxt(mapOf("proto" to b("punktfunk/1"), "pair" to b("optional")))
|
|
||||||
assertFalse(t.pairingRequired)
|
|
||||||
assertNull(t.fp)
|
|
||||||
assertNull(t.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun emptyMapYieldsAllNull() {
|
|
||||||
val t = parseTxt(emptyMap())
|
|
||||||
assertNull(t.proto)
|
|
||||||
assertNull(t.fp)
|
|
||||||
assertNull(t.pair)
|
|
||||||
assertNull(t.id)
|
|
||||||
assertFalse(t.isPunktfunk)
|
|
||||||
assertFalse(t.pairingRequired)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nullAndEmptyValuesTreatedAsAbsent() {
|
|
||||||
// NSD delivers present-but-empty TXT keys as null / empty ByteArray.
|
|
||||||
val t = parseTxt(mapOf("fp" to null, "id" to ByteArray(0), "proto" to b("punktfunk/1")))
|
|
||||||
assertNull(t.fp)
|
|
||||||
assertNull(t.id)
|
|
||||||
assertTrue(t.isPunktfunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nonPunktfunkProtoIsNotAccepted() {
|
|
||||||
assertFalse(parseTxt(mapOf("proto" to b("moonlight/7"))).isPunktfunk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,12 @@ crate-type = ["cdylib"]
|
|||||||
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
|
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
|
||||||
jni = "0.21"
|
jni = "0.21"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
|
||||||
|
# Linux/Windows clients use (`clients/linux/src/discovery.rs`), replacing Android's per-OEM
|
||||||
|
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
|
||||||
|
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
|
||||||
|
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
|
||||||
|
mdns-sd = "0.20"
|
||||||
|
|
||||||
# Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still
|
# Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still
|
||||||
# compiles this crate (as a host cdylib) — the Android-framework glue (logging now; AMediaCodec via
|
# compiles this crate (as a host cdylib) — the Android-framework glue (logging now; AMediaCodec via
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
//! 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. Ring logic ported from
|
//! producer) plus a shutdown flag; the realtime callback thread is owned by AAudio.
|
||||||
//! `punktfunk-client-linux/src/audio.rs` (prime ~3 quanta, drop-oldest cap, re-prime on drain).
|
//!
|
||||||
|
//! 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
|
||||||
|
//! PipeWire, which adaptively rate-matches the stream and absorbs a shallow buffer — hands us a raw
|
||||||
|
//! realtime callback and makes us own the buffer. So this client diverges deliberately to stop the
|
||||||
|
//! Android-only crackle: (1) the callback is allocation/free-free — decoded buffers are recycled to
|
||||||
|
//! the producer via a free-list instead of being freed on the audio thread (Android's Scudo `free`
|
||||||
|
//! has unbounded tail latency); (2) the jitter ring is deeper (~40 ms prime / ~150 ms hard cap) and
|
||||||
|
//! decoupled from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain
|
||||||
|
//! doesn't manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and
|
||||||
|
//! grown on XRuns (Google's anti-glitch technique).
|
||||||
|
|
||||||
use ndk::audio::{
|
use ndk::audio::{
|
||||||
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode,
|
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode,
|
||||||
@@ -13,16 +26,75 @@ use punktfunk_core::error::PunktfunkError;
|
|||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
|
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 MILLISECONDS (scaled to interleaved-f32 samples at runtime). --------
|
||||||
|
// The channel count is negotiated, not a compile-time const, so these are kept in ms and multiplied
|
||||||
|
// by `ms` (interleaved-f32 samples per millisecond at the resolved layout) inside `start`.
|
||||||
|
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
|
||||||
|
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
|
||||||
|
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
|
||||||
|
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
|
||||||
|
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
|
||||||
|
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
|
||||||
|
const PRIME_FLOOR_MS: usize = 40;
|
||||||
|
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
|
||||||
|
const PRIME_CEIL_MS: usize = 80;
|
||||||
|
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
|
||||||
|
/// without overflowing.
|
||||||
|
const JITTER_HEADROOM_MS: usize = 80;
|
||||||
|
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
|
||||||
|
const HARD_CAP_MS: usize = 150;
|
||||||
|
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
|
||||||
|
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
|
||||||
|
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
|
||||||
|
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
|
||||||
|
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).
|
||||||
@@ -42,27 +114,57 @@ 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
|
||||||
|
// realtime callback never frees heap (Android's Scudo allocator has unbounded free() tail
|
||||||
|
// latency — a free on the audio thread is an XRun = a click) and the decode thread rarely
|
||||||
|
// allocates. Same depth as the data channel.
|
||||||
|
let (free_tx, free_rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||||
|
|
||||||
// Realtime consumer state, owned by the callback (FnMut) — no lock: AAudio calls it from a
|
// Realtime consumer state, owned by the callback (FnMut) — no lock: AAudio calls it from a
|
||||||
// single high-priority thread, and the decode thread only touches `tx`.
|
// single high-priority thread, and the decode thread only touches `tx`/`free_rx`.
|
||||||
let cb_counters = counters.clone();
|
let cb_counters = counters.clone();
|
||||||
let mut ring: VecDeque<f32> = VecDeque::with_capacity(PCM_SCRATCH);
|
// Pre-reserve the ring so `extend` never reallocates on the realtime thread. Worst transient
|
||||||
|
// 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
|
||||||
|
// would force a one-time realloc, asserted (not silently corrupted) in `decode_loop`.
|
||||||
|
let mut ring: VecDeque<f32> = VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms);
|
||||||
let mut primed = false;
|
let mut primed = false;
|
||||||
let callback = move |_s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
|
||||||
let want = num_frames as usize * CHANNELS;
|
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
|
||||||
|
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
|
||||||
|
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
||||||
|
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) };
|
||||||
while let Ok(chunk) = rx.try_recv() {
|
// Drain decoded chunks into the ring WITHOUT freeing on the RT thread: `drain(..)` empties
|
||||||
ring.extend(chunk);
|
// each Vec but keeps its capacity, then the empty buffer is handed back for reuse. The
|
||||||
|
// only RT-thread free is the rare case where the recycle channel is momentarily full.
|
||||||
|
while let Ok(mut chunk) = rx.try_recv() {
|
||||||
|
ring.extend(chunk.drain(..));
|
||||||
|
let _ = free_tx.try_send(chunk);
|
||||||
}
|
}
|
||||||
// Prime to ~3 quanta (15 ms; floor 15 ms / ceiling 200 ms); drop OLDEST above the cap.
|
// Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained drain;
|
||||||
let target = (3 * want).clamp(720 * CHANNELS, 9600 * CHANNELS);
|
// drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst `want` (tiny
|
||||||
while ring.len() > target.max(want) + want {
|
// on the LowLatency MMAP path) so the depth doesn't collapse to a single quantum.
|
||||||
|
let target = (3 * want).clamp(prime_floor, prime_ceil);
|
||||||
|
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
|
||||||
|
while ring.len() > hard_cap {
|
||||||
ring.pop_front();
|
ring.pop_front();
|
||||||
}
|
}
|
||||||
if !primed && ring.len() >= target {
|
if !primed && ring.len() >= target {
|
||||||
@@ -79,12 +181,34 @@ impl AudioPlayback {
|
|||||||
out.fill(0.0);
|
out.fill(0.0);
|
||||||
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
|
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
// Re-prime only after a RUN of empty callbacks, not a single transient one — otherwise
|
||||||
|
// every momentary drain costs a fresh 40 ms silence (the old behaviour, self-inflicted
|
||||||
|
// crackle on any jitter spike).
|
||||||
if ring.is_empty() {
|
if ring.is_empty() {
|
||||||
primed = false; // re-prime after a genuine drain (avoids sustained crackle on loss)
|
empties += 1;
|
||||||
|
if empties >= DEPRIME_AFTER_CALLBACKS {
|
||||||
|
primed = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
empties = 0;
|
||||||
}
|
}
|
||||||
cb_counters
|
cb_counters
|
||||||
.ring_depth
|
.ring_depth
|
||||||
.store(ring.len() as u64, Ordering::Relaxed);
|
.store(ring.len() as u64, Ordering::Relaxed);
|
||||||
|
// Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the HW
|
||||||
|
// buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are both
|
||||||
|
// callback-safe / non-blocking, and set clamps to capacity so it self-limits. Throttled.
|
||||||
|
cb_count = cb_count.wrapping_add(1);
|
||||||
|
if cb_count % XRUN_CHECK_EVERY == 0 {
|
||||||
|
let xr = s.x_run_count();
|
||||||
|
if xr > last_xrun {
|
||||||
|
last_xrun = xr;
|
||||||
|
let burst = s.frames_per_burst().max(1);
|
||||||
|
let grown =
|
||||||
|
(s.buffer_size_in_frames() + burst).min(s.buffer_capacity_in_frames());
|
||||||
|
let _ = s.set_buffer_size_in_frames(grown);
|
||||||
|
}
|
||||||
|
}
|
||||||
AudioCallbackResult::Continue
|
AudioCallbackResult::Continue
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,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)
|
||||||
@@ -109,19 +237,31 @@ impl AudioPlayback {
|
|||||||
log::error!("audio: request_start: {e}");
|
log::error!("audio: request_start: {e}");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// Lift the AAudio HW buffer off its brittle ~2-burst LowLatency default so a single late
|
||||||
|
// callback doesn't immediately underrun; the in-callback XRun loop grows it further if the
|
||||||
|
// device still glitches. set_buffer_size_in_frames clamps to capacity.
|
||||||
|
let burst = stream.frames_per_burst().max(1);
|
||||||
|
let _ =
|
||||||
|
stream.set_buffer_size_in_frames((burst * 3).min(stream.buffer_capacity_in_frames()));
|
||||||
|
// perf != LowLatency or rate != 48000 means AAudio silently fell to a resampled legacy path
|
||||||
|
// (different burst behaviour) — surface it so the field can tell that apart from plain jitter.
|
||||||
log::info!(
|
log::info!(
|
||||||
"audio: AAudio started rate={} ch={} fmt={:?} burst={}",
|
"audio: AAudio started rate={} ch={} fmt={:?} perf={:?} share={:?} burst={} buf={}/{}",
|
||||||
stream.sample_rate(),
|
stream.sample_rate(),
|
||||||
stream.channel_count(),
|
stream.channel_count(),
|
||||||
stream.format(),
|
stream.format(),
|
||||||
|
stream.performance_mode(),
|
||||||
|
stream.sharing_mode(),
|
||||||
stream.frames_per_burst(),
|
stream.frames_per_burst(),
|
||||||
|
stream.buffer_size_in_frames(),
|
||||||
|
stream.buffer_capacity_in_frames(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
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, sd, counters))
|
.spawn(move || decode_loop(client, tx, free_rx, sd, counters, channels))
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
Some(AudioPlayback {
|
Some(AudioPlayback {
|
||||||
@@ -143,31 +283,53 @@ impl Drop for AudioPlayback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Producer: `next_audio` → Opus `decode_float` → push interleaved f32 into the ring channel.
|
/// Producer: `next_audio` → Opus `decode_float` → push interleaved f32 into the ring channel.
|
||||||
|
/// Buffers come from (and return to) the realtime callback's recycle free-list so the steady state
|
||||||
|
/// is allocation-free on both threads.
|
||||||
fn decode_loop(
|
fn decode_loop(
|
||||||
client: Arc<NativeClient>,
|
client: Arc<NativeClient>,
|
||||||
tx: SyncSender<Vec<f32>>,
|
tx: SyncSender<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/ch)
|
||||||
|
// 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.
|
||||||
|
debug_assert!(
|
||||||
|
n <= 5 * ms,
|
||||||
|
"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;
|
||||||
match tx.try_send(pcm[..n].to_vec()) {
|
// Reuse a recycled buffer if the callback handed one back; only allocate when the
|
||||||
|
// free-list is momentarily empty (startup / after a backpressure drop).
|
||||||
|
let mut buf = free_rx
|
||||||
|
.try_recv()
|
||||||
|
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
|
||||||
|
buf.clear();
|
||||||
|
buf.extend_from_slice(&pcm[..n]);
|
||||||
|
match tx.try_send(buf) {
|
||||||
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest under backpressure
|
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest under backpressure
|
||||||
Err(TrySendError::Disconnected(_)) => break,
|
Err(TrySendError::Disconnected(_)) => break,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,24 @@ pub fn run(
|
|||||||
format.set_i32("priority", 0); // 0 = realtime
|
format.set_i32("priority", 0); // 0 = realtime
|
||||||
format.set_i32("operating-rate", mode.refresh_hz as i32);
|
format.set_i32("operating-rate", mode.refresh_hz as i32);
|
||||||
|
|
||||||
|
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
|
||||||
|
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
|
||||||
|
// MediaCodec wants it BEFORE configure(), and the host sends a 0xCE right after the handshake,
|
||||||
|
// so it's typically already queued; wait briefly otherwise. The Surface DataSpace (applied on
|
||||||
|
// OutputFormatChanged below) carries transfer/primaries regardless — this adds the luminance the
|
||||||
|
// tone-mapper needs. A non-HDR display still gets sensible SurfaceFlinger tone-mapping.
|
||||||
|
if client.color.is_hdr() {
|
||||||
|
match client.next_hdr_meta(Duration::from_millis(250)) {
|
||||||
|
Ok(meta) => {
|
||||||
|
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
|
||||||
|
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
|
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
|
||||||
log::error!("decode: configure failed: {e}");
|
log::error!("decode: configure failed: {e}");
|
||||||
return;
|
return;
|
||||||
@@ -258,3 +276,35 @@ fn hdr_dataspace(codec: &MediaCodec) -> Option<DataSpace> {
|
|||||||
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
|
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialize [`HdrMeta`](punktfunk_core::quic::HdrMeta) into Android's `KEY_HDR_STATIC_INFO`
|
||||||
|
/// (`hdr-static-info`) layout: a 25-byte CTA-861.3 / `HDRStaticInfo.Type1` blob — descriptor id 0,
|
||||||
|
/// then primaries in **R, G, B** order, white point, max/min display luminance, MaxCLL, MaxFALL, all
|
||||||
|
/// **little-endian** `u16`. Two conversions vs our wire form: HdrMeta stores primaries in ST.2086
|
||||||
|
/// **G, B, R** order (reorder to R, G, B), and `max_display_mastering_luminance` is in 0.0001-cd/m²
|
||||||
|
/// units while Android wants **whole nits** (min stays 0.0001-nit). Chromaticities (1/50000) and
|
||||||
|
/// MaxCLL/MaxFALL (nits) match 1:1.
|
||||||
|
fn android_hdr_static_info(m: &punktfunk_core::quic::HdrMeta) -> [u8; 25] {
|
||||||
|
let [g, b_, r] = m.display_primaries; // ST.2086 G, B, R
|
||||||
|
let max_nits = (m.max_display_mastering_luminance / 10_000).min(u16::MAX as u32) as u16;
|
||||||
|
let min_units = m.min_display_mastering_luminance.min(u16::MAX as u32) as u16;
|
||||||
|
let fields: [u16; 12] = [
|
||||||
|
r[0],
|
||||||
|
r[1],
|
||||||
|
g[0],
|
||||||
|
g[1],
|
||||||
|
b_[0],
|
||||||
|
b_[1], // R, G, B primaries
|
||||||
|
m.white_point[0],
|
||||||
|
m.white_point[1], // white point
|
||||||
|
max_nits,
|
||||||
|
min_units, // max (nits) / min (0.0001-nit) display luminance
|
||||||
|
m.max_cll,
|
||||||
|
m.max_fall, // MaxCLL / MaxFALL (nits)
|
||||||
|
];
|
||||||
|
let mut out = [0u8; 25]; // out[0] = 0 (Type 1 descriptor id), already zero
|
||||||
|
for (i, v) in fields.iter().enumerate() {
|
||||||
|
out[1 + i * 2..3 + i * 2].copy_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
//! LAN host discovery over mDNS, in Rust via `mdns-sd` — the same crate + service type the
|
||||||
|
//! Linux/Windows clients use (`clients/linux/src/discovery.rs`), exposed to Kotlin over JNI.
|
||||||
|
//!
|
||||||
|
//! Why not `NsdManager`: that API delegates to a per-OEM system mDNS daemon whose reliability
|
||||||
|
//! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust
|
||||||
|
//! core — the crate is already linked for the whole protocol — gives one tested code path across
|
||||||
|
//! every desktop + mobile client and removes the system-daemon dependency. Kotlin still holds the
|
||||||
|
//! Wi-Fi `MulticastLock` for the browse lifetime (raw multicast *reception* needs it) and owns the
|
||||||
|
//! permission UX; this module owns the socket + resolve.
|
||||||
|
//!
|
||||||
|
//! Shape: [`Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart`] spins up a
|
||||||
|
//! [`ServiceDaemon`] browsing `_punktfunk._udp.local.` on a background thread that folds
|
||||||
|
//! resolve/remove events into a shared map; Kotlin polls `nativeDiscoveryPoll` ~1 Hz for a
|
||||||
|
//! newline-joined snapshot and calls `nativeDiscoveryStop` to tear it down. Polling (not a JVM
|
||||||
|
//! callback) mirrors `nativeVideoStats`: no `AttachCurrentThread`/global-ref lifecycle to get
|
||||||
|
//! wrong, and 1 Hz is plenty for a host picker.
|
||||||
|
|
||||||
|
use crate::session::jni_guard;
|
||||||
|
use jni::objects::JObject;
|
||||||
|
use jni::sys::jlong;
|
||||||
|
use jni::JNIEnv;
|
||||||
|
use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
|
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
|
||||||
|
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
|
||||||
|
/// Wire protocol id in the `proto` TXT record; a host advertising anything else is skipped.
|
||||||
|
const PROTO: &str = "punktfunk/1";
|
||||||
|
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||||
|
const FIELD_SEP: char = '\u{1f}';
|
||||||
|
|
||||||
|
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]).
|
||||||
|
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
||||||
|
/// every field so no value can break it.
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
struct Host {
|
||||||
|
key: String,
|
||||||
|
name: String,
|
||||||
|
addr: String,
|
||||||
|
port: u16,
|
||||||
|
fp: String,
|
||||||
|
pair: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Host {
|
||||||
|
fn encode(&self) -> String {
|
||||||
|
// mDNS instance labels + TXT values are arbitrary UTF-8 from an UNauthenticated source, so
|
||||||
|
// strip the field/record separators: a rogue advert that smuggled '\n'/U+001F could otherwise
|
||||||
|
// inject or suppress picker rows. (Trust is still gated on connect — this only protects the
|
||||||
|
// list's integrity.)
|
||||||
|
fn clean(s: &str) -> String {
|
||||||
|
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||||
|
clean(&self.key),
|
||||||
|
clean(&self.name),
|
||||||
|
clean(&self.addr),
|
||||||
|
self.port,
|
||||||
|
clean(&self.fp),
|
||||||
|
clean(&self.pair),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running browse behind the `jlong` handle: the daemon, the shared resolved-host map keyed by
|
||||||
|
/// mDNS fullname (stable across re-announce and present on both resolve *and* remove — which fixes
|
||||||
|
/// the old `NsdManager` key mismatch that leaked stale hosts), and the event-fold thread.
|
||||||
|
struct Discovery {
|
||||||
|
daemon: ServiceDaemon,
|
||||||
|
hosts: Arc<Mutex<HashMap<String, Host>>>,
|
||||||
|
thread: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Discovery {
|
||||||
|
fn start() -> Option<Discovery> {
|
||||||
|
let daemon = match ServiceDaemon::new() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("mDNS daemon failed — discovery disabled: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let rx = match daemon.browse(SERVICE_TYPE) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("mDNS browse failed — discovery disabled: {e}");
|
||||||
|
let _ = daemon.shutdown();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let hosts: Arc<Mutex<HashMap<String, Host>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let map = hosts.clone();
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("pf-mdns".into())
|
||||||
|
.spawn(move || {
|
||||||
|
// Exits when the daemon is shut down (the browse channel closes → recv errors).
|
||||||
|
while let Ok(event) = rx.recv() {
|
||||||
|
match event {
|
||||||
|
ServiceEvent::ServiceResolved(info) => {
|
||||||
|
if let Some(host) = resolve(&info) {
|
||||||
|
map.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(info.get_fullname().to_string(), host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ServiceEvent::ServiceRemoved(_ty, fullname) => {
|
||||||
|
map.lock().unwrap().remove(&fullname);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let thread = match spawned {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
// The daemon thread + bound :5353 socket outlive a dropped handle (no Drop impl), so
|
||||||
|
// shut it down explicitly — same cleanup as the browse-failure path above.
|
||||||
|
log::error!("mDNS fold thread spawn failed: {e}");
|
||||||
|
let _ = daemon.shutdown();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
log::info!("native mDNS discovery started ({SERVICE_TYPE})");
|
||||||
|
Some(Discovery {
|
||||||
|
daemon,
|
||||||
|
hosts,
|
||||||
|
thread: Some(thread),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current resolved-host set, newline-joined (empty string = none). Sorted for a stable order
|
||||||
|
/// across polls; Kotlin re-sorts by display name.
|
||||||
|
fn snapshot(&self) -> String {
|
||||||
|
let mut records: Vec<String> = self
|
||||||
|
.hosts
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.map(Host::encode)
|
||||||
|
.collect();
|
||||||
|
records.sort();
|
||||||
|
records.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(mut self) {
|
||||||
|
let _ = self.daemon.shutdown(); // closes the browse channel → the fold thread exits
|
||||||
|
if let Some(t) = self.thread.take() {
|
||||||
|
let _ = t.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a [`Host`] from a resolved mDNS record, or `None` if it isn't a usable punktfunk host
|
||||||
|
/// (incompatible advertised proto, or no IPv4 address). IPv4 only on purpose: the core dials with
|
||||||
|
/// `format!("{host}:{port}").parse::<SocketAddr>()`, which can't parse a bare/scoped IPv6 literal
|
||||||
|
/// (it needs the `[addr%scope]:port` form), so surfacing a v6-only host would present a card that
|
||||||
|
/// fails on every tap. Dropping it shows the honest "not found" instead.
|
||||||
|
fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||||
|
let val = |k: &str| info.get_property_val_str(k).unwrap_or("").to_string();
|
||||||
|
let proto = val("proto");
|
||||||
|
if !proto.is_empty() && proto != PROTO {
|
||||||
|
return None; // some other DNS-SD service sharing the type — ignore
|
||||||
|
}
|
||||||
|
let addr = info
|
||||||
|
.get_addresses_v4()
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.map(|a| a.to_string())?;
|
||||||
|
let id = val("id");
|
||||||
|
let fullname = info.get_fullname();
|
||||||
|
Some(Host {
|
||||||
|
key: if id.is_empty() {
|
||||||
|
fullname.to_string()
|
||||||
|
} else {
|
||||||
|
id
|
||||||
|
},
|
||||||
|
name: fullname.split('.').next().unwrap_or("?").to_string(),
|
||||||
|
addr,
|
||||||
|
port: info.get_port(),
|
||||||
|
fp: val("fp"),
|
||||||
|
pair: val("pair"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeDiscoveryStart(): Long` — start browsing `_punktfunk._udp`; returns an opaque
|
||||||
|
/// handle, or `0` on failure (logged). Pair with exactly one [`nativeDiscoveryStop`]. Kotlin must
|
||||||
|
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
|
||||||
|
///
|
||||||
|
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
) -> jlong {
|
||||||
|
jni_guard(0, || match Discovery::start() {
|
||||||
|
Some(d) => Box::into_raw(Box::new(d)) as jlong,
|
||||||
|
None => 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||||
|
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts /
|
||||||
|
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||||
|
env: JNIEnv<'local>,
|
||||||
|
_this: JObject<'local>,
|
||||||
|
handle: jlong,
|
||||||
|
) -> jni::sys::jstring {
|
||||||
|
jni_guard(std::ptr::null_mut(), || {
|
||||||
|
let out = if handle == 0 {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
// SAFETY: live handle per the start/stop contract — Kotlin owns the lifecycle and never
|
||||||
|
// polls after stop (it nulls the handle first).
|
||||||
|
let d = unsafe { &*(handle as *const Discovery) };
|
||||||
|
d.snapshot()
|
||||||
|
};
|
||||||
|
match env.new_string(out) {
|
||||||
|
Ok(s) => s.into_raw(),
|
||||||
|
Err(_) => std::ptr::null_mut(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeDiscoveryStop(handle)` — stop the browse, shut the daemon down and join its
|
||||||
|
/// thread. No-op on `0`.
|
||||||
|
///
|
||||||
|
/// # Safety contract
|
||||||
|
/// `handle` must be `0` or a live handle from [`nativeDiscoveryStart`], stopped exactly once and not
|
||||||
|
/// concurrently with [`nativeDiscoveryPoll`] (Kotlin owns this; all calls are on the main thread).
|
||||||
|
///
|
||||||
|
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
|
||||||
|
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
jni_guard((), || {
|
||||||
|
if handle != 0 {
|
||||||
|
// SAFETY: live handle from nativeDiscoveryStart, stopped exactly once per the contract.
|
||||||
|
let d = unsafe { Box::from_raw(handle as *mut Discovery) };
|
||||||
|
d.stop();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_round_trips_all_fields_with_unit_separator() {
|
||||||
|
let h = Host {
|
||||||
|
key: "host-123".into(),
|
||||||
|
name: "home-worker-2".into(),
|
||||||
|
addr: "192.168.1.70".into(),
|
||||||
|
port: 9777,
|
||||||
|
fp: "ab".repeat(32),
|
||||||
|
pair: "required".into(),
|
||||||
|
};
|
||||||
|
let encoded = h.encode();
|
||||||
|
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||||
|
assert_eq!(fields.len(), 6);
|
||||||
|
assert_eq!(fields[0], "host-123");
|
||||||
|
assert_eq!(fields[1], "home-worker-2");
|
||||||
|
assert_eq!(fields[2], "192.168.1.70");
|
||||||
|
assert_eq!(fields[3], "9777");
|
||||||
|
assert_eq!(fields[4], "ab".repeat(32));
|
||||||
|
assert_eq!(fields[5], "required");
|
||||||
|
assert!(
|
||||||
|
!encoded.contains('\n'),
|
||||||
|
"a record must never contain the record separator"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
||||||
|
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
|
||||||
|
// them so the snapshot stays exactly one record of exactly six fields.
|
||||||
|
let h = Host {
|
||||||
|
key: "k\u{1f}injected".into(),
|
||||||
|
name: "evil\nhost\r".into(),
|
||||||
|
addr: "10.0.0.5".into(),
|
||||||
|
port: 9777,
|
||||||
|
fp: "ab\u{1f}cd".into(),
|
||||||
|
pair: "required\n".into(),
|
||||||
|
};
|
||||||
|
let encoded = h.encode();
|
||||||
|
assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields");
|
||||||
|
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||||
|
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||||
|
assert_eq!(fields[0], "kinjected");
|
||||||
|
assert_eq!(fields[1], "evilhost");
|
||||||
|
assert_eq!(fields[4], "abcd");
|
||||||
|
assert_eq!(fields[5], "required");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
//! Not android-gated: `next_rumble`/`next_hidout` are pure-Rust on the `quic` feature, so these
|
//! Not android-gated: `next_rumble`/`next_hidout` are pure-Rust on the `quic` feature, so these
|
||||||
//! compile on the host build too (parity with the input shims in [`crate::session`]).
|
//! compile on the host build too (parity with the input shims in [`crate::session`]).
|
||||||
|
|
||||||
use crate::session::SessionHandle;
|
use crate::session::{jni_guard, SessionHandle};
|
||||||
use jni::objects::{JByteBuffer, JObject};
|
use jni::objects::{JByteBuffer, JObject};
|
||||||
use jni::sys::{jint, jlong};
|
use jni::sys::{jint, jlong};
|
||||||
use jni::JNIEnv;
|
use jni::JNIEnv;
|
||||||
@@ -32,17 +32,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
|||||||
_this: JObject,
|
_this: JObject,
|
||||||
handle: jlong,
|
handle: jlong,
|
||||||
) -> jlong {
|
) -> jlong {
|
||||||
if handle == 0 {
|
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
||||||
return -1;
|
jni_guard(-1, || {
|
||||||
}
|
if handle == 0 {
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
|
return -1;
|
||||||
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
}
|
||||||
// threads (and joins them) before nativeClose frees the handle.
|
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
||||||
match h.client.next_rumble(PULL_TIMEOUT) {
|
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
||||||
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
match h.client.next_rumble(PULL_TIMEOUT) {
|
||||||
}
|
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
|
||||||
|
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
||||||
@@ -58,57 +61,65 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|||||||
handle: jlong,
|
handle: jlong,
|
||||||
buf: JByteBuffer,
|
buf: JByteBuffer,
|
||||||
) -> jint {
|
) -> jint {
|
||||||
if handle == 0 {
|
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
||||||
return -1;
|
jni_guard(-1, || {
|
||||||
}
|
if handle == 0 {
|
||||||
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
|
return -1;
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
}
|
||||||
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
|
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
|
||||||
Ok(ev) => ev,
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
Err(_) => return -1, // timeout or closed — Kotlin loops
|
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
|
||||||
};
|
Ok(ev) => ev,
|
||||||
|
Err(_) => return -1, // timeout or closed — Kotlin loops
|
||||||
|
};
|
||||||
|
|
||||||
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
|
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
|
||||||
let cap = match env.get_direct_buffer_capacity(&buf) {
|
let cap = match env.get_direct_buffer_capacity(&buf) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return -1,
|
Err(_) => return -1,
|
||||||
};
|
};
|
||||||
let ptr = match env.get_direct_buffer_address(&buf) {
|
let ptr = match env.get_direct_buffer_address(&buf) {
|
||||||
Ok(p) if !p.is_null() => p,
|
Ok(p) if !p.is_null() => p,
|
||||||
_ => return -1,
|
_ => return -1,
|
||||||
};
|
};
|
||||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
||||||
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
||||||
|
|
||||||
let n = match ev {
|
let n = match ev {
|
||||||
HidOutput::Led { r, g, b, .. } => {
|
HidOutput::Led { r, g, b, .. } => {
|
||||||
if cap < 4 {
|
if cap < 4 {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
out[0] = TAG_LED;
|
||||||
|
out[1] = r;
|
||||||
|
out[2] = g;
|
||||||
|
out[3] = b;
|
||||||
|
4
|
||||||
|
}
|
||||||
|
HidOutput::PlayerLeds { bits, .. } => {
|
||||||
|
if cap < 2 {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
out[0] = TAG_PLAYER_LEDS;
|
||||||
|
out[1] = bits;
|
||||||
|
2
|
||||||
|
}
|
||||||
|
HidOutput::Trigger { which, effect, .. } => {
|
||||||
|
let n = 2 + effect.len();
|
||||||
|
if cap < n {
|
||||||
|
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
||||||
|
}
|
||||||
|
out[0] = TAG_TRIGGER;
|
||||||
|
out[1] = which;
|
||||||
|
out[2..n].copy_from_slice(&effect);
|
||||||
|
n
|
||||||
|
}
|
||||||
|
HidOutput::TrackpadHaptic { .. } => {
|
||||||
|
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
|
||||||
|
// rumble already rides the universal 0xCA plane).
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
out[0] = TAG_LED;
|
};
|
||||||
out[1] = r;
|
n as jint
|
||||||
out[2] = g;
|
})
|
||||||
out[3] = b;
|
|
||||||
4
|
|
||||||
}
|
|
||||||
HidOutput::PlayerLeds { bits, .. } => {
|
|
||||||
if cap < 2 {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
out[0] = TAG_PLAYER_LEDS;
|
|
||||||
out[1] = bits;
|
|
||||||
2
|
|
||||||
}
|
|
||||||
HidOutput::Trigger { which, effect, .. } => {
|
|
||||||
let n = 2 + effect.len();
|
|
||||||
if cap < n {
|
|
||||||
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
|
||||||
}
|
|
||||||
out[0] = TAG_TRIGGER;
|
|
||||||
out[1] = which;
|
|
||||||
out[2..n].copy_from_slice(&effect);
|
|
||||||
n
|
|
||||||
}
|
|
||||||
};
|
|
||||||
n as jint
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,17 @@
|
|||||||
//! Architecture: the **Rust-heavy** client model (like `punktfunk-client-linux`, *not* the
|
//! Architecture: the **Rust-heavy** client model (like `punktfunk-client-linux`, *not* the
|
||||||
//! thin-native-over-C-ABI Apple model). This `cdylib` links `punktfunk-core` directly and drives
|
//! thin-native-over-C-ABI Apple model). This `cdylib` links `punktfunk-core` directly and drives
|
||||||
//! the whole `punktfunk/1` protocol through [`punktfunk_core::client::NativeClient`]; Kotlin owns
|
//! the whole `punktfunk/1` protocol through [`punktfunk_core::client::NativeClient`]; Kotlin owns
|
||||||
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture,
|
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture, the
|
||||||
//! `NsdManager` discovery, Keystore). The JNI seam below is the one place the two languages meet.
|
//! Wi-Fi `MulticastLock` + permission UX, Keystore). The JNI seam below is the one place the two
|
||||||
|
//! languages meet.
|
||||||
//!
|
//!
|
||||||
//! Why Rust-heavy: Kotlin cannot `import` the cbindgen C header the way Swift can, so a native
|
//! Why Rust-heavy: Kotlin cannot `import` the cbindgen C header the way Swift can, so a native
|
||||||
//! bridge is unavoidable. Writing it in Rust lets the Android client reuse the Linux client's
|
//! bridge is unavoidable. Writing it in Rust lets the Android client reuse the Linux client's
|
||||||
//! orchestration verbatim — audio jitter ring, the VK keymap inverse, latency/skew math, the
|
//! orchestration verbatim — audio jitter ring, the VK keymap inverse, latency/skew math, the
|
||||||
//! input capture state machine, trust/pairing logic — instead of re-porting it into Kotlin.
|
//! input capture state machine, trust/pairing logic, **mDNS discovery** ([`discovery`], the same
|
||||||
|
//! `mdns-sd` browse the Linux/Windows clients use) — instead of re-porting it into Kotlin. Kotlin
|
||||||
|
//! keeps only the Android-framework surface it must (Compose UI, `SurfaceView`, input capture, the
|
||||||
|
//! Wi-Fi `MulticastLock` + permission UX, Keystore identity).
|
||||||
//!
|
//!
|
||||||
//! JNI symbols map to `io.unom.punktfunk.kit.NativeBridge` in the `:kit` Gradle module
|
//! JNI symbols map to `io.unom.punktfunk.kit.NativeBridge` in the `:kit` Gradle module
|
||||||
//! (`clients/android`). The current surface is the scaffold's native-link proof
|
//! (`clients/android`). The current surface is the scaffold's native-link proof
|
||||||
@@ -25,6 +29,9 @@ use jni::JNIEnv;
|
|||||||
mod audio;
|
mod audio;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod decode;
|
mod decode;
|
||||||
|
// Ungated: pure `mdns-sd` + `jni`, so the browse + its JNI seam link into the host workspace build
|
||||||
|
// (and its unit test runs there) exactly like `session`/`stats`. Kotlin only ever calls it on device.
|
||||||
|
mod discovery;
|
||||||
mod feedback;
|
mod feedback;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod mic;
|
mod mic;
|
||||||
|
|||||||
@@ -19,11 +19,28 @@ use jni::JNIEnv;
|
|||||||
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 punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
|
use std::panic::AssertUnwindSafe;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Run a JNI body, catching any panic at the FFI boundary and returning `default` instead.
|
||||||
|
///
|
||||||
|
/// A panic unwinding out of an `extern "system"` function aborts the whole process on Rust ≥ 1.81 —
|
||||||
|
/// a hard crash of the embedding Android app with no logcat trace. This mirrors the discipline the C
|
||||||
|
/// ABI already enforces (`punktfunk_core::abi` wraps every entry point in `catch_unwind`); the
|
||||||
|
/// `panic = "unwind"` profile in the workspace `Cargo.toml` exists precisely so these guards work.
|
||||||
|
/// We apply it to the teardown + background-thread shims (the "leaving a stream" path), where an
|
||||||
|
/// unexpected panic (e.g. a poisoned `Mutex` during concurrent teardown) must degrade to a logged
|
||||||
|
/// no-op rather than kill the app.
|
||||||
|
pub(crate) fn jni_guard<T>(default: T, f: impl FnOnce() -> T) -> T {
|
||||||
|
std::panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| {
|
||||||
|
log::error!("punktfunk JNI: caught a panic at the FFI boundary (returning default)");
|
||||||
|
default
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
|
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
|
||||||
pub(crate) struct SessionHandle {
|
pub(crate) struct SessionHandle {
|
||||||
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
|
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
|
||||||
@@ -123,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>(
|
||||||
@@ -144,6 +165,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
bitrate_kbps: jint,
|
bitrate_kbps: jint,
|
||||||
compositor_pref: jint,
|
compositor_pref: jint,
|
||||||
gamepad_pref: jint,
|
gamepad_pref: jint,
|
||||||
|
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(),
|
||||||
@@ -184,14 +208,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
CompositorPref::from_u8(compositor_pref.clamp(0, u8::MAX as jint) as u8),
|
CompositorPref::from_u8(compositor_pref.clamp(0, u8::MAX as jint) as u8),
|
||||||
GamepadPref::from_u8(gamepad_pref.clamp(0, u8::MAX as jint) as u8),
|
GamepadPref::from_u8(gamepad_pref.clamp(0, u8::MAX as jint) as u8),
|
||||||
bitrate_kbps.max(0) as u32, // 0 = host default
|
bitrate_kbps.max(0) as u32, // 0 = host default
|
||||||
// Advertise 10-bit + HDR: the host (e.g. Windows) only upgrades to a Main10 / BT.2020 PQ
|
// Advertise 10-bit + HDR ONLY when this device's display can actually present it (Kotlin
|
||||||
// encode when the client sets these. AMediaCodec decodes Main10 from the SPS and the decode
|
// checks Display.getHdrCapabilities() and passes the result): the host (e.g. Windows) then
|
||||||
// loop signals the Surface's HDR dataspace from the reported colour (see crate::decode).
|
// upgrades to a Main10 / BT.2020 PQ encode. On an SDR display we advertise 0 so the host
|
||||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR,
|
// sends a proper 8-bit BT.709 stream rather than PQ the panel would mis-tone-map. AMediaCodec
|
||||||
|
// decodes Main10 from the SPS and the decode loop signals the Surface HDR dataspace + static
|
||||||
|
// metadata (see crate::decode).
|
||||||
|
if hdr_enabled != 0 {
|
||||||
|
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||||
|
} else {
|
||||||
|
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 {
|
||||||
@@ -223,10 +261,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
|||||||
_this: JObject,
|
_this: JObject,
|
||||||
handle: jlong,
|
handle: jlong,
|
||||||
) {
|
) {
|
||||||
if handle != 0 {
|
jni_guard((), || {
|
||||||
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer.
|
if handle != 0 {
|
||||||
unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) };
|
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer.
|
||||||
}
|
unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) };
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
|
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
|
||||||
@@ -359,55 +399,70 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
|||||||
_this: JObject,
|
_this: JObject,
|
||||||
handle: jlong,
|
handle: jlong,
|
||||||
) {
|
) {
|
||||||
if handle != 0 {
|
jni_guard((), || {
|
||||||
// SAFETY: live handle per the contract.
|
if handle != 0 {
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
// SAFETY: live handle per the contract.
|
||||||
h.stop_video();
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
}
|
h.stop_video();
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `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,
|
||||||
_this: JObject,
|
_this: JObject,
|
||||||
handle: jlong,
|
handle: jlong,
|
||||||
) -> jdoubleArray {
|
) -> jdoubleArray {
|
||||||
if handle == 0 {
|
jni_guard(std::ptr::null_mut(), || {
|
||||||
return std::ptr::null_mut();
|
if handle == 0 {
|
||||||
}
|
return std::ptr::null_mut();
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
}
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
let snap = match h.video.lock().unwrap().as_ref() {
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
Some(vt) => vt.stats.drain(),
|
let snap = match h.video.lock().unwrap().as_ref() {
|
||||||
None => return std::ptr::null_mut(), // not streaming → no stats
|
Some(vt) => vt.stats.drain(),
|
||||||
};
|
None => return std::ptr::null_mut(), // not streaming → no stats
|
||||||
let mode = h.client.mode();
|
};
|
||||||
let buf: [f64; 10] = [
|
let mode = h.client.mode();
|
||||||
snap.fps,
|
let color = h.client.color;
|
||||||
snap.mbps,
|
let buf: [f64; 14] = [
|
||||||
snap.lat_p50_ms,
|
snap.fps,
|
||||||
snap.lat_p95_ms,
|
snap.mbps,
|
||||||
if snap.lat_valid { 1.0 } else { 0.0 },
|
snap.lat_p50_ms,
|
||||||
if snap.skew_corrected { 1.0 } else { 0.0 },
|
snap.lat_p95_ms,
|
||||||
mode.width as f64,
|
if snap.lat_valid { 1.0 } else { 0.0 },
|
||||||
mode.height as f64,
|
if snap.skew_corrected { 1.0 } else { 0.0 },
|
||||||
mode.refresh_hz as f64,
|
mode.width as f64,
|
||||||
h.client.frames_dropped() as f64,
|
mode.height as f64,
|
||||||
];
|
mode.refresh_hz as f64,
|
||||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
h.client.frames_dropped() as f64,
|
||||||
Ok(a) => a,
|
// Video-feed properties the host resolved at the handshake (Welcome): encode bit depth
|
||||||
Err(_) => return std::ptr::null_mut(),
|
// (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
|
||||||
if env.set_double_array_region(&arr, 0, &buf).is_err() {
|
// chroma_format_idc (1 = 4:2:0, 3 = 4:4:4). Static for the session unless renegotiated.
|
||||||
return std::ptr::null_mut();
|
h.client.bit_depth as f64,
|
||||||
}
|
color.primaries as f64,
|
||||||
arr.into_raw()
|
color.transfer as f64,
|
||||||
|
h.client.chroma_format as f64,
|
||||||
|
];
|
||||||
|
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => return std::ptr::null_mut(),
|
||||||
|
};
|
||||||
|
if env.set_double_array_region(&arr, 0, &buf).is_err() {
|
||||||
|
return std::ptr::null_mut();
|
||||||
|
}
|
||||||
|
arr.into_raw()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
|
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
|
||||||
@@ -443,11 +498,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
|||||||
_this: JObject,
|
_this: JObject,
|
||||||
handle: jlong,
|
handle: jlong,
|
||||||
) {
|
) {
|
||||||
if handle != 0 {
|
jni_guard((), || {
|
||||||
// SAFETY: live handle per the contract.
|
if handle != 0 {
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
// SAFETY: live handle per the contract.
|
||||||
h.stop_audio();
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
}
|
h.stop_audio();
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
|
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
|
||||||
@@ -484,11 +541,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
|||||||
_this: JObject,
|
_this: JObject,
|
||||||
handle: jlong,
|
handle: jlong,
|
||||||
) {
|
) {
|
||||||
if handle != 0 {
|
jni_guard((), || {
|
||||||
// SAFETY: live handle per the contract.
|
if handle != 0 {
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
// SAFETY: live handle per the contract.
|
||||||
h.stop_mic();
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
}
|
h.stop_mic();
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Input plane: Kotlin capture → NativeClient::send_input ----------------------------------
|
// ---- Input plane: Kotlin capture → NativeClient::send_input ----------------------------------
|
||||||
@@ -522,6 +581,38 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendPointerAbs(handle, x, y, surfaceWidth, surfaceHeight)` — absolute cursor
|
||||||
|
/// position: the host moves the pointer to `x`/`y` in a `surfaceWidth`×`surfaceHeight` pixel space,
|
||||||
|
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
|
||||||
|
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
|
||||||
|
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
x: jint,
|
||||||
|
y: jint,
|
||||||
|
surface_width: jint,
|
||||||
|
surface_height: jint,
|
||||||
|
) {
|
||||||
|
if handle == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: live handle per the contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
let w = (surface_width.max(0) as u32) & 0xffff;
|
||||||
|
let ht = (surface_height.max(0) as u32) & 0xffff;
|
||||||
|
let _ = h.client.send_input(&InputEvent {
|
||||||
|
kind: InputKind::MouseMoveAbs,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code: 0,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
flags: (w << 16) | ht,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
|
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
|
||||||
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
|
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+53
-1
@@ -174,6 +174,58 @@ signing, bundle id `io.unom.punktfunk`. Notes:
|
|||||||
in a simulator via `xcrun simctl install/launch` — `SIMCTL_CHILD_PUNKTFUNK_AUTOCONNECT=…`
|
in a simulator via `xcrun simctl install/launch` — `SIMCTL_CHILD_PUNKTFUNK_AUTOCONNECT=…`
|
||||||
passes the dev autoconnect env through).
|
passes the dev autoconnect env through).
|
||||||
|
|
||||||
|
## App Store screenshots
|
||||||
|
|
||||||
|
Automated, faithful screenshots of the real UI for App Store Connect — one set per platform at
|
||||||
|
exactly the accepted pixel sizes. Driver: **`tools/screenshots.sh`**.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tools/screenshots.sh all # macOS + (if full Xcode) iOS, iPadOS, tvOS → ./screenshots
|
||||||
|
tools/screenshots.sh macos # just macOS
|
||||||
|
OUT=~/Desktop/shots tools/screenshots.sh ios ipad tvos
|
||||||
|
PUNKTFUNK_SHOT_HERO=~/frame.png tools/screenshots.sh ios # real captured frame behind the hero
|
||||||
|
```
|
||||||
|
|
||||||
|
How it works: the app has a DEBUG-only **shot mode** (`Sources/PunktfunkClient/Screenshots/`).
|
||||||
|
Launched with `PUNKTFUNK_SHOT_SCENE=<name>` it renders **one** mock-populated screen full-bleed
|
||||||
|
(`ScreenshotHostView`) instead of `ContentView`, then the OS screenshots the *real, fully-rendered*
|
||||||
|
window — `screencapture` on macOS, `xcrun simctl io booted screenshot` on the Simulators. The five
|
||||||
|
scenes (`ShotScenes.all`): `01-stream` (the stream hero — a synthetic frame + the glass HUD, since
|
||||||
|
`StreamView` needs a live connection), `02-hosts`, `03-pair`, `04-trust`, `05-settings`. Mock data
|
||||||
|
is in `ShotMock`; nothing touches a host.
|
||||||
|
|
||||||
|
Output pixels are App Store Connect's required/largest sizes (Apple auto-derives the smaller ones):
|
||||||
|
`mac` 2880×1800 · `iphone-6.9` 1320×2868 (hero 2868×1320) · `ipad-13` 2064×2752 (hero 2752×2064) ·
|
||||||
|
`appletv` 1920×1080.
|
||||||
|
|
||||||
|
Why not `ImageRenderer` (the obvious offscreen route)? It can't rasterize this app's chrome —
|
||||||
|
`NavigationStack`, `Form`/`TabView`, and Liquid-Glass/`NSVisualEffect` materials all render black or
|
||||||
|
SwiftUI's "can't render" placeholder. Capturing the live window/Simulator avoids that entirely.
|
||||||
|
|
||||||
|
Requirements / gotchas:
|
||||||
|
- **macOS**: only the Swift toolchain is needed, **plus a one-time Screen Recording grant** for
|
||||||
|
your terminal (System Settings → Privacy & Security → Screen Recording) — without it
|
||||||
|
`screencapture -l` fails with "could not create image from window". (A no-permission fallback,
|
||||||
|
`PUNKTFUNK_SHOT_SELFCAPTURE=<dir>`, uses `cacheDisplay` — but it omits material blur and can't
|
||||||
|
read `ScrollView` content, so it's for quick checks, not submission.)
|
||||||
|
- **iOS/iPadOS/tvOS**: needs **full Xcode** (xcodebuild + Simulators), not just Command Line Tools,
|
||||||
|
and the matching device Simulators installed (iPhone 16 Pro Max, iPad Pro 13", Apple TV). Run it
|
||||||
|
on a full-Xcode Mac (e.g. the `macos-arm64` CI mini).
|
||||||
|
- The hero defaults to a synthetic synthwave frame — set `PUNKTFUNK_SHOT_HERO` to a real captured
|
||||||
|
frame for a production-quality lead screenshot.
|
||||||
|
|
||||||
|
**CI**: the `apple` workflow's **`screenshots`** job runs on the `macos-arm64` runner on every main
|
||||||
|
push + manual dispatch (skipped on PRs), and attaches the result as a single zip artifact,
|
||||||
|
**`punktfunk-appstore-screenshots`** (download it from the run's Artifacts; `upload-artifact@v3` —
|
||||||
|
Gitea's backend rejects v4). It captures the two **required iOS sizes — iPhone 6.9" + iPad 13"** —
|
||||||
|
on the Simulator (auto-creating the device if the runner lacks it), and is isolated from the
|
||||||
|
build/test job so a capture hiccup never reds the build.
|
||||||
|
|
||||||
|
**macOS and tvOS are NOT in CI**, by design: the self-hosted runner is **headless** (no
|
||||||
|
window-server session), so the macOS window capture can't run there, and tvOS needs the Tier-3
|
||||||
|
build-std slice. Generate those on a GUI Mac: `tools/screenshots.sh macos tvos`. (If the runner is
|
||||||
|
ever switched to a logged-in GUI session, re-adding macOS to the job's capture step is one line.)
|
||||||
|
|
||||||
## Notes for whoever picks this up next
|
## Notes for whoever picks this up next
|
||||||
|
|
||||||
1. **cbindgen import quirk** (the predicted "small compile fixes", now fixed): the
|
1. **cbindgen import quirk** (the predicted "small compile fixes", now fixed): the
|
||||||
@@ -309,4 +361,4 @@ signing, bundle id `io.unom.punktfunk`. Notes:
|
|||||||
- Mid-stream renegotiation (resolution change without reconnect) is designed-for but not
|
- Mid-stream renegotiation (resolution change without reconnect) is designed-for but not
|
||||||
implemented (the Welcome is one-shot today).
|
implemented (the Welcome is one-shot today).
|
||||||
- Host-side gamepad injection needs `/dev/uinput` access on the box (udev rule from
|
- Host-side gamepad injection needs `/dev/uinput` access on the box (udev rule from
|
||||||
`docs/linux-setup.md`).
|
`design/linux-setup.md`).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
// DEBUG-only controller test panel, reached from Settings → Controllers → "Test Controller…".
|
||||||
|
// It shows the live input of the active controller and lets you fire the host→client feedback
|
||||||
|
// channels — rumble, DualSense adaptive triggers, lightbar, player LEDs — straight at the
|
||||||
|
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
|
||||||
|
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
|
||||||
|
//
|
||||||
|
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
|
||||||
|
// layout); macOS + iOS/iPadOS cover the validation need.
|
||||||
|
|
||||||
|
#if DEBUG && !os(tvOS)
|
||||||
|
import GameController
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct ControllerTestView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
@StateObject private var tester = ControllerTester()
|
||||||
|
|
||||||
|
@State private var heavyOn = false
|
||||||
|
@State private var lightOn = false
|
||||||
|
@State private var intensity = 0.75
|
||||||
|
@State private var triggerTarget = TriggerTarget.both
|
||||||
|
@State private var playerLED = -1
|
||||||
|
|
||||||
|
private enum TriggerTarget: String, CaseIterable, Identifiable {
|
||||||
|
case left = "L2", right = "R2", both = "Both"
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TriggerDemo: Identifiable {
|
||||||
|
let label: String
|
||||||
|
let effect: DualSenseTriggerEffect
|
||||||
|
var id: String { label }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let triggerDemos: [TriggerDemo] = [
|
||||||
|
.init(label: "Off", effect: .off),
|
||||||
|
.init(label: "Resistance", effect: .feedback(start: 0.3, strength: 0.7)),
|
||||||
|
.init(label: "Weapon", effect: .weapon(start: 0.4, end: 0.7, strength: 0.9)),
|
||||||
|
.init(label: "Vibration", effect: .vibration(start: 0.1, amplitude: 0.8, frequency: 0.5)),
|
||||||
|
.init(label: "Bow", effect: .slope(start: 0.2, end: 0.9, startStrength: 0.2, endStrength: 0.9)),
|
||||||
|
]
|
||||||
|
|
||||||
|
// (display name, hardware colour, swatch colour)
|
||||||
|
private static let lightSwatches: [(String, GCColor, Color)] = [
|
||||||
|
("Red", GCColor(red: 1, green: 0, blue: 0), .red),
|
||||||
|
("Green", GCColor(red: 0, green: 1, blue: 0), .green),
|
||||||
|
("Blue", GCColor(red: 0, green: 0.2, blue: 1), .blue),
|
||||||
|
("White", GCColor(red: 1, green: 1, blue: 1), .white),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Test Controller").font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
|
Spacer()
|
||||||
|
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if let active = gamepads.active {
|
||||||
|
header(active)
|
||||||
|
inputCard
|
||||||
|
rumbleCard()
|
||||||
|
triggerCard(active)
|
||||||
|
extrasCard(active)
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No controller",
|
||||||
|
systemImage: "gamecontroller",
|
||||||
|
description: Text("Connect a controller and pick it under "
|
||||||
|
+ "Settings → Controllers → Use controller."))
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 220)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 420, minHeight: 540)
|
||||||
|
.onAppear { tester.target(gamepads.active?.controller) }
|
||||||
|
.onDisappear { tester.stop() }
|
||||||
|
.onChange(of: gamepads.active?.id) { _, _ in
|
||||||
|
heavyOn = false
|
||||||
|
lightOn = false
|
||||||
|
playerLED = -1
|
||||||
|
tester.target(gamepads.active?.controller)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Header
|
||||||
|
|
||||||
|
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
|
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Input
|
||||||
|
|
||||||
|
private var inputCard: some View {
|
||||||
|
card("Input") {
|
||||||
|
// Poll the live controller at 30 Hz — no handlers installed, so nothing else's
|
||||||
|
// capture is disturbed.
|
||||||
|
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
|
||||||
|
if let gp = gamepads.active?.controller.extendedGamepad {
|
||||||
|
inputReadout(gp, controller: gamepads.active?.controller)
|
||||||
|
} else {
|
||||||
|
Text("Not an extended gamepad").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack(alignment: .top, spacing: 20) {
|
||||||
|
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
||||||
|
pressed: g.leftThumbstickButton?.isPressed ?? false)
|
||||||
|
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
||||||
|
pressed: g.rightThumbstickButton?.isPressed ?? false)
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
triggerBar("L2", value: g.leftTrigger.value)
|
||||||
|
triggerBar("R2", value: g.rightTrigger.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttonGrid(g)
|
||||||
|
if let tp = Self.touchpad(g) {
|
||||||
|
touchpadView(tp)
|
||||||
|
}
|
||||||
|
if let m = controller?.motion {
|
||||||
|
motionReadout(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
ZStack {
|
||||||
|
Circle().stroke(Color.secondary.opacity(0.3))
|
||||||
|
Circle()
|
||||||
|
.fill(pressed ? Color.accentColor : Color.secondary)
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
|
||||||
|
}
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerBar(_ label: String, value: Float) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(label).font(.caption2.monospaced()).frame(width: 22, alignment: .leading)
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule().fill(Color.secondary.opacity(0.15))
|
||||||
|
Capsule().fill(Color.accentColor).frame(width: geo.size.width * CGFloat(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 10)
|
||||||
|
Text(mag(value)).font(.caption2.monospaced()).frame(width: 34, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(width: 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
|
||||||
|
var items: [(String, Bool)] = [
|
||||||
|
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
||||||
|
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
||||||
|
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
||||||
|
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
||||||
|
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
||||||
|
("Menu", g.buttonMenu.isPressed),
|
||||||
|
("Opts", g.buttonOptions?.isPressed ?? false),
|
||||||
|
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
||||||
|
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
||||||
|
]
|
||||||
|
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
|
||||||
|
return LazyVGrid(
|
||||||
|
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
|
||||||
|
) {
|
||||||
|
ForEach(items.indices, id: \.self) { i in
|
||||||
|
Text(items[i].0)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 24)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(items[i].1 ? Color.accentColor : Color.secondary.opacity(0.15)))
|
||||||
|
.foregroundStyle(items[i].1 ? Color.white : Color.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func touchpadView(
|
||||||
|
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||||
|
button: GCControllerButtonInput)
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||||
|
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||||
|
fingerDot(tp.primary, color: .accentColor)
|
||||||
|
fingerDot(tp.secondary, color: .orange)
|
||||||
|
}
|
||||||
|
.frame(width: 150, height: 74)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
|
||||||
|
let x = pad.xAxis.value, y = pad.yAxis.value
|
||||||
|
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
|
||||||
|
return Circle().fill(color).frame(width: 10, height: 10)
|
||||||
|
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
|
||||||
|
.opacity(active ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func motionReadout(_ m: GCMotion) -> some View {
|
||||||
|
let a = Self.totalAccel(m)
|
||||||
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||||
|
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Rumble
|
||||||
|
|
||||||
|
private func rumbleCard() -> some View {
|
||||||
|
card("Rumble") {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Picker("Strength", selection: $intensity) {
|
||||||
|
Text("25%").tag(0.25)
|
||||||
|
Text("50%").tag(0.5)
|
||||||
|
Text("75%").tag(0.75)
|
||||||
|
Text("100%").tag(1.0)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||||
|
Toggle("Light motor (right)", isOn: $lightOn)
|
||||||
|
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||||
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
|
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
|
||||||
|
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
||||||
|
+ "can't reach its motors on macOS).")
|
||||||
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
||||||
|
.onChange(of: lightOn) { _, _ in applyRumble() }
|
||||||
|
.onChange(of: intensity) { _, _ in applyRumble() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyRumble() {
|
||||||
|
tester.rumble(low: heavyOn ? Float(intensity) : 0, high: lightOn ? Float(intensity) : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Adaptive triggers
|
||||||
|
|
||||||
|
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||||
|
card("Adaptive triggers") {
|
||||||
|
if c.hasAdaptiveTriggers {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Picker("Apply to", selection: $triggerTarget) {
|
||||||
|
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8
|
||||||
|
) {
|
||||||
|
ForEach(Self.triggerDemos) { demo in
|
||||||
|
Button(demo.label) { applyTrigger(demo.effect) }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
|
||||||
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Adaptive triggers need a DualSense.")
|
||||||
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyTrigger(_ e: DualSenseTriggerEffect) {
|
||||||
|
switch triggerTarget {
|
||||||
|
case .left: tester.applyTrigger(e, right: false)
|
||||||
|
case .right: tester.applyTrigger(e, right: true)
|
||||||
|
case .both:
|
||||||
|
tester.applyTrigger(e, right: false)
|
||||||
|
tester.applyTrigger(e, right: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Lightbar + player LED
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||||
|
if c.hasLight {
|
||||||
|
card("Lightbar & player LED") {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(Self.lightSwatches.indices, id: \.self) { i in
|
||||||
|
Button { tester.setLight(Self.lightSwatches[i].1) } label: {
|
||||||
|
Circle().fill(Self.lightSwatches[i].2)
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.overlay(Circle().stroke(Color.secondary.opacity(0.4)))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Button("Off") { tester.setLight(nil) }.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
Picker("Player LED", selection: $playerLED) {
|
||||||
|
Text("Off").tag(-1)
|
||||||
|
Text("1").tag(0)
|
||||||
|
Text("2").tag(1)
|
||||||
|
Text("3").tag(2)
|
||||||
|
Text("4").tag(3)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: playerLED) { _, v in
|
||||||
|
tester.setPlayerIndex(GCControllerPlayerIndex(rawValue: v) ?? .indexUnset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Helpers
|
||||||
|
|
||||||
|
private func card<Content: View>(
|
||||||
|
_ title: String, @ViewBuilder _ content: () -> Content
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(title).font(.geist(15, .semibold, relativeTo: .subheadline))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(14)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.08)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sgn(_ v: Float) -> String { String(format: "%+.2f", v) }
|
||||||
|
private func mag(_ v: Float) -> String { String(format: "%.2f", v) }
|
||||||
|
|
||||||
|
/// The touchpad surface of a PlayStation pad — `GCDualSenseGamepad` and `GCDualShockGamepad`
|
||||||
|
/// don't share a touchpad type, so downcast either. `nil` for any other controller.
|
||||||
|
private static func touchpad(
|
||||||
|
_ g: GCExtendedGamepad
|
||||||
|
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||||
|
button: GCControllerButtonInput)? {
|
||||||
|
if let ds = g as? GCDualSenseGamepad {
|
||||||
|
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
|
||||||
|
}
|
||||||
|
if let ds4 = g as? GCDualShockGamepad {
|
||||||
|
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total acceleration in g: gravity + user when the pad splits them, else the raw vector.
|
||||||
|
private static func totalAccel(_ m: GCMotion) -> (Double, Double, Double) {
|
||||||
|
if m.hasGravityAndUserAcceleration {
|
||||||
|
return (m.gravity.x + m.userAcceleration.x,
|
||||||
|
m.gravity.y + m.userAcceleration.y,
|
||||||
|
m.gravity.z + m.userAcceleration.z)
|
||||||
|
}
|
||||||
|
return (m.acceleration.x, m.acceleration.y, m.acceleration.z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -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,9 +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") {
|
||||||
ContentView()
|
// Pin the whole app's tint to the brand purple explicitly — the asset-catalog accent
|
||||||
|
// resolution is environment/timing-sensitive and can fall back to system blue. Wraps the
|
||||||
|
// screenshot harness too, so captured screens are on-brand.
|
||||||
|
Group {
|
||||||
|
#if DEBUG
|
||||||
|
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
||||||
|
// 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()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.tint(.brand)
|
||||||
|
// Geist Sans is the app's typeface. This sets the default for unstyled text and the
|
||||||
|
// 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.
|
||||||
@@ -23,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// App Store screenshot harness — device catalog.
|
||||||
|
//
|
||||||
|
// The harness captures the REAL running UI (not an offscreen ImageRenderer snapshot, which can't
|
||||||
|
// rasterize NavigationStack / Form / Liquid-Glass — they come out black). The app is launched in
|
||||||
|
// "shot mode" (PUNKTFUNK_SHOT_SCENE=<name>, see ScreenshotHost) showing one mock-populated scene
|
||||||
|
// full-bleed, and the OS screenshots it: `xcrun simctl io booted screenshot` on the iOS/tvOS
|
||||||
|
// simulators (native pixels = the exact App Store size), `screencapture` for the mac window.
|
||||||
|
// tools/screenshots.sh drives it. DEBUG-only — none of this ships in Release.
|
||||||
|
//
|
||||||
|
// This catalog records the target App Store sizes; on Apple platforms only the mac size is read
|
||||||
|
// at runtime (to size the capture window) — the simulator IS the device, so iOS/tvOS pixels are
|
||||||
|
// whatever the booted device is.
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
enum ShotOrientation { case natural, portrait, landscape }
|
||||||
|
|
||||||
|
/// A target App Store canvas: a natural-orientation pixel size + backing scale.
|
||||||
|
struct ShotDevice {
|
||||||
|
let id: String
|
||||||
|
let naturalWidth: Int
|
||||||
|
let naturalHeight: Int
|
||||||
|
let scale: CGFloat
|
||||||
|
|
||||||
|
func pixels(_ o: ShotOrientation) -> (w: Int, h: Int) {
|
||||||
|
let long = max(naturalWidth, naturalHeight)
|
||||||
|
let short = min(naturalWidth, naturalHeight)
|
||||||
|
switch o {
|
||||||
|
case .natural: return (naturalWidth, naturalHeight)
|
||||||
|
case .portrait: return (short, long)
|
||||||
|
case .landscape: return (long, short)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logical point size (pixels / scale) — used to size the mac capture window so that a
|
||||||
|
/// `screencapture` on a 2× display yields exactly `pixels(_:)`.
|
||||||
|
func points(_ o: ShotOrientation) -> CGSize {
|
||||||
|
let (w, h) = pixels(o)
|
||||||
|
return CGSize(width: CGFloat(w) / scale, height: CGFloat(h) / scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mac: 2880×1800 (16:10 Retina) — an accepted size; on a 1× display the window capture is
|
||||||
|
/// 1440×900, also accepted.
|
||||||
|
static let mac = ShotDevice(id: "mac", naturalWidth: 2880, naturalHeight: 1800, scale: 2)
|
||||||
|
|
||||||
|
/// iPhone 6.9" (required) — for reference / the driver script's simulator choice.
|
||||||
|
static let iphone69 = ShotDevice(id: "iphone-6.9", naturalWidth: 1320, naturalHeight: 2868,
|
||||||
|
scale: 3)
|
||||||
|
/// iPad 13" (required).
|
||||||
|
static let ipad13 = ShotDevice(id: "ipad-13", naturalWidth: 2064, naturalHeight: 2752,
|
||||||
|
scale: 2)
|
||||||
|
/// Apple TV (always landscape).
|
||||||
|
static let appleTV = ShotDevice(id: "appletv", naturalWidth: 1920, naturalHeight: 1080,
|
||||||
|
scale: 1)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// App Store screenshot harness — the in-app "shot mode" root.
|
||||||
|
//
|
||||||
|
// Launched with PUNKTFUNK_SHOT_SCENE=<name> (one of ShotScenes.all), the app shows that single
|
||||||
|
// mock-populated scene full-bleed instead of ContentView, so the OS can screenshot the REAL,
|
||||||
|
// fully-rendered UI (materials, NavigationStack, glass — all the things ImageRenderer can't
|
||||||
|
// rasterize offscreen). tools/screenshots.sh drives one launch per scene per device.
|
||||||
|
//
|
||||||
|
// Capture per platform:
|
||||||
|
// • iOS / tvOS simulator → `xcrun simctl io booted screenshot` (native pixels = exact size).
|
||||||
|
// • macOS → `screencapture -l<windowID>` of the borderless capture window (the configurator
|
||||||
|
// prints `PF_SHOT_WINDOW=<id>`), or the no-permission self-capture fallback
|
||||||
|
// (PUNKTFUNK_SHOT_SELFCAPTURE=<dir> → cacheDisplay; renders the real hierarchy but, like all
|
||||||
|
// non-window-server capture, omits material blur).
|
||||||
|
//
|
||||||
|
// Every screen prints `PF_SHOT_READY scene=<name>` to stdout once it has settled, so the driver
|
||||||
|
// can wait for layout instead of guessing with a fixed sleep.
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
import SwiftUI
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
import ImageIO
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum ScreenshotMode {
|
||||||
|
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
|
||||||
|
static var requestedScene: ShotScene? {
|
||||||
|
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
|
||||||
|
guard !name.isEmpty else { return nil }
|
||||||
|
return ShotScenes.all.first { $0.name == name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-bleed host for a single scene, with per-platform window sizing / orientation and a
|
||||||
|
/// readiness ping for the capture script.
|
||||||
|
struct ScreenshotHostView: View {
|
||||||
|
let scene: ShotScene
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
scene.make()
|
||||||
|
.environment(\.colorScheme, scene.colorScheme)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.black)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
#if os(macOS)
|
||||||
|
.background(MacShotWindowConfigurator(scene: scene))
|
||||||
|
#elseif os(iOS)
|
||||||
|
.background(IOSOrientationConfigurator(orientation: scene.orientation))
|
||||||
|
#endif
|
||||||
|
.task {
|
||||||
|
// Let layout + materials settle, then signal the driver.
|
||||||
|
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||||
|
announceReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func announceReady() {
|
||||||
|
print("PF_SHOT_READY scene=\(scene.name)")
|
||||||
|
fflush(stdout)
|
||||||
|
#if os(macOS)
|
||||||
|
MacSelfCapture.captureIfRequested(scene: scene)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// Sizes the hosting window to the mac canvas, strips the title bar to a clean full-bleed
|
||||||
|
/// surface, and prints the CGWindowID for `screencapture -l`.
|
||||||
|
private struct MacShotWindowConfigurator: NSViewRepresentable {
|
||||||
|
let scene: ShotScene
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSView { NSView() }
|
||||||
|
|
||||||
|
func updateNSView(_ view: NSView, context: Context) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let window = view.window, !context.coordinator.configured else { return }
|
||||||
|
context.coordinator.configured = true
|
||||||
|
// NavigationStack / Form / material chrome follow the WINDOW's appearance, not the
|
||||||
|
// SwiftUI colorScheme — without this the dark scenes render on a light window (white
|
||||||
|
// background, washed-out materials).
|
||||||
|
window.appearance = NSAppearance(named: scene.colorScheme == .dark ? .darkAqua : .aqua)
|
||||||
|
let size = ShotDevice.mac.points(scene.orientation)
|
||||||
|
window.styleMask = [.titled, .fullSizeContentView]
|
||||||
|
window.titlebarAppearsTransparent = true
|
||||||
|
window.titleVisibility = .hidden
|
||||||
|
window.isMovable = false
|
||||||
|
for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
|
||||||
|
window.standardWindowButton(button)?.isHidden = true
|
||||||
|
}
|
||||||
|
window.setContentSize(size)
|
||||||
|
window.center()
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
print("PF_SHOT_WINDOW=\(window.windowNumber) scene=\(scene.name) "
|
||||||
|
+ "size=\(Int(size.width))x\(Int(size.height))pt")
|
||||||
|
fflush(stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||||
|
final class Coordinator { var configured = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No-permission fallback: capture the window's view tree via cacheDisplay. Renders the real
|
||||||
|
/// hierarchy (NavigationStack/Form/cards — unlike ImageRenderer) but omits material blur, which
|
||||||
|
/// only the window server (screencapture) composites. Used when PUNKTFUNK_SHOT_SELFCAPTURE is set.
|
||||||
|
enum MacSelfCapture {
|
||||||
|
static func captureIfRequested(scene: ShotScene) {
|
||||||
|
guard let dir = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SELFCAPTURE"],
|
||||||
|
!dir.isEmpty,
|
||||||
|
let window = NSApp.windows.first(where: { $0.isVisible }),
|
||||||
|
let content = window.contentView else { return }
|
||||||
|
let outDir = URL(fileURLWithPath: (dir as NSString).expandingTildeInPath, isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)
|
||||||
|
guard let rep = content.bitmapImageRepForCachingDisplay(in: content.bounds) else { return }
|
||||||
|
content.cacheDisplay(in: content.bounds, to: rep)
|
||||||
|
let url = outDir.appendingPathComponent("\(ShotDevice.mac.id)-\(scene.name).png")
|
||||||
|
if let dest = CGImageDestinationCreateWithURL(
|
||||||
|
url as CFURL, "public.png" as CFString, 1, nil), let cg = rep.cgImage {
|
||||||
|
CGImageDestinationAddImage(dest, cg, nil)
|
||||||
|
CGImageDestinationFinalize(dest)
|
||||||
|
print("PF_SHOT_SAVED \(url.path) \(rep.pixelsWide)x\(rep.pixelsHigh)px")
|
||||||
|
}
|
||||||
|
fflush(stdout)
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
|
||||||
|
/// for chrome). Requires the app to allow those orientations in Info.plist.
|
||||||
|
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
|
||||||
|
let orientation: ShotOrientation
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
|
||||||
|
|
||||||
|
func updateUIViewController(_ vc: UIViewController, context: Context) {
|
||||||
|
guard let scene = vc.view.window?.windowScene else { return }
|
||||||
|
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
|
||||||
|
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
||||||
|
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
// App Store screenshot scenes — the actual screens we render, each wired with mock data so it
|
||||||
|
// looks populated without a live host. Every scene is built from the REAL app views (HomeView,
|
||||||
|
// SettingsView, PairSheet, TrustCardView) so the screenshots track the shipping UI; only the
|
||||||
|
// live stream is faked (StreamView needs a real punktfunk/1 connection — see ShotStreamHero).
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// One screen to capture: a name (→ file suffix), the canvas orientation, a color scheme, and a
|
||||||
|
/// factory that builds the populated view on the main actor.
|
||||||
|
struct ShotScene {
|
||||||
|
let name: String
|
||||||
|
let orientation: ShotOrientation
|
||||||
|
let colorScheme: ColorScheme
|
||||||
|
let make: @MainActor () -> AnyView
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum ShotScenes {
|
||||||
|
static let all: [ShotScene] = [
|
||||||
|
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
|
||||||
|
AnyView(ShotStreamHero())
|
||||||
|
},
|
||||||
|
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotHome())
|
||||||
|
},
|
||||||
|
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotPair())
|
||||||
|
},
|
||||||
|
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
|
||||||
|
AnyView(ShotTrust())
|
||||||
|
},
|
||||||
|
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotSettings())
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mock data
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum ShotMock {
|
||||||
|
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
|
||||||
|
static func hostStore() -> HostStore {
|
||||||
|
let store = HostStore()
|
||||||
|
store.hosts = [
|
||||||
|
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||||
|
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
|
||||||
|
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
||||||
|
pinnedSHA256: fingerprint),
|
||||||
|
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
|
||||||
|
]
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||||
|
pinnedSHA256: fingerprint)
|
||||||
|
|
||||||
|
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
|
||||||
|
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Home
|
||||||
|
|
||||||
|
private struct ShotHome: View {
|
||||||
|
@StateObject private var store = ShotMock.hostStore()
|
||||||
|
@StateObject private var model = SessionModel()
|
||||||
|
@StateObject private var discovery = HostDiscovery()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
HomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||||
|
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||||
|
connect: { _ in }, connectDiscovered: { _ in },
|
||||||
|
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||||
|
#else
|
||||||
|
HomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||||
|
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||||
|
showSettings: .constant(false),
|
||||||
|
connect: { _ in }, connectDiscovered: { _ in },
|
||||||
|
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings
|
||||||
|
|
||||||
|
private struct ShotSettings: View {
|
||||||
|
var body: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
// The mac Settings window is a fixed-size tabbed panel — float it over a dimmed host
|
||||||
|
// grid so the shot reads as the preferences window over the running app.
|
||||||
|
ZStack {
|
||||||
|
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
|
||||||
|
SettingsView()
|
||||||
|
.fixedSize()
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(radius: 40, y: 16)
|
||||||
|
}
|
||||||
|
#elseif os(iOS)
|
||||||
|
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
|
||||||
|
// rendered directly — a wrapping NavigationStack would nest a split view in a stack. Open
|
||||||
|
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
|
||||||
|
// the General page) instead of the bare category list.
|
||||||
|
SettingsView(initialCategory: .general)
|
||||||
|
#else
|
||||||
|
NavigationStack { SettingsView() }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pair (PIN ceremony)
|
||||||
|
|
||||||
|
private struct ShotPair: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
|
||||||
|
PairSheet(host: ShotMock.host, onPaired: { _ in })
|
||||||
|
.frame(maxWidth: 460)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||||
|
.shadow(radius: 40, y: 16)
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Trust (TOFU card over the blurred live stream)
|
||||||
|
|
||||||
|
private struct ShotTrust: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ShotDesktopFrame()
|
||||||
|
.blur(radius: 32)
|
||||||
|
.overlay(Color.black.opacity(0.45))
|
||||||
|
TrustCardView(
|
||||||
|
fingerprint: ShotMock.fingerprint, hostName: "Battlestation",
|
||||||
|
onCancel: {}, onTrust: {}, onPairInstead: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stream hero
|
||||||
|
|
||||||
|
/// The marketing hero: a stand-in streamed frame with the real glass HUD chip on top.
|
||||||
|
/// StreamView can't render here (it needs a live punktfunk/1 connection), so the frame is
|
||||||
|
/// synthetic — set `PUNKTFUNK_SHOT_HERO=/path/to/frame.png` to drop in a real captured frame.
|
||||||
|
private struct ShotStreamHero: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
ShotDesktopFrame()
|
||||||
|
ShotHUD()
|
||||||
|
}
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A faithful copy of StreamHUDView's overlay (which needs a live PunktfunkConnection for the
|
||||||
|
/// mode line) with representative numbers, reusing the app's real `.glassBackground`.
|
||||||
|
private struct ShotHUD: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle().fill(Color.accentColor).frame(width: 7, height: 7)
|
||||||
|
Text("5120×1440@240 240 fps 812.4 Mb/s")
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
}
|
||||||
|
Text("capture→client 1.3/2.1 ms p50/p95")
|
||||||
|
.font(.system(.caption2, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
#if os(macOS)
|
||||||
|
Text("⌘⎋ releases the mouse")
|
||||||
|
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||||
|
#elseif os(tvOS)
|
||||||
|
Text("Press Menu to disconnect")
|
||||||
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.glassBackground(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A synthetic "streamed frame" — a synthwave scene that reads as game content without shipping
|
||||||
|
/// any real art. Replaced wholesale when `PUNKTFUNK_SHOT_HERO` points at a real PNG.
|
||||||
|
private struct ShotDesktopFrame: View {
|
||||||
|
var body: some View {
|
||||||
|
if let image = Self.overrideImage {
|
||||||
|
image.resizable().scaledToFill()
|
||||||
|
} else {
|
||||||
|
synthetic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var synthetic: some View {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.05, green: 0.02, blue: 0.16),
|
||||||
|
Color(red: 0.35, green: 0.05, blue: 0.42),
|
||||||
|
Color(red: 0.95, green: 0.30, blue: 0.35),
|
||||||
|
Color(red: 0.99, green: 0.62, blue: 0.32),
|
||||||
|
],
|
||||||
|
startPoint: .top, endPoint: .bottom)
|
||||||
|
Canvas { ctx, size in
|
||||||
|
let horizon = size.height * 0.52
|
||||||
|
// Sun.
|
||||||
|
let sunR = size.height * 0.20
|
||||||
|
let sun = CGRect(x: size.width / 2 - sunR, y: horizon - sunR * 1.6,
|
||||||
|
width: sunR * 2, height: sunR * 2)
|
||||||
|
ctx.fill(Path(ellipseIn: sun),
|
||||||
|
with: .linearGradient(
|
||||||
|
Gradient(colors: [Color(red: 1, green: 0.95, blue: 0.5),
|
||||||
|
Color(red: 1, green: 0.35, blue: 0.45)]),
|
||||||
|
startPoint: CGPoint(x: sun.midX, y: sun.minY),
|
||||||
|
endPoint: CGPoint(x: sun.midX, y: sun.maxY)))
|
||||||
|
// Sun scanlines — clip a copy so the base context stays unclipped (GraphicsContext
|
||||||
|
// is a value type; there is no resetClip).
|
||||||
|
var sunCtx = ctx
|
||||||
|
sunCtx.clip(to: Path(ellipseIn: sun))
|
||||||
|
for i in 0..<7 {
|
||||||
|
let y = sun.minY + sun.height * (0.55 + Double(i) * 0.07)
|
||||||
|
let bar = CGRect(x: sun.minX, y: y, width: sun.width,
|
||||||
|
height: sun.height * (0.012 + Double(i) * 0.006))
|
||||||
|
sunCtx.fill(Path(bar), with: .color(.black.opacity(0.85)))
|
||||||
|
}
|
||||||
|
// Perspective grid below the horizon.
|
||||||
|
ctx.opacity = 0.55
|
||||||
|
let cx = size.width / 2
|
||||||
|
for col in -10...10 {
|
||||||
|
var p = Path()
|
||||||
|
p.move(to: CGPoint(x: cx, y: horizon))
|
||||||
|
p.addLine(to: CGPoint(x: cx + Double(col) * size.width * 0.11,
|
||||||
|
y: size.height))
|
||||||
|
ctx.stroke(p, with: .color(Color(red: 0.6, green: 0.95, blue: 1)),
|
||||||
|
lineWidth: 1.5)
|
||||||
|
}
|
||||||
|
var row = horizon
|
||||||
|
var step = size.height * 0.012
|
||||||
|
while row < size.height {
|
||||||
|
var p = Path()
|
||||||
|
p.move(to: CGPoint(x: 0, y: row))
|
||||||
|
p.addLine(to: CGPoint(x: size.width, y: row))
|
||||||
|
ctx.stroke(p, with: .color(Color(red: 0.6, green: 0.95, blue: 1)),
|
||||||
|
lineWidth: 1.5)
|
||||||
|
step *= 1.32
|
||||||
|
row += step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
// A small "now playing" chip so the frame reads as live content, not a wallpaper.
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "gamecontroller.fill")
|
||||||
|
Text("Streaming from Battlestation")
|
||||||
|
.font(.geist(16, .semibold, relativeTo: .callout))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 9)
|
||||||
|
.glassBackground(Capsule())
|
||||||
|
.padding(18)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUNKTFUNK_SHOT_HERO=/abs/path.png` → use a real captured frame as the hero background.
|
||||||
|
static var overrideImage: Image? {
|
||||||
|
guard let path = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_HERO"],
|
||||||
|
!path.isEmpty, FileManager.default.fileExists(atPath: path) else { return nil }
|
||||||
|
#if os(macOS)
|
||||||
|
guard let ns = NSImage(contentsOfFile: path) else { return nil }
|
||||||
|
return Image(nsImage: ns)
|
||||||
|
#else
|
||||||
|
guard let ui = UIImage(contentsOfFile: path) else { return nil }
|
||||||
|
return Image(uiImage: ui)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -5,6 +5,12 @@ import Foundation
|
|||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
#if canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#elseif canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
|
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
|
||||||
/// values. NSLock instead of an actor — the writer is the (non-async) pump thread.
|
/// values. NSLock instead of an actor — the writer is the (non-async) pump thread.
|
||||||
final class FrameMeter: @unchecked Sendable {
|
final class FrameMeter: @unchecked Sendable {
|
||||||
@@ -89,29 +95,76 @@ 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,
|
||||||
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
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
let pin = host.pinnedSHA256
|
let pin = host.pinnedSHA256
|
||||||
|
// Capability gate (main-actor — screen APIs): only advertise HDR when this display can
|
||||||
|
// actually present it, so the host sends a proper SDR stream to an SDR display rather than
|
||||||
|
// BT.2020 PQ the panel would mis-tone-map. The display self-tone-maps HDR from the mastering
|
||||||
|
// metadata we apply (Step 2) when it IS HDR.
|
||||||
|
let displayHDR: Bool = {
|
||||||
|
#if os(macOS)
|
||||||
|
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
||||||
|
#else
|
||||||
|
return UIScreen.main.potentialEDRHeadroom > 1.0
|
||||||
|
#endif
|
||||||
|
}()
|
||||||
|
let hdrCapable = hdrEnabled && displayHDR
|
||||||
|
// 4:4:4 opt-out (default on); the hardware-decode probe below is the real gate.
|
||||||
|
let want444 = (UserDefaults.standard.object(forKey: DefaultsKey.enable444) as? Bool) ?? true
|
||||||
Task.detached(priority: .userInitiated) {
|
Task.detached(priority: .userInitiated) {
|
||||||
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
||||||
// actor. The persistent identity is presented on every connect so a paired
|
// actor. The persistent identity is presented on every connect so a paired
|
||||||
// host recognizes this Mac (nil = anonymous, fine for hosts without
|
// host recognizes this Mac (nil = anonymous, fine for hosts without
|
||||||
// --require-pairing; Keychain/generation failure must not block connecting).
|
// --require-pairing; Keychain/generation failure must not block connecting).
|
||||||
let identity = (try? ClientIdentityStore.shared.load())?.identity
|
let identity = (try? ClientIdentityStore.shared.load())?.identity
|
||||||
|
// Advertise 10-bit + HDR10 when enabled: the host upgrades to a BT.2020 PQ Main10 stream
|
||||||
|
// only for actual HDR content (its own gate); the VideoToolbox/Metal present path is
|
||||||
|
// HDR-capable (P010 + itur_2100_PQ + EDR). 0 keeps the 8-bit BT.709 SDR stream.
|
||||||
|
var videoCaps: UInt8 = hdrCapable
|
||||||
|
? (PunktfunkConnection.videoCap10Bit | PunktfunkConnection.videoCapHDR)
|
||||||
|
: 0
|
||||||
|
// Advertise full-chroma 4:4:4 only when allowed AND this device can HARDWARE-decode it
|
||||||
|
// (software 4:4:4 is too slow for real-time). The host content-gates depth, so an
|
||||||
|
// HDR-advertised session can still receive an 8-bit 4:4:4 stream (SDR content) — require
|
||||||
|
// BOTH depths there. Otherwise a no-op (the host emits 4:4:4 only if it too opted in);
|
||||||
|
// `chromaFormat` on the connection reflects what was actually resolved.
|
||||||
|
let canDecode444 =
|
||||||
|
hdrCapable
|
||||||
|
? (Stage444Probe.hwDecode444_8bit && Stage444Probe.hwDecode444_10bit)
|
||||||
|
: Stage444Probe.hwDecode444_8bit
|
||||||
|
if want444, canDecode444 {
|
||||||
|
videoCaps |= PunktfunkConnection.videoCap444
|
||||||
|
}
|
||||||
let result = Result { try PunktfunkConnection(
|
let result = Result { try PunktfunkConnection(
|
||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
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, launchID: launchID) }
|
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||||
|
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
|
||||||
@@ -125,7 +178,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()
|
||||||
@@ -147,16 +202,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,13 +23,35 @@ 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.enable444) private var enable444 = 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)
|
||||||
|
@State private var showControllerTest = false
|
||||||
|
#endif
|
||||||
|
#if os(iOS)
|
||||||
|
@AppStorage(DefaultsKey.pointerCapture) private var pointerCapture = true
|
||||||
|
// 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 = ""
|
||||||
@@ -35,6 +59,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
|
||||||
@@ -62,6 +95,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Form {
|
Form {
|
||||||
presenterSection
|
presenterSection
|
||||||
|
hdrSection
|
||||||
windowSection
|
windowSection
|
||||||
statisticsSection
|
statisticsSection
|
||||||
}
|
}
|
||||||
@@ -94,31 +128,124 @@ 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
|
||||||
|
pointerSection
|
||||||
|
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
|
||||||
@@ -146,6 +273,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
|
||||||
@@ -170,22 +301,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)
|
||||||
@@ -205,10 +345,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)
|
||||||
@@ -227,6 +369,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("×")
|
||||||
@@ -237,6 +436,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 {
|
||||||
@@ -251,7 +451,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,13 +461,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("")
|
||||||
@@ -300,11 +579,35 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// iPad-only pointer-capture toggle: lock the mouse/trackpad for relative movement (games) vs
|
||||||
|
/// forward an absolute cursor position (desktop). Empty on iPhone (no hardware-pointer lock —
|
||||||
|
/// the mouse path there is always the absolute fallback).
|
||||||
|
@ViewBuilder private var pointerSection: some View {
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||||
|
Section {
|
||||||
|
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||||
|
} header: {
|
||||||
|
Text("Pointer")
|
||||||
|
} footer: {
|
||||||
|
Text("With a mouse or trackpad connected, lock the pointer and send relative "
|
||||||
|
+ "movement — the expected behavior for games (mouse-look). Turn this off for "
|
||||||
|
+ "desktop use to keep the pointer free and send its absolute position instead. "
|
||||||
|
+ "The lock needs the stream full-screen and frontmost; it falls back to the "
|
||||||
|
+ "absolute pointer automatically (Stage Manager, Slide Over). Finger touch is "
|
||||||
|
+ "unaffected. Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@ViewBuilder private var compositorSection: some View {
|
@ViewBuilder private var compositorSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Picker("Compositor", selection: $compositor) {
|
Picker("Compositor", selection: $compositor) {
|
||||||
@@ -320,7 +623,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,26 +637,50 @@ 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)
|
||||||
|
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
||||||
|
} header: {
|
||||||
|
Text("Video quality")
|
||||||
|
} footer: {
|
||||||
|
Text("HDR requests a 10-bit BT.2020 PQ (HDR10) stream — it only engages when the host is "
|
||||||
|
+ "sending HDR content AND this display supports HDR. 4:4:4 requests full chroma "
|
||||||
|
+ "(sharper text/UI, more bandwidth) — it only engages when this device can "
|
||||||
|
+ "hardware-decode it AND the host opted in. Otherwise the stream stays 8-bit "
|
||||||
|
+ "4:2:0 SDR. Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,7 +698,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,7 +713,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,11 +738,16 @@ struct SettingsView: View {
|
|||||||
Text(option.label).tag(option.tag)
|
Text(option.label).tag(option.tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if DEBUG && !os(tvOS)
|
||||||
|
Button("Test Controller…") { showControllerTest = true }
|
||||||
|
.disabled(gamepads.active == nil)
|
||||||
|
.sheet(isPresented: $showControllerTest) { ControllerTestView() }
|
||||||
|
#endif
|
||||||
} header: {
|
} header: {
|
||||||
Text("Controllers")
|
Text("Controllers")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -511,15 +843,18 @@ struct SettingsView: View {
|
|||||||
private static let padTypes: [(label: String, tag: Int)] = [
|
private static let padTypes: [(label: String, tag: Int)] = [
|
||||||
("Automatic", 0),
|
("Automatic", 0),
|
||||||
("Xbox 360", 1),
|
("Xbox 360", 1),
|
||||||
|
("Xbox One", 3),
|
||||||
("DualSense", 2),
|
("DualSense", 2),
|
||||||
|
("DualShock 4", 4),
|
||||||
]
|
]
|
||||||
|
|
||||||
private static let controllersFooter =
|
private static let controllersFooter =
|
||||||
"One controller is forwarded to the host, as player 1 — Automatic picks the most "
|
"One controller is forwarded to the host, as player 1 — Automatic picks the most "
|
||||||
+ "recently connected one. The type is the virtual pad the host creates: Automatic "
|
+ "recently connected one. The type is the virtual pad the host creates: Automatic "
|
||||||
+ "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad "
|
+ "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad "
|
||||||
+ "and motion), and changes apply from the next session. Two identical controllers "
|
+ "and motion; a DualShock 4 the same minus adaptive triggers), and changes apply "
|
||||||
+ "may swap a manual selection after reconnecting."
|
+ "from the next session. Two identical controllers may swap a manual selection "
|
||||||
|
+ "after reconnecting."
|
||||||
|
|
||||||
/// "Use controller" choices: Automatic, every forwardable controller, and — so a stale
|
/// "Use controller" choices: Automatic, every forwardable controller, and — so a stale
|
||||||
/// pin stays visible instead of leaving the Picker selection tag-less — any pinned id
|
/// pin stays visible instead of leaving the Picker selection tag-less — any pinned id
|
||||||
@@ -537,7 +872,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
private func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View {
|
private func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: controller.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
Image(systemName: controller.hasTouchpadAndMotion ? "playstation.logo" : "gamecontroller.fill")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(controller.name)
|
Text(controller.name)
|
||||||
@@ -564,13 +899,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)))
|
||||||
@@ -592,6 +927,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -602,3 +941,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,13 +15,29 @@ 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"
|
||||||
|
/// Request a full-chroma 4:4:4 stream when this device can HARDWARE-decode it (`Stage444Probe`).
|
||||||
|
/// On by default; only takes effect when the host also opted in to 4:4:4 (otherwise the stream
|
||||||
|
/// stays 4:2:0). Sharper text/UI at the cost of more bandwidth.
|
||||||
|
public static let enable444 = "punktfunk.enable444"
|
||||||
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"
|
||||||
|
/// iPad: capture the mouse/trackpad pointer (pointer lock → relative movement) for games,
|
||||||
|
/// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad
|
||||||
|
/// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost
|
||||||
|
/// scene and silently falls back to the absolute pointer when it can't (Stage Manager / Slide
|
||||||
|
/// Over). Read by `StreamViewController.prefersPointerLocked`.
|
||||||
|
public static let pointerCapture = "punktfunk.pointerCapture"
|
||||||
/// Experimental: show the host's game library (browsed over the management API). Off by default.
|
/// Experimental: show the host's game library (browsed over the management API). Off by default.
|
||||||
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
||||||
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Raw-HID DualSense rumble for macOS.
|
||||||
|
//
|
||||||
|
// Apple's GameController/CHHapticEngine path does NOT drive the DualSense's rumble motors on
|
||||||
|
// macOS — a documented platform gap: adaptive triggers, lightbar and player LEDs all work
|
||||||
|
// (different APIs), but `CHHapticEngine` output never reaches the motors. So we write the motor
|
||||||
|
// amplitudes straight into the DualSense HID output report, exactly the way SDL and the Linux
|
||||||
|
// `hid-playstation` driver do (the same report that already rumbles this pad on a Linux host).
|
||||||
|
//
|
||||||
|
// USB (report 0x02, 48 bytes, no CRC) and Bluetooth (report 0x31, 78 bytes, trailing CRC32) are
|
||||||
|
// both handled. The App Sandbox permits the raw-HID access via the app's `device.usb` +
|
||||||
|
// `device.bluetooth` entitlements, and this coexists with GameController holding the same device
|
||||||
|
// (non-seized open). Output-only, so no run-loop scheduling is needed.
|
||||||
|
//
|
||||||
|
// macOS-only: IOKit HID device access isn't available to apps on iOS/tvOS.
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
import Foundation
|
||||||
|
import IOKit
|
||||||
|
import IOKit.hid
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||||
|
|
||||||
|
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||||
|
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
|
||||||
|
final class DualSenseHID {
|
||||||
|
private let manager: IOHIDManager
|
||||||
|
private var device: IOHIDDevice?
|
||||||
|
private var bluetooth = false
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
private static let vendorSony = 0x054C
|
||||||
|
// DualSense (0x0CE6) and DualSense Edge (0x0DF2). The DualShock 4 uses a different report
|
||||||
|
// layout and is intentionally not handled here.
|
||||||
|
private static let productIDs = [0x0CE6, 0x0DF2]
|
||||||
|
|
||||||
|
/// "USB" or "Bluetooth" — for logs / the debug panel. Valid after a successful `open()`.
|
||||||
|
var transport: String { bluetooth ? "Bluetooth" : "USB" }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { close() }
|
||||||
|
|
||||||
|
/// Find and open the first connected DualSense. Returns false if none is present or it can't
|
||||||
|
/// be opened (caller then falls back to CoreHaptics).
|
||||||
|
func open() -> Bool {
|
||||||
|
let matches = Self.productIDs.map { pid in
|
||||||
|
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||||
|
}
|
||||||
|
IOHIDManagerSetDeviceMatchingMultiple(manager, matches as CFArray)
|
||||||
|
guard IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
|
||||||
|
log.info("rumble: DualSense HID manager open failed — falling back to CoreHaptics")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
|
||||||
|
let dev = devices.first
|
||||||
|
else {
|
||||||
|
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
|
||||||
|
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
device = dev
|
||||||
|
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
|
||||||
|
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
|
||||||
|
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
|
||||||
|
/// each 0...255. (0, 0) stops.
|
||||||
|
func rumble(low: UInt8, high: UInt8) {
|
||||||
|
guard let dev = device else { return }
|
||||||
|
let report = bluetooth
|
||||||
|
? Self.bluetoothReport(low: low, high: high)
|
||||||
|
: Self.usbReport(low: low, high: high)
|
||||||
|
let rc = report.withUnsafeBufferPointer { buf in
|
||||||
|
IOHIDDeviceSetReport(
|
||||||
|
dev, kIOHIDReportTypeOutput, CFIndex(report[0]), buf.baseAddress!, buf.count)
|
||||||
|
}
|
||||||
|
if rc != kIOReturnSuccess {
|
||||||
|
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
guard !closed else { return }
|
||||||
|
closed = true
|
||||||
|
if device != nil { rumble(low: 0, high: 0) } // silence the motors before releasing
|
||||||
|
device = nil
|
||||||
|
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report builders
|
||||||
|
|
||||||
|
// DualSense effects payload (DS5EffectsState_t / hid-playstation `common`) — offsets relative
|
||||||
|
// to the payload start:
|
||||||
|
// 0 flag0 (enable bits) 2 motor_right (high-freq) 3 motor_left (low-freq)
|
||||||
|
// 1 flag1 38 flag2 (enhanced enable)
|
||||||
|
// We mirror the Linux driver: flag0 = COMPATIBLE_VIBRATION | HAPTICS_SELECT, flag2 =
|
||||||
|
// COMPATIBLE_VIBRATION2 (the enhanced-firmware path), motors sent directly. valid_flag1 stays
|
||||||
|
// 0 so this rumble-only report leaves the lightbar / triggers / player LEDs (driven by
|
||||||
|
// GameController) untouched.
|
||||||
|
private static func fillEffects(_ data: inout [UInt8], at base: Int, low: UInt8, high: UInt8) {
|
||||||
|
data[base + 0] = 0x03 // COMPATIBLE_VIBRATION (0x01) | HAPTICS_SELECT (0x02)
|
||||||
|
data[base + 2] = high // motor_right
|
||||||
|
data[base + 3] = low // motor_left
|
||||||
|
data[base + 38] = 0x04 // COMPATIBLE_VIBRATION2 (enhanced rumble, firmware ≥ 0x0224)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `usbReport` / `bluetoothReport` / `crc32` are internal (not private) so the unit tests can
|
||||||
|
// pin the exact wire layout against the SDL / hid-playstation spec without a physical pad.
|
||||||
|
static func usbReport(low: UInt8, high: UInt8) -> [UInt8] {
|
||||||
|
var d = [UInt8](repeating: 0, count: 48)
|
||||||
|
d[0] = 0x02 // report id
|
||||||
|
fillEffects(&d, at: 1, low: low, high: high)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
static func bluetoothReport(low: UInt8, high: UInt8) -> [UInt8] {
|
||||||
|
var d = [UInt8](repeating: 0, count: 78)
|
||||||
|
d[0] = 0x31 // report id
|
||||||
|
d[1] = 0x00 // seq/tag (static, as SDL)
|
||||||
|
d[2] = 0x10 // magic
|
||||||
|
fillEffects(&d, at: 3, low: low, high: high)
|
||||||
|
// Trailing CRC32 over a 0xA2 seed byte + the report minus its 4 CRC bytes, little-endian.
|
||||||
|
let crc = Self.crc32(seed: 0xA2, d[0..<(d.count - 4)])
|
||||||
|
d[74] = UInt8(crc & 0xFF)
|
||||||
|
d[75] = UInt8((crc >> 8) & 0xFF)
|
||||||
|
d[76] = UInt8((crc >> 16) & 0xFF)
|
||||||
|
d[77] = UInt8((crc >> 24) & 0xFF)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard reflected CRC32 (zlib poly 0xEDB88320, init 0xFFFFFFFF, final XOR) over `seed`
|
||||||
|
/// followed by `bytes` — the DualSense Bluetooth output-report checksum (seed 0xA2). Matches
|
||||||
|
/// SDL's `SDL_crc32`/the kernel's `crc32_le` framing.
|
||||||
|
static func crc32<S: Sequence>(seed: UInt8, _ bytes: S) -> UInt32
|
||||||
|
where S.Element == UInt8 {
|
||||||
|
var crc: UInt32 = 0xFFFF_FFFF
|
||||||
|
func step(_ b: UInt8) {
|
||||||
|
crc ^= UInt32(b)
|
||||||
|
for _ in 0..<8 {
|
||||||
|
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB8_8320 : crc >> 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step(seed)
|
||||||
|
for b in bytes { step(b) }
|
||||||
|
return ~crc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -6,12 +6,14 @@
|
|||||||
// full GCExtendedGamepad state on every valueChanged and diff against the previous
|
// full GCExtendedGamepad state on every valueChanged and diff against the previous
|
||||||
// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255.
|
// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255.
|
||||||
//
|
//
|
||||||
// DualSense extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
||||||
// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion
|
// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion
|
||||||
// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g —
|
// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g —
|
||||||
// derived from the host's fixed calibration blob; the conversion lives in ONE place,
|
// derived from the host's fixed calibration blob; the conversion lives in ONE place,
|
||||||
// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both
|
// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both
|
||||||
// unless the session's virtual pad is a DualSense.
|
// unless the session's virtual pad is a DualSense or DualShock 4 — both carry a touchpad
|
||||||
|
// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same
|
||||||
|
// `touchpad*` surface as `GCDualSenseGamepad`).
|
||||||
//
|
//
|
||||||
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture
|
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture
|
||||||
// toggle — a controller can't click local UI, so it always drives the host while the app
|
// toggle — a controller can't click local UI, so it always drives the host while the app
|
||||||
@@ -154,8 +156,9 @@ public final class GamepadCapture {
|
|||||||
releaseAll()
|
releaseAll()
|
||||||
if let ext = bound?.extendedGamepad {
|
if let ext = bound?.extendedGamepad {
|
||||||
ext.valueChangedHandler = nil
|
ext.valueChangedHandler = nil
|
||||||
(ext as? GCDualSenseGamepad)?.touchpadPrimary.valueChangedHandler = nil
|
let tp = Self.touchpad(ext)
|
||||||
(ext as? GCDualSenseGamepad)?.touchpadSecondary.valueChangedHandler = nil
|
tp?.primary.valueChangedHandler = nil
|
||||||
|
tp?.secondary.valueChangedHandler = nil
|
||||||
}
|
}
|
||||||
if let motion = bound?.motion {
|
if let motion = bound?.motion {
|
||||||
motion.valueChangedHandler = nil
|
motion.valueChangedHandler = nil
|
||||||
@@ -186,11 +189,11 @@ public final class GamepadCapture {
|
|||||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))
|
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))
|
||||||
sync(ext)
|
sync(ext)
|
||||||
|
|
||||||
if let ds = ext as? GCDualSenseGamepad {
|
if let tp = Self.touchpad(ext) {
|
||||||
ds.touchpadPrimary.valueChangedHandler = { [weak self] _, x, y in
|
tp.primary.valueChangedHandler = { [weak self] _, x, y in
|
||||||
MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) }
|
MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) }
|
||||||
}
|
}
|
||||||
ds.touchpadSecondary.valueChangedHandler = { [weak self] _, x, y in
|
tp.secondary.valueChangedHandler = { [weak self] _, x, y in
|
||||||
MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) }
|
MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,12 +260,29 @@ public final class GamepadCapture {
|
|||||||
if g.buttonB.isPressed { b |= GamepadWire.b }
|
if g.buttonB.isPressed { b |= GamepadWire.b }
|
||||||
if g.buttonX.isPressed { b |= GamepadWire.x }
|
if g.buttonX.isPressed { b |= GamepadWire.x }
|
||||||
if g.buttonY.isPressed { b |= GamepadWire.y }
|
if g.buttonY.isPressed { b |= GamepadWire.y }
|
||||||
if (g as? GCDualSenseGamepad)?.touchpadButton.isPressed == true {
|
if Self.touchpad(g)?.button.isPressed == true {
|
||||||
b |= GamepadWire.touchpadClick
|
b |= GamepadWire.touchpadClick
|
||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The touchpad surface of a PlayStation pad — present on both `GCDualSenseGamepad` and
|
||||||
|
/// `GCDualShockGamepad` (DualShock 4), which don't share a common touchpad type, so we
|
||||||
|
/// downcast either and project the identical `touchpad*` properties. `nil` for any other
|
||||||
|
/// controller (Xbox, MFi).
|
||||||
|
private static func touchpad(
|
||||||
|
_ g: GCExtendedGamepad
|
||||||
|
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||||
|
button: GCControllerButtonInput)? {
|
||||||
|
if let ds = g as? GCDualSenseGamepad {
|
||||||
|
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
|
||||||
|
}
|
||||||
|
if let ds4 = g as? GCDualShockGamepad {
|
||||||
|
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
/// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on
|
/// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on
|
||||||
/// lift — treated as the lift signal (a real finger landing on the precise center
|
/// lift — treated as the lift signal (a real finger landing on the precise center
|
||||||
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
|
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger.
|
// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger.
|
||||||
//
|
//
|
||||||
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
|
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
|
||||||
// only on DualSense sessions — the drain always polls both planes with short timeouts and
|
// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) — the
|
||||||
// never spins, so an Xbox session just renders rumble. GameController profile mutation
|
// drain always polls both planes with short timeouts and never spins, so an Xbox session
|
||||||
|
// just renders rumble. GameController profile mutation
|
||||||
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
|
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
|
||||||
// touches neither. When GamepadManager switches the active controller mid-session, the
|
// touches neither. When GamepadManager switches the active controller mid-session, the
|
||||||
// old pad is reset (triggers off, player index unset) and the last known feedback state
|
// old pad is reset (triggers off, player index unset) and the last known feedback state
|
||||||
@@ -49,10 +50,12 @@ private final class FeedbackStopFlag: @unchecked Sendable {
|
|||||||
private final class RumbleRenderer: @unchecked Sendable {
|
private final class RumbleRenderer: @unchecked Sendable {
|
||||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||||
|
|
||||||
|
/// One actuator's started engine plus the player currently driving it (nil = idle). The
|
||||||
|
/// player is rebuilt per level change — `drive` bakes the target intensity into a fresh
|
||||||
|
/// continuous event rather than scaling a long-lived one with a dynamic parameter.
|
||||||
private struct Motor {
|
private struct Motor {
|
||||||
let engine: CHHapticEngine
|
let engine: CHHapticEngine
|
||||||
let player: CHHapticAdvancedPatternPlayer
|
var player: CHHapticAdvancedPatternPlayer?
|
||||||
var playing = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var controller: GCController?
|
private var controller: GCController?
|
||||||
@@ -65,12 +68,41 @@ 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
|
||||||
|
|
||||||
func retarget(_ c: GCController?) {
|
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
||||||
|
/// defined frequency to move at all — an intensity-only event (no sharpness) left them
|
||||||
|
/// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid
|
||||||
|
/// value the known-working macOS DualSense rumble implementations use. (Used only on the
|
||||||
|
/// CoreHaptics path — a DualSense on macOS is driven over raw HID instead, see below.)
|
||||||
|
private static let sharpness: Float = 0.5
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
|
||||||
|
/// does not reach them on macOS — adaptive triggers/lightbar work, rumble is silent). nil for
|
||||||
|
/// every other controller, which keeps the CoreHaptics path.
|
||||||
|
private var dualSenseHID: DualSenseHID?
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
||||||
|
/// rumble backend now in use — for the debug controller-test panel.
|
||||||
|
func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) {
|
||||||
queue.async {
|
queue.async {
|
||||||
self.teardown()
|
self.teardown()
|
||||||
|
self.closeHID()
|
||||||
self.controller = c
|
self.controller = c
|
||||||
self.broken = false
|
self.broken = false
|
||||||
|
self.consecutiveFailures = 0
|
||||||
|
self.retryAfter = .distantPast
|
||||||
|
_ = self.openHIDIfDualSense(c)
|
||||||
|
onBackend?(self.backendNote(for: c))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,22 +114,43 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
log.debug(
|
log.debug(
|
||||||
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
// A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every
|
||||||
|
// other pad (and for a DualSense whose HID device could not be opened).
|
||||||
|
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
||||||
guard !self.broken else { return }
|
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
|
||||||
if self.high != nil {
|
if self.high != nil {
|
||||||
self.drive(&self.low, Float(lowAmp) / 65535)
|
// Per-handle: low = left/heavy motor, high = right/light — the XInput convention
|
||||||
self.drive(&self.high, Float(highAmp) / 65535)
|
// the wire carries.
|
||||||
|
let okLow = self.drive(&self.low, Float(lowAmp) / 65535)
|
||||||
|
let okHigh = self.drive(&self.high, Float(highAmp) / 65535)
|
||||||
|
ok = okLow && okHigh
|
||||||
} else {
|
} else {
|
||||||
// Combined engine: whichever motor is stronger wins.
|
// Combined engine: whichever motor is stronger wins.
|
||||||
self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
|
ok = self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
|
||||||
|
}
|
||||||
|
// Rebuild on the next nonzero amplitude if an engine errored — and tear down OUTSIDE
|
||||||
|
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
|
||||||
|
// still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
|
||||||
|
// update; once a player is actually running the path has recovered, so clear the backoff.
|
||||||
|
if !ok {
|
||||||
|
self.teardown()
|
||||||
|
self.scheduleRetryBackoff()
|
||||||
|
} else if self.low?.player != nil || self.high?.player != nil {
|
||||||
|
self.consecutiveFailures = 0
|
||||||
|
self.retryAfter = .distantPast
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
queue.sync { self.teardown() }
|
queue.sync {
|
||||||
|
self.teardown()
|
||||||
|
self.closeHID()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
|
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
|
||||||
@@ -121,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
|
||||||
@@ -143,44 +211,51 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
self?.queue.async { self?.teardown() }
|
self?.queue.async { self?.teardown() }
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
|
// Start the engine now; the player that actually moves the motor is built per level
|
||||||
|
// change in `drive` (a fresh event baked at the target intensity).
|
||||||
try engine.start()
|
try engine.start()
|
||||||
let event = CHHapticEvent(
|
return Motor(engine: engine, player: nil)
|
||||||
eventType: .hapticContinuous,
|
|
||||||
parameters: [CHHapticEventParameter(parameterID: .hapticIntensity, value: 1)],
|
|
||||||
relativeTime: 0,
|
|
||||||
duration: TimeInterval(GCHapticDurationInfinite))
|
|
||||||
let player = try engine.makeAdvancedPlayer(with: CHHapticPattern(events: [event], parameters: []))
|
|
||||||
return Motor(engine: engine, player: player)
|
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func drive(_ motor: inout Motor?, _ amplitude: Float) {
|
/// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
|
||||||
guard var m = motor else { return }
|
/// is BAKED into the event. On a DualSense this is what actually moves the actuators: a
|
||||||
|
/// fixed-intensity event scaled by a dynamic `.hapticIntensityControl` parameter (the old
|
||||||
|
/// path) drives the iPhone Taptic Engine but is silent on a controller's haptic engine. The
|
||||||
|
/// event carries an explicit sharpness (frequency) so the voice coils respond, and an infinite
|
||||||
|
/// duration so a single host update — the host sends rumble only when the level changes —
|
||||||
|
/// sustains until the next one. Returns false if the engine errored; the caller tears down for
|
||||||
|
/// a rebuild (done outside this `inout` access to avoid an exclusivity violation).
|
||||||
|
private func drive(_ motor: inout Motor?, _ amplitude: Float) -> Bool {
|
||||||
|
guard var m = motor else { return true }
|
||||||
|
// Replace any running player: stop the old, and for a zero level leave the motor idle.
|
||||||
|
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||||
|
m.player = nil
|
||||||
|
guard amplitude > 0 else { motor = m; return true }
|
||||||
do {
|
do {
|
||||||
if amplitude > 0 {
|
let event = CHHapticEvent(
|
||||||
if !m.playing {
|
eventType: .hapticContinuous,
|
||||||
try m.player.start(atTime: CHHapticTimeImmediate)
|
parameters: [
|
||||||
m.playing = true
|
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
|
||||||
}
|
CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
|
||||||
try m.player.sendParameters(
|
],
|
||||||
[CHHapticDynamicParameter(
|
relativeTime: 0,
|
||||||
parameterID: .hapticIntensityControl,
|
duration: TimeInterval(GCHapticDurationInfinite))
|
||||||
value: amplitude, relativeTime: 0)],
|
let player = try m.engine.makeAdvancedPlayer(
|
||||||
atTime: CHHapticTimeImmediate)
|
with: CHHapticPattern(events: [event], parameters: []))
|
||||||
} else if m.playing {
|
try player.start(atTime: CHHapticTimeImmediate)
|
||||||
try m.player.stop(atTime: CHHapticTimeImmediate)
|
m.player = player
|
||||||
m.playing = false
|
|
||||||
}
|
|
||||||
motor = m
|
motor = m
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
// A transient failure (the engine stopped/reset between its handler firing and now).
|
// A transient failure (the engine stopped/reset between its handler firing and now).
|
||||||
// Tear down so the next nonzero amplitude rebuilds — do NOT latch rumble off for the
|
// Signal a rebuild — do NOT latch rumble off for the session (the old "spotty" bug).
|
||||||
// session (that was the old "spotty" behaviour).
|
|
||||||
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
|
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
|
||||||
teardown()
|
motor = m
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,12 +265,56 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
||||||
m.engine.stoppedHandler = { _ in }
|
m.engine.stoppedHandler = { _ in }
|
||||||
m.engine.resetHandler = {}
|
m.engine.resetHandler = {}
|
||||||
try? m.player.stop(atTime: CHHapticTimeImmediate)
|
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||||
m.engine.stop()
|
m.engine.stop()
|
||||||
}
|
}
|
||||||
low = nil
|
low = nil
|
||||||
high = nil
|
high = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - DualSense raw-HID rumble (macOS)
|
||||||
|
//
|
||||||
|
// On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
|
||||||
|
// we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path.
|
||||||
|
// All three run on the serial `queue`, like the rest of the renderer state.
|
||||||
|
|
||||||
|
private func openHIDIfDualSense(_ c: GCController?) -> Bool {
|
||||||
|
#if os(macOS)
|
||||||
|
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
|
||||||
|
let hid = DualSenseHID()
|
||||||
|
guard hid.open() else { return false }
|
||||||
|
dualSenseHID = hid
|
||||||
|
return true
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the DualSense's motors over HID if that's the active backend; false → not a HID pad,
|
||||||
|
/// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255.
|
||||||
|
private func hidRumble(low: UInt16, high: UInt16) -> Bool {
|
||||||
|
#if os(macOS)
|
||||||
|
guard let hid = dualSenseHID else { return false }
|
||||||
|
hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8))
|
||||||
|
return true
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeHID() {
|
||||||
|
#if os(macOS)
|
||||||
|
dualSenseHID?.close()
|
||||||
|
dualSenseHID = nil
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func backendNote(for c: GCController?) -> String {
|
||||||
|
#if os(macOS)
|
||||||
|
if let hid = dualSenseHID { return "DualSense HID · \(hid.transport)" }
|
||||||
|
#endif
|
||||||
|
return c == nil ? "—" : "CoreHaptics"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public final class GamepadFeedback {
|
public final class GamepadFeedback {
|
||||||
@@ -248,29 +367,35 @@ public final class GamepadFeedback {
|
|||||||
public func start() {
|
public func start() {
|
||||||
guard !drainStarted else { return }
|
guard !drainStarted else { return }
|
||||||
drainStarted = true
|
drainStarted = true
|
||||||
// No hidout traffic can exist on a non-DualSense session — poll that plane
|
// Hidout traffic (lightbar / player LEDs / triggers) only exists on a PlayStation-pad
|
||||||
// nonblocking there and let rumble own the wait.
|
// session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
|
||||||
let hidTimeout: UInt32 = connection.resolvedGamepad == .dualSense ? 10 : 0
|
// let rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
|
||||||
let thread = Thread { [connection, flag, drainDone, weak self] in
|
let thread = Thread { [connection, flag, drainDone, weak self] in
|
||||||
while !flag.isStopped {
|
while !flag.isStopped {
|
||||||
do {
|
do {
|
||||||
if let r = try connection.nextRumble(timeoutMs: 10), r.pad == 0 {
|
// Poll the feedback planes NON-BLOCKING. A blocking poll (timeoutMs > 0) holds
|
||||||
|
// the connection's shared feedback lock for its whole wait; the video pump drains
|
||||||
|
// HDR mastering metadata (nextHdrMeta) on the SAME lock every frame, so a blocking
|
||||||
|
// poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR
|
||||||
|
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
|
||||||
|
// rumble/HID latency low while leaving the lock free between polls.
|
||||||
|
if let r = try connection.nextRumble(timeoutMs: 0), r.pad == 0 {
|
||||||
self?.rumble.apply(low: r.low, high: r.high)
|
self?.rumble.apply(low: r.low, high: r.high)
|
||||||
}
|
}
|
||||||
// Drain a BOUNDED burst of hidout events: only the first poll waits,
|
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||||
// and the cap + stop check keep sustained 0xCD traffic (a game writing
|
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||||
// per-frame LED/trigger reports) from starving the rumble poll above
|
|
||||||
// or blocking stop() past one cycle.
|
|
||||||
var burst = 0
|
var burst = 0
|
||||||
while burst < 64, !flag.isStopped,
|
while burst < 64, !flag.isStopped,
|
||||||
let ev = try connection.nextHidOutput(
|
let ev = try connection.nextHidOutput(timeoutMs: 0) {
|
||||||
timeoutMs: burst == 0 ? hidTimeout : 0) {
|
|
||||||
self?.render(ev)
|
self?.render(ev)
|
||||||
burst += 1
|
burst += 1
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
break // .closed (or fatal) — the session is over
|
break // .closed (or fatal) — the session is over
|
||||||
}
|
}
|
||||||
|
// ~8 ms poll cadence (≈125 Hz), slept OUTSIDE the feedback lock — low rumble/HID
|
||||||
|
// latency without holding the lock the HDR-meta drain needs.
|
||||||
|
if !flag.isStopped { Thread.sleep(forTimeInterval: 0.008) }
|
||||||
}
|
}
|
||||||
drainDone.signal()
|
drainDone.signal()
|
||||||
}
|
}
|
||||||
@@ -365,3 +490,74 @@ public final class GamepadFeedback {
|
|||||||
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Local feedback driver for the Settings → Controllers "Test Controller" panel (DEBUG builds
|
||||||
|
/// only). It drives the SAME CoreHaptics rumble renderer and `DualSenseTriggerEffect` path a
|
||||||
|
/// live session uses — just aimed at the physically-connected controller instead of the
|
||||||
|
/// host→client feedback planes — so rumble, the adaptive triggers, the lightbar and the player
|
||||||
|
/// LEDs can be confirmed on-device without a host. Reusing the real renderers is the point:
|
||||||
|
/// a passing test exercises the exact code a session runs.
|
||||||
|
@MainActor
|
||||||
|
public final class ControllerTester: ObservableObject {
|
||||||
|
private let renderer = RumbleRenderer()
|
||||||
|
private weak var controller: GCController?
|
||||||
|
|
||||||
|
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
||||||
|
/// for the test panel to display so it's obvious which path a given pad takes.
|
||||||
|
@Published public private(set) var rumbleBackend = "—"
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Aim the feedback at a controller (nil releases it). Idempotent — safe to call on every
|
||||||
|
/// active-controller change.
|
||||||
|
public func target(_ c: GCController?) {
|
||||||
|
guard c !== controller else { return }
|
||||||
|
controller = c
|
||||||
|
renderer.retarget(c) { [weak self] note in
|
||||||
|
Task { @MainActor in self?.rumbleBackend = note }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive both motors at 0...1 amplitudes — low = left/heavy, high = right/light — mapped to
|
||||||
|
/// the 0...0xFFFF wire range the session carries, through the real `RumbleRenderer`.
|
||||||
|
public func rumble(low: Float, high: Float) {
|
||||||
|
func u16(_ v: Float) -> UInt16 { UInt16((min(max(v, 0), 1) * 65535).rounded()) }
|
||||||
|
renderer.apply(low: u16(low), high: u16(high))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stopRumble() { renderer.apply(low: 0, high: 0) }
|
||||||
|
|
||||||
|
/// Replay an adaptive-trigger effect on a DualSense via the real `DualSenseTriggerEffect`
|
||||||
|
/// renderer. `right == false` → L2, `true` → R2. No-op on a non-DualSense pad.
|
||||||
|
public func applyTrigger(_ effect: DualSenseTriggerEffect, right: Bool) {
|
||||||
|
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
||||||
|
effect.apply(to: right ? ds.rightTrigger : ds.leftTrigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func resetTriggers() {
|
||||||
|
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
||||||
|
ds.leftTrigger.setModeOff()
|
||||||
|
ds.rightTrigger.setModeOff()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lightbar colour (DualSense / DualShock 4); nil turns it off. No-op without a light.
|
||||||
|
public func setLight(_ color: GCColor?) {
|
||||||
|
controller?.light?.color = color ?? GCColor(red: 0, green: 0, blue: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Player-indicator LEDs (`.index1`...`.index4`, or `.indexUnset` to clear).
|
||||||
|
public func setPlayerIndex(_ index: GCControllerPlayerIndex) {
|
||||||
|
controller?.playerIndex = index
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Silence every channel and release the controller — call on the panel's disappear.
|
||||||
|
public func stop() {
|
||||||
|
resetTriggers()
|
||||||
|
setPlayerIndex(.indexUnset)
|
||||||
|
setLight(nil)
|
||||||
|
renderer.retarget(nil) // async teardown: stops the motors + drops the controller ref
|
||||||
|
controller = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -30,11 +30,22 @@ public final class GamepadManager: ObservableObject {
|
|||||||
public let productCategory: String
|
public let productCategory: String
|
||||||
/// The full extended profile exists — only these are forwardable.
|
/// The full extended profile exists — only these are forwardable.
|
||||||
public let isExtended: Bool
|
public let isExtended: Bool
|
||||||
public let isDualSense: Bool
|
/// The virtual-pad type a physical match resolves to under `.auto`: DualSense →
|
||||||
|
/// `.dualSense`, DualShock 4 → `.dualShock4`, an Xbox pad → `.xboxOne`, anything
|
||||||
|
/// else → `.xbox360`. (`.auto` is never stored here.)
|
||||||
|
public let kind: PunktfunkConnection.GamepadType
|
||||||
public let hasLight: Bool
|
public let hasLight: Bool
|
||||||
public let hasHaptics: Bool
|
public let hasHaptics: Bool
|
||||||
public let hasMotion: Bool
|
public let hasMotion: Bool
|
||||||
public let hasAdaptiveTriggers: Bool
|
public let hasAdaptiveTriggers: Bool
|
||||||
|
/// Specifically a DualSense — gates the DualSense-only feedback (adaptive triggers,
|
||||||
|
/// player LEDs) and the PlayStation glyph in Settings.
|
||||||
|
public var isDualSense: Bool { kind == .dualSense }
|
||||||
|
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) — gates
|
||||||
|
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
|
||||||
|
public var hasTouchpadAndMotion: Bool {
|
||||||
|
kind == .dualSense || kind == .dualShock4
|
||||||
|
}
|
||||||
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
|
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
|
||||||
public let batteryLevel: Float?
|
public let batteryLevel: Float?
|
||||||
public let isCharging: Bool
|
public let isCharging: Bool
|
||||||
@@ -102,7 +113,8 @@ public final class GamepadManager: ObservableObject {
|
|||||||
|
|
||||||
/// Connect-time resolution of the user's controller-type setting: an explicit choice
|
/// Connect-time resolution of the user's controller-type setting: an explicit choice
|
||||||
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense →
|
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense →
|
||||||
/// DualSense, anything else → Xbox 360); no controller at all defers to the host.
|
/// DualSense, DualShock 4 → DualShock 4, an Xbox pad → Xbox One, anything else → Xbox
|
||||||
|
/// 360); no controller at all defers to the host.
|
||||||
public func resolveType(
|
public func resolveType(
|
||||||
setting: PunktfunkConnection.GamepadType
|
setting: PunktfunkConnection.GamepadType
|
||||||
) -> PunktfunkConnection.GamepadType {
|
) -> PunktfunkConnection.GamepadType {
|
||||||
@@ -113,7 +125,7 @@ public final class GamepadManager: ObservableObject {
|
|||||||
// pad. `rebuild()` re-reads `GCController.controllers()` synchronously, closing that race.
|
// pad. `rebuild()` re-reads `GCController.controllers()` synchronously, closing that race.
|
||||||
rebuild()
|
rebuild()
|
||||||
guard let active else { return .auto }
|
guard let active else { return .auto }
|
||||||
return active.isDualSense ? .dualSense : .xbox360
|
return active.kind
|
||||||
}
|
}
|
||||||
|
|
||||||
private func noteConnected(_ c: GCController) {
|
private func noteConnected(_ c: GCController) {
|
||||||
@@ -152,20 +164,38 @@ public final class GamepadManager: ObservableObject {
|
|||||||
|
|
||||||
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
||||||
let extended = c.extendedGamepad
|
let extended = c.extendedGamepad
|
||||||
let ds = extended as? GCDualSenseGamepad
|
let kind = padKind(extended)
|
||||||
return DiscoveredController(
|
return DiscoveredController(
|
||||||
id: id,
|
id: id,
|
||||||
name: c.vendorName ?? c.productCategory,
|
name: c.vendorName ?? c.productCategory,
|
||||||
productCategory: c.productCategory,
|
productCategory: c.productCategory,
|
||||||
isExtended: extended != nil,
|
isExtended: extended != nil,
|
||||||
isDualSense: ds != nil,
|
kind: kind,
|
||||||
hasLight: c.light != nil,
|
hasLight: c.light != nil,
|
||||||
hasHaptics: c.haptics != nil,
|
hasHaptics: c.haptics != nil,
|
||||||
hasMotion: c.motion != nil,
|
hasMotion: c.motion != nil,
|
||||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration.
|
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the
|
||||||
hasAdaptiveTriggers: ds != nil,
|
// DualShock 4 has none.
|
||||||
|
hasAdaptiveTriggers: kind == .dualSense,
|
||||||
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
||||||
isCharging: c.battery?.batteryState == .charging,
|
isCharging: c.battery?.batteryState == .charging,
|
||||||
controller: c)
|
controller: c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
||||||
|
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then
|
||||||
|
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent
|
||||||
|
/// profile also falls back to `.xbox360` (it's never forwarded anyway).
|
||||||
|
private static func padKind(
|
||||||
|
_ extended: GCExtendedGamepad?
|
||||||
|
) -> PunktfunkConnection.GamepadType {
|
||||||
|
guard let extended else { return .xbox360 }
|
||||||
|
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
||||||
|
// here, so no `@available` guard is needed — matching the unguarded
|
||||||
|
// `GCDualSenseGamepad` use elsewhere in the package.
|
||||||
|
if extended is GCDualSenseGamepad { return .dualSense }
|
||||||
|
if extended is GCDualShockGamepad { return .dualShock4 }
|
||||||
|
if extended is GCXboxGamepad { return .xboxOne }
|
||||||
|
return .xbox360
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,23 @@ public final class InputCapture {
|
|||||||
/// macOS (no GCMouse handlers installed; `sendMouseAbs` is never called there). Main-queue.
|
/// macOS (no GCMouse handlers installed; `sendMouseAbs` is never called there). Main-queue.
|
||||||
public var gcMouseForwarding = false
|
public var gcMouseForwarding = false
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// Whether any device is attached as a `GCMouse` right now. The Magic Keyboard TRACKPAD does
|
||||||
|
/// not always register as a GCMouse on iPadOS (only a standalone mouse does) — when no GCMouse
|
||||||
|
/// is present the relative GCMouse path can't carry pointer motion. Main-queue.
|
||||||
|
public var hasGCMouse: Bool { !mice.isEmpty }
|
||||||
|
|
||||||
|
/// Diagnostic: a one-line description of every attached GCMouse (count + GCDevice identity), so
|
||||||
|
/// PUNKTFUNK_INPUT_DEBUG can reveal whether the trackpad showed up as a mouse at all.
|
||||||
|
public var attachedMiceSummary: String {
|
||||||
|
guard !mice.isEmpty else { return "0 mice" }
|
||||||
|
let parts = mice.map { mouse -> String in
|
||||||
|
"\(mouse.productCategory)/\(mouse.vendorName ?? "?")"
|
||||||
|
}
|
||||||
|
return "\(mice.count) mice: \(parts.joined(separator: ", "))"
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Fired on ⌘⎋ (the capture toggle — detected here so it works in both states; the
|
/// Fired on ⌘⎋ (the capture toggle — detected here so it works in both states; the
|
||||||
/// event itself is swallowed). Main queue.
|
/// event itself is swallowed). Main queue.
|
||||||
public var onToggleCapture: (() -> Void)?
|
public var onToggleCapture: (() -> Void)?
|
||||||
@@ -394,6 +411,12 @@ public final class InputCapture {
|
|||||||
!mice.contains(where: { $0 === mouse }) // re-delivered on wake — attach once
|
!mice.contains(where: { $0 === mouse }) // re-delivered on wake — attach once
|
||||||
else { return }
|
else { return }
|
||||||
mice.append(mouse)
|
mice.append(mouse)
|
||||||
|
#if os(iOS)
|
||||||
|
if inputDebug {
|
||||||
|
inputLog.debug(
|
||||||
|
"GCMouse attached: \(mouse.productCategory, privacy: .public)/\(mouse.vendorName ?? "?", privacy: .public) — now \(self.attachedMiceSummary, privacy: .public)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
// macOS drives motion + buttons from NSEvent (StreamLayerView's local monitor →
|
// macOS drives motion + buttons from NSEvent (StreamLayerView's local monitor →
|
||||||
// sendMotion/sendMouseButton) because GCMouse's handlers proved unreliable there;
|
// sendMotion/sendMouseButton) because GCMouse's handlers proved unreliable there;
|
||||||
// installing them too would double-send. iOS keeps GCMouse (raw deltas under
|
// installing them too would double-send. iOS keeps GCMouse (raw deltas under
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -1,21 +1,35 @@
|
|||||||
// Stage-2 presenter, present half: draw a decoded NV12 CVPixelBuffer into a CAMetalLayer
|
// Stage-2 presenter, present half: draw a decoded NV12 / P010 / 4:4:4 CVPixelBuffer into a CAMetalLayer
|
||||||
// drawable with a BT.709 YUV→RGB shader. The display link (owned by the hosting view) drives
|
// drawable with a Y′CbCr→RGB shader. The hosting view's CADisplayLink drives `render` once per vsync
|
||||||
// `render` once per vsync with the target present time, so a present can finally be stamped and
|
// (via Stage2Pipeline.renderTick) with the target present time, so a present can be stamped and the
|
||||||
// the present tail hand-paced. See docs apple-stage2-presenter.md.
|
// present tail hand-paced. See docs apple-stage2-presenter.md.
|
||||||
//
|
//
|
||||||
// Main-thread only: created during view setup, `render` called from the view's CADisplayLink
|
// Main-thread only: created during view setup, `render`/`configure` called from the view's CADisplayLink
|
||||||
// (which fires on the main runloop). The Metal objects + texture cache are touched only here.
|
// (which fires on the main runloop). The Metal objects + texture cache are touched only here. The one
|
||||||
|
// exception is `setHdrMeta`, called from the pump thread — it hops the layer write to main so every
|
||||||
|
// CALayer mutation stays on one thread.
|
||||||
|
|
||||||
#if canImport(Metal) && canImport(QuartzCore)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import CoreVideo
|
import CoreVideo
|
||||||
import Metal
|
import Metal
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
import os
|
||||||
|
|
||||||
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and a
|
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
|
||||||
/// BT.709 limited-range NV12→RGB fragment shader. uv.y is flipped (1 - p.y) so the top-left-
|
|
||||||
/// origin texture presents upright (NDC y is up), not upside down. (Colorspace is BT.709 SDR
|
/// HDR reference white (BT.2408 "HDR Reference White"): the absolute luminance, in nits, that the
|
||||||
/// for now — matches the host; 10-bit/HDR + other matrices are a later tie-in.)
|
/// PQ signal's diffuse white sits at. Passed to `CAEDRMetadata.hdr10(opticalOutputScale:)`, it anchors
|
||||||
|
/// 203-nit diffuse white at EDR 1.0 (the display's SDR-white level) and lets the system tone-map the
|
||||||
|
/// brighter highlights into the panel's headroom. This is the missing anchor that made the old HDR path
|
||||||
|
/// render "way too bright" (no `edrMetadata` → no reference-white anchoring); a LARGER value renders
|
||||||
|
/// dimmer. Matches the host's standard PQ reference white.
|
||||||
|
private let hdrReferenceWhiteNits: Float = 203.0
|
||||||
|
|
||||||
|
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and BT.709 SDR
|
||||||
|
/// and BT.2020-PQ HDR Y′CbCr→RGB fragment shaders. uv.y is flipped (1 - p.y) so the top-left-origin
|
||||||
|
/// texture presents upright (NDC y is up). The HDR shader outputs PQ-encoded R′G′B′ as-is — the
|
||||||
|
/// CAMetalLayer's `itur_2100_PQ` colour space + `edrMetadata` tell the system compositor the samples
|
||||||
|
/// are PQ and how to tone-map them (no EOTF here, matching the host's BT.2020 PQ emission).
|
||||||
private let shaderSource = """
|
private let shaderSource = """
|
||||||
#include <metal_stdlib>
|
#include <metal_stdlib>
|
||||||
using namespace metal;
|
using namespace metal;
|
||||||
@@ -30,11 +44,46 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDR: 8-bit NV12 / 4:4:4 (BT.709, limited/video range) → full-range RGB. Chroma is sampled at the
|
||||||
|
// same UV as luma, so a full-size 4:4:4 chroma plane needs no shader change vs 4:2:0.
|
||||||
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);
|
||||||
@@ -46,18 +95,18 @@ fragment float4 pf_frag(VOut in [[stage_in]],
|
|||||||
return float4(saturate(float3(r, g, b)), 1.0);
|
return float4(saturate(float3(r, g, b)), 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// HDR: 10-bit P010 (BT.2020, limited range), Y'CbCr that is PQ-encoded. We apply the BT.2020
|
// HDR: 10-bit P010 / 4:4:4 (BT.2020, limited range), Y′CbCr that is PQ-encoded. We apply the BT.2020
|
||||||
// matrix to get PQ-encoded R'G'B' and output it as-is — the CAMetalLayer's itur_2100_PQ colour
|
// matrix to get PQ-encoded R′G′B′ and output it as-is — the CAMetalLayer's itur_2100_PQ colour space
|
||||||
// space + EDR tells the compositor the samples are PQ, so it does the PQ→display mapping. No EOTF
|
// + edrMetadata tell the compositor the samples are PQ, so it does the PQ→display tone-map. No EOTF
|
||||||
// here (matching the host, which emitted BT.2020 PQ). P010 stores the 10-bit code in the high bits
|
// here. P010/x444 store the 10-bit code in the high bits of each 16-bit sample, so an .r16Unorm sample
|
||||||
// of each 16-bit sample, so an .r16Unorm sample reads ~code/1023 (the /1024 vs /1023 error is < 0.1%).
|
// reads ~code/1023 (the /1024 vs /1023 error is < 0.1%).
|
||||||
fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
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);
|
||||||
float u = (c.x - 512.0/1023.0) * (1023.0/896.0);
|
float u = (c.x - 512.0/1023.0) * (1023.0/896.0);
|
||||||
float v = (c.y - 512.0/1023.0) * (1023.0/896.0);
|
float v = (c.y - 512.0/1023.0) * (1023.0/896.0);
|
||||||
@@ -74,21 +123,34 @@ public final class MetalVideoPresenter {
|
|||||||
|
|
||||||
private let device: MTLDevice
|
private let device: MTLDevice
|
||||||
private let queue: MTLCommandQueue
|
private let queue: MTLCommandQueue
|
||||||
/// SDR (BT.709 8-bit NV12 → bgra8) and HDR (BT.2020 PQ 10-bit P010 → rgba16Float) pipelines.
|
/// SDR (BT.709 8-bit → bgra8) and HDR (BT.2020 PQ 10-bit → rgba16Float) pipelines. Selected per
|
||||||
/// Selected per frame by `render`; the layer is reconfigured when the mode flips (HDR toggle).
|
/// frame in `render`; the layer is reconfigured to match when the session flips (HDR toggle).
|
||||||
private let pipelineSDR: MTLRenderPipelineState
|
private let pipelineSDR: MTLRenderPipelineState
|
||||||
private let pipelineHDR: MTLRenderPipelineState
|
private let pipelineHDR: MTLRenderPipelineState
|
||||||
private var textureCache: CVMetalTextureCache?
|
private var textureCache: CVMetalTextureCache?
|
||||||
/// Current layer configuration — switched lazily in `configure(hdr:)` when a frame's mode differs.
|
|
||||||
private var hdrActive = false
|
|
||||||
|
|
||||||
/// nil if Metal is unavailable (no GPU / a headless CI) — the caller falls back to stage-1.
|
/// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs.
|
||||||
public init?() {
|
/// Main-thread only (read + written from `render`/`configure`, all on the display-link runloop).
|
||||||
|
private var hdrActive = false
|
||||||
|
/// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session
|
||||||
|
/// SDR→HDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the
|
||||||
|
/// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write
|
||||||
|
/// `edrMetadata`). Main-thread only.
|
||||||
|
private var lastHdrMeta: PunktfunkConnection.HdrMeta?
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Last logged "decoded→drawable" signature, so the diagnostic logs only on a size/HDR change.
|
||||||
|
private var lastSizeSig = ""
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// nil if Metal is unavailable (no GPU / a headless CI) or a shader fails to compile — the caller
|
||||||
|
/// falls back to stage-1.
|
||||||
|
public static func make() -> MetalVideoPresenter? {
|
||||||
guard let device = MTLCreateSystemDefaultDevice(),
|
guard let device = MTLCreateSystemDefaultDevice(),
|
||||||
let queue = device.makeCommandQueue()
|
let queue = device.makeCommandQueue()
|
||||||
else { return nil }
|
else { return nil }
|
||||||
self.device = device
|
let pipelineSDR: MTLRenderPipelineState
|
||||||
self.queue = queue
|
let pipelineHDR: MTLRenderPipelineState
|
||||||
do {
|
do {
|
||||||
let library = try device.makeLibrary(source: shaderSource, options: nil)
|
let library = try device.makeLibrary(source: shaderSource, options: nil)
|
||||||
let vtx = library.makeFunction(name: "pf_vtx")
|
let vtx = library.makeFunction(name: "pf_vtx")
|
||||||
@@ -105,76 +167,137 @@ public final class MetalVideoPresenter {
|
|||||||
} catch {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &textureCache)
|
var cache: CVMetalTextureCache?
|
||||||
guard textureCache != nil else { return nil }
|
CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &cache)
|
||||||
|
guard let textureCache = cache else { return nil }
|
||||||
|
|
||||||
let layer = CAMetalLayer()
|
let layer = CAMetalLayer()
|
||||||
layer.device = device
|
layer.device = device
|
||||||
layer.pixelFormat = .bgra8Unorm
|
layer.pixelFormat = .bgra8Unorm
|
||||||
layer.framebufferOnly = true
|
layer.framebufferOnly = true
|
||||||
layer.isOpaque = true
|
layer.isOpaque = true
|
||||||
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the
|
|
||||||
// display-link / MAIN thread) has to block waiting for one to free.
|
|
||||||
layer.maximumDrawableCount = 3
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// The display link already paces exactly one present per vsync. Leaving the layer's
|
// The display link already paces exactly one present per vsync. Leaving the layer's own vsync
|
||||||
// own vsync wait on means `commandBuffer.present` ALSO blocks for the hardware vsync,
|
// wait on means `commandBuffer.present` ALSO blocks for the hardware vsync, so `nextDrawable()`
|
||||||
// so `nextDrawable()` stalls the MAIN thread until a drawable frees — windowed, the
|
// stalls the MAIN thread until a drawable frees — windowed, the WindowServer's looser
|
||||||
// WindowServer's looser compositing hides it; FULLSCREEN's tighter, more-direct path
|
// compositing hides it; FULLSCREEN's tighter path serializes the main thread to the display and
|
||||||
// serializes the main thread to the display and the stall surfaces as bad judder.
|
// the stall surfaces as bad judder. Disabling the layer-level sync lets present return promptly
|
||||||
// Disabling the layer-level sync lets present return promptly (the display link is the
|
// (the display link is the pacing source) — the fix for the fullscreen stutter. macOS-only.
|
||||||
// pacing source), which is what fixes the fullscreen stutter. macOS-only property.
|
|
||||||
layer.displaySyncEnabled = false
|
layer.displaySyncEnabled = false
|
||||||
#endif
|
#endif
|
||||||
|
// 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 uses. A native-resolution present is then pixel-exact (1:1, no
|
||||||
|
// shader scaling); a resized window rescales via the system's scaler.
|
||||||
|
layer.contentsGravity = .resizeAspect
|
||||||
|
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the display-link /
|
||||||
|
// MAIN thread) has to block waiting for one to free.
|
||||||
|
layer.maximumDrawableCount = 3
|
||||||
|
|
||||||
|
return MetalVideoPresenter(
|
||||||
|
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||||
|
textureCache: textureCache, layer: layer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(
|
||||||
|
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||||
|
pipelineHDR: MTLRenderPipelineState, textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||||
|
) {
|
||||||
|
self.device = device
|
||||||
|
self.queue = queue
|
||||||
|
self.pipelineSDR = pipelineSDR
|
||||||
|
self.pipelineHDR = pipelineHDR
|
||||||
|
self.textureCache = textureCache
|
||||||
self.layer = layer
|
self.layer = layer
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track the stream mode (the host can Reconfigure mid-stream). Size is in pixels.
|
/// Configure the layer + active pipeline for an SDR or HDR session. MAIN THREAD ONLY. Called once at
|
||||||
public func setDrawableSize(_ size: CGSize) {
|
/// session start and again per-frame from `render` (idempotent — the guard makes a same-state call a
|
||||||
guard size.width > 0, size.height > 0 else { return }
|
/// no-op), so a mid-session HDR toggle (the host re-inits its encoder; the decoded `frame.isHDR`
|
||||||
if layer.drawableSize != size { layer.drawableSize = size }
|
/// flips) reconfigures here automatically. HDR uses an rgba16Float drawable + BT.2020 PQ colour space
|
||||||
}
|
/// + EDR with a 203-nit reference-white anchor; SDR uses the plain 8-bit sRGB path.
|
||||||
|
public func configure(hdr: Bool) {
|
||||||
/// 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
|
|
||||||
/// display; SDR uses the plain 8-bit sRGB path. Main-thread only (called from `render`).
|
|
||||||
private func configure(hdr: Bool) {
|
|
||||||
guard hdr != hdrActive else { return }
|
guard hdr != hdrActive else { return }
|
||||||
hdrActive = hdr
|
hdrActive = hdr
|
||||||
|
configureColor(hdr: hdr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the layer's pixel format + colour config for SDR or HDR. MAIN THREAD ONLY. EDR is requested
|
||||||
|
/// on ALL platforms — the property is available on macOS/iOS/tvOS at our deployment floor, and the
|
||||||
|
/// old `#if os(macOS)` guard left iOS/tvOS EDR half-engaged.
|
||||||
|
private func configureColor(hdr: Bool) {
|
||||||
if hdr {
|
if hdr {
|
||||||
layer.pixelFormat = .rgba16Float
|
layer.pixelFormat = .rgba16Float
|
||||||
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
||||||
#if os(macOS)
|
|
||||||
layer.wantsExtendedDynamicRangeContent = true
|
layer.wantsExtendedDynamicRangeContent = true
|
||||||
#endif
|
// Anchor reference white. Re-apply the real grade if one already arrived (0xCE before the
|
||||||
|
// flip); otherwise the bare 203-nit anchor. Without this anchor the PQ signal is too bright.
|
||||||
|
layer.edrMetadata = makeEDR(lastHdrMeta)
|
||||||
} else {
|
} else {
|
||||||
|
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable; a nil colorspace tags it device/sRGB
|
||||||
|
// (the proven SDR path — never showed the "too bright" issue, which was HDR-only).
|
||||||
layer.pixelFormat = .bgra8Unorm
|
layer.pixelFormat = .bgra8Unorm
|
||||||
layer.colorspace = nil
|
layer.colorspace = nil
|
||||||
#if os(macOS)
|
|
||||||
layer.wantsExtendedDynamicRangeContent = false
|
layer.wantsExtendedDynamicRangeContent = false
|
||||||
#endif
|
layer.edrMetadata = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw one decoded frame to the next drawable and present it. `isHDR` selects the 10-bit
|
private func makeEDR(_ meta: PunktfunkConnection.HdrMeta?) -> CAEDRMetadata {
|
||||||
/// BT.2020 PQ path (P010 input) vs the 8-bit BT.709 path (NV12 input). Returns true on success;
|
CAEDRMetadata.hdr10(
|
||||||
/// false when there's no drawable yet, a texture couldn't be made, or Metal errored — the
|
displayInfo: meta?.masteringDisplayColorVolume(),
|
||||||
/// caller then doesn't stamp a present for this frame.
|
contentInfo: meta?.contentLightLevelInfo(),
|
||||||
|
opticalOutputScale: hdrReferenceWhiteNits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the HDR mastering metadata (drained from the host's 0xCE datagram) to refine the system
|
||||||
|
/// tone-map from the real grade. Called from the PUMP thread, so the layer write is hopped to MAIN
|
||||||
|
/// (every CALayer mutation stays on one thread). The grade is cached so a later SDR→HDR
|
||||||
|
/// `configureColor` re-applies it; the `edrMetadata` write is gated on `hdrActive` (setting it on an
|
||||||
|
/// SDR layer is harmless but pointless, and the flip will apply it anyway).
|
||||||
|
public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.lastHdrMeta = meta
|
||||||
|
if self.hdrActive { self.layer.edrMetadata = self.makeEDR(meta) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw one decoded frame to the next drawable and present it. MAIN THREAD (the display link).
|
||||||
|
/// `isHDR` selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
|
||||||
|
/// layer config via `configure`. Returns true on success; false when there's no drawable yet, a
|
||||||
|
/// texture couldn't be made, or Metal errored — the caller then doesn't stamp a present.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func render(_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false) -> Bool {
|
public func render(_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false) -> Bool {
|
||||||
|
// Reconcile the layer with the decoded frame's HDR-ness (handles a mid-session SDR↔HDR flip).
|
||||||
configure(hdr: isHDR)
|
configure(hdr: isHDR)
|
||||||
// P010 stores 10-bit luma/chroma in 16-bit samples → R16/RG16; NV12 is 8-bit → R8/RG8.
|
|
||||||
let lumaFmt: MTLPixelFormat = isHDR ? .r16Unorm : .r8Unorm
|
// P010/x444 store 10-bit luma/chroma in 16-bit samples → R16/RG16; NV12/444v is 8-bit → R8/RG8.
|
||||||
let chromaFmt: MTLPixelFormat = isHDR ? .rg16Unorm : .rg8Unorm
|
// Derived from the actual decoded buffer so a 4:4:4 (full chroma plane) frame just works.
|
||||||
|
let pf = CVPixelBufferGetPixelFormatType(pixelBuffer)
|
||||||
|
let tenBit =
|
||||||
|
pf == kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
|
||||||
|
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|
||||||
|
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|
||||||
|
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
|
||||||
guard let textureCache,
|
guard let textureCache,
|
||||||
let luma = makeTexture(pixelBuffer, plane: 0, format: lumaFmt, cache: textureCache),
|
let luma = makeTexture(
|
||||||
let chroma = makeTexture(pixelBuffer, plane: 1, format: chromaFmt, cache: textureCache)
|
pixelBuffer, plane: 0, format: tenBit ? .r16Unorm : .r8Unorm, cache: textureCache),
|
||||||
|
let chroma = makeTexture(
|
||||||
|
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, 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 1:1 (pixel-exact);
|
||||||
// out. The fullscreen triangle scales the decoded texture to fill the drawable.
|
// the layer's contentsGravity then scales it to the on-screen bounds via the system compositor
|
||||||
guard layer.drawableSize.width > 0, layer.drawableSize.height > 0,
|
// (matching stage-1). drawableSize does NOT track bounds (defaults to 0), so set it BEFORE
|
||||||
let drawable = layer.nextDrawable(),
|
// nextDrawable; re-set only on a change (first frame / Reconfigure / HDR flip).
|
||||||
|
let decodedSize = CGSize(
|
||||||
|
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||||
|
if layer.drawableSize != decodedSize { layer.drawableSize = decodedSize }
|
||||||
|
#if DEBUG
|
||||||
|
logSizeIfChanged(decoded: decodedSize)
|
||||||
|
#endif
|
||||||
|
guard let drawable = layer.nextDrawable(),
|
||||||
let commandBuffer = queue.makeCommandBuffer()
|
let commandBuffer = queue.makeCommandBuffer()
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
@@ -186,24 +309,23 @@ public final class MetalVideoPresenter {
|
|||||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
encoder.setRenderPipelineState(isHDR ? pipelineHDR : pipelineSDR)
|
encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR)
|
||||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
||||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
||||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||||
encoder.endEncoding()
|
encoder.endEncoding()
|
||||||
commandBuffer.present(drawable) // present at the next vsync — lowest latency
|
commandBuffer.present(drawable) // present at the next vsync — lowest latency
|
||||||
// Hold the CVMetalTextures + the source pixel buffer (its IOSurface) alive until the GPU
|
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes
|
||||||
// finishes sampling — releasing them at scope exit could free the backing mid-read.
|
// sampling — releasing them at scope exit could free the backing mid-read.
|
||||||
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
|
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
|
||||||
commandBuffer.commit()
|
commandBuffer.commit()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the CVMetalTexture (not just its MTLTexture) so the caller can keep it alive past
|
/// Returns the CVMetalTexture (not just its MTLTexture) so the caller can keep it alive past the
|
||||||
/// the draw — the MTLTexture is only valid while its CVMetalTexture is retained.
|
/// draw — the MTLTexture is only valid while its CVMetalTexture is retained.
|
||||||
private func makeTexture(
|
private func makeTexture(
|
||||||
_ pixelBuffer: CVPixelBuffer, plane: Int, format: MTLPixelFormat,
|
_ pixelBuffer: CVPixelBuffer, plane: Int, format: MTLPixelFormat, cache: CVMetalTextureCache
|
||||||
cache: CVMetalTextureCache
|
|
||||||
) -> CVMetalTexture? {
|
) -> CVMetalTexture? {
|
||||||
let w = CVPixelBufferGetWidthOfPlane(pixelBuffer, plane)
|
let w = CVPixelBufferGetWidthOfPlane(pixelBuffer, plane)
|
||||||
let h = CVPixelBufferGetHeightOfPlane(pixelBuffer, plane)
|
let h = CVPixelBufferGetHeightOfPlane(pixelBuffer, plane)
|
||||||
@@ -215,5 +337,16 @@ public final class MetalVideoPresenter {
|
|||||||
else { return nil }
|
else { return nil }
|
||||||
return cvTexture
|
return cvTexture
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private func logSizeIfChanged(decoded: CGSize) {
|
||||||
|
let sig = "\(Int(decoded.width))x\(Int(decoded.height))|hdr\(hdrActive ? 1 : 0)"
|
||||||
|
if sig != lastSizeSig {
|
||||||
|
lastSizeSig = sig
|
||||||
|
let msg = "stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) hdr=\(hdrActive)"
|
||||||
|
presenterLog.info("\(msg, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Steers the system's iPad pointer-lock resolution down to a chosen "anchor" view controller.
|
||||||
|
//
|
||||||
|
// `UIViewController.prefersPointerLocked` is resolved the same way as the status bar: the system
|
||||||
|
// walks DOWN from the window's root view controller through `childViewControllerForPointerLock`.
|
||||||
|
// SwiftUI's hosting / container view controllers do NOT forward that query to their children, so a
|
||||||
|
// `UIViewControllerRepresentable` controller buried in the SwiftUI tree (our StreamViewController)
|
||||||
|
// is never consulted — its `prefersPointerLocked = true` is silently ignored and a Magic Keyboard
|
||||||
|
// trackpad / mouse falls through to the absolute-pointer path instead of being captured.
|
||||||
|
//
|
||||||
|
// Swizzling the DEFAULT implementation isn't enough: the controllers that break the chain
|
||||||
|
// (UIHostingController and SwiftUI's internal containers) provide their OWN implementation of the
|
||||||
|
// property, so a base-class swizzle never runs for them. Instead we walk UP the LIVE `parent`
|
||||||
|
// chain from the anchor to the window root and, on each real ancestor, force
|
||||||
|
// `childViewControllerForPointerLock` to return the next controller toward the anchor. Each forced
|
||||||
|
// value is a genuine direct child (we follow the actual containment chain), so the system's
|
||||||
|
// downward walk reaches the anchor and reads its `prefersPointerLocked`.
|
||||||
|
//
|
||||||
|
// The forcing is per-INSTANCE — an associated object — gated behind a one-time per-CLASS IMP
|
||||||
|
// swizzle. Only the specific controllers in the anchor's chain are affected; every other instance
|
||||||
|
// of those classes keeps its original behavior (associated object nil → original IMP). The forced
|
||||||
|
// values are cleared on disengage so the long-lived SwiftUI parents don't retain a stale controller
|
||||||
|
// across sessions. Only the PUBLIC `childViewControllerForPointerLock` selector is touched
|
||||||
|
// (App-Store-safe; no private API).
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import ObjectiveC
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum PointerLockChain {
|
||||||
|
private static var forcedChildKey: UInt8 = 0
|
||||||
|
/// Classes whose `childViewControllerForPointerLock` we've already IMP-swizzled (keyed by the
|
||||||
|
/// class object). Main-thread only — pointer-lock resolution and capture toggles are all main.
|
||||||
|
private static var swizzledClasses = Set<ObjectIdentifier>()
|
||||||
|
/// Ancestors we've stamped with a forced child this engagement, held weakly so a deallocated
|
||||||
|
/// SwiftUI controller drops out on its own (no dangling). disengage() clears every one — even
|
||||||
|
/// if the live `parent` chain has since broken — so a stamped parent can never retain a stale
|
||||||
|
/// controller subtree across sessions. One anchor is ever engaged at a time.
|
||||||
|
private static let stampedParents = NSHashTable<UIViewController>.weakObjects()
|
||||||
|
|
||||||
|
private static func forcedChild(of vc: UIViewController) -> UIViewController? {
|
||||||
|
objc_getAssociatedObject(vc, &forcedChildKey) as? UIViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func setForcedChild(_ child: UIViewController?, on vc: UIViewController) {
|
||||||
|
// RETAIN: while steering, the parent must keep the toward-anchor child alive. It's also
|
||||||
|
// already a strong child of `vc` via UIKit containment, so this adds no cycle (the reverse
|
||||||
|
// `.parent` link is weak), and disengage() always clears it — so it can't outlive a session.
|
||||||
|
objc_setAssociatedObject(vc, &forcedChildKey, child, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure `cls`'s `childViewControllerForPointerLock` getter consults the per-instance forced
|
||||||
|
/// child first, falling back to the class's original implementation. Idempotent per class.
|
||||||
|
private static func ensureSwizzled(_ cls: AnyClass) {
|
||||||
|
let id = ObjectIdentifier(cls)
|
||||||
|
guard !swizzledClasses.contains(id) else { return }
|
||||||
|
swizzledClasses.insert(id)
|
||||||
|
let selector = #selector(getter: UIViewController.childViewControllerForPointerLock)
|
||||||
|
guard let method = class_getInstanceMethod(cls, selector) else { return }
|
||||||
|
let originalIMP = method_getImplementation(method)
|
||||||
|
typealias OriginalFn = @convention(c) (AnyObject, Selector) -> UIViewController?
|
||||||
|
let original = unsafeBitCast(originalIMP, to: OriginalFn.self)
|
||||||
|
let forwarding: @convention(block) (UIViewController) -> UIViewController? = { vc in
|
||||||
|
if let forced = forcedChild(of: vc) { return forced }
|
||||||
|
return original(vc, selector)
|
||||||
|
}
|
||||||
|
method_setImplementation(method, imp_implementationWithBlock(forwarding))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force every ancestor of `anchor` to forward pointer-lock resolution toward it, then ask the
|
||||||
|
/// system to re-resolve. No-op when `anchor` isn't in a view-controller hierarchy yet (it
|
||||||
|
/// re-runs from the anchor's appearance/parent callbacks once it is).
|
||||||
|
static func engage(_ anchor: UIViewController) {
|
||||||
|
disengage(anchor) // clear any prior engagement first (reparent / re-anchor)
|
||||||
|
var child = anchor
|
||||||
|
while let parent = child.parent {
|
||||||
|
ensureSwizzled(object_getClass(parent)!)
|
||||||
|
setForcedChild(child, on: parent)
|
||||||
|
stampedParents.add(parent)
|
||||||
|
child = parent
|
||||||
|
}
|
||||||
|
anchor.setNeedsUpdateOfPrefersPointerLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the forced forwarding on every stamped ancestor (so the SwiftUI parents stop retaining
|
||||||
|
/// the anchor's subtree) and re-resolve to drop the lock.
|
||||||
|
static func disengage(_ anchor: UIViewController) {
|
||||||
|
for parent in stampedParents.allObjects {
|
||||||
|
setForcedChild(nil, on: parent)
|
||||||
|
}
|
||||||
|
stampedParents.removeAllObjects()
|
||||||
|
anchor.setNeedsUpdateOfPrefersPointerLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Synthetic 4:4:4 HEVC keyframes used only by `Stage444Probe` to probe decode capability.
|
||||||
|
//
|
||||||
|
// Each is the first IDR access unit (VPS + SPS + PPS + IDR slice, Annex-B) of a 256×256 HEVC
|
||||||
|
// Range-Extensions clip — `chroma_format_idc = 3` — generated offline with libx265:
|
||||||
|
// ffmpeg -f lavfi -i color=c=gray:s=256x256:r=30:d=0.1 -frames:v 3 \
|
||||||
|
// -pix_fmt yuv444p[10le] -c:v libx265 \
|
||||||
|
// -x265-params keyint=1:min-keyint=1:no-info=1:repeat-headers=1:aud=0 -f hevc out.hevc
|
||||||
|
// 256×256 clears the hardware decoder's minimum-dimension floor (a 16×16 clip is rejected for every
|
||||||
|
// chroma format). Validated to hardware-decode to `444v`/`x444` on Apple Silicon (M3).
|
||||||
|
enum Probe444Blobs {
|
||||||
|
/// 256×256 HEVC Range-Extensions 4:4:4 keyframe (Annex-B): 134 bytes.
|
||||||
|
static let au444_8bit: [UInt8] = [
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x04, 0x08, 0x00, 0x00, 0x03, 0x00,
|
||||||
|
0x9e, 0x28, 0x00, 0x00, 0x03, 0x00, 0x00, 0x3c, 0xba, 0x02, 0x40, 0x00, 0x00, 0x00, 0x01, 0x42,
|
||||||
|
0x01, 0x01, 0x04, 0x08, 0x00, 0x00, 0x03, 0x00, 0x9e, 0x28, 0x00, 0x00, 0x03, 0x00, 0x00, 0x3c,
|
||||||
|
0x90, 0x01, 0x01, 0x00, 0x80, 0xb2, 0xdd, 0x49, 0x26, 0x57, 0x80, 0xb4, 0x04, 0x00, 0x00, 0x03,
|
||||||
|
0x00, 0x04, 0x00, 0x00, 0x03, 0x00, 0x78, 0x20, 0x00, 0x00, 0x00, 0x01, 0x44, 0x01, 0xc1, 0x72,
|
||||||
|
0x86, 0x0c, 0x06, 0x24, 0x00, 0x00, 0x00, 0x01, 0x28, 0x01, 0xaf, 0x72, 0x15, 0xe8, 0x34, 0xeb,
|
||||||
|
0xae, 0xfb, 0xfe, 0x75, 0x57, 0xca, 0xc1, 0x71, 0x43, 0x16, 0xf5, 0xc2, 0x40, 0xbd, 0x80, 0xa6,
|
||||||
|
0x65, 0x35, 0x20, 0x28, 0x81, 0xa2, 0x5e, 0xc0, 0x93, 0x04, 0x10, 0x9b, 0x00, 0x34, 0xe0, 0x87,
|
||||||
|
0x00, 0x00, 0x03, 0x00, 0x5b, 0x40,
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 256×256 HEVC Range-Extensions 4:4:4 10-bit keyframe (Annex-B): 133 bytes.
|
||||||
|
static let au444_10bit: [UInt8] = [
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x04, 0x08, 0x00, 0x00, 0x03, 0x00,
|
||||||
|
0x9c, 0x28, 0x00, 0x00, 0x03, 0x00, 0x00, 0x3c, 0xba, 0x02, 0x40, 0x00, 0x00, 0x00, 0x01, 0x42,
|
||||||
|
0x01, 0x01, 0x04, 0x08, 0x00, 0x00, 0x03, 0x00, 0x9c, 0x28, 0x00, 0x00, 0x03, 0x00, 0x00, 0x3c,
|
||||||
|
0x90, 0x01, 0x01, 0x00, 0x80, 0x9b, 0x2d, 0xd4, 0x92, 0x65, 0x78, 0x0b, 0x40, 0x40, 0x00, 0x00,
|
||||||
|
0x03, 0x00, 0x40, 0x00, 0x00, 0x07, 0x82, 0x00, 0x00, 0x00, 0x01, 0x44, 0x01, 0xc1, 0x72, 0x86,
|
||||||
|
0x0c, 0x06, 0x24, 0x00, 0x00, 0x00, 0x01, 0x28, 0x01, 0xaf, 0x72, 0x15, 0xe8, 0x34, 0xeb, 0xae,
|
||||||
|
0xfb, 0xfe, 0x75, 0x57, 0xca, 0xc1, 0x71, 0x43, 0x16, 0xf5, 0xc2, 0x40, 0xbd, 0x80, 0xa6, 0x65,
|
||||||
|
0x35, 0x20, 0x28, 0x81, 0xa2, 0x5e, 0xc0, 0x93, 0x04, 0x10, 0x9b, 0x00, 0x34, 0xe0, 0x87, 0x00,
|
||||||
|
0x00, 0x03, 0x00, 0x5b, 0x40,
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -170,13 +170,23 @@ public final class PunktfunkConnection {
|
|||||||
|
|
||||||
/// Which virtual gamepad the host creates for this session's pads (the
|
/// Which virtual gamepad the host creates for this session's pads (the
|
||||||
/// `PUNKTFUNK_GAMEPAD_*` ABI values). `.auto` lets the host decide (its env var, else
|
/// `PUNKTFUNK_GAMEPAD_*` ABI values). `.auto` lets the host decide (its env var, else
|
||||||
/// X-Box 360); `.dualSense` is honored only on hosts with UHID (Linux) — games then see
|
/// X-Box 360); `.dualSense` / `.dualShock4` are honored only on hosts with UHID (Linux) —
|
||||||
/// a real DualSense and their lightbar / adaptive-trigger writes come back on the
|
/// games then see a real PlayStation pad and its lightbar (and, on a DualSense,
|
||||||
/// HID-output plane (`nextHidOutput`). The host's actual choice is `resolvedGamepad`.
|
/// adaptive-trigger / player-LED) writes come back on the HID-output plane
|
||||||
|
/// (`nextHidOutput`). `.xboxOne` is an X-Box-Series-glyph variant of `.xbox360` (same
|
||||||
|
/// buttons/sticks/triggers + rumble, no touchpad/motion/lightbar). The host's actual
|
||||||
|
/// choice is `resolvedGamepad`.
|
||||||
public enum GamepadType: UInt32, CaseIterable, Sendable {
|
public enum GamepadType: UInt32, CaseIterable, Sendable {
|
||||||
case auto = 0
|
case auto = 0
|
||||||
case xbox360 = 1
|
case xbox360 = 1
|
||||||
case dualSense = 2
|
case dualSense = 2
|
||||||
|
case xboxOne = 3
|
||||||
|
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`.
|
||||||
@@ -184,7 +194,11 @@ public final class PunktfunkConnection {
|
|||||||
switch name.lowercased() {
|
switch name.lowercased() {
|
||||||
case "auto", "default": self = .auto
|
case "auto", "default": self = .auto
|
||||||
case "xbox", "xbox360", "x360", "uinput": self = .xbox360
|
case "xbox", "xbox360", "x360", "uinput": self = .xbox360
|
||||||
case "dualsense", "ds", "ps5": self = .dualSense
|
case "dualsense", "ds", "ds5", "ps5": self = .dualSense
|
||||||
|
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,6 +228,33 @@ public final class PunktfunkConnection {
|
|||||||
/// (20 000) when 0 was requested. `0` = an older host that didn't report it.
|
/// (20 000) when 0 was requested. `0` = an older host that didn't report it.
|
||||||
public private(set) var resolvedBitrateKbps: UInt32 = 0
|
public private(set) var resolvedBitrateKbps: UInt32 = 0
|
||||||
|
|
||||||
|
/// The colour signalling the host actually encodes with (CICP code points): `colorPrimaries`
|
||||||
|
/// (1=BT.709, 9=BT.2020), `colorTransfer` (1=BT.709, 16=PQ, 18=HLG), `colorMatrix`
|
||||||
|
/// (1=BT.709, 9=BT.2020-NCL), `colorFullRange`. BT.709 limited SDR for an older host. Configure
|
||||||
|
/// the decoder/presenter from these; mastering metadata arrives via `nextHdrMeta`.
|
||||||
|
public private(set) var colorPrimaries: UInt8 = 1
|
||||||
|
public private(set) var colorTransfer: UInt8 = 1
|
||||||
|
public private(set) var colorMatrix: UInt8 = 1
|
||||||
|
public private(set) var colorFullRange: Bool = false
|
||||||
|
/// Encoded bit depth (8 or 10).
|
||||||
|
public private(set) var bitDepth: UInt8 = 8
|
||||||
|
/// The chroma subsampling the host resolved for this session, as the HEVC `chroma_format_idc`:
|
||||||
|
/// `1` = 4:2:0 (every pre-4:4:4 host, and the back-compat default) or `3` = full-chroma 4:4:4
|
||||||
|
/// (only when this client advertised `videoCap444` *and* the host could open a real 4:4:4
|
||||||
|
/// encoder). Drive the decoder's requested pixel format from this. See `isChroma444`.
|
||||||
|
public private(set) var chromaFormat: UInt8 = 1
|
||||||
|
/// Convenience: the resolved stream is full-chroma 4:4:4 (`chroma_format_idc == 3`).
|
||||||
|
public var isChroma444: Bool { chromaFormat == 3 }
|
||||||
|
/// True when the negotiated stream is HDR (PQ or HLG transfer) — drive an HDR present path and
|
||||||
|
/// drain `nextHdrMeta`.
|
||||||
|
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`.
|
||||||
///
|
///
|
||||||
@@ -242,11 +283,15 @@ public final class PunktfunkConnection {
|
|||||||
compositor: Compositor = .auto,
|
compositor: Compositor = .auto,
|
||||||
gamepad: GamepadType = .auto,
|
gamepad: GamepadType = .auto,
|
||||||
bitrateKbps: UInt32 = 0,
|
bitrateKbps: UInt32 = 0,
|
||||||
|
videoCaps: UInt8 = 0,
|
||||||
|
audioChannels: UInt8 = 2,
|
||||||
launchID: String? = nil,
|
launchID: String? = nil,
|
||||||
timeoutMs: UInt32 = 10_000
|
timeoutMs: UInt32 = 10_000
|
||||||
) throws {
|
) throws {
|
||||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||||
var observed = [UInt8](repeating: 0, count: 32)
|
var observed = [UInt8](repeating: 0, count: 32)
|
||||||
|
// `videoCaps` advertises decode/present capability (PUNKTFUNK_VIDEO_CAP_10BIT | _HDR): the
|
||||||
|
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
|
||||||
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
|
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
|
||||||
// the session; the host resolves it against its own library — nil = the host's default.
|
// the session; the host resolves it against its own library — nil = the host's default.
|
||||||
handle = host.withCString { cs in
|
handle = host.withCString { cs in
|
||||||
@@ -255,16 +300,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_ex4(
|
punktfunk_connect_ex6(
|
||||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
gamepad.rawValue, bitrateKbps, 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_ex4(
|
return punktfunk_connect_ex6(
|
||||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
gamepad.rawValue, bitrateKbps, launch,
|
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, launch,
|
||||||
nil, &observed, cert, key, timeoutMs)
|
nil, &observed, cert, key, timeoutMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,6 +334,19 @@ public final class PunktfunkConnection {
|
|||||||
var br: UInt32 = 0
|
var br: UInt32 = 0
|
||||||
_ = punktfunk_connection_bitrate(handle, &br)
|
_ = punktfunk_connection_bitrate(handle, &br)
|
||||||
resolvedBitrateKbps = br
|
resolvedBitrateKbps = br
|
||||||
|
var prim: UInt8 = 1, trc: UInt8 = 1, mtx: UInt8 = 1, fullRange: UInt8 = 0, depth: UInt8 = 8
|
||||||
|
_ = punktfunk_connection_color_info(handle, &prim, &trc, &mtx, &fullRange, &depth)
|
||||||
|
colorPrimaries = prim
|
||||||
|
colorTransfer = trc
|
||||||
|
colorMatrix = mtx
|
||||||
|
colorFullRange = fullRange != 0
|
||||||
|
bitDepth = depth
|
||||||
|
var cf: UInt8 = 1
|
||||||
|
_ = punktfunk_connection_chroma_format(handle, &cf)
|
||||||
|
chromaFormat = cf
|
||||||
|
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`.
|
||||||
@@ -437,6 +495,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`.
|
||||||
@@ -473,10 +575,11 @@ public final class PunktfunkConnection {
|
|||||||
case triggerEffect(pad: UInt8, which: UInt8, effect: [UInt8])
|
case triggerEffect(pad: UInt8, which: UInt8, effect: [UInt8])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull the next DualSense feedback event (lightbar / player LEDs / adaptive triggers);
|
/// Pull the next PlayStation-pad feedback event (lightbar / player LEDs / adaptive
|
||||||
/// nil on timeout, throws `.closed` once the session ended. Drain from the (single)
|
/// triggers); nil on timeout, throws `.closed` once the session ended. Drain from the
|
||||||
/// feedback thread, alongside `nextRumble`. Nothing ever arrives unless
|
/// (single) feedback thread, alongside `nextRumble`. Nothing arrives unless the session's
|
||||||
/// `resolvedGamepad == .dualSense` — poll with a short timeout, never spin.
|
/// virtual pad is a DualSense (all three) or a DualShock 4 (lightbar only) — poll with a
|
||||||
|
/// short timeout, never spin.
|
||||||
public func nextHidOutput(timeoutMs: UInt32 = 0) throws -> HidOutputEvent? {
|
public func nextHidOutput(timeoutMs: UInt32 = 0) throws -> HidOutputEvent? {
|
||||||
feedbackLock.lock()
|
feedbackLock.lock()
|
||||||
defer { feedbackLock.unlock() }
|
defer { feedbackLock.unlock() }
|
||||||
@@ -508,6 +611,87 @@ public final class PunktfunkConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Video-capability bit: the client can decode a 10-bit (Main10) HEVC stream.
|
||||||
|
public static let videoCap10Bit: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_10BIT)
|
||||||
|
/// Video-capability bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||||
|
public static let videoCapHDR: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_HDR)
|
||||||
|
/// Video-capability bit: the client can decode a full-chroma 4:4:4 HEVC stream (Range
|
||||||
|
/// Extensions). Advertise only when the device can *hardware*-decode it (`Stage444Probe`);
|
||||||
|
/// the host then emits 4:4:4 only if it too opted in. `chromaFormat` reflects the real value.
|
||||||
|
public static let videoCap444: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_444)
|
||||||
|
|
||||||
|
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
|
||||||
|
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
||||||
|
/// 1/50000 units; mastering luminance in 0.0001 cd/m²; MaxCLL/MaxFALL in nits.
|
||||||
|
public struct HdrMeta: Sendable, Equatable {
|
||||||
|
public let primariesX: [UInt16] // [green, blue, red]
|
||||||
|
public let primariesY: [UInt16]
|
||||||
|
public let whitePointX: UInt16
|
||||||
|
public let whitePointY: UInt16
|
||||||
|
public let maxMasteringLuminance: UInt32 // 0.0001 cd/m²
|
||||||
|
public let minMasteringLuminance: UInt32 // 0.0001 cd/m²
|
||||||
|
public let maxCLL: UInt16
|
||||||
|
public let maxFALL: UInt16
|
||||||
|
|
||||||
|
/// The 24-byte `mastering_display_colour_volume` payload (big-endian, ST.2086 G,B,R) — pass
|
||||||
|
/// directly to `kCVImageBufferMasteringDisplayColorVolumeKey` or `CAEDRMetadata`'s displayInfo.
|
||||||
|
public func masteringDisplayColorVolume() -> Data {
|
||||||
|
var d = Data()
|
||||||
|
func be16(_ v: UInt16) { d.append(UInt8(v >> 8)); d.append(UInt8(v & 0xFF)) }
|
||||||
|
func be32(_ v: UInt32) {
|
||||||
|
d.append(UInt8((v >> 24) & 0xFF)); d.append(UInt8((v >> 16) & 0xFF))
|
||||||
|
d.append(UInt8((v >> 8) & 0xFF)); d.append(UInt8(v & 0xFF))
|
||||||
|
}
|
||||||
|
for i in 0..<3 { be16(primariesX[i]); be16(primariesY[i]) } // G, B, R
|
||||||
|
be16(whitePointX); be16(whitePointY)
|
||||||
|
be32(maxMasteringLuminance); be32(minMasteringLuminance)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 4-byte `content_light_level_info` payload (big-endian: MaxCLL, MaxFALL) — for
|
||||||
|
/// `kCVImageBufferContentLightLevelInfoKey` or `CAEDRMetadata`'s contentInfo.
|
||||||
|
public func contentLightLevelInfo() -> Data {
|
||||||
|
var d = Data()
|
||||||
|
func be16(_ v: UInt16) { d.append(UInt8(v >> 8)); d.append(UInt8(v & 0xFF)) }
|
||||||
|
be16(maxCLL); be16(maxFALL)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the next static HDR metadata update; nil on timeout, throws `.closed` once the session
|
||||||
|
/// ended. Drain from the feedback thread alongside `nextRumble`/`nextHidOutput`. Nothing arrives
|
||||||
|
/// unless `isHDR` — poll with a short timeout, never spin.
|
||||||
|
public func nextHdrMeta(timeoutMs: UInt32 = 0) throws -> HdrMeta? {
|
||||||
|
feedbackLock.lock()
|
||||||
|
defer { feedbackLock.unlock() }
|
||||||
|
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||||
|
|
||||||
|
var out = PunktfunkHdrMeta()
|
||||||
|
let rc = punktfunk_connection_next_hdr_meta(h, &out, timeoutMs)
|
||||||
|
switch rc {
|
||||||
|
case statusOK:
|
||||||
|
// The fixed C `uint16_t[3]` arrays import as tuples — copy them out.
|
||||||
|
let px = withUnsafeBytes(of: out.display_primaries_x) {
|
||||||
|
Array($0.bindMemory(to: UInt16.self))
|
||||||
|
}
|
||||||
|
let py = withUnsafeBytes(of: out.display_primaries_y) {
|
||||||
|
Array($0.bindMemory(to: UInt16.self))
|
||||||
|
}
|
||||||
|
return HdrMeta(
|
||||||
|
primariesX: px, primariesY: py,
|
||||||
|
whitePointX: out.white_point_x, whitePointY: out.white_point_y,
|
||||||
|
maxMasteringLuminance: out.max_display_mastering_luminance,
|
||||||
|
minMasteringLuminance: out.min_display_mastering_luminance,
|
||||||
|
maxCLL: out.max_cll, maxFALL: out.max_fall)
|
||||||
|
case statusNoFrame:
|
||||||
|
return nil
|
||||||
|
case statusClosed:
|
||||||
|
throw PunktfunkClientError.closed
|
||||||
|
default:
|
||||||
|
throw PunktfunkClientError.status(rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
|
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
|
||||||
/// silently dropped after close.
|
/// silently dropped after close.
|
||||||
public func send(_ event: PunktfunkInputEvent) {
|
public func send(_ event: PunktfunkInputEvent) {
|
||||||
|
|||||||
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)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
// Stage-2 presenter orchestrator: a pump thread pulls AUs → VideoDecoder; the decoder's async
|
// Stage-2 presenter orchestrator: a pump thread pulls AUs → VideoDecoder; the decoder's async output
|
||||||
// output drops the newest decoded frame into a 1-slot ring; the hosting view's display link
|
// drops the newest decoded frame into a 1-slot ring; the hosting view's display link calls `renderTick`
|
||||||
// calls `renderTick` once per vsync to draw + present the newest ready frame and stamp
|
// once per vsync to draw + present the newest ready frame and stamp capture→present. Mirrors
|
||||||
// capture→present. Mirrors StreamPump's lifecycle (one per start; cancel is permanent).
|
// 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 (lock-guarded)
|
||||||
// Only the ring + decoder cross threads and both are internally locked.
|
// and the decoder/presenter (internally locked / main-hopped) cross threads.
|
||||||
|
|
||||||
#if canImport(Metal) && canImport(QuartzCore)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
|
||||||
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view
|
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view directly
|
||||||
/// directly makes a `view → link → view` cycle that only `invalidate()` breaks — if a teardown
|
/// makes a `view → link → view` cycle that only `invalidate()` breaks — if a teardown is ever missed
|
||||||
/// is ever missed the view leaks and keeps ticking. This proxy holds the handler weakly, so the
|
/// the view leaks and keeps ticking. This proxy holds the handler weakly, so the view can deallocate
|
||||||
/// view can deallocate and its `deinit` invalidate the link.
|
/// and its `deinit` invalidate the link.
|
||||||
public final class DisplayLinkProxy: NSObject {
|
public final class DisplayLinkProxy: NSObject {
|
||||||
private let onTick: (CADisplayLink) -> Void
|
private let onTick: (CADisplayLink) -> Void
|
||||||
public init(_ onTick: @escaping (CADisplayLink) -> Void) { self.onTick = onTick }
|
public init(_ onTick: @escaping (CADisplayLink) -> Void) { self.onTick = onTick }
|
||||||
@@ -44,10 +44,10 @@ private final class PumpToken: @unchecked Sendable {
|
|||||||
func cancel() { lock.lock(); live = false; lock.unlock() }
|
func cancel() { lock.lock(); live = false; lock.unlock() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Throttled host keyframe requests for decode recovery. The decoder's async error callback
|
/// Throttled host keyframe requests for decode recovery. The decoder's async error callback (a VT
|
||||||
/// (a VT thread) and the pump thread (a submit failure) both signal a wedge; this coalesces
|
/// thread) and the pump thread (a submit failure) both signal a wedge; this coalesces them so the
|
||||||
/// them so the control stream isn't flooded while the decode stays stalled for several frames
|
/// control stream isn't flooded while the decode stays stalled for several frames until the requested
|
||||||
/// until the requested IDR lands. Bound to the live connection in `start`, unbound in `stop`.
|
/// IDR lands. Bound to the live connection in `start`, unbound in `stop`.
|
||||||
private final class KeyframeRecovery: @unchecked Sendable {
|
private final class KeyframeRecovery: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var connection: PunktfunkConnection?
|
private var connection: PunktfunkConnection?
|
||||||
@@ -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
|
||||||
if due { lastNs = now }
|
if due { lastNs = now }
|
||||||
let conn = due ? connection : nil
|
let conn = due ? connection : nil
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
@@ -76,30 +76,36 @@ public final class Stage2Pipeline {
|
|||||||
private let recovery = KeyframeRecovery()
|
private let recovery = KeyframeRecovery()
|
||||||
private var token = PumpToken()
|
private var token = PumpToken()
|
||||||
private var offsetNs: Int64 = 0
|
private var offsetNs: Int64 = 0
|
||||||
|
/// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()`
|
||||||
|
/// — otherwise a pump iteration already past its `token.isLive` check can rebuild a decode session
|
||||||
|
/// right after the reset (a brief orphan session). `pumpJoinable` is armed by `start`, consumed by
|
||||||
|
/// the first `stop` (so the idempotent second `stop`/deinit doesn't block on an already-drained
|
||||||
|
/// semaphore). start/stop are sequential lifecycle calls, so the plain flag is safe.
|
||||||
|
private let pumpStopped = DispatchSemaphore(value: 0)
|
||||||
|
private var pumpJoinable = false
|
||||||
|
|
||||||
/// The Metal layer the hosting view installs + sizes. nil-init fails when Metal is
|
/// The Metal layer the hosting view installs + sizes.
|
||||||
/// unavailable so the caller can fall back to stage-1.
|
|
||||||
public var layer: CAMetalLayer { presenter.layer }
|
public var layer: CAMetalLayer { presenter.layer }
|
||||||
|
|
||||||
/// `presentMeter` records capture→present (the glass-to-glass term). Returns nil if Metal
|
/// `presentMeter` records capture→present (the glass-to-glass term). Returns nil if Metal can't be
|
||||||
/// can't be set up (headless / no GPU) — caller falls back to the stage-1 presenter.
|
/// set up (headless / no GPU) — caller falls back to the stage-1 presenter.
|
||||||
public init?(presentMeter: LatencyMeter) {
|
public init?(presentMeter: LatencyMeter) {
|
||||||
guard let presenter = MetalVideoPresenter() else { return nil }
|
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||||
self.presenter = presenter
|
self.presenter = presenter
|
||||||
self.presentMeter = presentMeter
|
self.presentMeter = presentMeter
|
||||||
let ring = ring
|
let ring = ring
|
||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
self.decoder = VideoDecoder(
|
self.decoder = VideoDecoder(
|
||||||
onDecoded: { ring.submit($0) },
|
onDecoded: { ring.submit($0) },
|
||||||
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump
|
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
|
||||||
// resets to re-gate on the next IDR, and we ask the host to send one now (infinite
|
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP — it wouldn't
|
||||||
// GOP — it wouldn't otherwise come soon). Throttled in KeyframeRecovery.
|
// otherwise come soon). Throttled in KeyframeRecovery.
|
||||||
onDecodeError: { _ in recovery.request() })
|
onDecodeError: { _ in recovery.request() })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start pulling AUs into the decoder. `onFrame` fires per AU at receipt (capture→client
|
/// Start pulling AUs into the decoder. MAIN THREAD. `onFrame` fires per AU at receipt (capture→client
|
||||||
/// meter, exactly as stage-1); `onSessionEnd` on close. `clockOffsetNs` (host minus client)
|
/// meter, exactly as stage-1); `onSessionEnd` on close. `clockOffsetNs` (host minus client) makes the
|
||||||
/// makes the present stamp cross-machine valid.
|
/// present stamp cross-machine valid.
|
||||||
public func start(
|
public func start(
|
||||||
connection: PunktfunkConnection,
|
connection: PunktfunkConnection,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
@@ -108,38 +114,70 @@ public final class Stage2Pipeline {
|
|||||||
offsetNs = connection.clockOffsetNs
|
offsetNs = connection.clockOffsetNs
|
||||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||||
token = PumpToken() // fresh token per start — cancel is permanent (like StreamPump)
|
token = PumpToken() // fresh token per start — cancel is permanent (like StreamPump)
|
||||||
|
|
||||||
|
// Configure the decoder's chroma + the layer's initial colorimetry before the first frame. The
|
||||||
|
// chroma subsampling drives only the decode pixel format (orthogonal to HDR/depth); the HDR
|
||||||
|
// config is the Welcome's latched value, which a mid-session flip then overrides per-frame.
|
||||||
|
decoder.setChroma444(connection.isChroma444)
|
||||||
|
presenter.configure(hdr: connection.isHDR)
|
||||||
|
|
||||||
let token = token
|
let token = token
|
||||||
let decoder = decoder
|
let decoder = decoder
|
||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
|
let presenter = presenter
|
||||||
|
let pumpStopped = pumpStopped
|
||||||
let thread = Thread {
|
let thread = Thread {
|
||||||
|
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
|
||||||
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):
|
||||||
|
// keep asking until an IDR lands so a request swallowed by the throttle is re-sent.
|
||||||
|
var awaitingIDR = false
|
||||||
|
// 4:4:4 backstop: a run of decode/create failures in a 4:4:4 session means this device can't
|
||||||
|
// decode 4:4:4 at the negotiated resolution (the HW probe clears the common case but not a
|
||||||
|
// resolution-ceiling miss). End cleanly instead of looping on a black screen.
|
||||||
|
var decodeFailRun = 0
|
||||||
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 and the
|
||||||
// AUs (framesDropped) and the decoder then conceals the reference-missing delta
|
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
||||||
// frames that follow — often rendering them WITHOUT an error callback — so the
|
// so key off the drop count climbing, then keep asking (awaitingIDR) until a fresh
|
||||||
// onDecodeError trigger rarely fires after a real network blip. Ask the host for
|
// IDR re-anchors decode.
|
||||||
// a fresh IDR whenever the drop count climbs (throttled in KeyframeRecovery).
|
|
||||||
// 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 HDR mastering metadata (0xCE) and hand it to the PRESENTER (→ CAEDRMetadata).
|
||||||
|
// Polled UNCONDITIONALLY (not gated on connection.isHDR, the fixed Welcome flag): the
|
||||||
|
// host sends 0xCE only for HDR, INCLUDING a mid-session SDR→HDR transition (a game
|
||||||
|
// entering HDR — the host re-inits its encoder) the Welcome flag would never reflect.
|
||||||
|
// Non-blocking; nil for an SDR stream.
|
||||||
|
if let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
||||||
|
presenter.setHdrMeta(meta)
|
||||||
}
|
}
|
||||||
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
|
decodeFailRun = 0
|
||||||
// in-band parameter sets (a delta frame can't recover) — stage-1's policy
|
} else {
|
||||||
// — and ask the host for that IDR now (infinite GOP; throttled).
|
// Submit/decoder error: drop the session and re-gate on the next IDR's in-band
|
||||||
|
// parameter sets (a delta frame can't recover) and keep asking for that IDR.
|
||||||
decoder.reset()
|
decoder.reset()
|
||||||
recovery.request()
|
awaitingIDR = true
|
||||||
|
decodeFailRun += 1
|
||||||
|
// ~3 s of solid failure in a 4:4:4 session (and only there — a 4:2:0 loss
|
||||||
|
// recovers within a GOP) ⇒ 4:4:4 isn't decodable here; end the session.
|
||||||
|
if connection.isChroma444, decodeFailRun >= 180 {
|
||||||
|
if token.isLive { onSessionEnd?() }
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if token.isLive { onSessionEnd?() }
|
if token.isLive { onSessionEnd?() }
|
||||||
@@ -149,27 +187,30 @@ public final class Stage2Pipeline {
|
|||||||
}
|
}
|
||||||
thread.name = "punktfunk-stage2-pump"
|
thread.name = "punktfunk-stage2-pump"
|
||||||
thread.qualityOfService = .userInteractive
|
thread.qualityOfService = .userInteractive
|
||||||
|
pumpJoinable = true
|
||||||
thread.start()
|
thread.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MAIN thread, once per vsync. Present the newest ready frame (if any) and stamp
|
/// MAIN thread, once per vsync. Present the newest ready frame (if any) and stamp capture→present at
|
||||||
/// capture→present at `targetPresentNs` — the display link's target present instant, already
|
/// `targetPresentNs` — the display link's target present instant, already converted to
|
||||||
/// converted to `CLOCK_REALTIME` (see `realtimeNs(forDisplayLinkTimestamp:)`).
|
/// `CLOCK_REALTIME` (see `realtimeNs(forDisplayLinkTimestamp:)`).
|
||||||
public func renderTick(targetPresentNs: Int64) {
|
public func renderTick(targetPresentNs: Int64) {
|
||||||
guard let frame = ring.take() else { return }
|
guard let frame = ring.take() else { return }
|
||||||
guard presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) else { return }
|
guard presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) else { return }
|
||||||
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).
|
/// Stop the pump (≤ one poll timeout) and drop the decode session. MAIN THREAD; idempotent. Does not
|
||||||
public func setDrawableSize(_ size: CGSize) {
|
/// close the connection. A restart needs a fresh Stage2Pipeline (cancel is permanent).
|
||||||
presenter.setDrawableSize(size)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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).
|
|
||||||
public func stop() {
|
public func stop() {
|
||||||
token.cancel()
|
token.cancel()
|
||||||
|
// Join the pump (bounded: ≤ one nextAU poll + an in-flight decode) before resetting the decoder,
|
||||||
|
// so the pump can't rebuild a session right after the reset. Only the first stop joins; a
|
||||||
|
// repeat/deinit stop skips the already-drained semaphore.
|
||||||
|
if pumpJoinable {
|
||||||
|
pumpJoinable = false
|
||||||
|
_ = pumpStopped.wait(timeout: .now() + 0.5)
|
||||||
|
}
|
||||||
decoder.reset()
|
decoder.reset()
|
||||||
recovery.bind(nil) // stop requesting keyframes once the session is torn down
|
recovery.bind(nil) // stop requesting keyframes once the session is torn down
|
||||||
}
|
}
|
||||||
@@ -177,8 +218,8 @@ public final class Stage2Pipeline {
|
|||||||
deinit { token.cancel() }
|
deinit { token.cancel() }
|
||||||
|
|
||||||
/// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME`
|
/// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME`
|
||||||
/// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the
|
/// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the target
|
||||||
/// target present time (when the frame is actually on glass), not the moment we drew.
|
/// present time (when the frame is actually on glass), not the moment we drew.
|
||||||
public static func realtimeNs(forDisplayLinkTimestamp t: CFTimeInterval) -> Int64 {
|
public static func realtimeNs(forDisplayLinkTimestamp t: CFTimeInterval) -> Int64 {
|
||||||
let caNow = CACurrentMediaTime()
|
let caNow = CACurrentMediaTime()
|
||||||
var ts = timespec()
|
var ts = timespec()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user