Compare commits
286 Commits
b3811ff72e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ef63756ea | |||
| a4c84ac620 | |||
| 2c416a4bff | |||
| 019f2677a7 | |||
| 40fefd73ca | |||
| b5fc017b19 | |||
| f48dc5dfce | |||
| 9074781acd | |||
| cac5b31535 | |||
| 133e25849d | |||
| e925d00194 | |||
| bd4e15b68d | |||
| 3678c182d5 | |||
| 12843fe253 | |||
| ffc0b07b46 | |||
| e7b07d2363 | |||
| 7c976bc8c3 | |||
| dd4da9e04d | |||
| d6596ff81b | |||
| 7975a95cd6 | |||
| 0604c4fba9 | |||
| ecbbff5544 | |||
| c8be614d9a | |||
| 246552b75e | |||
| e78805798d | |||
| ca79f7f2d2 | |||
| 2262332150 | |||
| 71e3618f2e | |||
| 4563a0490c | |||
| ba39b08e09 | |||
| e1bc9fda22 | |||
| 12c7ec9e57 | |||
| 5a89a64920 | |||
| 4306d4f914 | |||
| 915f11a712 | |||
| fc35ea8c31 | |||
| 1e9a15699c | |||
| 6c2942ee45 | |||
| 188b26b584 | |||
| 83ee53290e | |||
| 0f798d62b6 | |||
| 080c55dbf7 | |||
| 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 |
@@ -0,0 +1,50 @@
|
|||||||
|
# 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",
|
||||||
|
|
||||||
|
# quick-xml DoS advisories (RUSTSEC-2026-0194 quadratic-time duplicate-attribute check;
|
||||||
|
# RUSTSEC-2026-0195 unbounded namespace-declaration allocation in NsReader). Both are
|
||||||
|
# exploited by feeding attacker-controlled XML to a running parser. In this tree quick-xml is
|
||||||
|
# a BUILD-TIME-ONLY, transitive dependency of `wayland-scanner` (a proc-macro that parses the
|
||||||
|
# TRUSTED wayland protocol XML files shipped with the wayland-rs crates at compile time). It is
|
||||||
|
# never linked into any shipped binary and never parses runtime/attacker-controlled input, so
|
||||||
|
# neither DoS is reachable. There is no fix to bump to: wayland-scanner 0.31.10 (latest) pins
|
||||||
|
# `quick-xml ^0.39`, and the fixes only exist in quick-xml >=0.41. Revisit (drop these) when
|
||||||
|
# wayland-scanner releases against quick-xml >=0.41, or if quick-xml is ever pulled onto a
|
||||||
|
# runtime path that parses untrusted XML.
|
||||||
|
"RUSTSEC-2026-0194",
|
||||||
|
"RUSTSEC-2026-0195",
|
||||||
|
]
|
||||||
+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.5.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
|
||||||
|
|||||||
+45
-24
@@ -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.5.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.5.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.5.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.
|
||||||
@@ -83,12 +87,13 @@ jobs:
|
|||||||
git config --global --add safe.directory "$PWD"
|
git config --global --add safe.directory "$PWD"
|
||||||
cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked
|
cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked
|
||||||
|
|
||||||
- name: Build + smoke-boot web console (node-server preset)
|
- name: Build + smoke-boot web console (bun preset)
|
||||||
# Gate the .deb on a real node boot: the punktfunk-web .deb runs `node .output/server`,
|
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||||
# so prove the node-server build exists, isn't a bun bundle, and actually serves /login.
|
# (our Bun.serve TLS entry), so prove the build IS a bun bundle and serves /login.
|
||||||
|
# No TLS env here, so the custom entry binds plain HTTP — the smoke curl stays simple.
|
||||||
run: |
|
run: |
|
||||||
# bun builds the console. It's baked into the rust-ci image, but bootstrap it here too so
|
# bun builds AND runs the console. Baked into the rust-ci image; bootstrap here too so the
|
||||||
# the job stays green against the PREVIOUS image (docker.yml bootstrap lag).
|
# job stays green against the PREVIOUS image (docker.yml bootstrap lag).
|
||||||
command -v bun >/dev/null || {
|
command -v bun >/dev/null || {
|
||||||
apt-get install -y --no-install-recommends unzip
|
apt-get install -y --no-install-recommends unzip
|
||||||
curl -fsSL https://bun.sh/install | bash
|
curl -fsSL https://bun.sh/install | bash
|
||||||
@@ -97,21 +102,23 @@ jobs:
|
|||||||
cd web
|
cd web
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run build
|
bun run build
|
||||||
if grep -q 'Bun\.serve' .output/server/index.mjs; then
|
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
|
||||||
echo "ERROR: web build is a bun bundle (Bun.serve) — need the node-server preset"; exit 1
|
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
|
||||||
fi
|
fi
|
||||||
PORT=3009 HOST=127.0.0.1 PUNKTFUNK_UI_PASSWORD=ci node .output/server/index.mjs &
|
PORT=3009 HOST=127.0.0.1 PUNKTFUNK_UI_PASSWORD=ci bun .output/server/index.mjs &
|
||||||
NP=$!; sleep 3
|
NP=$!; sleep 3
|
||||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3009/login || echo 000)
|
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3009/login || echo 000)
|
||||||
kill "$NP" 2>/dev/null || true
|
kill "$NP" 2>/dev/null || true
|
||||||
echo "web console smoke: /login -> $code"
|
echo "web console smoke: /login -> $code"
|
||||||
[ "$code" = 200 ] || { echo "ERROR: web console failed to boot under node"; exit 1; }
|
[ "$code" = 200 ] || { echo "ERROR: web console failed to boot under bun"; exit 1; }
|
||||||
|
|
||||||
- name: Build .debs
|
- name: Build .debs
|
||||||
run: |
|
run: |
|
||||||
|
export PATH="$HOME/.bun/bin:$PATH"
|
||||||
VERSION="$VERSION" bash packaging/debian/build-deb.sh
|
VERSION="$VERSION" bash packaging/debian/build-deb.sh
|
||||||
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
|
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
|
||||||
VERSION="$VERSION" bash packaging/debian/build-web-deb.sh
|
# Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading.
|
||||||
|
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
|
||||||
|
|
||||||
- name: Publish to the Gitea apt registry
|
- name: Publish to the Gitea apt registry
|
||||||
env:
|
env:
|
||||||
@@ -124,3 +131,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.5.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.5.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.5.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.5.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.5.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.5.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,152 @@
|
|||||||
|
# Windows driver workspace CI — runs on a self-hosted Windows runner (home-windows-runner-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 - the driver-build job below self-provisions it via
|
||||||
|
# scripts/ci/ensure-windows-toolchain.ps1, a fast no-op once already present.
|
||||||
|
|
||||||
|
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 Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||||
|
# Shared self-provision step (also used by windows.yml/windows-msix.yml/windows-host.yml) so
|
||||||
|
# driver-build is self-sufficient on any windows-amd64 runner and never races a manually
|
||||||
|
# dispatched provisioning workflow landing on a different one. Path is relative to the job
|
||||||
|
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
|
||||||
|
run: ../../../scripts/ci/ensure-windows-toolchain.ps1
|
||||||
|
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay)
|
||||||
|
# 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,7 +1,9 @@
|
|||||||
# 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
|
||||||
# (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml.
|
# bun) from one signed setup.exe. Runs on a self-hosted windows-amd64 runner
|
||||||
|
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
|
||||||
|
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
|
||||||
#
|
#
|
||||||
# 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
|
||||||
# CreateProcessAsUserW's into the interactive session for secure-desktop capture, and bundles a
|
# CreateProcessAsUserW's into the interactive session for secure-desktop capture, and bundles a
|
||||||
@@ -11,18 +13,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 +38,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 +58,26 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||||
|
shell: pwsh
|
||||||
|
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||||
|
|
||||||
|
- 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 +86,24 @@ 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/provision-windows-punktfunk-extras.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
|
||||||
|
}
|
||||||
|
# VBCABLE_DIR: the pinned official VB-CABLE package (provisioned by
|
||||||
|
# provision-windows-punktfunk-extras.ps1) -> pack-host-installer.ps1 bundles the
|
||||||
|
# streaming virtual microphone. Same daemon-env-or-fallback pattern as FFMPEG_DIR
|
||||||
|
# (the daemon env only refreshes on a runner-task restart).
|
||||||
|
if (-not $env:VBCABLE_DIR) {
|
||||||
|
"VBCABLE_DIR=C:\Users\Public\vbcable" | 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,22 +115,80 @@ 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: Ensure Inno Setup
|
- 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: Fetch portable bun runtime (build tool + bundled to run the console)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
if (-not (Test-Path 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe') -and -not (Get-Command iscc -ErrorAction SilentlyContinue)) {
|
# ONE pinned bun, used both to BUILD the console and shipped in the installer to RUN it. The
|
||||||
Write-Output "installing Inno Setup via choco"
|
# .output is self-contained (Nitro noExternals — deps bundled + tree-shaken, no node_modules),
|
||||||
choco install innosetup -y --no-progress
|
# 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 (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
|
||||||
|
throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -108,21 +207,39 @@ jobs:
|
|||||||
# Check curl's exit code ourselves — a best-effort DELETE (404 on first run) must not abort.
|
# Check curl's exit code ourselves — a best-effort DELETE (404 on first run) must not abort.
|
||||||
$PSNativeCommandUseErrorActionPreference = $false
|
$PSNativeCommandUseErrorActionPreference = $false
|
||||||
function Publish-File($f, $url) {
|
function Publish-File($f, $url) {
|
||||||
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
|
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||||
if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" }
|
# re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
|
||||||
Write-Output "published $url"
|
# alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
|
||||||
|
# status code instead of aborting on it.
|
||||||
|
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
|
||||||
|
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
|
||||||
|
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
|
||||||
|
Write-Output "published ($code): $url"
|
||||||
}
|
}
|
||||||
$files = @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
$files = @($env:HOST_SETUP_PATH, $env:HOST_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" }
|
||||||
$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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
|
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
|
||||||
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start
|
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start
|
||||||
# tile, clean install/uninstall) instead of a loose exe. Runs on the self-hosted Windows runner
|
# tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64
|
||||||
# (host mode; scripts/ci/setup-windows-runner.ps1) — the MSVC/WinUI/FFmpeg toolchain + the Windows
|
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, FFmpeg
|
||||||
# SDK's makeappx/signtool are baked into the runner's daemon env, same as windows.yml.
|
# self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the
|
||||||
|
# Windows SDK's makeappx/signtool are baked into the runner's daemon env.
|
||||||
#
|
#
|
||||||
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
|
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
|
||||||
# toolset has the ARM64 cross compiler; the matrix points FFMPEG_DIR at the ARM64 FFmpeg tree). See
|
# toolset has the ARM64 cross compiler; the matrix points FFMPEG_DIR at the ARM64 FFmpeg tree). See
|
||||||
@@ -11,11 +12,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 +36,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:
|
||||||
@@ -61,6 +63,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||||
|
shell: pwsh
|
||||||
|
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||||
|
|
||||||
- name: Configure + version
|
- name: Configure + version
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
@@ -72,10 +78,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 +108,49 @@ 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) {
|
||||||
|
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||||
|
# re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
|
||||||
|
# alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
|
||||||
|
# status code instead of aborting on it.
|
||||||
|
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
|
||||||
|
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
|
||||||
|
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
|
||||||
|
Write-Output "published ($code): $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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Windows client CI — runs on the self-hosted Windows runner (home-windows-1, host mode; see
|
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
|
||||||
# scripts/ci/setup-windows-runner.ps1). Build + clippy + fmt + test the WinUI 3 client
|
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg, WDK, Inno
|
||||||
|
# Setup, the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a
|
||||||
|
# fast no-op once already present, so any runner with that label works with no manual dispatch
|
||||||
|
# step first). Build + clippy + fmt + test the WinUI 3 client
|
||||||
# (windows-reactor + D3D11/SwapChainPanel + WASAPI + SDL3).
|
# (windows-reactor + D3D11/SwapChainPanel + WASAPI + SDL3).
|
||||||
#
|
#
|
||||||
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
|
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
|
||||||
@@ -61,6 +64,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||||
|
shell: pwsh
|
||||||
|
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||||
|
|
||||||
- name: Configure + toolchain versions
|
- name: Configure + toolchain versions
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
@@ -68,9 +75,15 @@ jobs:
|
|||||||
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
|
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
|
||||||
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
|
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
|
||||||
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
# Per-arch FFmpeg import libs (the runner provisions both — setup-windows-runner.ps1).
|
# Per-arch FFmpeg import libs (provision-windows-punktfunk-extras.ps1 fetches both).
|
||||||
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
|
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
|
||||||
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
# $ff\bin on PATH too (not just FFMPEG_DIR, which only satisfies the linker): the test
|
||||||
|
# binary needs the actual DLLs to load at runtime. Set here rather than relying on the
|
||||||
|
# daemon's own env (project-env.ps1) - on a freshly cloned/registered runner the daemon
|
||||||
|
# starts before this job's "Ensure Windows toolchain" step ever writes that file, so its
|
||||||
|
# PATH doesn't include this yet on a first run (confirmed live: STATUS_DLL_NOT_FOUND).
|
||||||
|
"$ff\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||||
rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }}
|
||||||
rustc --version
|
rustc --version
|
||||||
cargo --version
|
cargo --version
|
||||||
|
|||||||
@@ -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**
|
||||||
@@ -65,18 +73,79 @@ 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 on Linux / the pf-xusb UMDF driver on
|
||||||
- **Windows host: implemented and shipping (NVIDIA-only, x64-only).** `#[cfg(windows)]` backends
|
Windows), **Xbox One/Series** (the same
|
||||||
behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA**
|
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
|
||||||
virtual display per session (`vdisplay/sudovda.rs`), NVENC encode (`--features nvenc`), SendInput +
|
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
|
||||||
**ViGEm** gamepads (`inject/gamepad_windows.rs`), WASAPI loopback + virtual mic (`audio/wasapi_*`).
|
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
|
||||||
Ships as a **signed Inno Setup installer** that registers a `LocalSystem` SCM service launching into
|
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
|
||||||
the interactive session for secure-desktop (UAC/lock-screen) capture (`service.rs`), bundles the
|
LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows
|
||||||
SudoVDA driver, and is published by `windows-host.yml`. **HDR (10-bit)**: WGC captures the HDR
|
(UMDF minidriver)** backend — `inject/windows/dualsense_windows.rs` + `inject/windows/dualshock4_windows.rs`, one
|
||||||
desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), NVENC forces HEVC Main10 + BT.2020 PQ,
|
driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4
|
||||||
the client auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`;
|
reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays
|
||||||
**Windows host only** (the Linux host stays 8-bit, blocked upstream). Newer/less battle-tested than
|
Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller
|
||||||
the Linux host; no AMD/Intel/software encode path. Packaging: `packaging/windows/`.
|
(DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad
|
||||||
|
dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver
|
||||||
|
(`packaging/windows/drivers/pf-xusb/`, `inject/windows/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB`
|
||||||
|
and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState`
|
||||||
|
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
|
||||||
|
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI
|
||||||
|
(`packaging/windows/drivers/`) and installed by the Inno Setup installer via
|
||||||
|
`punktfunk-host.exe driver install --gamepad`.
|
||||||
|
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
|
||||||
|
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
|
||||||
|
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
|
||||||
|
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
|
||||||
|
the remaining piece.)
|
||||||
|
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
|
||||||
|
behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx
|
||||||
|
**pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`;
|
||||||
|
DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`), GPU encode (NVENC
|
||||||
|
`--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers
|
||||||
|
(`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire
|
||||||
|
convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK;
|
||||||
|
the Windows client derives it from the scancode, NOT the layout-resolved `vkCode`) — the Windows
|
||||||
|
injector resolves them via a fixed table mirroring the Linux `vk_to_evdev` (never through a
|
||||||
|
keyboard layout: the SYSTEM service thread's layout re-reads positions as characters — the
|
||||||
|
German y↔z / ö→ü scramble), while GameStream/Moonlight VKs are layout-semantic
|
||||||
|
(`KEY_FLAG_SEMANTIC_VK`, resolved under the foreground app's layout, Sunshine's model). Linux
|
||||||
|
renders positions under the session compositor's layout (libei) or the virtual keyboard's
|
||||||
|
uploaded keymap (Sway/wlroots — honors `XKB_DEFAULT_LAYOUT` et al., default US); the Android
|
||||||
|
client reads `KeyEvent.scanCode` first so a user-selected physical-keyboard layout can't
|
||||||
|
re-map keycodes semantically. Ships as a **signed
|
||||||
|
Inno Setup installer** that registers a `LocalSystem` SCM service launching into the interactive
|
||||||
|
session for secure-desktop (UAC/lock-screen) capture (`windows/service.rs`), bundles the
|
||||||
|
pf-vdisplay driver + the FFmpeg DLLs (+ VB-CABLE for the virtual mic), and is published by
|
||||||
|
`windows-host.yml`. **Encoder is GPU-aware** (`encode.rs` `open_video` + `windows_resolved_backend`):
|
||||||
|
`PUNKTFUNK_ENCODER=auto` (the host.env default) reads the **selected render adapter's** vendor →
|
||||||
|
**NVENC** (NVIDIA, direct SDK, `encode/windows/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec
|
||||||
|
(`encode/windows/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`,
|
||||||
|
system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a
|
||||||
|
system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is
|
||||||
|
probed per-GPU on AMF/QSV (`windows_codec_support` → `serverinfo`, AV1 gated; cached per selected
|
||||||
|
GPU). **Multi-GPU is first-class** (`gpu.rs`): GPU inventory + a persisted auto/manual preference
|
||||||
|
(`<config>/gpu-settings.json`, stored by stable PCI identity — LUIDs are per-boot) exposed over
|
||||||
|
`GET /api/v1/gpus` + `PUT /api/v1/gpus/preference` and a web-console GPU card (Host page: list,
|
||||||
|
Automatic/Prefer, "In use · backend" badge). One selection — precedence **console preference >
|
||||||
|
`PUNKTFUNK_RENDER_ADAPTER` > max VRAM**, graceful fallback when the preferred GPU is absent —
|
||||||
|
feeds `win_adapter::resolve_render_adapter_luid` (capture ring + IddCx render pin), the encoder
|
||||||
|
vendor auto-detect (previously DXGI adapter 0 — wrong on hybrid boxes like NVIDIA dGPU + Intel
|
||||||
|
Arc iGPU), and the NVENC 4:4:4 probe; a preference change applies to the next session. On Linux a
|
||||||
|
matched manual preference picks the VAAPI render node / NVENC-vs-VAAPI auto choice (auto mode
|
||||||
|
unchanged). *Implemented + unit-tested; not yet on-glass validated on the hybrid box.* **HDR (10-bit)**: WGC
|
||||||
|
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
|
||||||
|
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
|
||||||
|
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
|
||||||
|
host only** (the Linux host stays 8-bit, blocked upstream). **Vulkan-game HDR over the virtual
|
||||||
|
display**: NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an IddCx
|
||||||
|
indirect display (so Vulkan games — Doom: The Dark Ages, id Tech, etc. — say "device does not support
|
||||||
|
HDR"), even though the ICD happily *accepts + presents* a forced HDR swapchain there. A tiny always-on
|
||||||
|
Vulkan **implicit layer** (`packaging/windows/pf-vkhdr-layer/`, `VK_LAYER_PUNKTFUNK_hdr_inject`)
|
||||||
|
injects the `HDR10_ST2084`/scRGB surface formats into `vkGetPhysicalDeviceSurfaceFormats[2]KHR`,
|
||||||
|
self-gated on the display's actual advanced-color state (no-op on SDR / real monitors); bundled +
|
||||||
|
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
|
||||||
|
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
|
||||||
|
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
|
||||||
|
|
||||||
## What's left
|
## What's left
|
||||||
|
|
||||||
@@ -94,16 +163,47 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
|
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
|
||||||
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
|
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
|
||||||
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
|
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
|
||||||
motion sign/scale derived, not yet live-verified. Tests: `swift test` in
|
motion sign/scale derived, not yet live-verified. **Gamepad UI (iOS/iPadOS + macOS,
|
||||||
|
2026-07-02 rework):** a connected pad swaps the home for a console-style launcher
|
||||||
|
(`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add
|
||||||
|
Host tile (A connect · Y library · X settings · B back), a controller-navigable
|
||||||
|
settings screen (vertical `GamepadMenuList`, left/right steps values), an add-host
|
||||||
|
flow with an on-screen controller keyboard (`GamepadKeyboard` — no touch needed
|
||||||
|
anywhere), and the coverflow library, all over an animated aurora backdrop
|
||||||
|
(`GamepadScreenBackground`, TimelineView-driven drifting blobs — pure SwiftUI ON
|
||||||
|
PURPOSE: a .metal lib only reliably bundles in one of the two build systems (SPM vs
|
||||||
|
xcodeproj synced folders) these sources compile under). Input is the polled
|
||||||
|
`GamepadMenuInput` (handlers don't fire outside a stream; on (re)start it SNAPSHOTS
|
||||||
|
held buttons so a handoff press never double-fires), haptics dual-channel (device +
|
||||||
|
`MenuHaptics` on the pad). macOS: same screens, settings/add-host as sheets (no
|
||||||
|
fullScreenCover), NSScreen-based mode lists, scroll indicators `.never` (macOS
|
||||||
|
"always show scroll bars" overrides `.hidden`); launcher/settings/add-host/keyboard
|
||||||
|
render-verified live on this Mac via `PUNKTFUNK_FORCE_GAMEPAD_UI=1` (dev hook, forces
|
||||||
|
the mode without a pad). Controller-in-hand on-glass validation still pending on all
|
||||||
|
platforms. Tests: `swift test` in
|
||||||
`clients/apple` (unit + real-codec round trip),
|
`clients/apple` (unit + real-codec round trip),
|
||||||
`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
|
||||||
@@ -134,23 +234,39 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
**Windows stage 1 done 2026-06-15** (`clients/windows`, binary
|
**Windows stage 1 done 2026-06-15** (`clients/windows`, binary
|
||||||
`punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like
|
`punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like
|
||||||
framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The
|
framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The
|
||||||
video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain** (WARP fallback for
|
video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain**, presented from a
|
||||||
the GPU-less dev box; runtime-compiled fullscreen-triangle shaders, Contain-fit letterbox),
|
**dedicated render thread** (`render.rs`, 2026-07-02 rewrite — presenting never touches or is
|
||||||
driven by reactor's per-frame `on_rendering`. **FFmpeg HEVC decode with a D3D11VA
|
stalled by the XAML thread): frame-latency-waitable swapchain + `SetMaximumFrameLatency(1)`
|
||||||
zero-copy hardware path** (`gpu.rs` shares one D3D11 device — hardware+`VIDEO_SUPPORT`, WARP
|
(≤1 queued present, newest-wins drain after the wait, so a stream faster than the display drops
|
||||||
fallback, multithread-protected — between the decoder and presenter; the decoder outputs
|
backlog before any GPU work), **HiDPI-correct** (pixel-sized buffers + `SetMatrixTransform`
|
||||||
NV12/P010 `ID3D11Texture2D` array slices with `BIND_SHADER_RESOURCE` and the presenter samples
|
96/DPI — DIP-sized buffers were blurry at 125/150 %), Contain-fit letterbox, WARP fallback.
|
||||||
them via per-plane SRVs + YUV→RGB shaders — NV12/BT.709, P010/BT.2020-PQ; **software CPU decode
|
**FFmpeg decode with a D3D11VA hardware path on all vendors** (`gpu.rs` shares one D3D11 device
|
||||||
stays as the robust fallback**, auto-selected with a `DecoderPref` override). **HDR10**: the
|
between decoder + presenter, adapter picked by console pref `PUNKTFUNK_ADAPTER` > the window's
|
||||||
client advertises 10-bit/HDR (Settings toggle), detects PQ in-band (`transfer == SMPTE2084`),
|
monitor's adapter > default; `PUNKTFUNK_D3D_DEBUG=1` adds the debug layer): the decode pool is
|
||||||
and flips the swapchain to `R10G10B10A2` + ST.2084 with HDR10 metadata. **WASAPI** render + mic
|
**decoder-only bind, sized/aligned by libavcodec itself** (get_format returns `AV_PIX_FMT_D3D11`
|
||||||
capture, **SDL3** gamepads (rumble/lightbar/DualSense), `mdns-sd` discovery, and the full trust
|
and lets `hw_device_ctx` drive — three hand-built-frames-context strikes are why: NVIDIA rejects
|
||||||
surface — all **in-app**: a polished WinUI shell (host cards w/ monogram + status pills,
|
`DECODER|SHADER_RESOURCE` arrays, `BindFlags=0` fails texture creation, and Intel rejects
|
||||||
|
non-128-aligned HEVC surfaces at the first `SubmitDecoderBuffers`), a DXVA **profile probe**
|
||||||
|
before the hwdevice commits software-vs-hardware up front (no burned first IDR), and the
|
||||||
|
presenter copies the decoded slice with ONE display-size-boxed `CopySubresourceRegion` (a planar
|
||||||
|
slice is a single subresource in D3D11 — the old two-copy D3D12-style code silently no-opped =
|
||||||
|
the black screen) into its sampleable NV12/P010 texture → per-plane SRVs + YUV→RGB shaders
|
||||||
|
(NV12/BT.709, P010/BT.2020-PQ). **Software CPU decode is the fallback** (auto-selected,
|
||||||
|
`DecoderPref` override, mid-session demotion + keyframe re-request) and now feeds the SAME
|
||||||
|
shaders (swscale → NV12/P010 planes → two dynamic plane textures) so hw/sw colour math is
|
||||||
|
identical. **HDR10**: the client advertises 10-bit/HDR (Settings toggle, gated on an HDR
|
||||||
|
display), detects PQ in-band (`transfer == SMPTE2084`), and flips the swapchain to
|
||||||
|
`R10G10B10A2` + ST.2084 with HDR10 metadata (0xCE mastering metadata plumbed). **WASAPI** render
|
||||||
|
+ mic capture, **SDL3** gamepads (rumble/lightbar/DualSense), `mdns-sd` discovery, and the full
|
||||||
|
trust surface — all **in-app**: a polished WinUI shell (host tiles w/ monogram + status pills,
|
||||||
`InfoBar` errors/hints, `ToggleSwitch` settings, status-chip stream HUD showing GPU/CPU decode +
|
`InfoBar` errors/hints, `ToggleSwitch` settings, status-chip stream HUD showing GPU/CPU decode +
|
||||||
HDR), host list (live mDNS + saved + manual), settings (resolution/refresh/decoder/bitrate/HDR/
|
HDR), host list (live mDNS + saved + manual), settings (resolution/refresh/decoder/bitrate/HDR/
|
||||||
mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch re-pair. **(D3D11VA + HDR present + the
|
mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch re-pair. **Live-validated 2026-07-02
|
||||||
GUI polish are written against the windows-rs/reactor APIs but not yet on-glass validated — the
|
on the hybrid laptop (Intel Arc Pro iGPU + RTX 3500 Ada) against the local Windows host**:
|
||||||
dev VM is headless/WARP; needs the RTX box.)** **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor
|
D3D11VA hardware decode 60 fps on BOTH vendors (headless, `PUNKTFUNK_ADAPTER`-forced; NVIDIA
|
||||||
|
0.2 ms decode, Intel 0.2 ms), software path, and the GUI on glass (real decoded desktop pixels,
|
||||||
|
GPU-decode HUD chip, ~18 ms capture→decoded p50 over loopback — dominated by the host's 60 Hz
|
||||||
|
virtual-display capture cadence). HDR-on-glass still pending. **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor
|
||||||
exposes no raw key/pointer events; native Windows VK + absolute mouse (client-rect Contain-fit) +
|
exposes no raw key/pointer events; native Windows VK + absolute mouse (client-rect Contain-fit) +
|
||||||
wheel, Ctrl+Alt+Shift+Q capture toggle. `--headless`/`--discover` keep CLI paths. Builds + clippy
|
wheel, Ctrl+Alt+Shift+Q capture toggle. `--headless`/`--discover` keep CLI paths. Builds + clippy
|
||||||
+ fmt green on **`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`** — the latter
|
+ fmt green on **`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`** — the latter
|
||||||
@@ -161,15 +277,33 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
dep pinned to commit `b4129fcc`; `windows` pinned to the SAME commit so `IDXGISwapChain1` unifies
|
dep pinned to commit `b4129fcc`; `windows` pinned to the SAME commit so `IDXGISwapChain1` unifies
|
||||||
with `set_swap_chain`); its `build.rs` downloads the Win App SDK NuGets + needs `CARGO_WORKSPACE_DIR`
|
with `set_swap_chain`); its `build.rs` downloads the Win App SDK NuGets + needs `CARGO_WORKSPACE_DIR`
|
||||||
set (in the VM build env; `/temp`+`/winmd` gitignored). Gotcha: `CARGO_HOME` must be an ASCII path
|
set (in the VM build env; `/temp`+`/winmd` gitignored). Gotcha: `CARGO_HOME` must be an ASCII path
|
||||||
— the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. Next: **on-glass
|
— the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. **Parity/cleanup
|
||||||
validation** of the D3D11VA decode + HDR present + GUI on the RTX box (the dev VM is
|
batch (2026-07-02)**: `app.rs` split into per-screen `app/` modules (mod=root/router · hosts ·
|
||||||
headless/Session-0/WARP → the WinUI window + hardware decode need a real display+GPU: RDP or the
|
connect · pair · speed · settings · licenses · stream · style; thread-driven state lives in ROOT
|
||||||
RTX box), then RAWINPUT relative-mouse pointer-lock and a per-host speed test in the UI.
|
`use_async_state` and flows down as props — a child's own async-state write does NOT re-render it);
|
||||||
|
"Native display" now resolves the real monitor mode at connect (`MonitorFromWindow` →
|
||||||
|
`EnumDisplaySettingsW`, was hardcoded 1080p60); per-host **speed test** (saved-host card button +
|
||||||
|
`--headless --speed-test`, probe burst → recommended ≈70 % bitrate applied in one tap; bitrate
|
||||||
|
setting is now a free-form NumberBox); **forget host** (ContentDialog confirm →
|
||||||
|
`KnownHosts::remove_by_fp`); settings gained forwarded-controller picker + gamepad type + host
|
||||||
|
compositor + capture-system-shortcuts — the previously-dead `Settings.compositor`/
|
||||||
|
`inhibit_shortcuts` are now honored (off = Alt+Tab/Alt+Esc/Ctrl+Esc/Win act locally);
|
||||||
|
**click-to-recapture** after a Ctrl+Alt+Shift+Q release with the HUD hint tracking capture state;
|
||||||
|
input hook caches lock geometry (no per-move `GetClientRect`), audio jitter-ring trims via
|
||||||
|
`drain`. Validated on the bare-metal RTX box: `--discover` (3 live LAN hosts), synthetic-host
|
||||||
|
loopback E2E (TOFU connect → clock skew → HEVC negotiate → shared-D3D11 + D3D11VA init → WASAPI →
|
||||||
|
session end; synthetic payload isn't decodable so decode output stays unvalidated), speed-test
|
||||||
|
E2E. The WinUI window itself CANNOT be launched from SSH (session-0 → WinAppSDK 0x80070005,
|
||||||
|
pre-existing; needs the console session, e.g. PsExec -i 1).
|
||||||
|
Next: **HDR on-glass validation** (Windows host with `PUNKTFUNK_10BIT` → the HDR laptop
|
||||||
|
display), then RAWINPUT relative-mouse pointer-lock.
|
||||||
**Android stage 1 done** (`clients/android`, Kotlin app + `native/` Rust JNI core linking
|
**Android stage 1 done** (`clients/android`, Kotlin app + `native/` Rust JNI core linking
|
||||||
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
|
`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/AAudio 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.
|
||||||
@@ -213,8 +347,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
|
||||||
@@ -224,7 +358,25 @@ clients are deliberately NOT containerized); `apple.yml` builds the xcframework
|
|||||||
provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows:
|
provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows:
|
||||||
`deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml`
|
`deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml`
|
||||||
(Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG +
|
(Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG +
|
||||||
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner.
|
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner
|
||||||
|
(`home-windows-runner-1`, vmid 210, `windows-amd64:host` label). The runner is reproducible and
|
||||||
|
**owned by `unom/infra`**, not this repo, since it's shared across unom Windows projects going
|
||||||
|
forward: `unom/infra`'s `windows-runner/` Packer template bakes a generic Windows 11 template (OS
|
||||||
|
install + OpenSSH Server + VS Build Tools/NASM/CMake/LLVM + the act_runner/Node/rustup base, no
|
||||||
|
registration) on Proxmox once; `proxmox/windows-runner/` (Terraform, `bpg/proxmox`) full-clones it
|
||||||
|
(agent-based IP discovery, no pre-provisioned DHCP reservation needed) and registers the instance
|
||||||
|
over SSH remote-exec — the same bake-once/clone-fast split `proxmox/unom-1` uses for the Linux CI
|
||||||
|
host, just without a native Windows cloud-init (registration goes over `remote-exec`/SSH instead of
|
||||||
|
`initialization{}`; WinRM was tried first but is deprecated in OpenTofu, so this moved to SSH via
|
||||||
|
Windows' in-box OpenSSH Server). punktfunk layers its own extras on top of that generic runner:
|
||||||
|
`scripts/ci/provision-windows-wdk.ps1` (WDK + cargo-wdk for the UMDF drivers) and
|
||||||
|
`scripts/ci/provision-windows-punktfunk-extras.ps1` (FFmpeg x64/ARM64 trees, Inno Setup, the
|
||||||
|
`aarch64-pc-windows-msvc` rustup target) — both idempotent, and both run automatically at the start
|
||||||
|
of every Windows CI job via the shared `scripts/ci/ensure-windows-toolchain.ps1` step (a fast no-op
|
||||||
|
once already provisioned), rather than a separate manually-dispatched provisioning workflow — that
|
||||||
|
avoided a real footgun once there could be more than one `windows-amd64` runner: a manually
|
||||||
|
dispatched provisioning workflow has no way to target a *specific* runner instance, so it could
|
||||||
|
land on an already-provisioned box instead of the one that actually needed it.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -232,18 +384,23 @@ TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hoste
|
|||||||
crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated)
|
crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated)
|
||||||
crates/punktfunk-host/
|
crates/punktfunk-host/
|
||||||
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
|
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
|
||||||
vdisplay/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
|
vdisplay/linux/{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)
|
vdisplay/windows/{pf_vdisplay,manager,identity}.rs all-Rust IddCx virtual display (pf-vdisplay)
|
||||||
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
|
linux/zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
||||||
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs
|
inject/linux/{libei,wlr,gamepad,dualsense,dualshock4,steam_*}.rs Linux input (uinput xpad · UHID pads · virtual Deck)
|
||||||
|
inject/windows/{sendinput,gamepad_windows,dualsense_windows,dualshock4_windows}.rs Windows input (UMDF shared-mem pads)
|
||||||
|
encode/linux/{mod,vaapi}.rs · encode/windows/{nvenc,ffmpeg_win}.rs · encode/sw.rs per-GPU encoders (NVENC/CUDA · VAAPI · AMF/QSV) + GPU-less openh264
|
||||||
|
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
|
||||||
|
windows/{service,install,interactive}.rs SCM service + in-binary driver/web install
|
||||||
|
capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs
|
||||||
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
clients/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
|
packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI)
|
||||||
web/ TanStack web console over the mgmt API (status · devices · pairing)
|
web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs)
|
||||||
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
|
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/
|
||||||
@@ -298,7 +455,23 @@ FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKT
|
|||||||
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1`, `PUNKTFUNK_GAMESCOPE_APP=...`,
|
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1`, `PUNKTFUNK_GAMESCOPE_APP=...`,
|
||||||
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
|
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
|
||||||
test), `PUNKTFUNK_FEC_PCT=N`, `PUNKTFUNK_DSCP=1` (opt-in DSCP/SO_PRIORITY media QoS on the data +
|
test), `PUNKTFUNK_FEC_PCT=N`, `PUNKTFUNK_DSCP=1` (opt-in DSCP/SO_PRIORITY media QoS on the data +
|
||||||
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy).
|
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy),
|
||||||
|
`PUNKTFUNK_444=1` (full-chroma HEVC 4:4:4, see below).
|
||||||
|
|
||||||
|
**HEVC 4:4:4 (full chroma, Range Extensions)**: opt-in via `PUNKTFUNK_444`, negotiated like 10-bit —
|
||||||
|
the host emits 4:4:4 only when the client advertised `VIDEO_CAP_444` (wire bit `0x04` + ABI
|
||||||
|
`PUNKTFUNK_VIDEO_CAP_444`), the codec is HEVC, the session is single-process, **and** a GPU probe
|
||||||
|
(`encode::can_encode_444`, run before the Welcome) confirms support — else it resolves to 4:2:0 and
|
||||||
|
`Welcome::chroma_format` reflects the real value (honest downgrade; the client reads it via
|
||||||
|
`punktfunk_connection_chroma_format`). **punktfunk/1-native only** — GameStream/Moonlight stays 4:2:0
|
||||||
|
(stock clients can't decode 4:4:4). **NVENC is the implemented path**: Linux `hevc_nvenc` feeds a
|
||||||
|
swscale'd `yuv444p` (RGB-in is always 4:2:0 — verified on the RTX 5070 Ti — so the session forces CPU
|
||||||
|
RGB capture for 4:4:4); Windows NVENC keeps ARGB input + FREXT profile + `chromaFormatIDC=3` and the
|
||||||
|
DDA capturer delivers RGB. VAAPI / AMF / QSV **decline** (probe returns false — no validated 4:4:4
|
||||||
|
hardware in the lab; they'd produce 4:2:0). Software (openh264) is 4:2:0-only. Test with
|
||||||
|
`PUNKTFUNK_CLIENT_444=1 punktfunk-probe --out x.h265` then `ffprobe x.h265` (expect `pix_fmt yuv444p`).
|
||||||
|
*Linux NVENC mechanism validated on the RTX 5070 Ti (ffmpeg CLI); Windows NVENC + 10-bit-4:4:4 not yet
|
||||||
|
on-glass validated.*
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Contributing to punktfunk
|
||||||
|
|
||||||
|
Thanks for your interest in contributing!
|
||||||
|
|
||||||
|
## Licensing of contributions (inbound = outbound)
|
||||||
|
|
||||||
|
punktfunk is dual-licensed under **MIT OR Apache-2.0**.
|
||||||
|
|
||||||
|
> Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
|
||||||
|
> the work by you, as defined in the Apache-2.0 license, shall be dual licensed as **MIT OR
|
||||||
|
> Apache-2.0**, without any additional terms or conditions.
|
||||||
|
|
||||||
|
By opening a pull request you agree to license your contribution under these terms. This is the
|
||||||
|
standard Rust-ecosystem "inbound = outbound" model; it keeps the project's licensing unambiguous
|
||||||
|
(including the Apache-2.0 §5 contributor patent grant) and any future relicensing clean. You retain
|
||||||
|
the copyright to your contributions.
|
||||||
|
|
||||||
|
### Do not paste copyleft (or otherwise incompatibly-licensed) code
|
||||||
|
|
||||||
|
The single thing that could poison the permissive license is **copied source from a copyleft
|
||||||
|
project**. Several adjacent projects (Sunshine, Apollo, Moonlight) are GPL-3.0. You may study them
|
||||||
|
and reimplement a *technique*, protocol, or wire format — those are not copyrightable — but **never
|
||||||
|
paste their code**, and do not translate a GPL implementation line-by-line. When a comment credits
|
||||||
|
prior art, make clear it is an independent reimplementation, not a copy. The same applies to any
|
||||||
|
third party's code under a license incompatible with MIT/Apache.
|
||||||
|
|
||||||
|
If you add a new third-party dependency, it must be permissive (MIT / Apache-2.0 / BSD / ISC / Zlib /
|
||||||
|
Unicode-3.0 / etc.). `about.toml` holds the accepted-license allow-list; regenerate the attribution
|
||||||
|
file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
||||||
|
|
||||||
|
## Before you push
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt --all --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated artifacts are checked in and CI fails on drift: `include/punktfunk_core.h` (cbindgen) and
|
||||||
|
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
|
||||||
|
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
|
||||||
|
|
||||||
|
See [`CLAUDE.md`](CLAUDE.md) for the full build/test/run guide and design invariants.
|
||||||
Generated
+561
-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.4.2"
|
||||||
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,9 +26,16 @@ 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-box,
|
||||||
|
~1.3 ms cross-machine on a LAN. (AMD/Intel encode via VAAPI, and a GPU-less software H.264
|
||||||
|
encoder exists as a fallback.)
|
||||||
- **Works with what you already have.** Any Moonlight/Artemis client connects over GameStream — and
|
- **Works with what you already have.** Any Moonlight/Artemis client connects over GameStream — and
|
||||||
native apps for macOS, Linux, Windows, and Android use the lower-latency `punktfunk/1` protocol.
|
native apps for macOS, Linux, Windows, and Android use the lower-latency `punktfunk/1` protocol.
|
||||||
- **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices
|
- **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices
|
||||||
@@ -35,10 +49,10 @@ 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, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
|
| **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, AAudio audio, controllers, discovery, pairing |
|
||||||
| **Windows client** (`clients/windows`, WinUI 3) | 🟡 Stage 1 complete, ships as signed MSIX (x64 + ARM64); D3D11VA decode + HDR present pending on-glass validation |
|
| **Windows client** (`clients/windows`, WinUI 3) | 🟡 Stage 1 complete, ships as signed MSIX (x64 + ARM64); D3D11VA decode + HDR present pending on-glass validation |
|
||||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing |
|
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing |
|
||||||
|
|
||||||
@@ -61,14 +75,14 @@ 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` inside your desktop session (the secure native default;
|
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||||
@@ -113,18 +127,18 @@ 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)
|
||||||
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
|
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
|
||||||
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · Oboe)
|
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
|
||||||
probe/ headless reference / measurement client for punktfunk/1
|
probe/ headless reference / measurement client for punktfunk/1
|
||||||
decky/ Steam Deck Decky plugin
|
decky/ Steam Deck Decky plugin
|
||||||
web/ web console (TanStack) over the management API — status · devices · pairing
|
web/ web console (TanStack) over the management API — status · devices · pairing
|
||||||
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)
|
||||||
```
|
```
|
||||||
@@ -143,4 +157,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"
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.0.1"
|
"version": "0.4.2"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
@@ -138,6 +138,100 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/gpus": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"gpu"
|
||||||
|
],
|
||||||
|
"summary": "GPU inventory and selection",
|
||||||
|
"description": "Lists the host's hardware GPUs, the persisted auto/manual preference, the GPU the next session\nwill use (and why), and the GPU live sessions encode on right now.",
|
||||||
|
"operationId": "listGpus",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "GPU inventory + selection state",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/GpuState"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/gpus/preference": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"gpu"
|
||||||
|
],
|
||||||
|
"summary": "Set the GPU preference",
|
||||||
|
"description": "`auto` restores automatic selection (`PUNKTFUNK_RENDER_ADAPTER` pin, else max dedicated VRAM);\n`manual` pins capture + encode to the given GPU. Persisted across restarts; applies to the\n**next** session (a running session keeps its GPU). If the preferred GPU is absent at session\nstart the host falls back to automatic selection rather than failing.",
|
||||||
|
"operationId": "setGpuPreference",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SetGpuPreference"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Preference stored; the new selection state",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/GpuState"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Unknown mode, or `gpu_id` missing / not a listed GPU",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Preference could not be persisted",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/health": {
|
"/api/v1/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -229,6 +323,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/library/art/{id}/{kind}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"library"
|
||||||
|
],
|
||||||
|
"summary": "Fetch one cover-art image for a library entry",
|
||||||
|
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
|
||||||
|
"operationId": "getLibraryArt",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The store-qualified library id, e.g. `steam:570`",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "kind",
|
||||||
|
"in": "path",
|
||||||
|
"description": "`portrait` | `hero` | `logo` | `header`",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Image bytes",
|
||||||
|
"content": {
|
||||||
|
"image/jpeg": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No art of that kind for that id",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/library/custom": {
|
"/api/v1/library/custom": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -978,6 +1130,309 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/stats/capture/live": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "Live in-progress capture",
|
||||||
|
"description": "The full sample time-series of the capture currently recording, for live graphing. `404` when\nnothing is armed.",
|
||||||
|
"operationId": "statsCaptureLive",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The in-progress capture (meta + samples so far)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Capture"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No capture is currently recording",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/stats/capture/start": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "Start a stats capture",
|
||||||
|
"description": "Arms a new performance-stats capture. Idempotent: if a capture is already running this returns\nthe current status unchanged. While armed, the streaming loops emit aggregated samples (~ every\n1–2 s) into the in-progress capture, readable live via `GET /stats/capture/live`.",
|
||||||
|
"operationId": "statsCaptureStart",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Capture armed (or already running)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/StatsStatus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/stats/capture/status": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "Stats capture status",
|
||||||
|
"description": "Whether a capture is armed, its sample count, and start time. Poll this (e.g. every 2 s) to\ndrive the capture-control UI.",
|
||||||
|
"operationId": "statsCaptureStatus",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "In-progress capture status (idle when not armed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/StatsStatus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/stats/capture/stop": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "Stop the stats capture",
|
||||||
|
"description": "Disarms the in-progress capture and writes it to disk atomically, returning its summary. If\nnothing was recording, returns `204 No Content`.",
|
||||||
|
"operationId": "statsCaptureStop",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Capture stopped and saved",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CaptureMeta"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"204": {
|
||||||
|
"description": "Nothing was recording"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not write the recording to disk",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/stats/recordings": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "List saved recordings",
|
||||||
|
"description": "Every saved capture's summary (the `meta` head only — not the sample body), newest first.",
|
||||||
|
"operationId": "statsRecordingsList",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Saved capture summaries, newest first",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/CaptureMeta"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/stats/recordings/{id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "Get a saved recording",
|
||||||
|
"description": "The full capture (meta + samples) for `id`, for graphing or download.",
|
||||||
|
"operationId": "statsRecordingGet",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The recording id (its filename stem)",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The full capture",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Capture"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No recording with that id",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "The recording file is unreadable",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "Delete a saved recording",
|
||||||
|
"description": "Removes the recording `id` from disk. `404` if there is no such recording.",
|
||||||
|
"operationId": "statsRecordingDelete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The recording id (its filename stem)",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Recording deleted"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No recording with that id",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not delete the recording",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/status": {
|
"/api/v1/status": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1012,6 +1467,40 @@
|
|||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"ApiActiveGpu": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The GPU live sessions are encoding on right now.",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"vendor",
|
||||||
|
"backend",
|
||||||
|
"sessions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"backend": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`)."
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Stable id matching an entry of `gpus` (empty for the CPU/software encoder)."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Number of live encode sessions on it.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"vendor": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`nvidia` | `amd` | `intel` | `other`."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ApiCodec": {
|
"ApiCodec": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Video codec identifier.",
|
"description": "Video codec identifier.",
|
||||||
@@ -1033,6 +1522,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ApiGpu": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One hardware GPU on the host (software/WARP adapters are never listed).",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"vendor",
|
||||||
|
"vram_mb"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Stable identifier (`vendorid-deviceid-occurrence`, hex PCI ids) — pass to `setGpuPreference`.\nStable across reboots and driver updates, unlike an adapter index or LUID.",
|
||||||
|
"example": "10de-2c05-0"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Adapter/marketing name.",
|
||||||
|
"example": "NVIDIA GeForce RTX 5070 Ti"
|
||||||
|
},
|
||||||
|
"vendor": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`nvidia` | `amd` | `intel` | `other`."
|
||||||
|
},
|
||||||
|
"vram_mb": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Dedicated VRAM in MiB (0 where the platform doesn't expose it).",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ApiSelectedGpu": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The GPU the **next** session's pipeline will be created on, and why. (A preference change\napplies to the next session; a running session keeps the GPU it opened on.)",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"vendor",
|
||||||
|
"source"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Why this GPU was selected: `preference` (the manual choice), `env`\n(`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM / platform default), or\n`preference_missing` (a manual choice is set but that GPU is absent — auto-selected\ninstead so the host keeps streaming)."
|
||||||
|
},
|
||||||
|
"vendor": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`nvidia` | `amd` | `intel` | `other`."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ApprovePending": {
|
"ApprovePending": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
|
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
|
||||||
@@ -1051,6 +1598,14 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Arm-native-pairing request body.",
|
"description": "Arm-native-pairing request body.",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"fingerprint": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).\nWhen set, only a pairing attempt from that fingerprint consumes the window — so an unpaired\nLAN peer can neither pair nor burn a window armed for a specific device (security-review #9).\nOmit for an unbound window (any device may use the PIN — trusted-LAN only).",
|
||||||
|
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||||
|
},
|
||||||
"ttl_secs": {
|
"ttl_secs": {
|
||||||
"type": [
|
"type": [
|
||||||
"integer",
|
"integer",
|
||||||
@@ -1125,6 +1680,89 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Capture": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A full capture: summary + the sample time-series. The wire + on-disk shape.",
|
||||||
|
"required": [
|
||||||
|
"meta",
|
||||||
|
"samples"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/components/schemas/CaptureMeta"
|
||||||
|
},
|
||||||
|
"samples": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/StatsSample"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CaptureMeta": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Capture summary — the filename stem plus the negotiated mode/codec/client. Stored at the head\nof each on-disk recording and listed standalone (without the sample body) by\n[`StatsRecorder::list`].",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"started_unix_ms",
|
||||||
|
"duration_ms",
|
||||||
|
"kind",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"fps",
|
||||||
|
"codec",
|
||||||
|
"client",
|
||||||
|
"sample_count"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"client": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Short label / fingerprint prefix, or `\"\"` if unknown."
|
||||||
|
},
|
||||||
|
"codec": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`\"h264\" | \"hevc\" | \"av1\"`."
|
||||||
|
},
|
||||||
|
"duration_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"fps": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem."
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`\"native\" | \"gamestream\"`."
|
||||||
|
},
|
||||||
|
"sample_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"started_unix_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"width": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"CustomEntry": {
|
"CustomEntry": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.",
|
"description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.",
|
||||||
@@ -1219,6 +1857,75 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"GpuState": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now.",
|
||||||
|
"required": [
|
||||||
|
"gpus",
|
||||||
|
"mode",
|
||||||
|
"preferred_available"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"active": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ApiActiveGpu",
|
||||||
|
"description": "The GPU live sessions use right now (absent while nothing is streaming)."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"env_override": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "`PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is\n`auto`; a manual preference overrides it."
|
||||||
|
},
|
||||||
|
"gpus": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ApiGpu"
|
||||||
|
},
|
||||||
|
"description": "The host's hardware GPUs."
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`auto` or `manual`."
|
||||||
|
},
|
||||||
|
"preferred_available": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the preferred GPU is currently present."
|
||||||
|
},
|
||||||
|
"preferred_id": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "The manually preferred GPU's stable id, when one is stored (kept while `mode` is `auto` so\na console can offer returning to it). May reference a GPU that is currently absent."
|
||||||
|
},
|
||||||
|
"preferred_name": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "The stored name of the preferred GPU (a usable label even when it is absent)."
|
||||||
|
},
|
||||||
|
"selected": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ApiSelectedGpu",
|
||||||
|
"description": "The GPU the next session will use."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Health": {
|
"Health": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Liveness + version probe.",
|
"description": "Liveness + version probe.",
|
||||||
@@ -1595,6 +2302,166 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SetGpuPreference": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Request body for `setGpuPreference`.",
|
||||||
|
"required": [
|
||||||
|
"mode"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"gpu_id": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Required when `mode` is `manual`: the stable `id` of a currently listed GPU\n(see `listGpus`).",
|
||||||
|
"example": "10de-2c05-0"
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`auto` (env pin, else max dedicated VRAM — the default) or `manual`.",
|
||||||
|
"example": "manual"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"StageTiming": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One pipeline stage's latency in an aggregation window (microseconds).",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"p50_us",
|
||||||
|
"p99_us"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`\"capture\" | \"submit\" | \"encode\" | \"packetize\" | \"send\"` (path-dependent)."
|
||||||
|
},
|
||||||
|
"p50_us": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "float"
|
||||||
|
},
|
||||||
|
"p99_us": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "float"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"StatsSample": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One aggregated sample (~ every 2 s native, ~ every 1 s GameStream).",
|
||||||
|
"required": [
|
||||||
|
"t_ms",
|
||||||
|
"session_id",
|
||||||
|
"stages",
|
||||||
|
"fps",
|
||||||
|
"repeat_fps",
|
||||||
|
"mbps",
|
||||||
|
"bitrate_kbps",
|
||||||
|
"frames_dropped",
|
||||||
|
"packets_dropped",
|
||||||
|
"send_dropped",
|
||||||
|
"fec_recovered"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"bitrate_kbps": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Configured target bitrate.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"fec_recovered": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "FEC shards recovered this window (delta).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"fps": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "float",
|
||||||
|
"description": "Genuine NEW frames/s from the source."
|
||||||
|
},
|
||||||
|
"frames_dropped": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Frames dropped this window (delta).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"mbps": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "float",
|
||||||
|
"description": "Transmit goodput (Mb/s)."
|
||||||
|
},
|
||||||
|
"packets_dropped": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Packets dropped this window (receiver-side / reassembler, where known).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"repeat_fps": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "float",
|
||||||
|
"description": "Re-encoded holds/s (source-starvation indicator)."
|
||||||
|
},
|
||||||
|
"send_dropped": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Host send-buffer overflow / EAGAIN this window (delta).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Disambiguates concurrent sessions (usually constant).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"stages": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/StageTiming"
|
||||||
|
},
|
||||||
|
"description": "Ordered pipeline stages for this path."
|
||||||
|
},
|
||||||
|
"t_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]).",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"StatsStatus": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Snapshot of the in-progress capture for the management API.",
|
||||||
|
"required": [
|
||||||
|
"armed",
|
||||||
|
"sample_count",
|
||||||
|
"started_unix_ms",
|
||||||
|
"kind"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"armed": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Capture currently running."
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path of the in-progress capture (`\"\"` if idle)."
|
||||||
|
},
|
||||||
|
"sample_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Samples in the in-progress capture.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"started_unix_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Unix start time of the in-progress capture (`0` if idle).",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"StreamInfo": {
|
"StreamInfo": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "RTSP-negotiated stream parameters.",
|
"description": "RTSP-negotiated stream parameters.",
|
||||||
@@ -1677,6 +2544,10 @@
|
|||||||
"name": "host",
|
"name": "host",
|
||||||
"description": "Host identity, capabilities, and liveness"
|
"description": "Host identity, capabilities, and liveness"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "gpu",
|
||||||
|
"description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "clients",
|
"name": "clients",
|
||||||
"description": "Paired Moonlight client management"
|
"description": "Paired Moonlight client management"
|
||||||
@@ -1696,6 +2567,10 @@
|
|||||||
{
|
{
|
||||||
"name": "library",
|
"name": "library",
|
||||||
"description": "Game library: installed-store titles (Steam) plus user-curated custom entries"
|
"description": "Game library: installed-store titles (Steam) plus user-curated custom entries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stats",
|
||||||
|
"description": "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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 |
@@ -16,8 +16,9 @@ RUN dnf -y install \
|
|||||||
"https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" \
|
"https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" \
|
||||||
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
|
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
|
||||||
&& dnf -y install \
|
&& dnf -y install \
|
||||||
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache)
|
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache) only
|
||||||
# AND the punktfunk-web .output at runtime; unzip is for the bun installer below.
|
# — the punktfunk-web console builds AND runs on bun (installed below); unzip is for the bun
|
||||||
|
# installer.
|
||||||
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
|
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
|
||||||
# build toolchain + bindgen
|
# build toolchain + bindgen
|
||||||
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
|
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
|
||||||
@@ -28,9 +29,10 @@ RUN dnf -y install \
|
|||||||
gtk4-devel libadwaita-devel SDL3-devel \
|
gtk4-devel libadwaita-devel SDL3-devel \
|
||||||
&& dnf clean all
|
&& dnf clean all
|
||||||
|
|
||||||
# bun — the build tool for the punktfunk-web console (`bun run build` -> the node-server .output
|
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
|
||||||
# the punktfunk-web RPM ships and runs with plain node). Not in Fedora repos; install the official
|
# Nitro `bun`-preset .output, served by `Bun.serve` with TLS — HTTP/1.1 over TLS). The
|
||||||
# standalone binary to a system PATH dir so the rpmbuild `%build` (run as any uid) finds it.
|
# RPM vendors THIS bun binary. Not in Fedora repos; install the official standalone binary to a
|
||||||
|
# system PATH dir so the rpmbuild `%build`/`%install` (run as any uid) find it.
|
||||||
RUN curl -fsSL https://bun.sh/install | bash \
|
RUN curl -fsSL https://bun.sh/install | bash \
|
||||||
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
|
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
|
||||||
&& bun --version
|
&& bun --version
|
||||||
|
|||||||
+52
-55
@@ -1,82 +1,79 @@
|
|||||||
# punktfunk Android client
|
# punktfunk — Android client (phone & TV)
|
||||||
|
|
||||||
Native Android client for **punktfunk/1**, targeting **phone + TV** (Compose, D-pad + touch).
|
The native **Android** app for streaming a punktfunk host to your phone, tablet, or Android TV. A
|
||||||
|
Compose app that finds hosts on your network, pairs with a PIN, and streams at the display's own
|
||||||
|
resolution — with hardware HEVC decode, HDR10, and controller support, built for both touch and the
|
||||||
|
couch (D-pad / gamepad focus navigation).
|
||||||
|
|
||||||
## Architecture — Rust-heavy (like the Linux client, not thin-native like Apple)
|
## Features
|
||||||
|
|
||||||
Kotlin cannot `import` the cbindgen C header the way Swift can, so a native bridge is unavoidable.
|
- **Hardware decode** — NDK `AMediaCodec` HEVC → `SurfaceView`, including **HDR10** (Main10 /
|
||||||
We write it in **Rust** and link `punktfunk-core` directly — so the Android client reuses the Linux
|
BT.2020 PQ), with low-latency tuning and a live stats HUD.
|
||||||
|
- **Audio both ways** — Opus + AAudio playback with a jitter ring, plus mic uplink to the host.
|
||||||
|
- **Controller support** — buttons + axes with rumble and HID feedback (lightbar / adaptive
|
||||||
|
triggers); D-pad / gamepad focus navigation for TV and phone.
|
||||||
|
- **Find hosts automatically** — native mDNS discovery; first connect does a one-time **SPAKE2 PIN
|
||||||
|
pairing** (or TOFU on trusted LANs), then reconnects on a Keystore-wrapped, pinned identity.
|
||||||
|
- **Compose UI** — Connect / Settings / Stream screens with Material You theming.
|
||||||
|
|
||||||
|
Built for `arm64-v8a` + `x86_64`.
|
||||||
|
|
||||||
|
## Get it
|
||||||
|
|
||||||
|
Published to **Google Play (Internal Testing)** — join the beta via the
|
||||||
|
[Discord](https://discord.gg/kaPNvzMuGU). Per-device setup and pairing:
|
||||||
|
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
|
||||||
|
|
||||||
|
## How it's built — Rust-heavy
|
||||||
|
|
||||||
|
Kotlin can't `import` the cbindgen C header the way Swift can, so a native bridge is unavoidable. We
|
||||||
|
write it in **Rust** and link `punktfunk-core` directly — so the Android client reuses the Linux
|
||||||
client's orchestration (audio jitter ring, VK keymap inverse, latency/skew math, capture state
|
client's orchestration (audio jitter ring, VK keymap inverse, latency/skew math, capture state
|
||||||
machine, trust logic) instead of re-porting it into Kotlin.
|
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** (`native/` → `libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB → `AMediaCodec` decode (incl. HDR10), Opus + AAudio audio + mic, controller feedback, latency math, trust/pairing, `mdns-sd` discovery |
|
||||||
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, `NsdManager` discovery, Keystore identity, permissions |
|
| **Kotlin** (`app/`, `kit/`) | Compose UI, `SurfaceView` lifecycle, input capture, the Wi-Fi `MulticastLock` + permission UX, Keystore identity |
|
||||||
|
|
||||||
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_*`.
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
```
|
||||||
clients/android/native/ Rust cdylib (workspace member) — links punktfunk-core directly
|
native/ Rust cdylib (workspace member) — links punktfunk-core directly
|
||||||
src/lib.rs JNI seam (connect/pair, input, plane getters, abi/core version)
|
src/lib.rs crate doc · JNI_OnLoad · version probes
|
||||||
src/session.rs session lifecycle + plane pumps
|
src/session/ session lifecycle: connect/pair + trust, plane start/stop, input shims
|
||||||
src/decode.rs AnnexB → AMediaCodec HEVC hardware decode → SurfaceView (incl. HDR10)
|
src/decode.rs AnnexB → AMediaCodec HEVC hardware decode → SurfaceView (incl. HDR10)
|
||||||
src/audio.rs · src/mic.rs Opus + Oboe playback / mic uplink (jitter ring)
|
src/audio.rs · src/mic.rs Opus + AAudio playback / mic uplink
|
||||||
src/feedback.rs rumble + HID output (lightbar / adaptive triggers)
|
src/feedback.rs · src/stats.rs rumble + HID feedback; live video stats
|
||||||
src/stats.rs live video stats
|
src/discovery.rs native mdns-sd browse of the host's _punktfunk._udp advert
|
||||||
|
app/ :app — Compose UI: Connect / Settings / Stream (phone + TV)
|
||||||
clients/android/ Gradle project (this dir)
|
kit/ :kit — NativeBridge · native mDNS discovery · Gamepad · Keymap · Keystore identity
|
||||||
settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew
|
|
||||||
app/ :app — Compose UI: Connect / Settings / Stream screens (phone + TV)
|
|
||||||
kit/ :kit — NativeBridge · discovery (NsdManager) · Gamepad · Keymap ·
|
|
||||||
security (Keystore identity + known-host store) · cargo-ndk build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Android SDK + **NDK r30** (`30.0.14904198`), `platforms;android-37.0`, `build-tools;37.0.0`,
|
|
||||||
**`cmake;3.22.1`** (`sdkmanager "cmake;3.22.1"` — the `cmake` crate builds libopus with it)
|
|
||||||
- **JDK 21** for Gradle/AGP (AGP 9.2 runs on JDK 17–21, *not* a newer default JDK like 25)
|
|
||||||
- Rust + `rustup target add aarch64-linux-android x86_64-linux-android` + `cargo install cargo-ndk`
|
|
||||||
|
|
||||||
Toolchain pinned: AGP 9.2.0 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM 2026.05.01 ·
|
|
||||||
compileSdk 37 · targetSdk 36 · minSdk 31 · ABIs arm64-v8a + x86_64.
|
|
||||||
|
|
||||||
## Build & run
|
## Build & run
|
||||||
|
|
||||||
**Android Studio:** open `clients/android` — it uses its bundled JBR 21 automatically. The
|
**Prerequisites:** Android SDK + **NDK r30** (`30.0.14904198`), `platforms;android-37.0`,
|
||||||
`cargoNdk*` task builds the `.so` as part of the normal build.
|
`build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 17–21, not
|
||||||
|
a newer default); Rust with `rustup target add aarch64-linux-android x86_64-linux-android` and
|
||||||
|
`cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM
|
||||||
|
2026.05.01 · compileSdk 37 · minSdk 31).
|
||||||
|
|
||||||
**CLI** (point Gradle at a JDK 21 if your machine default is newer, e.g. JDK 25):
|
**Android Studio:** open `clients/android` — it uses its bundled JBR 21, and the `cargoNdk*` task
|
||||||
|
builds the `.so` as part of the normal build.
|
||||||
|
|
||||||
|
**CLI** (point Gradle at JDK 21 if your machine default is newer):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Adoptium/Temurin 21 (installed by the Android Studio setup, or `brew install temurin@21`):
|
export JAVA_HOME="$(/usr/libexec/java_home -v 21)" # or your Temurin 21 path
|
||||||
export JAVA_HOME="$(/usr/libexec/java_home -v 21)"
|
|
||||||
cd clients/android
|
cd clients/android
|
||||||
./gradlew :app:assembleDebug # cargo-ndk cross-compiles libpunktfunk_android.so first
|
./gradlew :app:assembleDebug # cargo-ndk cross-compiles libpunktfunk_android.so first
|
||||||
./gradlew :app:installDebug # onto a running emulator/device
|
./gradlew :app:installDebug # onto a running emulator/device
|
||||||
|
# emulators from env setup: emulator -avd pf_phone | emulator -avd pf_tv
|
||||||
# Emulators (created during env setup): emulator -avd pf_phone | emulator -avd pf_tv
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The debug APK lands in `app/build/outputs/apk/debug/`. Launch it, pick a host from the list, pair,
|
The debug APK lands in `app/build/outputs/apk/debug/`. Launch it, pick a host, pair, and stream.
|
||||||
and stream.
|
|
||||||
|
|
||||||
## Status
|
## Related
|
||||||
|
|
||||||
A working native client (phone + Android TV), at parity with the Linux and Apple apps for the core
|
- **[Documentation](https://docs.punktfunk.unom.io)** — quick start, pairing, troubleshooting
|
||||||
streaming experience:
|
- **[Project README](../../README.md)** — the host, the other clients, and how it all fits together
|
||||||
|
|
||||||
- **Video** — `AMediaCodec` hardware HEVC decode → `SurfaceView`, including **HDR10** (Main10 /
|
|
||||||
BT.2020 PQ), with low-latency decode tuning and a live stats HUD.
|
|
||||||
- **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 /
|
|
||||||
game-controller focus navigation for the couch (TV + phone).
|
|
||||||
- **Discovery & trust** — `NsdManager` mDNS host list, SPAKE2 PIN pairing and TOFU, with a
|
|
||||||
Keystore-wrapped client identity and a known-host store.
|
|
||||||
- **UI** — Compose host list / settings / stream screens, Material You theming.
|
|
||||||
- **Shipping** — built for `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing).
|
|
||||||
|
|
||||||
`crates/punktfunk-core` uses the `ring` `rcgen` backend so the client `.so` is aws-lc-free.
|
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,355 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
|
import io.unom.punktfunk.models.PendingTrust
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Add host" bottom sheet: optional name + address + port, then connect at [modeLabel]. Field
|
||||||
|
* state stays hoisted in ConnectScreen so a dismissed sheet keeps its half-typed values.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun AddHostSheet(
|
||||||
|
hostName: String,
|
||||||
|
onHostNameChange: (String) -> Unit,
|
||||||
|
host: String,
|
||||||
|
onHostChange: (String) -> Unit,
|
||||||
|
port: String,
|
||||||
|
onPortChange: (String) -> Unit,
|
||||||
|
connecting: Boolean,
|
||||||
|
modeLabel: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConnect: (host: String, port: Int, name: String) -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
) {
|
||||||
|
Text("Add a host", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
"Enter its address. You'll pair with the host's PIN on first connect.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = hostName,
|
||||||
|
onValueChange = onHostNameChange,
|
||||||
|
label = { Text("Name (optional)") },
|
||||||
|
placeholder = { Text("e.g. Living Room") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = host,
|
||||||
|
onValueChange = onHostChange,
|
||||||
|
label = { Text("Host") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = port,
|
||||||
|
onValueChange = { v -> onPortChange(v.filter { it.isDigit() }.take(5)) },
|
||||||
|
label = { Text("Port") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Button(
|
||||||
|
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
val h = host.trim()
|
||||||
|
val p = port.toIntOrNull() ?: 9777
|
||||||
|
val n = hostName
|
||||||
|
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
||||||
|
onDismiss()
|
||||||
|
onConnect(h, p, n)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Connect ($modeLabel)") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
|
||||||
|
@Composable
|
||||||
|
internal fun TrustNewHostDialog(
|
||||||
|
pt: PendingTrust,
|
||||||
|
onTrust: () -> Unit,
|
||||||
|
onPairInstead: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Trust this host?") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("First connection to ${pt.host}:${pt.port}.")
|
||||||
|
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}…") }
|
||||||
|
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(onClick = onTrust) { Text("Trust (TOFU)") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = onPairInstead) { Text("Pair with PIN…") }
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
|
||||||
|
@Composable
|
||||||
|
internal fun FingerprintChangedDialog(
|
||||||
|
pt: PendingTrust,
|
||||||
|
onRepair: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Host identity changed") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"The pinned fingerprint for ${pt.host} no longer matches what it now " +
|
||||||
|
"advertises. This can mean a host reinstall — or an impostor. Re-pair " +
|
||||||
|
"with the host's PIN to continue.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onRepair) { Text("Re-pair") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { 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.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun RequestAccessDialog(
|
||||||
|
pt: PendingTrust,
|
||||||
|
onRequestAccess: () -> Unit,
|
||||||
|
onUsePin: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
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(onClick = onRequestAccess) { Text("Request access") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = onUsePin) { Text("Use a PIN…") }
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SPAKE2 PIN ceremony dialog. Runs [NativeBridge.nativePair] off the UI thread itself (the
|
||||||
|
* pin/name/error state is dialog-local); on success hands the host's verified fingerprint to
|
||||||
|
* [onPaired], which saves + connects. Dismissal is blocked while a pair attempt is in flight.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun PairPinDialog(
|
||||||
|
pt: PendingTrust,
|
||||||
|
identity: ClientIdentity?,
|
||||||
|
onPaired: (fpHex: String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var pin by remember(pt) { mutableStateOf("") }
|
||||||
|
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
||||||
|
var pairing by remember(pt) { mutableStateOf(false) }
|
||||||
|
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { if (!pairing) onDismiss() },
|
||||||
|
title = { Text("Pair with PIN") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("Enter the 4-digit PIN shown on the host.")
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pin,
|
||||||
|
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
|
||||||
|
label = { Text("PIN") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("This device") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
err?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !pairing && pin.length == 4 && identity != null,
|
||||||
|
onClick = {
|
||||||
|
val id = identity
|
||||||
|
if (id != null) {
|
||||||
|
pairing = true
|
||||||
|
err = null
|
||||||
|
scope.launch {
|
||||||
|
val fp = withContext(Dispatchers.IO) {
|
||||||
|
NativeBridge.nativePair(
|
||||||
|
pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pairing = false
|
||||||
|
if (fp.isNotEmpty()) {
|
||||||
|
onPaired(fp) // verified host fp — caller saves + connects
|
||||||
|
} else {
|
||||||
|
err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text(if (pairing) "Pairing…" else "Pair") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(enabled = !pairing, onClick = onDismiss) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
|
||||||
|
* this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late
|
||||||
|
* approval is torn down silently (see ConnectScreen.requestAccess) and resumes discovery.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
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 $hostLabel.")
|
||||||
|
}
|
||||||
|
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 = onCancel) { 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.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun RenameHostDialog(
|
||||||
|
target: KnownHost,
|
||||||
|
onRename: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var newName by remember(target) { mutableStateOf(target.name) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Rename host") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newName,
|
||||||
|
onValueChange = { newName = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
placeholder = { Text(target.address) },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = newName.isNotBlank(),
|
||||||
|
onClick = { onRename(newName.trim()) },
|
||||||
|
) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,11 +6,6 @@ import android.content.pm.PackageManager
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
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.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -27,24 +22,14 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
|||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -56,13 +41,13 @@ 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.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
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 +58,63 @@ 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
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
/** 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)
|
||||||
|
}
|
||||||
|
|
||||||
@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 +132,38 @@ 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) } }
|
||||||
|
|
||||||
|
// The one place the full nativeConnect is issued (shared by the normal connect and the
|
||||||
|
// request-access path), including the HDR/gamepad derivation both need.
|
||||||
|
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long {
|
||||||
|
// 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)
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
NativeBridge.nativeConnect(
|
||||||
|
targetHost, targetPort, w, h, hz,
|
||||||
|
id.certPem, id.privateKeyPem, pinHex,
|
||||||
|
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||||
|
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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,17 +178,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
status = "Connecting to $targetHost:$targetPort…"
|
status = "Connecting to $targetHost:$targetPort…"
|
||||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Advertise HDR only when this device's display can present it (else the host sends a
|
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||||
// proper SDR stream rather than PQ the panel would mis-tone-map).
|
|
||||||
val hdrEnabled = displaySupportsHdr(context)
|
|
||||||
val handle = withContext(Dispatchers.IO) {
|
|
||||||
NativeBridge.nativeConnect(
|
|
||||||
targetHost, targetPort, w, h, hz,
|
|
||||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
|
||||||
settings.bitrateKbps, settings.compositor, settings.gamepad,
|
|
||||||
hdrEnabled,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
connecting = false
|
connecting = false
|
||||||
if (handle != 0L) {
|
if (handle != 0L) {
|
||||||
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
||||||
@@ -167,14 +195,68 @@ 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 {
|
||||||
|
// 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 = connectNative(id, target.host, target.port, pinHex, 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) ->
|
||||||
@@ -186,13 +268,13 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val sheetState = rememberModalBottomSheetState()
|
|
||||||
var showManualSheet by remember { mutableStateOf(false) }
|
var showManualSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
@@ -255,7 +337,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()
|
||||||
}
|
}
|
||||||
@@ -276,16 +358,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}",
|
||||||
@@ -297,9 +380,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),
|
||||||
@@ -322,190 +406,108 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = true, // Static for now, could be based on scroll if needed
|
|
||||||
enter = scaleIn() + fadeIn(),
|
|
||||||
exit = scaleOut() + fadeOut(),
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(20.dp)
|
|
||||||
) {
|
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = { showManualSheet = true },
|
onClick = { showManualSheet = true },
|
||||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||||
text = { Text("Add host") },
|
text = { Text("Add host") },
|
||||||
expanded = !connecting,
|
expanded = !connecting,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (showManualSheet) {
|
if (showManualSheet) {
|
||||||
ModalBottomSheet(
|
AddHostSheet(
|
||||||
onDismissRequest = { showManualSheet = false },
|
hostName = hostName,
|
||||||
sheetState = sheetState,
|
onHostNameChange = { hostName = it },
|
||||||
) {
|
host = host,
|
||||||
Column(
|
onHostChange = { host = it },
|
||||||
modifier = Modifier
|
port = port,
|
||||||
.fillMaxWidth()
|
onPortChange = { port = it },
|
||||||
.padding(horizontal = 24.dp)
|
connecting = connecting,
|
||||||
.padding(bottom = 32.dp),
|
modeLabel = "$w×$h@$hz",
|
||||||
) {
|
onDismiss = { showManualSheet = false },
|
||||||
Text("Add a host", style = MaterialTheme.typography.titleLarge)
|
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
"Enter its address. You'll pair with the host's PIN on first connect.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = host,
|
|
||||||
onValueChange = { host = it },
|
|
||||||
label = { Text("Host") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = port,
|
|
||||||
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
|
||||||
label = { Text("Port") },
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
Button(
|
|
||||||
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
|
|
||||||
onClick = {
|
|
||||||
val h = host.trim()
|
|
||||||
val p = port.toIntOrNull() ?: 9777
|
|
||||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
|
||||||
showManualSheet = false
|
|
||||||
connect(h, p)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { Text("Connect ($w×$h@$hz)") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingTrust?.let { pt ->
|
pendingTrust?.let { pt ->
|
||||||
when (pt.kind) {
|
when (pt.kind) {
|
||||||
PendingTrust.Kind.TRUST_NEW -> AlertDialog(
|
PendingTrust.Kind.TRUST_NEW -> TrustNewHostDialog(
|
||||||
onDismissRequest = { pendingTrust = null },
|
pt = pt,
|
||||||
title = { Text("Trust this host?") },
|
onTrust = { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) },
|
||||||
text = {
|
onPairInstead = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
||||||
Column {
|
onDismiss = { pendingTrust = null },
|
||||||
Text("First connection to ${pt.host}:${pt.port}.")
|
|
||||||
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}…") }
|
|
||||||
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.",
|
|
||||||
)
|
)
|
||||||
}
|
PendingTrust.Kind.FP_CHANGED -> FingerprintChangedDialog(
|
||||||
},
|
pt = pt,
|
||||||
confirmButton = {
|
onRepair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
||||||
TextButton({ pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }) {
|
onDismiss = { pendingTrust = null },
|
||||||
Text("Trust (TOFU)")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
Row {
|
|
||||||
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) {
|
|
||||||
Text("Pair with PIN…")
|
|
||||||
}
|
|
||||||
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
PendingTrust.Kind.FP_CHANGED -> AlertDialog(
|
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessDialog(
|
||||||
onDismissRequest = { pendingTrust = null },
|
pt = pt,
|
||||||
title = { Text("Host identity changed") },
|
onRequestAccess = { pendingTrust = null; requestAccess(pt) },
|
||||||
text = {
|
onUsePin = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
||||||
Text(
|
onDismiss = { pendingTrust = null },
|
||||||
"The pinned fingerprint for ${pt.host} no longer matches what it now " +
|
|
||||||
"advertises. This can mean a host reinstall — or an impostor. Re-pair " +
|
|
||||||
"with the host's PIN to continue.",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) { Text("Re-pair") }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
PendingTrust.Kind.PAIR -> {
|
|
||||||
var pin by remember(pt) { mutableStateOf("") }
|
|
||||||
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
|
||||||
var pairing by remember(pt) { mutableStateOf(false) }
|
|
||||||
var err by remember(pt) { mutableStateOf<String?>(null) }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { if (!pairing) pendingTrust = null },
|
|
||||||
title = { Text("Pair with PIN") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text("Enter the 4-digit PIN shown on the host.")
|
|
||||||
OutlinedTextField(
|
|
||||||
value = pin,
|
|
||||||
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
|
|
||||||
label = { Text("PIN") },
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
label = { Text("This device") },
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
err?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
enabled = !pairing && pin.length == 4 && identity != null,
|
|
||||||
onClick = {
|
|
||||||
val id = identity
|
|
||||||
if (id != null) {
|
|
||||||
pairing = true
|
|
||||||
err = null
|
|
||||||
scope.launch {
|
|
||||||
val fp = withContext(Dispatchers.IO) {
|
|
||||||
NativeBridge.nativePair(
|
|
||||||
pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
pairing = false
|
|
||||||
if (fp.isNotEmpty()) {
|
|
||||||
// Verified host fp — save as a paired known host.
|
|
||||||
knownHostStore.save(
|
|
||||||
KnownHost(pt.host, pt.port, pt.name, fp, paired = true),
|
|
||||||
)
|
)
|
||||||
|
PendingTrust.Kind.PAIR -> PairPinDialog(
|
||||||
|
pt = pt,
|
||||||
|
identity = identity,
|
||||||
|
onPaired = { fp ->
|
||||||
|
// Verified host fp — save as a paired known host, then connect pinned.
|
||||||
|
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
|
||||||
savedHosts = knownHostStore.all()
|
savedHosts = knownHostStore.all()
|
||||||
pendingTrust = null
|
pendingTrust = null
|
||||||
doConnect(pt.host, pt.port, pt.name, fp)
|
doConnect(pt.host, pt.port, pt.name, fp)
|
||||||
} else {
|
|
||||||
err = "Pairing failed — wrong PIN, or the host isn't armed."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { Text(if (pairing) "Pairing…" else "Pair") }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(enabled = !pairing, onClick = { pendingTrust = null }) { Text("Cancel") }
|
|
||||||
},
|
},
|
||||||
|
onDismiss = { pendingTrust = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
awaiting?.let { req ->
|
||||||
|
AwaitingApprovalDialog(
|
||||||
|
hostLabel = req.target.name,
|
||||||
|
onCancel = {
|
||||||
|
req.cancelled.set(true)
|
||||||
|
awaiting = null
|
||||||
|
connecting = false
|
||||||
|
discovery.start() // the request may still be pending on the host; keep scanning
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
renameTarget?.let { kh ->
|
||||||
|
RenameHostDialog(
|
||||||
|
target = kh,
|
||||||
|
onRename = { newName ->
|
||||||
|
knownHostStore.rename(kh.address, kh.port, newName)
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
renameTarget = null
|
||||||
|
},
|
||||||
|
onDismiss = { renameTarget = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,7 +72,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
KeyEvent.ACTION_UP -> false
|
KeyEvent.ACTION_UP -> false
|
||||||
else -> return super.dispatchKeyEvent(event)
|
else -> return super.dispatchKeyEvent(event)
|
||||||
}
|
}
|
||||||
val vk = Keymap.toVk(event.keyCode)
|
// Full-event overload: evdev scancode first (positional under ANY selected
|
||||||
|
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
||||||
|
val vk = Keymap.toVk(event)
|
||||||
if (vk != 0) {
|
if (vk != 0) {
|
||||||
NativeBridge.nativeSendKey(handle, vk, down, 0)
|
NativeBridge.nativeSendKey(handle, vk, down, 0)
|
||||||
return true // consumed — don't let the system also act on it
|
return true // consumed — don't let the system also act on it
|
||||||
|
|||||||
@@ -14,11 +14,30 @@ 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,
|
||||||
|
/** Preferred video codec: `"auto"` (host decides), `"hevc"`, or `"h264"`. A soft preference — the
|
||||||
|
* host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */
|
||||||
|
val codec: String = "auto",
|
||||||
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. */
|
||||||
@@ -31,10 +50,14 @@ 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),
|
||||||
|
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||||
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) {
|
||||||
@@ -43,10 +66,14 @@ 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)
|
||||||
|
.putString(K_CODEC, s.codec)
|
||||||
.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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +82,14 @@ 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_CODEC = "codec"
|
||||||
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +155,28 @@ 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",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** (stored value, label) for the preferred video codec. `"auto"` = host decides. */
|
||||||
|
val CODEC_OPTIONS = listOf(
|
||||||
|
"auto" to "Automatic",
|
||||||
|
"hevc" to "HEVC (H.265)",
|
||||||
|
"h264" to "H.264 (AVC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2. */
|
||||||
|
fun Settings.preferredCodec(): Int = when (codec) {
|
||||||
|
"h264" -> 1
|
||||||
|
"hevc" -> 2
|
||||||
|
"av1" -> 4
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
/** (kbps, label). `0` = host default. */
|
/** (kbps, label). `0` = host default. */
|
||||||
val BITRATE_OPTIONS = listOf(
|
val BITRATE_OPTIONS = listOf(
|
||||||
0 to "Automatic",
|
0 to "Automatic",
|
||||||
@@ -142,9 +195,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,28 @@ 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)) }
|
||||||
|
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Video codec",
|
||||||
|
options = CODEC_OPTIONS,
|
||||||
|
selected = s.codec,
|
||||||
|
) { c -> update(s.copy(codec = c)) }
|
||||||
|
|
||||||
|
// 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 +133,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 +154,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 +172,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 +203,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 +246,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 +259,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(
|
|
||||||
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)
|
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
}
|
|
||||||
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) },
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live stats overlay — mirrors the Apple client's HUD. Reads the 14-double layout from
|
||||||
|
* [NativeBridge.nativeVideoStats]:
|
||||||
|
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped, bitDepth, colorPrimaries,
|
||||||
|
* colorTransfer, chromaFormatIdc]`. The trailing four (present on a current native lib) describe the
|
||||||
|
* negotiated video feed and render as a codec/depth/colour/chroma line; older layouts just omit it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||||
|
if (s.size < 10) return
|
||||||
|
val w = s[6].toInt()
|
||||||
|
val h = s[7].toInt()
|
||||||
|
val hz = s[8].toInt()
|
||||||
|
val latValid = s[4] != 0.0
|
||||||
|
val skew = s[5] != 0.0
|
||||||
|
val dropped = s[9].toLong()
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
|
||||||
|
color = Color.White,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
videoFeedLine(s)?.let { feed ->
|
||||||
|
Text(
|
||||||
|
feed,
|
||||||
|
color = Color.White,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (latValid) {
|
||||||
|
val tag = if (skew) "" else " (same-host)"
|
||||||
|
Text(
|
||||||
|
"capture→client ${"%.1f".format(s[2])}/${"%.1f".format(s[3])} ms p50/p95$tag",
|
||||||
|
color = Color.White,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (dropped > 0) {
|
||||||
|
Text(
|
||||||
|
"dropped $dropped",
|
||||||
|
color = Color(0xFFFFB0B0),
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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"
|
||||||
|
}
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
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
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -24,13 +19,9 @@ 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.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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
@@ -41,8 +32,6 @@ import io.unom.punktfunk.kit.GamepadFeedback
|
|||||||
import io.unom.punktfunk.kit.NativeBridge
|
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.roundToInt
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||||
@@ -59,16 +48,26 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
Manifest.permission.RECORD_AUDIO,
|
Manifest.permission.RECORD_AUDIO,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
// Live decode stats for the HUD. Poll once a second for the whole stream (cheap, and each call
|
// Live decode stats for the HUD. `showStats` gates the whole pipeline: the native per-frame
|
||||||
// drains+resets the native window so it never grows unbounded even while the overlay is hidden);
|
// sampling (nativeSetVideoStatsEnabled — hidden HUD costs one atomic load per frame) AND the
|
||||||
// `showStats` only gates rendering. A 3-finger tap toggles it live; the default comes from Settings.
|
// 1 s poll loop, which only runs while the overlay is visible. Enabling resets the native
|
||||||
|
// window, so re-showing never renders stale data. 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) }
|
||||||
LaunchedEffect(handle) {
|
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||||
|
val trackpad = initialSettings.trackpadMode
|
||||||
|
LaunchedEffect(handle, showStats) {
|
||||||
|
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
|
||||||
|
if (showStats) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
stats = NativeBridge.nativeVideoStats(handle)
|
stats = NativeBridge.nativeVideoStats(handle)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
stats = null // drop the last snapshot so a re-show never flashes stale numbers
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-shot teardown guard. Both the SurfaceView callback and DisposableEffect tear down on the
|
// One-shot teardown guard. Both the SurfaceView callback and DisposableEffect tear down on the
|
||||||
@@ -83,6 +82,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 +101,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,89 +148,12 @@ 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 (trackpad vs. direct pointing + the shared gesture vocabulary — see
|
||||||
// 2-finger drag → scroll; 3-finger tap → toggle the stats HUD. (Physical-mouse pointer
|
// streamTouchInput in TouchInput.kt).
|
||||||
// capture comes in a later increment.)
|
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().pointerInput(handle) {
|
Modifier.fillMaxSize().pointerInput(handle, trackpad) {
|
||||||
awaitEachGesture {
|
streamTouchInput(handle, trackpad, onToggleStats = { showStats = !showStats })
|
||||||
val first = awaitFirstDown(requireUnconsumed = false)
|
|
||||||
var moved = false
|
|
||||||
var maxFingers = 1
|
|
||||||
while (true) {
|
|
||||||
val ev = awaitPointerEvent()
|
|
||||||
val fingers = ev.changes.count { it.pressed }
|
|
||||||
if (fingers == 0) break
|
|
||||||
if (fingers > maxFingers) maxFingers = fingers
|
|
||||||
val primary = ev.changes.firstOrNull { it.id == first.id } ?: ev.changes.first()
|
|
||||||
val d = primary.positionChange()
|
|
||||||
if (abs(d.x) > 0.5f || abs(d.y) > 0.5f) {
|
|
||||||
moved = true
|
|
||||||
if (fingers >= 2) {
|
|
||||||
// screen +y down → wire +up, so negate y. Coarse divisor; tune live.
|
|
||||||
val sy = (-d.y / 4f).toInt()
|
|
||||||
val sx = (d.x / 4f).toInt()
|
|
||||||
if (sy != 0) NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
|
||||||
if (sx != 0) NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
|
||||||
} else {
|
|
||||||
NativeBridge.nativeSendPointerMove(handle, d.x.toInt(), d.y.toInt())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ev.changes.forEach { it.consume() }
|
|
||||||
}
|
|
||||||
if (!moved && maxFingers == 1) {
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, 1, true)
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, 1, false)
|
|
||||||
} else if (!moved && maxFingers >= 3) {
|
|
||||||
showStats = !showStats // quick in-stream HUD toggle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The live stats overlay — mirrors the Apple client's HUD. Reads the 10-double layout from
|
|
||||||
* [NativeBridge.nativeVideoStats]:
|
|
||||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped]`.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
|
||||||
if (s.size < 10) return
|
|
||||||
val w = s[6].toInt()
|
|
||||||
val h = s[7].toInt()
|
|
||||||
val hz = s[8].toInt()
|
|
||||||
val latValid = s[4] != 0.0
|
|
||||||
val skew = s[5] != 0.0
|
|
||||||
val dropped = s[9].toLong()
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
|
|
||||||
color = Color.White,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
if (latValid) {
|
|
||||||
val tag = if (skew) "" else " (same-host)"
|
|
||||||
Text(
|
|
||||||
"capture→client ${"%.1f".format(s[2])}/${"%.1f".format(s[3])} ms p50/p95$tag",
|
|
||||||
color = Color.White,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (dropped > 0) {
|
|
||||||
Text(
|
|
||||||
"dropped $dropped",
|
|
||||||
color = Color(0xFFFFB0B0),
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.hypot
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Touch → mouse, run inside the stream overlay's `pointerInput`. Two models, chosen by the
|
||||||
|
* Trackpad-mode setting:
|
||||||
|
* * trackpad (default): the cursor STAYS where it is on touch-down and moves by the finger's
|
||||||
|
* 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 = [onToggleStats] (the stats HUD).
|
||||||
|
*/
|
||||||
|
internal suspend fun PointerInputScope.streamTouchInput(
|
||||||
|
handle: Long,
|
||||||
|
trackpad: Boolean,
|
||||||
|
onToggleStats: () -> Unit,
|
||||||
|
) {
|
||||||
|
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 {
|
||||||
|
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 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) {
|
||||||
|
val ev = awaitPointerEvent()
|
||||||
|
val pressed = ev.changes.filter { it.pressed }
|
||||||
|
if (pressed.isEmpty()) {
|
||||||
|
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (pressed.size > maxFingers) maxFingers = pressed.size
|
||||||
|
|
||||||
|
if (pressed.size >= 2) {
|
||||||
|
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||||
|
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||||
|
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||||
|
if (!scrolling) {
|
||||||
|
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 {
|
||||||
|
moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ev.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDrag) {
|
||||||
|
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
||||||
|
} else if (!moved) {
|
||||||
|
when {
|
||||||
|
maxFingers >= 3 -> onToggleStats() // 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +119,16 @@ fun HostCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
|
if (onRename != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Rename") },
|
||||||
|
onClick = {
|
||||||
|
menu = false
|
||||||
|
onRename()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (onForget != null) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Forget") },
|
text = { Text("Forget") },
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -130,6 +141,7 @@ fun HostCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A circular avatar with the host's first letter (Apple-contact style). */
|
/** A circular avatar with the host's first letter (Apple-contact style). */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// `-PskipRustBuild` skips the cargo-ndk native build — for JVM-only tasks (the Roborazzi
|
||||||
|
// 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("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
|
||||||
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
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 ----
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,79 @@ package io.unom.punktfunk.kit
|
|||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Android `KEYCODE_*` → Windows Virtual-Key code (the punktfunk wire contract; the host maps VK →
|
* Hardware key → Windows Virtual-Key code (the punktfunk wire contract: **US-positional** — we
|
||||||
* evdev via `inject::vk_to_evdev`). The Android analogue of the Linux client's evdev→VK table
|
* forward the physical key, not the typed character; the host maps VK → evdev via
|
||||||
* (`punktfunk-client-linux/src/keymap.rs`) and the Apple client's `hidToVK`. Positional/US-layout —
|
* `inject::vk_to_evdev`). The Android analogue of the Linux client's evdev→VK table
|
||||||
* we forward the physical key, not the typed character. Unmapped keys → 0 (the Rust side drops them).
|
* (`punktfunk-client-linux/src/keymap.rs`) and the Apple client's `hidToVK`.
|
||||||
* Extend this alongside `punktfunk-host/src/inject.rs::vk_to_evdev` (emit only VKs the host knows).
|
*
|
||||||
|
* Prefer [toVk] with the full [KeyEvent]: it reads the raw evdev scancode first, because
|
||||||
|
* `KeyEvent.keyCode` is only positional under the stock US key layout — a user-selected physical
|
||||||
|
* keyboard layout (Settings → Physical keyboard) remaps keycodes semantically (AOSP's German .kcm
|
||||||
|
* carries `map key 21 Z` / `map key 44 Y`), which would apply the layout twice: once here, once on
|
||||||
|
* the host (the y↔z / ü-on-ö scramble). Unmapped keys → 0 (the Rust side drops them). Extend this
|
||||||
|
* alongside `punktfunk-host/src/inject.rs::vk_to_evdev` (emit only VKs the host knows).
|
||||||
*/
|
*/
|
||||||
object Keymap {
|
object Keymap {
|
||||||
|
/**
|
||||||
|
* Positional wire VK for a hardware key event: the evdev scancode table first (immune to the
|
||||||
|
* selected physical-keyboard layout), falling back to the keycode table for events without a
|
||||||
|
* scancode (soft keyboards, synthetic events) and for everything outside the typing area
|
||||||
|
* (layout-invariant there, incl. gamepad buttons whose scancodes lie outside the table).
|
||||||
|
*/
|
||||||
|
fun toVk(event: KeyEvent): Int {
|
||||||
|
val positional = evdevToVk(event.scanCode)
|
||||||
|
return if (positional != 0) positional else toVk(event.keyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux evdev keycode (`KeyEvent.scanCode`) → US-positional VK for the layout-**variant**
|
||||||
|
* typing area — the same 48-key table as the Linux client's `evdev_to_vk` and the hosts'
|
||||||
|
* fixed tables. Everything else → 0 (the keycode path is already positional for those).
|
||||||
|
*/
|
||||||
|
fun evdevToVk(scan: Int): Int = when (scan) {
|
||||||
|
in 2..10 -> 0x31 + (scan - 2) // KEY_1..KEY_9
|
||||||
|
11 -> 0x30 // KEY_0
|
||||||
|
12 -> 0xBD // KEY_MINUS -_ VK_OEM_MINUS (DE: ß)
|
||||||
|
13 -> 0xBB // KEY_EQUAL =+ VK_OEM_PLUS
|
||||||
|
16 -> 0x51 // Q
|
||||||
|
17 -> 0x57 // W
|
||||||
|
18 -> 0x45 // E
|
||||||
|
19 -> 0x52 // R
|
||||||
|
20 -> 0x54 // T
|
||||||
|
21 -> 0x59 // KEY_Y — US-Y position (QWERTZ: the Z key)
|
||||||
|
22 -> 0x55 // U
|
||||||
|
23 -> 0x49 // I
|
||||||
|
24 -> 0x4F // O
|
||||||
|
25 -> 0x50 // P
|
||||||
|
26 -> 0xDB // KEY_LEFTBRACE [{ VK_OEM_4 (DE: ü)
|
||||||
|
27 -> 0xDD // KEY_RIGHTBRACE ]} VK_OEM_6
|
||||||
|
30 -> 0x41 // A
|
||||||
|
31 -> 0x53 // S
|
||||||
|
32 -> 0x44 // D
|
||||||
|
33 -> 0x46 // F
|
||||||
|
34 -> 0x47 // G
|
||||||
|
35 -> 0x48 // H
|
||||||
|
36 -> 0x4A // J
|
||||||
|
37 -> 0x4B // K
|
||||||
|
38 -> 0x4C // L
|
||||||
|
39 -> 0xBA // KEY_SEMICOLON ;: VK_OEM_1 (DE: ö)
|
||||||
|
40 -> 0xDE // KEY_APOSTROPHE '" VK_OEM_7 (DE: ä)
|
||||||
|
41 -> 0xC0 // KEY_GRAVE `~ VK_OEM_3 (DE: ^)
|
||||||
|
43 -> 0xDC // KEY_BACKSLASH \| VK_OEM_5
|
||||||
|
44 -> 0x5A // KEY_Z — US-Z position (QWERTZ: the Y key)
|
||||||
|
45 -> 0x58 // X
|
||||||
|
46 -> 0x43 // C
|
||||||
|
47 -> 0x56 // V
|
||||||
|
48 -> 0x42 // B
|
||||||
|
49 -> 0x4E // N
|
||||||
|
50 -> 0x4D // M
|
||||||
|
51 -> 0xBC // KEY_COMMA ,< VK_OEM_COMMA
|
||||||
|
52 -> 0xBE // KEY_DOT .> VK_OEM_PERIOD
|
||||||
|
53 -> 0xBF // KEY_SLASH /? VK_OEM_2
|
||||||
|
86 -> 0xE2 // KEY_102ND <>| VK_OEM_102 (ISO)
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
fun toVk(keyCode: Int): Int = when (keyCode) {
|
fun toVk(keyCode: Int): Int = when (keyCode) {
|
||||||
in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> 0x41 + (keyCode - KeyEvent.KEYCODE_A) // A–Z
|
in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> 0x41 + (keyCode - KeyEvent.KEYCODE_A) // A–Z
|
||||||
in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> 0x30 + (keyCode - KeyEvent.KEYCODE_0) // 0–9 row
|
in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> 0x30 + (keyCode - KeyEvent.KEYCODE_0) // 0–9 row
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ object NativeBridge {
|
|||||||
* trust-on-first-use — read [nativeHostFingerprint] after; else 64-hex host SHA-256, mismatch →
|
* trust-on-first-use — read [nativeHostFingerprint] after; else 64-hex host SHA-256, mismatch →
|
||||||
* `0`). [width]/[height]/[refreshHz] are the requested virtual-output mode (the host streams at
|
* `0`). [width]/[height]/[refreshHz] are the requested virtual-output mode (the host streams at
|
||||||
* exactly this); [bitrateKbps] 0 = host default; [compositorPref]/[gamepadPref] are the
|
* exactly this); [bitrateKbps] 0 = host default; [compositorPref]/[gamepadPref] are the
|
||||||
* `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). Returns an opaque session handle, or `0`
|
* `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). [timeoutMs] is the handshake budget — the
|
||||||
* on failure. Pair with exactly one [nativeClose].
|
* normal path passes a short value, the no-PIN "request access" path a long one (≥ the host's
|
||||||
|
* approval-park window) so a slow operator approval lands on this same parked connection. Returns
|
||||||
|
* an opaque session handle, or `0` on failure. Pair with exactly one [nativeClose].
|
||||||
*/
|
*/
|
||||||
external fun nativeConnect(
|
external fun nativeConnect(
|
||||||
host: String,
|
host: String,
|
||||||
@@ -45,6 +47,10 @@ object NativeBridge {
|
|||||||
compositorPref: Int,
|
compositorPref: Int,
|
||||||
gamepadPref: Int,
|
gamepadPref: Int,
|
||||||
hdrEnabled: Boolean,
|
hdrEnabled: Boolean,
|
||||||
|
audioChannels: Int,
|
||||||
|
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
|
||||||
|
preferredCodec: 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. */
|
||||||
@@ -67,6 +73,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.
|
||||||
@@ -78,12 +105,23 @@ 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?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate per-frame stats sampling on the HUD being visible: while disabled the decode thread
|
||||||
|
* skips the per-AU clock read + lock, so toggle this with the overlay (and only poll
|
||||||
|
* [nativeVideoStats] while it's on). Enabling resets the measurement window — no stale data.
|
||||||
|
* Sticky for the session (survives video stop/start). No-op on `0`.
|
||||||
|
*/
|
||||||
|
external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust. No-op
|
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust. No-op
|
||||||
* if already started. Best-effort — a failure leaves video streaming.
|
* if already started. Best-effort — a failure leaves video streaming.
|
||||||
@@ -108,6 +146,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)
|
||||||
|
|
||||||
|
|||||||
+84
-134
@@ -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()
|
||||||
|
|
||||||
|
private val poll = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
if (!running) return
|
||||||
|
val hosts = snapshot()
|
||||||
|
if (hosts != last) {
|
||||||
|
last = hosts
|
||||||
|
onChange?.invoke(hosts)
|
||||||
|
}
|
||||||
|
handler.postDelayed(this, POLL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun start() {
|
fun start() {
|
||||||
if (running) return
|
if (running) return
|
||||||
running = true
|
|
||||||
acquireMulticastLock()
|
acquireMulticastLock()
|
||||||
val listener = makeDiscoveryListener()
|
val h = runCatching { NativeBridge.nativeDiscoveryStart() }
|
||||||
discoveryListener = listener
|
.onFailure { Log.e(TAG, "nativeDiscoveryStart threw", it) }
|
||||||
runCatching {
|
.getOrDefault(0L)
|
||||||
nsd.discoverServices(PUNKTFUNK_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
|
if (h == 0L) {
|
||||||
}.onFailure {
|
Log.e(TAG, "native mDNS discovery failed to start")
|
||||||
Log.e(TAG, "discoverServices failed", it)
|
releaseMulticastLock()
|
||||||
stop()
|
return
|
||||||
}
|
}
|
||||||
|
nativeHandle = h
|
||||||
|
running = true
|
||||||
|
last = emptyList()
|
||||||
|
handler.post(poll)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
if (!running) return
|
if (!running && nativeHandle == 0L) return
|
||||||
running = false
|
running = false
|
||||||
discoveryListener?.let { runCatching { nsd.stopServiceDiscovery(it) } }
|
handler.removeCallbacks(poll)
|
||||||
discoveryListener = null
|
val h = nativeHandle
|
||||||
if (Build.VERSION.SDK_INT >= 34) {
|
nativeHandle = 0L
|
||||||
for (cb in infoCallbacks) runCatching { nsd.unregisterServiceInfoCallback(cb) }
|
if (h != 0L) runCatching { NativeBridge.nativeDiscoveryStop(h) }
|
||||||
}
|
.onFailure { Log.e(TAG, "nativeDiscoveryStop threw", it) }
|
||||||
infoCallbacks.clear()
|
|
||||||
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() }
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure JVM test of the positional scancode table (`Keymap.evdevToVk`) — no Android runtime types
|
||||||
|
* (the `KeyEvent` constants in the keycode table are compile-time-inlined ints). Run:
|
||||||
|
* `./gradlew :kit:testDebugUnitTest`.
|
||||||
|
*/
|
||||||
|
class KeymapTest {
|
||||||
|
/**
|
||||||
|
* The German-scramble regression pins: the physical keys a QWERTZ board labels Z/Y/ö/ü/ä/ß
|
||||||
|
* must leave this client as their US-position VKs, regardless of the user-selected physical
|
||||||
|
* keyboard layout (which remaps `keyCode`, not `scanCode`).
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun positionalPinsForTheQwertzScramble() {
|
||||||
|
assertEquals(0x59, Keymap.evdevToVk(21)) // KEY_Y (QWERTZ: Z key) → VK_Y
|
||||||
|
assertEquals(0x5A, Keymap.evdevToVk(44)) // KEY_Z (QWERTZ: Y key) → VK_Z
|
||||||
|
assertEquals(0xBA, Keymap.evdevToVk(39)) // KEY_SEMICOLON (QWERTZ: ö) → VK_OEM_1
|
||||||
|
assertEquals(0xDB, Keymap.evdevToVk(26)) // KEY_LEFTBRACE (QWERTZ: ü) → VK_OEM_4
|
||||||
|
assertEquals(0xDE, Keymap.evdevToVk(40)) // KEY_APOSTROPHE (QWERTZ: ä) → VK_OEM_7
|
||||||
|
assertEquals(0xBD, Keymap.evdevToVk(12)) // KEY_MINUS (QWERTZ: ß) → VK_OEM_MINUS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exactly the 48 typing-area keys are covered (10 digits + 26 letters + 12 OEM) with unique
|
||||||
|
* VKs; everything else (nav, F-row, modifiers, gamepad buttons at 0x100+) falls through to
|
||||||
|
* the keycode table.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun tableCoversTheTypingAreaBijectively() {
|
||||||
|
val mapped = (0..0x200).mapNotNull { sc ->
|
||||||
|
Keymap.evdevToVk(sc).takeIf { it != 0 }?.let { sc to it }
|
||||||
|
}
|
||||||
|
assertEquals(48, mapped.size)
|
||||||
|
assertEquals(48, mapped.map { it.second }.toSet().size)
|
||||||
|
assertEquals(0, Keymap.evdevToVk(1)) // KEY_ESC — layout-invariant, keycode path
|
||||||
|
assertEquals(0, Keymap.evdevToVk(59)) // KEY_F1
|
||||||
|
assertEquals(0, Keymap.evdevToVk(304)) // BTN_SOUTH — gamepad, never a typing key
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,10 +19,16 @@ 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, AMediaCodec + AAudio
|
||||||
# `ndk` and Oboe/Opus audio later) is only pulled in for the real `*-linux-android` targets.
|
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
android_logger = "0.14"
|
android_logger = "0.14"
|
||||||
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
|
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
|
||||||
|
|||||||
@@ -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,70 @@ 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);
|
|
||||||
|
|
||||||
// Realtime consumer state, owned by the callback (FnMut) — no lock: AAudio calls it from a
|
// One open attempt at a given sharing mode. Everything the realtime callback captures
|
||||||
// single high-priority thread, and the decode thread only touches `tx`.
|
// (channels, ring, prime state) is rebuilt per attempt — `open_stream` consumes the builder
|
||||||
|
// AND the callback, so nothing survives a failed try to reuse.
|
||||||
|
let try_open = |sharing: AudioSharingMode| -> ndk::audio::Result<(
|
||||||
|
AudioStream,
|
||||||
|
SyncSender<Vec<f32>>,
|
||||||
|
Receiver<Vec<f32>>,
|
||||||
|
)> {
|
||||||
|
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 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(..)`
|
||||||
ring.extend(chunk);
|
// empties 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
|
||||||
let target = (3 * want).clamp(720 * CHANNELS, 9600 * CHANNELS);
|
// drain; drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst
|
||||||
while ring.len() > target.max(want) + want {
|
// `want` (tiny on the LowLatency MMAP path) so the depth doesn't collapse to a single
|
||||||
|
// quantum.
|
||||||
|
let target = (3 * want).clamp(prime_floor, prime_ceil);
|
||||||
|
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
|
||||||
|
while ring.len() > hard_cap {
|
||||||
ring.pop_front();
|
ring.pop_front();
|
||||||
}
|
}
|
||||||
if !primed && ring.len() >= target {
|
if !primed && ring.len() >= target {
|
||||||
@@ -79,49 +194,105 @@ 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
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream = AudioStreamBuilder::new()
|
let stream = AudioStreamBuilder::new()?
|
||||||
.map_err(|e| log::error!("audio: AudioStreamBuilder::new: {e}"))
|
|
||||||
.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(sharing)
|
||||||
.data_callback(Box::new(callback))
|
.data_callback(Box::new(callback))
|
||||||
.error_callback(Box::new(|_s, e| {
|
.error_callback(Box::new(|_s, e| {
|
||||||
log::warn!("audio: AAudio error (device reroute/disconnect?): {e:?}");
|
log::warn!("audio: AAudio error (device reroute/disconnect?): {e:?}");
|
||||||
}))
|
}))
|
||||||
.open_stream()
|
.open_stream()?;
|
||||||
.map_err(|e| log::error!("audio: open_stream: {e}"))
|
Ok((stream, tx, free_rx))
|
||||||
.ok()?;
|
};
|
||||||
|
|
||||||
|
// Exclusive first — MMAP-exclusive is AAudio's lowest-latency path (once proven on-device it
|
||||||
|
// may also allow lowering the jitter-ring depths above; those stay put pending crackle
|
||||||
|
// testing) — and fall back to Shared when the device refuses (no MMAP, output claimed, …).
|
||||||
|
// The started-log below prints the mode the device actually GRANTED (`share=`): AAudio may
|
||||||
|
// still resolve an Exclusive request to Shared.
|
||||||
|
let (stream, tx, free_rx) = match try_open(AudioSharingMode::Exclusive) {
|
||||||
|
Ok(opened) => opened,
|
||||||
|
Err(e) => {
|
||||||
|
log::info!("audio: Exclusive open failed ({e}) — retrying Shared");
|
||||||
|
match try_open(AudioSharingMode::Shared) {
|
||||||
|
Ok(opened) => opened,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("audio: open_stream: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if let Err(e) = stream.request_start() {
|
if let Err(e) = stream.request_start() {
|
||||||
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 +314,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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use ndk::media::media_format::MediaFormat;
|
|||||||
use ndk::native_window::{FrameRateCompatibility, NativeWindow};
|
use ndk::native_window::{FrameRateCompatibility, NativeWindow};
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::error::PunktfunkError;
|
use punktfunk_core::error::PunktfunkError;
|
||||||
|
use punktfunk_core::session::Frame;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -27,16 +28,23 @@ pub fn run(
|
|||||||
) {
|
) {
|
||||||
boost_thread_priority();
|
boost_thread_priority();
|
||||||
let mode = client.mode();
|
let mode = client.mode();
|
||||||
let codec = match MediaCodec::from_decoder_type("video/hevc") {
|
// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`): HEVC or H.264. AMediaCodec
|
||||||
|
// needs no out-of-band extradata — the in-band VPS/SPS/PPS on every IDR configure it either way.
|
||||||
|
let mime = match client.codec {
|
||||||
|
punktfunk_core::quic::CODEC_H264 => "video/avc",
|
||||||
|
_ => "video/hevc",
|
||||||
|
};
|
||||||
|
let codec = match MediaCodec::from_decoder_type(mime) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => {
|
None => {
|
||||||
log::error!("decode: no HEVC decoder on this device");
|
log::error!("decode: no {mime} decoder on this device");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
log::info!("decode: codec mime = {mime}");
|
||||||
|
|
||||||
let mut format = MediaFormat::new();
|
let mut format = MediaFormat::new();
|
||||||
format.set_str("mime", "video/hevc");
|
format.set_str("mime", mime);
|
||||||
format.set_i32("width", mode.width as i32);
|
format.set_i32("width", mode.width as i32);
|
||||||
format.set_i32("height", mode.height as i32);
|
format.set_i32("height", mode.height as i32);
|
||||||
// Generous input buffer so a large keyframe AU is never truncated.
|
// Generous input buffer so a large keyframe AU is never truncated.
|
||||||
@@ -46,6 +54,9 @@ pub fn run(
|
|||||||
);
|
);
|
||||||
// Ask for the low-latency decode path where the decoder supports it (no reordering buffer).
|
// Ask for the low-latency decode path where the decoder supports it (no reordering buffer).
|
||||||
format.set_i32("low-latency", 1);
|
format.set_i32("low-latency", 1);
|
||||||
|
// Best-effort vendor twin of the standard key: older Qualcomm decoders only honor their own
|
||||||
|
// extension. Unknown keys are ignored by other vendors' codecs, so this is safe to set blind.
|
||||||
|
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
|
||||||
// Advisory low-latency hints (KEY_PRIORITY / KEY_OPERATING_RATE), ignored where unsupported:
|
// Advisory low-latency hints (KEY_PRIORITY / KEY_OPERATING_RATE), ignored where unsupported:
|
||||||
// realtime priority + the target frame rate, so vendor decoders (e.g. Qualcomm) run at full
|
// realtime priority + the target frame rate, so vendor decoders (e.g. Qualcomm) run at full
|
||||||
// clocks instead of a power-saving cadence that adds dequeue latency.
|
// clocks instead of a power-saving cadence that adds dequeue latency.
|
||||||
@@ -95,6 +106,11 @@ pub fn run(
|
|||||||
|
|
||||||
let mut fed: u64 = 0;
|
let mut fed: u64 = 0;
|
||||||
let mut rendered: u64 = 0;
|
let mut rendered: u64 = 0;
|
||||||
|
let mut discarded: u64 = 0;
|
||||||
|
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
|
||||||
|
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
|
||||||
|
// round-trip) and we only pop the next one once it's queued.
|
||||||
|
let mut pending: Option<Frame> = None;
|
||||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it
|
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it
|
||||||
// climbs.
|
// climbs.
|
||||||
let mut last_dropped = client.frames_dropped();
|
let mut last_dropped = client.frames_dropped();
|
||||||
@@ -105,7 +121,13 @@ pub fn run(
|
|||||||
// The dataspace we've signalled on the Surface so far (None = default/SDR). Set reactively once
|
// The dataspace we've signalled on the Surface so far (None = default/SDR). Set reactively once
|
||||||
// the decoder reports an HDR stream (see `drain`); avoids re-applying every format event.
|
// the decoder reports an HDR stream (see `drain`); avoids re-applying every format event.
|
||||||
let mut applied_ds: Option<DataSpace> = None;
|
let mut applied_ds: Option<DataSpace> = None;
|
||||||
|
// One thread feeds AND drains: the NDK AMediaCodec wrapper isn't documented thread-safe for
|
||||||
|
// cross-thread feed/drain, so instead of splitting threads the loop decouples the two — input
|
||||||
|
// dequeue is non-blocking (never stalls presentation of already-decoded frames) and the only
|
||||||
|
// blocking wait is a short output dequeue while input is backed up (decoder progress is exactly
|
||||||
|
// what frees the next input buffer).
|
||||||
while !shutdown.load(Ordering::Relaxed) {
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
|
if pending.is_none() {
|
||||||
match client.next_frame(Duration::from_millis(5)) {
|
match client.next_frame(Duration::from_millis(5)) {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
if fed == 0 {
|
if fed == 0 {
|
||||||
@@ -116,18 +138,44 @@ pub fn run(
|
|||||||
&p[..p.len().min(6)]
|
&p[..p.len().min(6)]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fed += 1;
|
// HUD stat: capture→client-receipt latency = client_now + (host−client) −
|
||||||
// HUD stat: capture→client-receipt latency = client_now + (host−client) − capture_pts.
|
// capture_pts. Gated on the HUD being visible — `enabled` first so the hidden
|
||||||
let lat_ns = now_realtime_ns() + clock_offset as i128 - frame.pts_ns as i128;
|
// steady state skips the wall-clock read and the lock entirely.
|
||||||
let lat_us =
|
if stats.enabled() {
|
||||||
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
|
let lat_ns =
|
||||||
|
now_realtime_ns() + clock_offset as i128 - frame.pts_ns as i128;
|
||||||
|
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||||
|
.then_some((lat_ns / 1000) as u64);
|
||||||
stats.note(frame.data.len(), lat_us, clock_offset != 0);
|
stats.note(frame.data.len(), lat_us, clock_offset != 0);
|
||||||
feed(&codec, &frame.data, frame.pts_ns / 1000);
|
}
|
||||||
|
pending = Some(frame);
|
||||||
}
|
}
|
||||||
Err(PunktfunkError::NoFrame) => {} // timeout — still drain output below
|
Err(PunktfunkError::NoFrame) => {} // timeout — still drain output below
|
||||||
Err(_) => break, // session closed
|
Err(_) => break, // session closed
|
||||||
}
|
}
|
||||||
rendered += drain(&codec, &window, &mut applied_ds);
|
}
|
||||||
|
if let Some(frame) = pending.take() {
|
||||||
|
if feed(&codec, &frame.data, frame.pts_ns / 1000) {
|
||||||
|
fed += 1;
|
||||||
|
if fed % 300 == 0 {
|
||||||
|
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No input buffer free — transient back-pressure. Keep the AU and let `drain` block
|
||||||
|
// briefly below; a released output buffer is what recycles an input slot.
|
||||||
|
pending = Some(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drain every iteration. When input is blocked, wait ~2 ms on output so the loop rides
|
||||||
|
// decoder progress instead of busy-spinning against a full input queue.
|
||||||
|
let wait = if pending.is_some() {
|
||||||
|
Duration::from_millis(2)
|
||||||
|
} else {
|
||||||
|
Duration::ZERO
|
||||||
|
};
|
||||||
|
let (r, d) = drain(&codec, &window, &mut applied_ds, wait);
|
||||||
|
rendered += r;
|
||||||
|
discarded += d;
|
||||||
|
|
||||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
||||||
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
|
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
|
||||||
@@ -145,14 +193,10 @@ pub fn run(
|
|||||||
log::debug!("decode: requested keyframe (loss recovery, dropped={dropped})");
|
log::debug!("decode: requested keyframe (loss recovery, dropped={dropped})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if fed > 0 && fed % 300 == 0 {
|
|
||||||
log::info!("decode: fed={fed} rendered={rendered}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = codec.stop();
|
let _ = codec.stop();
|
||||||
log::info!("decode: stopped (fed={fed} rendered={rendered})");
|
log::info!("decode: stopped (fed={fed} rendered={rendered} discarded={discarded})");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wall-clock now in nanoseconds (CLOCK_REALTIME basis), to compare against the host-stamped
|
/// Wall-clock now in nanoseconds (CLOCK_REALTIME basis), to compare against the host-stamped
|
||||||
@@ -182,9 +226,12 @@ fn boost_thread_priority() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy one access unit into a codec input buffer and queue it.
|
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
|
||||||
fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) {
|
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
|
||||||
match codec.dequeue_input_buffer(Duration::from_millis(10)) {
|
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
|
||||||
|
/// parking it forever would wedge the loop on a broken codec).
|
||||||
|
fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) -> bool {
|
||||||
|
match codec.dequeue_input_buffer(Duration::ZERO) {
|
||||||
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
|
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
|
||||||
let n = {
|
let n = {
|
||||||
let dst = buf.buffer_mut();
|
let dst = buf.buffer_mut();
|
||||||
@@ -196,41 +243,63 @@ fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) {
|
|||||||
dst.len()
|
dst.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (slot, &b) in dst.iter_mut().zip(&au[..n]) {
|
// SAFETY: `au` and `dst` are distinct allocations (wire AU vs. codec buffer), both
|
||||||
slot.write(b);
|
// valid for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so the cast
|
||||||
|
// write initializes exactly `dst[..n]`.
|
||||||
|
unsafe {
|
||||||
|
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
|
||||||
}
|
}
|
||||||
n
|
n
|
||||||
};
|
};
|
||||||
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
|
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
|
||||||
log::warn!("decode: queue_input_buffer: {e}");
|
log::warn!("decode: queue_input_buffer: {e}");
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
Ok(DequeuedInputBufferResult::TryAgainLater) => {
|
Ok(DequeuedInputBufferResult::TryAgainLater) => false, // caller keeps the AU pending
|
||||||
// No input buffer free right now; the AU is dropped (FEC/keyframes recover).
|
Err(e) => {
|
||||||
|
log::warn!("decode: dequeue_input_buffer: {e}");
|
||||||
|
true
|
||||||
}
|
}
|
||||||
Err(e) => log::warn!("decode: dequeue_input_buffer: {e}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release any ready output buffers to the surface (render = true), latency-first. Returns the
|
/// Dequeue every ready output buffer and present only the NEWEST (render = true), discarding the
|
||||||
/// number of frames presented. Also reacts to `OutputFormatChanged` to signal HDR on the Surface.
|
/// rest (render = false) — when decode falls behind, a back-to-back burst of stale frames on glass
|
||||||
fn drain(codec: &MediaCodec, window: &NativeWindow, applied_ds: &mut Option<DataSpace>) -> u64 {
|
/// is worse than skipping straight to the freshest one (the Apple client's 1-slot newest-ready
|
||||||
let mut n = 0;
|
/// ring, ported). `first_wait` is the timeout for the first dequeue only: zero normally, ~2 ms when
|
||||||
|
/// the caller's input is blocked so the loop waits on decoder progress instead of busy-spinning.
|
||||||
|
/// Returns `(rendered, discarded)`. Also reacts to `OutputFormatChanged` (which can interleave
|
||||||
|
/// between buffers — handled without losing the held buffer) to signal HDR on the Surface.
|
||||||
|
fn drain(
|
||||||
|
codec: &MediaCodec,
|
||||||
|
window: &NativeWindow,
|
||||||
|
applied_ds: &mut Option<DataSpace>,
|
||||||
|
first_wait: Duration,
|
||||||
|
) -> (u64, u64) {
|
||||||
|
let mut held = None; // newest ready buffer so far, presented after the loop
|
||||||
|
let mut discarded: u64 = 0;
|
||||||
|
let mut wait = first_wait;
|
||||||
loop {
|
loop {
|
||||||
match codec.dequeue_output_buffer(Duration::from_millis(0)) {
|
match codec.dequeue_output_buffer(wait) {
|
||||||
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
||||||
if let Err(e) = codec.release_output_buffer(buf, true) {
|
wait = Duration::ZERO; // only the first dequeue may block
|
||||||
log::warn!("decode: release_output_buffer: {e}");
|
if let Some(stale) = held.replace(buf) {
|
||||||
break;
|
// A newer frame is ready — drop the held one without rendering.
|
||||||
|
if let Err(e) = codec.release_output_buffer(stale, false) {
|
||||||
|
log::warn!("decode: release_output_buffer(discard): {e}");
|
||||||
|
}
|
||||||
|
discarded += 1;
|
||||||
}
|
}
|
||||||
n += 1;
|
|
||||||
}
|
}
|
||||||
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
||||||
// The decoder has parsed the SPS and now reports the stream's real colour signalling
|
// The decoder has parsed the SPS and now reports the stream's real colour signalling
|
||||||
// (the AMediaCodec analogue of VideoToolbox's format description on the Apple client).
|
// (the AMediaCodec analogue of VideoToolbox's format description on the Apple client).
|
||||||
// If it's HDR (BT.2020 PQ/HLG), tell the Surface so the compositor/display switch to
|
// If it's HDR (BT.2020 PQ/HLG), tell the Surface so the compositor/display switch to
|
||||||
// HDR; SDR streams leave the default dataspace alone. The decoder itself picks a
|
// HDR; SDR streams leave the default dataspace alone. The decoder itself picks a
|
||||||
// Main10 path from the SPS — no profile override needed. Keep looping (buffers follow).
|
// Main10 path from the SPS — no profile override needed. Keep looping (buffers
|
||||||
|
// follow, and any held buffer stays held across this event).
|
||||||
|
wait = Duration::ZERO;
|
||||||
if let Some(ds) = hdr_dataspace(codec) {
|
if let Some(ds) = hdr_dataspace(codec) {
|
||||||
if *applied_ds != Some(ds) {
|
if *applied_ds != Some(ds) {
|
||||||
match window.set_buffers_data_space(ds) {
|
match window.set_buffers_data_space(ds) {
|
||||||
@@ -245,7 +314,7 @@ fn drain(codec: &MediaCodec, window: &NativeWindow, applied_ds: &mut Option<Data
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TryAgainLater / OutputBuffersChanged — nothing to render now.
|
// TryAgainLater / OutputBuffersChanged — nothing more to dequeue now.
|
||||||
Ok(_) => break,
|
Ok(_) => break,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("decode: dequeue_output_buffer: {e}");
|
log::warn!("decode: dequeue_output_buffer: {e}");
|
||||||
@@ -253,7 +322,15 @@ fn drain(codec: &MediaCodec, window: &NativeWindow, applied_ds: &mut Option<Data
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
n
|
// Present the newest ready frame, if any.
|
||||||
|
let mut rendered = 0;
|
||||||
|
if let Some(buf) = held {
|
||||||
|
match codec.release_output_buffer(buf, true) {
|
||||||
|
Ok(()) => rendered = 1,
|
||||||
|
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(rendered, discarded)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
||||||
|
jni_guard(-1, || {
|
||||||
if handle == 0 {
|
if handle == 0 {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
|
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
|
||||||
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
||||||
// threads (and joins them) before nativeClose frees the handle.
|
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
match h.client.next_rumble(PULL_TIMEOUT) {
|
match h.client.next_rumble(PULL_TIMEOUT) {
|
||||||
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
|
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
|
||||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
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,6 +61,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|||||||
handle: jlong,
|
handle: jlong,
|
||||||
buf: JByteBuffer,
|
buf: JByteBuffer,
|
||||||
) -> jint {
|
) -> jint {
|
||||||
|
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
||||||
|
jni_guard(-1, || {
|
||||||
if handle == 0 {
|
if handle == 0 {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
@@ -109,6 +114,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|||||||
out[2..n].copy_from_slice(&effect);
|
out[2..n].copy_from_slice(&effect);
|
||||||
n
|
n
|
||||||
}
|
}
|
||||||
|
HidOutput::TrackpadHaptic { .. } => {
|
||||||
|
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
|
||||||
|
// rumble already rides the universal 0xCA plane).
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
n as jint
|
n as jint
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,23 @@
|
|||||||
//! 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 surface: the native-link proof (`abiVersion`/`coreVersion`), mDNS host
|
||||||
//! (`abiVersion`/`coreVersion`) plus the session handle lifecycle in [`session`]; the per-plane
|
//! discovery ([`discovery`]), and the session lifecycle in [`session`] — connect/pair + the trust
|
||||||
//! pumps (video → AMediaCodec, audio → Oboe), input, audio, pairing and mode renegotiation are
|
//! surface, the per-plane pumps (video → AMediaCodec, audio ↔ AAudio, mic uplink), input, and
|
||||||
//! the next milestone (see the TODOs in [`session`]).
|
//! rumble/HID feedback ([`feedback`]). Mode renegotiation is still TODO (see [`session`]).
|
||||||
|
|
||||||
use jni::objects::JObject;
|
use jni::objects::JObject;
|
||||||
use jni::sys::jint;
|
use jni::sys::jint;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
//! Android microphone uplink (android-only): capture mic PCM via AAudio (LowLatency **input**),
|
//! Android microphone uplink (android-only): capture mic PCM via AAudio (LowLatency **input**),
|
||||||
//! Opus-encode 20 ms stereo frames, and push them to the host over the connector's mic plane
|
//! Opus-encode 20 ms stereo frames, and push them to the host over the connector's mic plane
|
||||||
//! (`send_mic` → 0xCB datagram). The mirror of [`crate::audio`] in reverse: AAudio's realtime input
|
//! (`send_mic` → 0xCB datagram). The mirror of [`crate::audio`] in reverse: AAudio's realtime input
|
||||||
//! callback hands captured interleaved f32 to a channel; a worker thread we own does the Opus encode
|
//! callback hands captured interleaved f32 to a channel; a worker thread we own does the Opus
|
||||||
//! + send (encoding is too heavy for the realtime callback, exactly as decode is on the playback
|
//! encode + send (encoding is too heavy for the realtime callback, exactly as decode is on the
|
||||||
//! side). Format matches the host decoder + the Linux client: 48 kHz **stereo**, 20 ms, Opus VOIP.
|
//! playback side). Like the playback path, the realtime callback is allocation-free: captured
|
||||||
|
//! bursts are copied into pre-allocated buffers from a recycle free-list (pool empty = drop the
|
||||||
|
//! chunk, never allocate on the capture thread). Format matches the host decoder + the Linux
|
||||||
|
//! client: 48 kHz **stereo**, 20 ms, Opus VOIP.
|
||||||
|
|
||||||
use ndk::audio::{
|
use ndk::audio::{
|
||||||
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode,
|
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode,
|
||||||
@@ -13,7 +16,7 @@ use punktfunk_core::client::NativeClient;
|
|||||||
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, Receiver, RecvTimeoutError, TrySendError};
|
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender, TrySendError};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -23,6 +26,10 @@ const SAMPLE_RATE: i32 = 48_000;
|
|||||||
const FRAME_SAMPLES: usize = 960;
|
const FRAME_SAMPLES: usize = 960;
|
||||||
/// Captured-chunk hand-off depth (each ~ one burst); drops on overflow (best-effort uplink).
|
/// Captured-chunk hand-off depth (each ~ one burst); drops on overflow (best-effort uplink).
|
||||||
const RING_CHUNKS: usize = 64;
|
const RING_CHUNKS: usize = 64;
|
||||||
|
/// Free-list buffer capacity, in interleaved f32 samples: comfortably above a LowLatency input
|
||||||
|
/// burst (typically ≤ ~480 frames). A device with larger bursts costs each buffer a one-time grow
|
||||||
|
/// on the capture thread, after which the steady state is allocation-free again.
|
||||||
|
const CHUNK_CAP_SAMPLES: usize = 1920; // 20 ms stereo
|
||||||
/// Opus VOIP target bitrate (speech; tunable).
|
/// Opus VOIP target bitrate (speech; tunable).
|
||||||
const MIC_BITRATE: i32 = 64_000;
|
const MIC_BITRATE: i32 = 64_000;
|
||||||
|
|
||||||
@@ -38,56 +45,109 @@ impl MicCapture {
|
|||||||
/// forwards captured PCM to a channel, then spawn the Opus encode + uplink thread. `None` on
|
/// forwards captured PCM to a channel, then spawn the Opus encode + uplink thread. `None` on
|
||||||
/// failure (the caller leaves the rest of the session streaming).
|
/// failure (the caller leaves the rest of the session streaming).
|
||||||
pub fn start(client: Arc<NativeClient>) -> Option<MicCapture> {
|
pub fn start(client: Arc<NativeClient>) -> Option<MicCapture> {
|
||||||
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
|
||||||
let captured = Arc::new(AtomicU64::new(0));
|
let captured = Arc::new(AtomicU64::new(0));
|
||||||
|
// Chunks discarded on the capture thread (free-list empty / encoder lagging); logged
|
||||||
|
// throttled from the encode worker.
|
||||||
|
let dropped = Arc::new(AtomicU64::new(0));
|
||||||
|
|
||||||
|
// One open attempt at a given sharing mode (same pattern as [`crate::audio`]: `open_stream`
|
||||||
|
// consumes the builder AND the callback, so each try rebuilds the channels it captures).
|
||||||
|
let try_open = |sharing: AudioSharingMode| -> ndk::audio::Result<(
|
||||||
|
AudioStream,
|
||||||
|
Receiver<Vec<f32>>,
|
||||||
|
SyncSender<Vec<f32>>,
|
||||||
|
)> {
|
||||||
|
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||||
|
// Recycle free-list, mirroring the playback path: the realtime capture callback must
|
||||||
|
// not touch the allocator (Android's Scudo has unbounded malloc/free tail latency — an
|
||||||
|
// allocation here is a missed burst), so it pops a pre-allocated buffer, copies the
|
||||||
|
// burst in and sends it; the encode worker returns drained buffers. Pool empty = DROP
|
||||||
|
// the chunk (counted) rather than allocate.
|
||||||
|
let (free_tx, free_rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||||
|
for _ in 0..RING_CHUNKS {
|
||||||
|
let _ = free_tx.try_send(Vec::with_capacity(CHUNK_CAP_SAMPLES));
|
||||||
|
}
|
||||||
let cb_captured = captured.clone();
|
let cb_captured = captured.clone();
|
||||||
|
let cb_dropped = dropped.clone();
|
||||||
|
let cb_free_tx = free_tx.clone(); // returns the buffer when the data channel is full
|
||||||
|
|
||||||
let callback = move |_s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
let callback = move |_s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
||||||
let n = num_frames as usize * CHANNELS;
|
let n = num_frames as usize * CHANNELS;
|
||||||
// SAFETY: for an input stream AAudio provides `num_frames * channel_count` captured F32
|
// SAFETY: for an input stream AAudio provides `num_frames * channel_count` captured
|
||||||
// samples at `data` (read-only for us).
|
// F32 samples at `data` (read-only for us).
|
||||||
let inp = unsafe { std::slice::from_raw_parts(data as *const f32, n) };
|
let inp = unsafe { std::slice::from_raw_parts(data as *const f32, n) };
|
||||||
match tx.try_send(inp.to_vec()) {
|
cb_captured.fetch_add(num_frames as u64, Ordering::Relaxed);
|
||||||
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest if the encoder lags
|
match free_rx.try_recv() {
|
||||||
|
Ok(mut buf) => {
|
||||||
|
buf.clear();
|
||||||
|
buf.extend_from_slice(inp); // retained capacity — no realloc past the first
|
||||||
|
match tx.try_send(buf) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(TrySendError::Full(buf)) => {
|
||||||
|
// Encoder lagging: drop the chunk, hand the buffer straight back.
|
||||||
|
let _ = cb_free_tx.try_send(buf);
|
||||||
|
cb_dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
Err(TrySendError::Disconnected(_)) => return AudioCallbackResult::Stop,
|
Err(TrySendError::Disconnected(_)) => return AudioCallbackResult::Stop,
|
||||||
}
|
}
|
||||||
cb_captured.fetch_add(num_frames as u64, Ordering::Relaxed);
|
}
|
||||||
|
// Pool empty (every buffer in flight): drop, never allocate on this thread.
|
||||||
|
Err(_) => {
|
||||||
|
cb_dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
AudioCallbackResult::Continue
|
AudioCallbackResult::Continue
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream = AudioStreamBuilder::new()
|
let stream = AudioStreamBuilder::new()?
|
||||||
.map_err(|e| log::error!("mic: AudioStreamBuilder::new: {e}"))
|
|
||||||
.ok()?
|
|
||||||
.direction(AudioDirection::Input)
|
.direction(AudioDirection::Input)
|
||||||
.sample_rate(SAMPLE_RATE)
|
.sample_rate(SAMPLE_RATE)
|
||||||
.channel_count(CHANNELS as i32)
|
.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(sharing)
|
||||||
.data_callback(Box::new(callback))
|
.data_callback(Box::new(callback))
|
||||||
.error_callback(Box::new(|_s, e| {
|
.error_callback(Box::new(|_s, e| {
|
||||||
log::warn!("mic: AAudio error (device reroute/disconnect?): {e:?}");
|
log::warn!("mic: AAudio error (device reroute/disconnect?): {e:?}");
|
||||||
}))
|
}))
|
||||||
.open_stream()
|
.open_stream()?;
|
||||||
.map_err(|e| log::error!("mic: open_stream (RECORD_AUDIO granted?): {e}"))
|
Ok((stream, rx, free_tx))
|
||||||
.ok()?;
|
};
|
||||||
|
|
||||||
|
// Exclusive first — MMAP-exclusive is AAudio's lowest-latency path — falling back to Shared
|
||||||
|
// when the device refuses (no MMAP, mic claimed, …). The started-log below prints the mode
|
||||||
|
// the device actually GRANTED (`share=`).
|
||||||
|
let (stream, rx, free_tx) = match try_open(AudioSharingMode::Exclusive) {
|
||||||
|
Ok(opened) => opened,
|
||||||
|
Err(e) => {
|
||||||
|
log::info!("mic: Exclusive open failed ({e}) — retrying Shared");
|
||||||
|
match try_open(AudioSharingMode::Shared) {
|
||||||
|
Ok(opened) => opened,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("mic: open_stream (RECORD_AUDIO granted?): {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if let Err(e) = stream.request_start() {
|
if let Err(e) = stream.request_start() {
|
||||||
log::error!("mic: request_start: {e}");
|
log::error!("mic: request_start: {e}");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
log::info!(
|
log::info!(
|
||||||
"mic: AAudio input started rate={} ch={} fmt={:?}",
|
"mic: AAudio input started rate={} ch={} fmt={:?} share={:?}",
|
||||||
stream.sample_rate(),
|
stream.sample_rate(),
|
||||||
stream.channel_count(),
|
stream.channel_count(),
|
||||||
stream.format(),
|
stream.format(),
|
||||||
|
stream.sharing_mode(),
|
||||||
);
|
);
|
||||||
|
|
||||||
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-mic".into())
|
.name("pf-mic".into())
|
||||||
.spawn(move || encode_loop(client, rx, sd, captured))
|
.spawn(move || encode_loop(client, rx, free_tx, sd, captured, dropped))
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
Some(MicCapture {
|
Some(MicCapture {
|
||||||
@@ -109,11 +169,15 @@ impl Drop for MicCapture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Consumer: drain captured f32 → accumulate → Opus `encode_float` 20 ms stereo frames → `send_mic`.
|
/// Consumer: drain captured f32 → accumulate → Opus `encode_float` 20 ms stereo frames → `send_mic`.
|
||||||
|
/// Drained chunk buffers go back to the callback's free-list; the encode scratch is reused across
|
||||||
|
/// frames (only the packet Vec handed to `send_mic` is allocated per frame — it's sent away owned).
|
||||||
fn encode_loop(
|
fn encode_loop(
|
||||||
client: Arc<NativeClient>,
|
client: Arc<NativeClient>,
|
||||||
rx: Receiver<Vec<f32>>,
|
rx: Receiver<Vec<f32>>,
|
||||||
|
free_tx: SyncSender<Vec<f32>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
captured: Arc<AtomicU64>,
|
captured: Arc<AtomicU64>,
|
||||||
|
dropped: Arc<AtomicU64>,
|
||||||
) {
|
) {
|
||||||
let mut enc = match opus::Encoder::new(
|
let mut enc = match opus::Encoder::new(
|
||||||
SAMPLE_RATE as u32,
|
SAMPLE_RATE as u32,
|
||||||
@@ -130,6 +194,7 @@ fn encode_loop(
|
|||||||
|
|
||||||
let frame = FRAME_SAMPLES * CHANNELS;
|
let frame = FRAME_SAMPLES * CHANNELS;
|
||||||
let mut ring: VecDeque<f32> = VecDeque::with_capacity(frame * 4);
|
let mut ring: VecDeque<f32> = VecDeque::with_capacity(frame * 4);
|
||||||
|
let mut pcm = vec![0f32; frame]; // reusable encode scratch (one 20 ms frame)
|
||||||
let mut out = vec![0u8; 4000]; // max Opus packet for a 20 ms frame fits easily
|
let mut out = vec![0u8; 4000]; // max Opus packet for a 20 ms frame fits easily
|
||||||
let mut seq: u32 = 0;
|
let mut seq: u32 = 0;
|
||||||
let mut sent: u64 = 0;
|
let mut sent: u64 = 0;
|
||||||
@@ -137,12 +202,19 @@ fn encode_loop(
|
|||||||
|
|
||||||
while !shutdown.load(Ordering::Relaxed) {
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||||
Ok(chunk) => ring.extend(chunk),
|
Ok(mut chunk) => {
|
||||||
|
// `drain(..)` keeps the Vec's capacity; hand the emptied buffer back to the
|
||||||
|
// callback's free-list (dropped only if the pool is momentarily full).
|
||||||
|
ring.extend(chunk.drain(..));
|
||||||
|
let _ = free_tx.try_send(chunk);
|
||||||
|
}
|
||||||
Err(RecvTimeoutError::Timeout) => continue, // wake to re-check shutdown
|
Err(RecvTimeoutError::Timeout) => continue, // wake to re-check shutdown
|
||||||
Err(RecvTimeoutError::Disconnected) => break,
|
Err(RecvTimeoutError::Disconnected) => break,
|
||||||
}
|
}
|
||||||
while ring.len() >= frame {
|
while ring.len() >= frame {
|
||||||
let pcm: Vec<f32> = ring.drain(..frame).collect();
|
for (dst, src) in pcm.iter_mut().zip(ring.drain(..frame)) {
|
||||||
|
*dst = src;
|
||||||
|
}
|
||||||
for &s in &pcm {
|
for &s in &pcm {
|
||||||
peak = peak.max(s.abs());
|
peak = peak.max(s.abs());
|
||||||
}
|
}
|
||||||
@@ -157,8 +229,9 @@ fn encode_loop(
|
|||||||
sent += 1;
|
sent += 1;
|
||||||
if sent % 250 == 0 {
|
if sent % 250 == 0 {
|
||||||
log::info!(
|
log::info!(
|
||||||
"mic: sent={sent} captured_frames={} peak={peak:.3}",
|
"mic: sent={sent} captured_frames={} dropped_chunks={} peak={peak:.3}",
|
||||||
captured.load(Ordering::Relaxed),
|
captured.load(Ordering::Relaxed),
|
||||||
|
dropped.load(Ordering::Relaxed),
|
||||||
);
|
);
|
||||||
peak = 0.0;
|
peak = 0.0;
|
||||||
}
|
}
|
||||||
@@ -168,7 +241,8 @@ fn encode_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
log::info!(
|
log::info!(
|
||||||
"mic: stopped (sent={sent} captured_frames={})",
|
"mic: stopped (sent={sent} captured_frames={} dropped_chunks={})",
|
||||||
captured.load(Ordering::Relaxed),
|
captured.load(Ordering::Relaxed),
|
||||||
|
dropped.load(Ordering::Relaxed),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,673 +0,0 @@
|
|||||||
//! Session lifecycle + plane wiring over JNI.
|
|
||||||
//!
|
|
||||||
//! A connected session is a [`SessionHandle`] — an `Arc<NativeClient>` plus the decode thread it
|
|
||||||
//! feeds — boxed and handed to Kotlin as an opaque `jlong`. The connector is `Sync`, so the decode
|
|
||||||
//! thread pulls the video plane (`next_frame`) directly while Kotlin still holds the handle.
|
|
||||||
//!
|
|
||||||
//! Wired: connect/close, the video plane (HEVC `next_frame` → NDK AMediaCodec → the SurfaceView's
|
|
||||||
//! `ANativeWindow`, see [`crate::decode`]), host→client audio ([`crate::audio`]), input
|
|
||||||
//! (`send_input` — mouse/keyboard/gamepad), rumble/DualSense HID feedback ([`crate::feedback`]),
|
|
||||||
//! and the trust surface: `nativeGenerateIdentity` (persistent identity, Keystore-wrapped on the
|
|
||||||
//! Kotlin side), `nativeConnect` with identity + pin (TOFU / pinned), and `nativePair` (SPAKE2 PIN).
|
|
||||||
//!
|
|
||||||
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
|
|
||||||
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
|
||||||
|
|
||||||
use jni::objects::{JObject, JString};
|
|
||||||
use jni::sys::{jboolean, jdoubleArray, jint, jlong, jsize};
|
|
||||||
use jni::JNIEnv;
|
|
||||||
use punktfunk_core::client::NativeClient;
|
|
||||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::thread::JoinHandle;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
|
|
||||||
pub(crate) struct SessionHandle {
|
|
||||||
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
|
|
||||||
// build (CI's workspace clippy/build) those readers are cfg'd out, so it's intentionally unused.
|
|
||||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
|
||||||
pub client: Arc<NativeClient>,
|
|
||||||
video: Mutex<Option<VideoThread>>,
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
mic: Mutex<Option<crate::mic::MicCapture>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct VideoThread {
|
|
||||||
shutdown: Arc<AtomicBool>,
|
|
||||||
join: Option<JoinHandle<()>>,
|
|
||||||
/// Live decode stats, written by the decode thread and drained ~1 Hz by `nativeVideoStats`.
|
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionHandle {
|
|
||||||
/// Signal the decode thread to stop and join it. Idempotent.
|
|
||||||
fn stop_video(&self) {
|
|
||||||
if let Some(mut vt) = self.video.lock().unwrap().take() {
|
|
||||||
vt.shutdown.store(true, Ordering::SeqCst);
|
|
||||||
if let Some(j) = vt.join.take() {
|
|
||||||
let _ = j.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop + close audio playback. Dropping the [`crate::audio::AudioPlayback`] joins its decode
|
|
||||||
/// thread and closes the AAudio stream. Idempotent.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
fn stop_audio(&self) {
|
|
||||||
let _ = self.audio.lock().unwrap().take();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop mic uplink. Dropping the [`crate::mic::MicCapture`] joins its encode thread and closes
|
|
||||||
/// the AAudio input stream. Idempotent.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
fn stop_mic(&self) {
|
|
||||||
let _ = self.mic.lock().unwrap().take();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for SessionHandle {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop_video();
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
self.stop_audio();
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
self.stop_mic();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SHA-256 fingerprint → 64 lowercase hex chars (matches the host log + client-rs).
|
|
||||||
fn hex32(fp: &[u8; 32]) -> String {
|
|
||||||
use std::fmt::Write;
|
|
||||||
fp.iter().fold(String::with_capacity(64), |mut s, b| {
|
|
||||||
let _ = write!(s, "{b:02x}");
|
|
||||||
s
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 64-hex → [u8; 32]; `None` on bad length/char.
|
|
||||||
fn parse_hex32(s: &str) -> Option<[u8; 32]> {
|
|
||||||
if s.len() != 64 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut out = [0u8; 32];
|
|
||||||
for (i, b) in out.iter_mut().enumerate() {
|
|
||||||
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
|
|
||||||
}
|
|
||||||
Some(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
|
|
||||||
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
|
|
||||||
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIdentity<'local>(
|
|
||||||
env: JNIEnv<'local>,
|
|
||||||
_this: JObject<'local>,
|
|
||||||
) -> jni::sys::jstring {
|
|
||||||
let out = match punktfunk_core::quic::endpoint::generate_identity() {
|
|
||||||
Ok((cert, key)) => format!("{cert}\n-----PUNKTFUNK-KEY-----\n{key}"),
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("nativeGenerateIdentity failed: {e}");
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match env.new_string(out) {
|
|
||||||
Ok(s) => s.into_raw(),
|
|
||||||
Err(_) => JObject::null().into_raw(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
|
||||||
/// compositorPref, gamepadPref): Long`. `certPem`/`keyPem` empty = anonymous, else presented as the
|
|
||||||
/// persistent identity. `pinHex` empty = TOFU (read `nativeHostFingerprint` after), else 64-hex
|
|
||||||
/// SHA-256 to pin the host (mismatch → 0). `bitrateKbps` 0 = host default. `compositorPref`/
|
|
||||||
/// `gamepadPref` are `CompositorPref`/`GamepadPref` wire bytes (0 = Auto; unknown → Auto).
|
|
||||||
/// Returns an opaque handle, or 0 on failure (logged).
|
|
||||||
#[no_mangle]
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
|
||||||
mut env: JNIEnv<'local>,
|
|
||||||
_this: JObject<'local>,
|
|
||||||
host: JString<'local>,
|
|
||||||
port: jint,
|
|
||||||
width: jint,
|
|
||||||
height: jint,
|
|
||||||
refresh_hz: jint,
|
|
||||||
cert_pem: JString<'local>,
|
|
||||||
key_pem: JString<'local>,
|
|
||||||
pin_hex: JString<'local>,
|
|
||||||
bitrate_kbps: jint,
|
|
||||||
compositor_pref: jint,
|
|
||||||
gamepad_pref: jint,
|
|
||||||
hdr_enabled: jboolean,
|
|
||||||
) -> jlong {
|
|
||||||
let host: String = match env.get_string(&host) {
|
|
||||||
Ok(s) => s.into(),
|
|
||||||
Err(_) => return 0,
|
|
||||||
};
|
|
||||||
let cert: String = env
|
|
||||||
.get_string(&cert_pem)
|
|
||||||
.map(Into::into)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default();
|
|
||||||
let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default();
|
|
||||||
|
|
||||||
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some((cert, key))
|
|
||||||
};
|
|
||||||
let pin: Option<[u8; 32]> = if pin_hex.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
match parse_hex32(&pin_hex) {
|
|
||||||
Some(fp) => Some(fp),
|
|
||||||
None => {
|
|
||||||
log::error!("nativeConnect: bad pin hex (len {})", pin_hex.len());
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mode = Mode {
|
|
||||||
width: width as u32,
|
|
||||||
height: height as u32,
|
|
||||||
refresh_hz: refresh_hz as u32,
|
|
||||||
};
|
|
||||||
match NativeClient::connect(
|
|
||||||
&host,
|
|
||||||
port as u16,
|
|
||||||
mode,
|
|
||||||
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),
|
|
||||||
bitrate_kbps.max(0) as u32, // 0 = host default
|
|
||||||
// Advertise 10-bit + HDR ONLY when this device's display can actually present it (Kotlin
|
|
||||||
// checks Display.getHdrCapabilities() and passes the result): the host (e.g. Windows) then
|
|
||||||
// upgrades to a Main10 / BT.2020 PQ encode. On an SDR display we advertise 0 so the host
|
|
||||||
// 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
|
|
||||||
},
|
|
||||||
None, // launch: default app
|
|
||||||
pin, // Some → Crypto on host-fp mismatch
|
|
||||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
|
||||||
Duration::from_secs(10),
|
|
||||||
) {
|
|
||||||
Ok(client) => {
|
|
||||||
let handle = SessionHandle {
|
|
||||||
client: Arc::new(client),
|
|
||||||
video: Mutex::new(None),
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
audio: Mutex::new(None),
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
mic: Mutex::new(None),
|
|
||||||
};
|
|
||||||
Box::into_raw(Box::new(handle)) as jlong
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("nativeConnect to {host}:{port} failed: {e}");
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeClose(handle)` — drop the session (stops the decode thread, then RAII-tears
|
|
||||||
/// down the connector). No-op on `0`.
|
|
||||||
///
|
|
||||||
/// # Safety contract
|
|
||||||
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
|
|
||||||
/// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this).
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) {
|
|
||||||
if handle != 0 {
|
|
||||||
// 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
|
|
||||||
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
|
|
||||||
/// connect. `""` on a `0` handle.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerprint<'local>(
|
|
||||||
env: JNIEnv<'local>,
|
|
||||||
_this: JObject<'local>,
|
|
||||||
handle: jlong,
|
|
||||||
) -> jni::sys::jstring {
|
|
||||||
let out = if handle == 0 {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
hex32(&h.client.host_fingerprint)
|
|
||||||
};
|
|
||||||
match env.new_string(out) {
|
|
||||||
Ok(s) => s.into_raw(),
|
|
||||||
Err(_) => JObject::null().into_raw(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN
|
|
||||||
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
|
|
||||||
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
|
|
||||||
/// `""` (logged). Blocking — Kotlin calls it off the UI thread.
|
|
||||||
#[no_mangle]
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local>(
|
|
||||||
mut env: JNIEnv<'local>,
|
|
||||||
_this: JObject<'local>,
|
|
||||||
host: JString<'local>,
|
|
||||||
port: jint,
|
|
||||||
cert_pem: JString<'local>,
|
|
||||||
key_pem: JString<'local>,
|
|
||||||
pin: JString<'local>,
|
|
||||||
name: JString<'local>,
|
|
||||||
) -> jni::sys::jstring {
|
|
||||||
let g = |e: &mut JNIEnv<'local>, j: &JString<'local>| -> String {
|
|
||||||
e.get_string(j).map(Into::into).unwrap_or_default()
|
|
||||||
};
|
|
||||||
let host = g(&mut env, &host);
|
|
||||||
let cert = g(&mut env, &cert_pem);
|
|
||||||
let key = g(&mut env, &key_pem);
|
|
||||||
let pin = g(&mut env, &pin);
|
|
||||||
let name = g(&mut env, &name);
|
|
||||||
|
|
||||||
let out = if host.is_empty() || cert.is_empty() || key.is_empty() {
|
|
||||||
log::error!("nativePair: missing host/identity");
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
match NativeClient::pair(
|
|
||||||
&host,
|
|
||||||
port as u16,
|
|
||||||
(&cert, &key), // borrowed identity
|
|
||||||
&pin,
|
|
||||||
&name,
|
|
||||||
Duration::from_secs(60),
|
|
||||||
) {
|
|
||||||
Ok(host_fp) => hex32(&host_fp),
|
|
||||||
Err(e) => {
|
|
||||||
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
|
|
||||||
log::error!("nativePair to {host}:{port} failed: {e}");
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match env.new_string(out) {
|
|
||||||
Ok(s) => s.into_raw(),
|
|
||||||
Err(_) => JObject::null().into_raw(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeStartVideo(handle, surface)` — wrap the SurfaceView's `Surface` as an
|
|
||||||
/// `ANativeWindow` and start the HEVC decode thread rendering onto it. No-op if already started.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
|
||||||
env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
surface: JObject,
|
|
||||||
) {
|
|
||||||
if handle == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
let mut guard = h.video.lock().unwrap();
|
|
||||||
if guard.is_some() {
|
|
||||||
return; // already streaming
|
|
||||||
}
|
|
||||||
// SAFETY: `env`/`surface` are valid JNI pointers for this call. `as *mut _` bridges any
|
|
||||||
// jni-sys version skew between the `jni` and `ndk` crates (both are raw `*mut _` pointers).
|
|
||||||
let window = match unsafe {
|
|
||||||
ndk::native_window::NativeWindow::from_surface(
|
|
||||||
env.get_native_interface() as *mut _,
|
|
||||||
surface.as_raw() as *mut _,
|
|
||||||
)
|
|
||||||
} {
|
|
||||||
Some(w) => w,
|
|
||||||
None => {
|
|
||||||
log::error!("nativeStartVideo: no ANativeWindow from Surface");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
|
||||||
let stats = Arc::new(crate::stats::VideoStats::new());
|
|
||||||
let client = h.client.clone();
|
|
||||||
let sd = shutdown.clone();
|
|
||||||
let st = stats.clone();
|
|
||||||
let join = std::thread::Builder::new()
|
|
||||||
.name("pf-decode".into())
|
|
||||||
.spawn(move || crate::decode::run(client, window, sd, st))
|
|
||||||
.ok();
|
|
||||||
*guard = Some(VideoThread {
|
|
||||||
shutdown,
|
|
||||||
join,
|
|
||||||
stats,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
|
|
||||||
/// session). No-op on `0`.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) {
|
|
||||||
if handle != 0 {
|
|
||||||
// SAFETY: live handle per the contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
h.stop_video();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
|
|
||||||
/// Returns 10 doubles
|
|
||||||
/// `[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;
|
|
||||||
/// each call resets the measurement window. Not android-gated — pure `jni` + connector reads, so it
|
|
||||||
/// links on the host build too (Kotlin only ever calls it on device).
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
|
||||||
env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) -> jdoubleArray {
|
|
||||||
if handle == 0 {
|
|
||||||
return std::ptr::null_mut();
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
let snap = match h.video.lock().unwrap().as_ref() {
|
|
||||||
Some(vt) => vt.stats.drain(),
|
|
||||||
None => return std::ptr::null_mut(), // not streaming → no stats
|
|
||||||
};
|
|
||||||
let mode = h.client.mode();
|
|
||||||
let buf: [f64; 10] = [
|
|
||||||
snap.fps,
|
|
||||||
snap.mbps,
|
|
||||||
snap.lat_p50_ms,
|
|
||||||
snap.lat_p95_ms,
|
|
||||||
if snap.lat_valid { 1.0 } else { 0.0 },
|
|
||||||
if snap.skew_corrected { 1.0 } else { 0.0 },
|
|
||||||
mode.width as f64,
|
|
||||||
mode.height as f64,
|
|
||||||
mode.refresh_hz as f64,
|
|
||||||
h.client.frames_dropped() 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
|
|
||||||
/// started or on a `0` handle. Best-effort: a failure leaves video streaming.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) {
|
|
||||||
if handle == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
let mut guard = h.audio.lock().unwrap();
|
|
||||||
if guard.is_some() {
|
|
||||||
return; // already playing
|
|
||||||
}
|
|
||||||
match crate::audio::AudioPlayback::start(h.client.clone()) {
|
|
||||||
Some(p) => *guard = Some(p),
|
|
||||||
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeStopAudio(handle)` — stop + join the audio thread and close AAudio (without
|
|
||||||
/// closing the session). No-op on `0`.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) {
|
|
||||||
if handle != 0 {
|
|
||||||
// SAFETY: live handle per the contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
h.stop_audio();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
|
|
||||||
/// No-op if already running or on a `0` handle. Caller MUST hold RECORD_AUDIO; a failure (e.g. no
|
|
||||||
/// permission) leaves the rest of the session streaming.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) {
|
|
||||||
if handle == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
let mut guard = h.mic.lock().unwrap();
|
|
||||||
if guard.is_some() {
|
|
||||||
return; // already capturing
|
|
||||||
}
|
|
||||||
match crate::mic::MicCapture::start(h.client.clone()) {
|
|
||||||
Some(m) => *guard = Some(m),
|
|
||||||
None => log::error!("nativeStartMic: mic init failed (RECORD_AUDIO? — session unaffected)"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeStopMic(handle)` — stop + join the mic thread and close the AAudio input
|
|
||||||
/// stream (without closing the session). No-op on `0`.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) {
|
|
||||||
if handle != 0 {
|
|
||||||
// SAFETY: live handle per the contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
h.stop_mic();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Input plane: Kotlin capture → NativeClient::send_input ----------------------------------
|
|
||||||
// All four are `&self` on the `Sync` connector (send_input is a non-blocking datagram push), safe
|
|
||||||
// from the Kotlin UI thread. NOT android-gated — send_input exists on the host build too, so these
|
|
||||||
// compile everywhere (parity with nativeConnect/nativeClose). The wire codes are the GameStream
|
|
||||||
// conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
|
|
||||||
// signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendPointerMove(handle, dx, dy)` — relative mouse motion (screen +y down).
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerMove(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
dx: jint,
|
|
||||||
dy: jint,
|
|
||||||
) {
|
|
||||||
if handle == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_input is &self.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
let _ = h.client.send_input(&InputEvent {
|
|
||||||
kind: InputKind::MouseMove,
|
|
||||||
_pad: [0; 3],
|
|
||||||
code: 0,
|
|
||||||
x: dx,
|
|
||||||
y: dy,
|
|
||||||
flags: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `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.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerButton(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
button: jint,
|
|
||||||
down: jboolean,
|
|
||||||
) {
|
|
||||||
if handle == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
let _ = h.client.send_input(&InputEvent {
|
|
||||||
kind: if down != 0 {
|
|
||||||
InputKind::MouseButtonDown
|
|
||||||
} else {
|
|
||||||
InputKind::MouseButtonUp
|
|
||||||
},
|
|
||||||
_pad: [0; 3],
|
|
||||||
code: button as u32,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
flags: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendScroll(handle, axis, delta)` — one scroll step. `axis`: 0=vertical,
|
|
||||||
/// 1=horizontal. `delta`: signed, WHEEL_DELTA(120)-scaled, +=up/right.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
axis: jint,
|
|
||||||
delta: jint,
|
|
||||||
) {
|
|
||||||
if handle == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
let _ = h.client.send_input(&InputEvent {
|
|
||||||
kind: InputKind::MouseScroll,
|
|
||||||
_pad: [0; 3],
|
|
||||||
code: axis as u32,
|
|
||||||
x: delta,
|
|
||||||
y: 0,
|
|
||||||
flags: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
|
|
||||||
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
|
|
||||||
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
vk: jint,
|
|
||||||
down: jboolean,
|
|
||||||
mods: jint,
|
|
||||||
) {
|
|
||||||
if handle == 0 || vk == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
let _ = h.client.send_input(&InputEvent {
|
|
||||||
kind: if down != 0 {
|
|
||||||
InputKind::KeyDown
|
|
||||||
} else {
|
|
||||||
InputKind::KeyUp
|
|
||||||
},
|
|
||||||
_pad: [0; 3],
|
|
||||||
code: vk as u32,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
flags: mods as u32,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
|
||||||
// Single-pad model: exactly one controller, forwarded as pad 0 (flags = 0). Buttons carry the
|
|
||||||
// gamepad::BTN_* bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id
|
|
||||||
// in `code` and the value in `x` (sticks i16 −32768..32767, +y = up; triggers 0..255). The host
|
|
||||||
// accumulates the incremental events into its virtual xpad. Wire contract: input.rs::gamepad.
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down)` — one gamepad button transition.
|
|
||||||
/// `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press, 0=release.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
bit: jint,
|
|
||||||
down: jboolean,
|
|
||||||
) {
|
|
||||||
if handle == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
let _ = h.client.send_input(&InputEvent {
|
|
||||||
kind: InputKind::GamepadButton,
|
|
||||||
_pad: [0; 3],
|
|
||||||
code: bit as u32,
|
|
||||||
x: i32::from(down != 0),
|
|
||||||
y: 0,
|
|
||||||
flags: 0, // pad index 0 — single-pad model
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value)` — one gamepad axis update.
|
|
||||||
/// `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16 (−32768..32767, +y=up) or
|
|
||||||
/// trigger 0..255.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
axis_id: jint,
|
|
||||||
value: jint,
|
|
||||||
) {
|
|
||||||
if handle == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
let _ = h.client.send_input(&InputEvent {
|
|
||||||
kind: InputKind::GamepadAxis,
|
|
||||||
_pad: [0; 3],
|
|
||||||
code: axis_id as u32,
|
|
||||||
x: value,
|
|
||||||
y: 0,
|
|
||||||
flags: 0, // pad index 0 — single-pad model
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
//! Connect lifecycle + the trust surface: identity mint, connect (TOFU / pinned), close,
|
||||||
|
//! host-fingerprint read, and the SPAKE2 PIN pairing ceremony.
|
||||||
|
|
||||||
|
use jni::objects::{JObject, JString};
|
||||||
|
use jni::sys::{jboolean, jint, jlong};
|
||||||
|
use jni::JNIEnv;
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::{hex32, jni_guard, parse_hex32, SessionHandle};
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
|
||||||
|
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
|
||||||
|
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIdentity<'local>(
|
||||||
|
env: JNIEnv<'local>,
|
||||||
|
_this: JObject<'local>,
|
||||||
|
) -> jni::sys::jstring {
|
||||||
|
let out = match punktfunk_core::quic::endpoint::generate_identity() {
|
||||||
|
Ok((cert, key)) => format!("{cert}\n-----PUNKTFUNK-KEY-----\n{key}"),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("nativeGenerateIdentity failed: {e}");
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match env.new_string(out) {
|
||||||
|
Ok(s) => s.into_raw(),
|
||||||
|
Err(_) => JObject::null().into_raw(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||||
|
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs): Long`.
|
||||||
|
/// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty
|
||||||
|
/// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0).
|
||||||
|
/// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref`
|
||||||
|
/// wire bytes (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.
|
||||||
|
/// `preferredCodec` is the soft codec preference wire byte (0 = Auto). `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]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
||||||
|
mut env: JNIEnv<'local>,
|
||||||
|
_this: JObject<'local>,
|
||||||
|
host: JString<'local>,
|
||||||
|
port: jint,
|
||||||
|
width: jint,
|
||||||
|
height: jint,
|
||||||
|
refresh_hz: jint,
|
||||||
|
cert_pem: JString<'local>,
|
||||||
|
key_pem: JString<'local>,
|
||||||
|
pin_hex: JString<'local>,
|
||||||
|
bitrate_kbps: jint,
|
||||||
|
compositor_pref: jint,
|
||||||
|
gamepad_pref: jint,
|
||||||
|
hdr_enabled: jboolean,
|
||||||
|
audio_channels: jint,
|
||||||
|
preferred_codec: jint,
|
||||||
|
timeout_ms: jint,
|
||||||
|
) -> jlong {
|
||||||
|
let host: String = match env.get_string(&host) {
|
||||||
|
Ok(s) => s.into(),
|
||||||
|
Err(_) => return 0,
|
||||||
|
};
|
||||||
|
let cert: String = env
|
||||||
|
.get_string(&cert_pem)
|
||||||
|
.map(Into::into)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default();
|
||||||
|
let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default();
|
||||||
|
|
||||||
|
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((cert, key))
|
||||||
|
};
|
||||||
|
let pin: Option<[u8; 32]> = if pin_hex.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match parse_hex32(&pin_hex) {
|
||||||
|
Some(fp) => Some(fp),
|
||||||
|
None => {
|
||||||
|
log::error!("nativeConnect: bad pin hex (len {})", pin_hex.len());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mode = Mode {
|
||||||
|
width: width as u32,
|
||||||
|
height: height as u32,
|
||||||
|
refresh_hz: refresh_hz as u32,
|
||||||
|
};
|
||||||
|
match NativeClient::connect(
|
||||||
|
&host,
|
||||||
|
port as u16,
|
||||||
|
mode,
|
||||||
|
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),
|
||||||
|
bitrate_kbps.max(0) as u32, // 0 = host default
|
||||||
|
// Advertise 10-bit + HDR ONLY when this device's display can actually present it (Kotlin
|
||||||
|
// checks Display.getHdrCapabilities() and passes the result): the host (e.g. Windows) then
|
||||||
|
// upgrades to a Main10 / BT.2020 PQ encode. On an SDR display we advertise 0 so the host
|
||||||
|
// 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),
|
||||||
|
// Codecs this device can decode — AMediaCodec decodes both HEVC and H.264 (AV1 isn't wired;
|
||||||
|
// hosts don't emit it on the native path yet). The host resolves the emitted codec from these
|
||||||
|
// + the soft `preferred_codec` and echoes it in `connector.codec`, which drives the mime below.
|
||||||
|
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC,
|
||||||
|
preferred_codec.clamp(0, u8::MAX as jint) as u8,
|
||||||
|
None, // launch: default app
|
||||||
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
|
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||||
|
// 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) => {
|
||||||
|
let handle = SessionHandle {
|
||||||
|
client: Arc::new(client),
|
||||||
|
stats: Arc::new(crate::stats::VideoStats::new()),
|
||||||
|
video: Mutex::new(None),
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
audio: Mutex::new(None),
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mic: Mutex::new(None),
|
||||||
|
};
|
||||||
|
Box::into_raw(Box::new(handle)) as jlong
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("nativeConnect to {host}:{port} failed: {e}");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeClose(handle)` — drop the session (stops the decode thread, then RAII-tears
|
||||||
|
/// down the connector). No-op on `0`.
|
||||||
|
///
|
||||||
|
/// # Safety contract
|
||||||
|
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
|
||||||
|
/// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
jni_guard((), || {
|
||||||
|
if handle != 0 {
|
||||||
|
// 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
|
||||||
|
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
|
||||||
|
/// connect. `""` on a `0` handle.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerprint<'local>(
|
||||||
|
env: JNIEnv<'local>,
|
||||||
|
_this: JObject<'local>,
|
||||||
|
handle: jlong,
|
||||||
|
) -> jni::sys::jstring {
|
||||||
|
let out = if handle == 0 {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
hex32(&h.client.host_fingerprint)
|
||||||
|
};
|
||||||
|
match env.new_string(out) {
|
||||||
|
Ok(s) => s.into_raw(),
|
||||||
|
Err(_) => JObject::null().into_raw(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN
|
||||||
|
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
|
||||||
|
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
|
||||||
|
/// `""` (logged). Blocking — Kotlin calls it off the UI thread.
|
||||||
|
#[no_mangle]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local>(
|
||||||
|
mut env: JNIEnv<'local>,
|
||||||
|
_this: JObject<'local>,
|
||||||
|
host: JString<'local>,
|
||||||
|
port: jint,
|
||||||
|
cert_pem: JString<'local>,
|
||||||
|
key_pem: JString<'local>,
|
||||||
|
pin: JString<'local>,
|
||||||
|
name: JString<'local>,
|
||||||
|
) -> jni::sys::jstring {
|
||||||
|
let g = |e: &mut JNIEnv<'local>, j: &JString<'local>| -> String {
|
||||||
|
e.get_string(j).map(Into::into).unwrap_or_default()
|
||||||
|
};
|
||||||
|
let host = g(&mut env, &host);
|
||||||
|
let cert = g(&mut env, &cert_pem);
|
||||||
|
let key = g(&mut env, &key_pem);
|
||||||
|
let pin = g(&mut env, &pin);
|
||||||
|
let name = g(&mut env, &name);
|
||||||
|
|
||||||
|
let out = if host.is_empty() || cert.is_empty() || key.is_empty() {
|
||||||
|
log::error!("nativePair: missing host/identity");
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
match NativeClient::pair(
|
||||||
|
&host,
|
||||||
|
port as u16,
|
||||||
|
(&cert, &key), // borrowed identity
|
||||||
|
&pin,
|
||||||
|
&name,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
) {
|
||||||
|
Ok(host_fp) => hex32(&host_fp),
|
||||||
|
Err(e) => {
|
||||||
|
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
|
||||||
|
log::error!("nativePair to {host}:{port} failed: {e}");
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match env.new_string(out) {
|
||||||
|
Ok(s) => s.into_raw(),
|
||||||
|
Err(_) => JObject::null().into_raw(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
//! Input plane: Kotlin capture → `NativeClient::send_input`.
|
||||||
|
//!
|
||||||
|
//! All shims are `&self` on the `Sync` connector (send_input is a non-blocking datagram push), safe
|
||||||
|
//! from the Kotlin UI thread. NOT android-gated — send_input exists on the host build too, so these
|
||||||
|
//! compile everywhere (parity with nativeConnect/nativeClose). The wire codes are the GameStream
|
||||||
|
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
|
||||||
|
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
|
||||||
|
|
||||||
|
use jni::objects::JObject;
|
||||||
|
use jni::sys::{jboolean, jint, jlong};
|
||||||
|
use jni::JNIEnv;
|
||||||
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
|
|
||||||
|
use super::SessionHandle;
|
||||||
|
|
||||||
|
/// Shared shim body: guard against a `0` handle, deref, and push one [`InputEvent`].
|
||||||
|
fn send_event(handle: jlong, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||||
|
if handle == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_input is &self.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
let _ = h.client.send_input(&InputEvent {
|
||||||
|
kind,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
flags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendPointerMove(handle, dx, dy)` — relative mouse motion (screen +y down).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerMove(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
dx: jint,
|
||||||
|
dy: jint,
|
||||||
|
) {
|
||||||
|
send_event(handle, InputKind::MouseMove, 0, dx, dy, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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,
|
||||||
|
) {
|
||||||
|
let w = (surface_width.max(0) as u32) & 0xffff;
|
||||||
|
let ht = (surface_height.max(0) as u32) & 0xffff;
|
||||||
|
send_event(handle, InputKind::MouseMoveAbs, 0, x, y, (w << 16) | ht);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerButton(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
button: jint,
|
||||||
|
down: jboolean,
|
||||||
|
) {
|
||||||
|
let kind = if down != 0 {
|
||||||
|
InputKind::MouseButtonDown
|
||||||
|
} else {
|
||||||
|
InputKind::MouseButtonUp
|
||||||
|
};
|
||||||
|
send_event(handle, kind, button as u32, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendScroll(handle, axis, delta)` — one scroll step. `axis`: 0=vertical,
|
||||||
|
/// 1=horizontal. `delta`: signed, WHEEL_DELTA(120)-scaled, +=up/right.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
axis: jint,
|
||||||
|
delta: jint,
|
||||||
|
) {
|
||||||
|
send_event(handle, InputKind::MouseScroll, axis as u32, delta, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
|
||||||
|
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
|
||||||
|
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
vk: jint,
|
||||||
|
down: jboolean,
|
||||||
|
mods: jint,
|
||||||
|
) {
|
||||||
|
if vk == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let kind = if down != 0 {
|
||||||
|
InputKind::KeyDown
|
||||||
|
} else {
|
||||||
|
InputKind::KeyUp
|
||||||
|
};
|
||||||
|
send_event(handle, kind, vk as u32, 0, 0, mods as u32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
||||||
|
// Single-pad model: exactly one controller, forwarded as pad 0 (flags = 0). Buttons carry the
|
||||||
|
// gamepad::BTN_* bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id
|
||||||
|
// in `code` and the value in `x` (sticks i16 −32768..32767, +y = up; triggers 0..255). The host
|
||||||
|
// accumulates the incremental events into its virtual xpad. Wire contract: input.rs::gamepad.
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down)` — one gamepad button transition.
|
||||||
|
/// `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press, 0=release.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
bit: jint,
|
||||||
|
down: jboolean,
|
||||||
|
) {
|
||||||
|
// flags = 0: pad index 0 — single-pad model.
|
||||||
|
send_event(
|
||||||
|
handle,
|
||||||
|
InputKind::GamepadButton,
|
||||||
|
bit as u32,
|
||||||
|
i32::from(down != 0),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value)` — one gamepad axis update.
|
||||||
|
/// `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16 (−32768..32767, +y=up) or
|
||||||
|
/// trigger 0..255.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
axis_id: jint,
|
||||||
|
value: jint,
|
||||||
|
) {
|
||||||
|
// flags = 0: pad index 0 — single-pad model.
|
||||||
|
send_event(handle, InputKind::GamepadAxis, axis_id as u32, value, 0, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//! Session lifecycle + plane wiring over JNI.
|
||||||
|
//!
|
||||||
|
//! A connected session is a [`SessionHandle`] — an `Arc<NativeClient>` plus the decode thread it
|
||||||
|
//! feeds — boxed and handed to Kotlin as an opaque `jlong`. The connector is `Sync`, so the decode
|
||||||
|
//! thread pulls the video plane (`next_frame`) directly while Kotlin still holds the handle.
|
||||||
|
//!
|
||||||
|
//! Wired: connect/close, the video plane (HEVC `next_frame` → NDK AMediaCodec → the SurfaceView's
|
||||||
|
//! `ANativeWindow`, see [`crate::decode`]), host→client audio ([`crate::audio`]), input
|
||||||
|
//! (`send_input` — mouse/keyboard/gamepad), rumble/DualSense HID feedback ([`crate::feedback`]),
|
||||||
|
//! and the trust surface: `nativeGenerateIdentity` (persistent identity, Keystore-wrapped on the
|
||||||
|
//! Kotlin side), `nativeConnect` with identity + pin (TOFU / pinned), and `nativePair` (SPAKE2 PIN).
|
||||||
|
//!
|
||||||
|
//! Split by concern: [`connect`] (identity + connect/close + the trust surface), [`planes`]
|
||||||
|
//! (video/audio/mic start/stop + the stats drain), [`input`] (the input-plane shims). This module
|
||||||
|
//! keeps the shared infrastructure they all deref through.
|
||||||
|
//!
|
||||||
|
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
|
||||||
|
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
||||||
|
|
||||||
|
mod connect;
|
||||||
|
mod input;
|
||||||
|
mod planes;
|
||||||
|
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use std::panic::AssertUnwindSafe;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub(crate) struct SessionHandle {
|
||||||
|
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
|
||||||
|
// build (CI's workspace clippy/build) those readers are cfg'd out, so it's intentionally unused.
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
pub client: Arc<NativeClient>,
|
||||||
|
/// Live decode stats, written by the decode thread and drained ~1 Hz by `nativeVideoStats`.
|
||||||
|
/// Session-lifetime (not per `VideoThread`) so the HUD's enable gate set via
|
||||||
|
/// `nativeSetVideoStatsEnabled` survives surface teardown/recreate and can land before
|
||||||
|
/// `nativeStartVideo` — enabling resets the window, so no stale data leaks across restarts.
|
||||||
|
pub stats: Arc<crate::stats::VideoStats>,
|
||||||
|
video: Mutex<Option<VideoThread>>,
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mic: Mutex<Option<crate::mic::MicCapture>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VideoThread {
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
join: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionHandle {
|
||||||
|
/// Signal the decode thread to stop and join it. Idempotent.
|
||||||
|
fn stop_video(&self) {
|
||||||
|
if let Some(mut vt) = self.video.lock().unwrap().take() {
|
||||||
|
vt.shutdown.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(j) = vt.join.take() {
|
||||||
|
let _ = j.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop + close audio playback. Dropping the [`crate::audio::AudioPlayback`] joins its decode
|
||||||
|
/// thread and closes the AAudio stream. Idempotent.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn stop_audio(&self) {
|
||||||
|
let _ = self.audio.lock().unwrap().take();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop mic uplink. Dropping the [`crate::mic::MicCapture`] joins its encode thread and closes
|
||||||
|
/// the AAudio input stream. Idempotent.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn stop_mic(&self) {
|
||||||
|
let _ = self.mic.lock().unwrap().take();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SessionHandle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop_video();
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
self.stop_audio();
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
self.stop_mic();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256 fingerprint → 64 lowercase hex chars (matches the host log + client-rs).
|
||||||
|
fn hex32(fp: &[u8; 32]) -> String {
|
||||||
|
use std::fmt::Write;
|
||||||
|
fp.iter().fold(String::with_capacity(64), |mut s, b| {
|
||||||
|
let _ = write!(s, "{b:02x}");
|
||||||
|
s
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 64-hex → [u8; 32]; `None` on bad length/char.
|
||||||
|
fn parse_hex32(s: &str) -> Option<[u8; 32]> {
|
||||||
|
if s.len() != 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
for (i, b) in out.iter_mut().enumerate() {
|
||||||
|
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
//! Plane start/stop: video (HEVC decode → Surface), host→client audio, mic uplink — plus the
|
||||||
|
//! ~1 Hz decode-stats drain for the HUD.
|
||||||
|
|
||||||
|
use jni::objects::JObject;
|
||||||
|
use jni::sys::{jboolean, jdoubleArray, jlong, jsize};
|
||||||
|
use jni::JNIEnv;
|
||||||
|
|
||||||
|
use super::{jni_guard, SessionHandle};
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeStartVideo(handle, surface)` — wrap the SurfaceView's `Surface` as an
|
||||||
|
/// `ANativeWindow` and start the HEVC decode thread rendering onto it. No-op if already started.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||||
|
env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
surface: JObject,
|
||||||
|
) {
|
||||||
|
use super::VideoThread;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
if handle == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
let mut guard = h.video.lock().unwrap();
|
||||||
|
if guard.is_some() {
|
||||||
|
return; // already streaming
|
||||||
|
}
|
||||||
|
// SAFETY: `env`/`surface` are valid JNI pointers for this call. `as *mut _` bridges any
|
||||||
|
// jni-sys version skew between the `jni` and `ndk` crates (both are raw `*mut _` pointers).
|
||||||
|
let window = match unsafe {
|
||||||
|
ndk::native_window::NativeWindow::from_surface(
|
||||||
|
env.get_native_interface() as *mut _,
|
||||||
|
surface.as_raw() as *mut _,
|
||||||
|
)
|
||||||
|
} {
|
||||||
|
Some(w) => w,
|
||||||
|
None => {
|
||||||
|
log::error!("nativeStartVideo: no ANativeWindow from Surface");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
let client = h.client.clone();
|
||||||
|
let sd = shutdown.clone();
|
||||||
|
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
|
||||||
|
let join = std::thread::Builder::new()
|
||||||
|
.name("pf-decode".into())
|
||||||
|
.spawn(move || crate::decode::run(client, window, sd, st))
|
||||||
|
.ok();
|
||||||
|
*guard = Some(VideoThread { shutdown, join });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
|
||||||
|
/// session). No-op on `0`.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
jni_guard((), || {
|
||||||
|
if handle != 0 {
|
||||||
|
// SAFETY: live handle per the contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
h.stop_video();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
|
||||||
|
/// Returns 14 doubles
|
||||||
|
/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped,
|
||||||
|
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`
|
||||||
|
/// (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — see below), or
|
||||||
|
/// `null` when no decode thread is running. Poll ~1 Hz from the UI; each call resets the measurement
|
||||||
|
/// window. Not android-gated — pure `jni` + connector reads, so it links on the host build too
|
||||||
|
/// (Kotlin only ever calls it on device).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||||
|
env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
) -> jdoubleArray {
|
||||||
|
jni_guard(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) };
|
||||||
|
if h.video.lock().unwrap().is_none() {
|
||||||
|
return std::ptr::null_mut(); // not streaming → no stats
|
||||||
|
}
|
||||||
|
let snap = h.stats.drain();
|
||||||
|
let mode = h.client.mode();
|
||||||
|
let color = h.client.color;
|
||||||
|
let buf: [f64; 14] = [
|
||||||
|
snap.fps,
|
||||||
|
snap.mbps,
|
||||||
|
snap.lat_p50_ms,
|
||||||
|
snap.lat_p95_ms,
|
||||||
|
if snap.lat_valid { 1.0 } else { 0.0 },
|
||||||
|
if snap.skew_corrected { 1.0 } else { 0.0 },
|
||||||
|
mode.width as f64,
|
||||||
|
mode.height as f64,
|
||||||
|
mode.refresh_hz as f64,
|
||||||
|
h.client.frames_dropped() as f64,
|
||||||
|
// Video-feed properties the host resolved at the handshake (Welcome): encode bit depth
|
||||||
|
// (8 / 10), the CICP colour primaries + transfer code points (Kotlin maps these to a
|
||||||
|
// colour-space / HDR label — transfer 16 = PQ, 18 = HLG ⇒ HDR), and the HEVC
|
||||||
|
// chroma_format_idc (1 = 4:2:0, 3 = 4:4:4). Static for the session unless renegotiated.
|
||||||
|
h.client.bit_depth as f64,
|
||||||
|
color.primaries as f64,
|
||||||
|
color.transfer as f64,
|
||||||
|
h.client.chroma_format as f64,
|
||||||
|
];
|
||||||
|
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||||
|
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.nativeSetVideoStatsEnabled(handle, enabled)` — gate per-frame stats sampling on the
|
||||||
|
/// HUD actually being visible: while disabled the decode thread skips the clock read + lock per AU.
|
||||||
|
/// Enabling resets the measurement window so a later show never reports stale data. Sticky for the
|
||||||
|
/// session (survives video stop/start across surface recreation). No-op on `0`. Not android-gated —
|
||||||
|
/// pure `jni` + an atomic store, so it links on the host build too.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoStatsEnabled(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
enabled: jboolean,
|
||||||
|
) {
|
||||||
|
jni_guard((), || {
|
||||||
|
if handle != 0 {
|
||||||
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
h.stats.set_enabled(enabled != 0);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
|
||||||
|
/// started or on a `0` handle. Best-effort: a failure leaves video streaming.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
if handle == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
let mut guard = h.audio.lock().unwrap();
|
||||||
|
if guard.is_some() {
|
||||||
|
return; // already playing
|
||||||
|
}
|
||||||
|
match crate::audio::AudioPlayback::start(h.client.clone()) {
|
||||||
|
Some(p) => *guard = Some(p),
|
||||||
|
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeStopAudio(handle)` — stop + join the audio thread and close AAudio (without
|
||||||
|
/// closing the session). No-op on `0`.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
jni_guard((), || {
|
||||||
|
if handle != 0 {
|
||||||
|
// SAFETY: live handle per the contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
h.stop_audio();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
|
||||||
|
/// No-op if already running or on a `0` handle. Caller MUST hold RECORD_AUDIO; a failure (e.g. no
|
||||||
|
/// permission) leaves the rest of the session streaming.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
if handle == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
let mut guard = h.mic.lock().unwrap();
|
||||||
|
if guard.is_some() {
|
||||||
|
return; // already capturing
|
||||||
|
}
|
||||||
|
match crate::mic::MicCapture::start(h.client.clone()) {
|
||||||
|
Some(m) => *guard = Some(m),
|
||||||
|
None => log::error!("nativeStartMic: mic init failed (RECORD_AUDIO? — session unaffected)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeStopMic(handle)` — stop + join the mic thread and close the AAudio input
|
||||||
|
/// stream (without closing the session). No-op on `0`.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
jni_guard((), || {
|
||||||
|
if handle != 0 {
|
||||||
|
// SAFETY: live handle per the contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
h.stop_mic();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
//! Live decode stats for the on-stream HUD (mirrors the Apple client's stats overlay): FPS,
|
//! Live decode stats for the on-stream HUD (mirrors the Apple client's stats overlay): FPS,
|
||||||
//! receive throughput, and capture→client-receipt latency (p50/p95). The decode thread is the sole
|
//! receive throughput, and capture→client-receipt latency (p50/p95). The decode thread is the sole
|
||||||
//! writer (`note` per access unit); the JNI accessor `nativeVideoStats` drains a snapshot ~1 Hz and
|
//! writer (`note` per access unit); the JNI accessor `nativeVideoStats` drains a snapshot ~1 Hz and
|
||||||
//! resets the window. Pure `std` so it compiles on the host build too (the decode thread is
|
//! resets the window. Sampling is gated on the HUD actually being visible (`set_enabled`, driven by
|
||||||
//! android-only, but `VideoThread` holds the shared handle unconditionally).
|
//! `nativeSetVideoStatsEnabled`) so the hidden steady state costs one relaxed atomic load per frame.
|
||||||
|
//! Pure `std` so it compiles on the host build too (the decode thread is android-only, but
|
||||||
|
//! `SessionHandle` holds the shared handle unconditionally).
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
/// Rolling per-window accumulator. Rates are computed over the actual elapsed wall-time at drain
|
/// Rolling per-window accumulator. Rates are computed over the actual elapsed wall-time at drain
|
||||||
/// (robust to poll jitter), so a poll that lands at 0.9 s or 1.1 s still reports the right FPS.
|
/// (robust to poll jitter), so a poll that lands at 0.9 s or 1.1 s still reports the right FPS.
|
||||||
pub struct VideoStats {
|
pub struct VideoStats {
|
||||||
|
/// HUD gate: `note` runs on the per-frame decode path, so while the overlay is hidden it (and
|
||||||
|
/// the caller's latency computation — see `enabled`) early-outs on this flag alone. Off until
|
||||||
|
/// Kotlin shows the HUD.
|
||||||
|
enabled: AtomicBool,
|
||||||
inner: Mutex<Inner>,
|
inner: Mutex<Inner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,11 +42,9 @@ pub struct Snapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VideoStats {
|
impl VideoStats {
|
||||||
// `new`/`note` are driven only by the android-only decode thread; `drain` (the JNI accessor) is
|
|
||||||
// ungated, so on the host build these two are unreferenced — that's expected, not dead code.
|
|
||||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
|
||||||
pub fn new() -> VideoStats {
|
pub fn new() -> VideoStats {
|
||||||
VideoStats {
|
VideoStats {
|
||||||
|
enabled: AtomicBool::new(false),
|
||||||
inner: Mutex::new(Inner {
|
inner: Mutex::new(Inner {
|
||||||
window_start: Instant::now(),
|
window_start: Instant::now(),
|
||||||
frames: 0,
|
frames: 0,
|
||||||
@@ -50,10 +55,44 @@ impl VideoStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the HUD wants samples. The decode thread checks this BEFORE building a latency
|
||||||
|
/// sample, so the per-frame wall-clock read is skipped too while hidden.
|
||||||
|
// Read only by the android-only decode thread; unreferenced on the host build — expected.
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
pub fn enabled(&self) -> bool {
|
||||||
|
self.enabled.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
|
||||||
|
/// in counters (or a window start) from before the overlay was visible.
|
||||||
|
pub fn set_enabled(&self, on: bool) {
|
||||||
|
let was = self.enabled.swap(on, Ordering::Relaxed);
|
||||||
|
if on && !was {
|
||||||
|
let mut g = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
g.window_start = Instant::now();
|
||||||
|
g.frames = 0;
|
||||||
|
g.bytes = 0;
|
||||||
|
g.lat_us.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Record one decoded access unit: its wire size and (if in range) its capture→client latency.
|
/// Record one decoded access unit: its wire size and (if in range) its capture→client latency.
|
||||||
|
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
pub fn note(&self, bytes: usize, lat_us: Option<u64>, skew_corrected: bool) {
|
pub fn note(&self, bytes: usize, lat_us: Option<u64>, skew_corrected: bool) {
|
||||||
let mut g = self.inner.lock().unwrap();
|
if !self.enabled.load(Ordering::Relaxed) {
|
||||||
|
return; // HUD hidden — skip the lock (the caller already skipped the clock read)
|
||||||
|
}
|
||||||
|
// Poison-proof: `note` runs per-frame on the decode thread, which has no catch_unwind —
|
||||||
|
// a panic elsewhere must not turn every later lock into a second panic (the counters
|
||||||
|
// stay consistent regardless).
|
||||||
|
let mut g = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
g.frames += 1;
|
g.frames += 1;
|
||||||
g.bytes += bytes as u64;
|
g.bytes += bytes as u64;
|
||||||
g.skew_corrected = skew_corrected;
|
g.skew_corrected = skew_corrected;
|
||||||
@@ -64,7 +103,11 @@ impl VideoStats {
|
|||||||
|
|
||||||
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
||||||
pub fn drain(&self) -> Snapshot {
|
pub fn drain(&self) -> Snapshot {
|
||||||
let mut g = self.inner.lock().unwrap();
|
// Poison-proof for the same reason as `note` — a poisoned window still drains fine.
|
||||||
|
let mut g = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
let elapsed = g.window_start.elapsed().as_secs_f64().max(1e-3);
|
let elapsed = g.window_start.elapsed().as_secs_f64().max(1e-3);
|
||||||
let fps = g.frames as f64 / elapsed;
|
let fps = g.frames as f64 / elapsed;
|
||||||
let mbps = g.bytes as f64 * 8.0 / 1_000_000.0 / elapsed;
|
let mbps = g.bytes as f64 * 8.0 / 1_000_000.0 / elapsed;
|
||||||
|
|||||||
@@ -16,5 +16,10 @@
|
|||||||
compliance question. -->
|
compliance question. -->
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<!-- Allow CADisplayLink above 60 Hz on ProMotion iPhones: without this key the system
|
||||||
|
silently caps the link at 60 even when SessionPresenter asks for the stream's rate
|
||||||
|
via preferredFrameRateRange, so a 120 fps stream would present at half rate. -->
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -355,7 +355,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk-macOS.entitlements;
|
CODE_SIGN_ENTITLEMENTS = "Config/Punktfunk-macOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
@@ -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.";
|
||||||
@@ -389,7 +389,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk-macOS.entitlements;
|
CODE_SIGN_ENTITLEMENTS = "Config/Punktfunk-macOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
@@ -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.";
|
||||||
@@ -425,11 +425,11 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
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.";
|
||||||
@@ -464,11 +464,11 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
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.";
|
||||||
@@ -502,11 +502,11 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
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;
|
||||||
@@ -532,11 +532,11 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
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;
|
||||||
|
|||||||
+100
-286
@@ -1,312 +1,126 @@
|
|||||||
# punktfunk Apple client (SwiftUI)
|
# punktfunk — Apple client (macOS · iOS · iPadOS · tvOS)
|
||||||
|
|
||||||
The native macOS/iOS client for **`punktfunk/1`** (the post-GameStream protocol). All
|
The native **Apple** app for streaming a punktfunk host to your Mac, iPhone, iPad, or Apple TV. A
|
||||||
networking/protocol work — QUIC control plane, UDP data plane, GF(2¹⁶) FEC, AES-GCM,
|
SwiftUI app that finds hosts on your network, pairs with a PIN, and streams at your display's own
|
||||||
input datagrams, Opus audio, cert pinning — lives in the shared Rust core (statically
|
resolution and refresh rate — with VideoToolbox hardware decode and full controller support.
|
||||||
linked as `PunktfunkCore.xcframework`); this package is the Swift shell: decode
|
|
||||||
(VideoToolbox), present (SwiftUI), input capture.
|
|
||||||
|
|
||||||
## Status — working client (macOS, with iOS / tvOS in the shared build)
|
All the networking and protocol work — QUIC control plane, UDP data plane, GF(2¹⁶) FEC, AES-GCM,
|
||||||
|
Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (statically linked as
|
||||||
|
`PunktfunkCore.xcframework`). This package is the Swift shell: decode, present, input, and UI.
|
||||||
|
|
||||||
A full streaming client: VideoToolbox HEVC decode, controllers incl. DualSense feedback, host
|
## Features
|
||||||
discovery, PIN pairing, and a network speed test. The lower-latency **stage-2 presenter**
|
|
||||||
(`VTDecompressionSession` → `CAMetalLayer`) is built and opt-in (Settings → Presenter); see below.
|
|
||||||
|
|
||||||
First light was achieved 2026-06-10 — validated live, Mac ↔ a Linux host over the LAN: gamescope
|
- **Hardware decode** — VideoToolbox HEVC, with a low-latency **stage-2 presenter**
|
||||||
virtual output → NVENC HEVC →
|
(`VTDecompressionSession` → `CAMetalLayer`, presented off a `CADisplayLink`, ~11 ms p50) as the
|
||||||
`punktfunk/1` (GF(2¹⁶) FEC + AES-GCM over UDP, QUIC control) → VideoToolbox →
|
default and an `AVSampleBufferDisplayLayer` fallback.
|
||||||
`AVSampleBufferDisplayLayer` on glass at 1280×720@60, with mouse/keyboard flowing back as
|
- **HDR & 4:4:4** — PQ passthrough with a correct reference-white anchor, mid-session SDR↔HDR
|
||||||
QUIC datagrams into the host's gamescope EIS injector (thousands of events injected during
|
reconfiguration, and hardware-probed 4:4:4 support.
|
||||||
the session). Headless variant of the same proof: `RemoteFirstLightTests` decoded 60/60
|
- **Your display's native mode** — the host builds a virtual output at exactly your WxH@Hz;
|
||||||
received AUs spanning 983 ms of host capture clock.
|
mid-stream resize renegotiates without reconnecting.
|
||||||
|
- **Audio both ways** — Opus playback (CoreAudio, no bundled libopus) with a jitter ring, plus mic
|
||||||
|
uplink; speaker/mic selectable in Settings.
|
||||||
|
- **Full controller support** — one selected controller forwarded as pad 0, including **DualSense**
|
||||||
|
feedback (rumble → CoreHaptics, lightbar, player LEDs, adaptive triggers) and touchpad/motion. The
|
||||||
|
virtual pad type auto-resolves from your physical controller.
|
||||||
|
- **Mouse & keyboard** — `GCMouse`/`GCKeyboard` capture with click-to-capture and a ⌘⎋ release, plus
|
||||||
|
iPad pointer lock and touch input.
|
||||||
|
- **Find hosts automatically** — mDNS discovery (`NWBrowser` over `_punktfunk._udp`); first connect
|
||||||
|
does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then reconnects on a pinned,
|
||||||
|
Keychain-stored identity.
|
||||||
|
- **Tune the stream** — a fps / Mb·s / **latency** HUD (skew-corrected across machines), a bitrate
|
||||||
|
control, a per-host **network speed test** with a recommended bitrate, and a host-compositor picker.
|
||||||
|
|
||||||
The connector underneath (`punktfunk_core::client::NativeClient` over the C ABI) carries the
|
Runs from one shared codebase across **macOS, iOS, iPadOS, and tvOS**.
|
||||||
full session: video AUs, **Opus audio** (`nextAudio()`), **rumble** (`nextRumble()`),
|
|
||||||
**DualSense feedback** (`nextHidOutput()` — lightbar, player LEDs, adaptive-trigger
|
|
||||||
effects), input incl. gamepads + DualSense touchpad/motion (`sendTouchpad`/`sendMotion`),
|
|
||||||
and **cert pinning + TOFU** (`pinSHA256:`/`hostFingerprint`) — see
|
|
||||||
`punktfunk1.rs::tests::c_abi_connection_roundtrip` (three sequential sessions: TOFU, pinned
|
|
||||||
reconnect, wrong-pin rejection). The host (`punktfunk-host punktfunk1-host`) is a persistent listener:
|
|
||||||
reconnect at will during development.
|
|
||||||
|
|
||||||
What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3):
|
## Get it
|
||||||
|
|
||||||
- **`PunktfunkKit`** (library)
|
Install from the App Store / TestFlight, or build from source below. Per-device install steps and the
|
||||||
- `PunktfunkConnection.swift` — wrapper over the C ABI. AUs/audio are copied into `Data`
|
pairing walkthrough:
|
||||||
(the C pointer is only valid until the next call of the same kind). `close()` is safe
|
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
|
||||||
from any thread: per-plane locks enforce the C contract ("never close with a
|
|
||||||
`next_au`/`next_audio` in flight") instead of leaving it to callers. Pinning + TOFU
|
|
||||||
via `pinSHA256:`/`hostFingerprint`.
|
|
||||||
- `AnnexB.swift` — in-band VPS/SPS/PPS → `CMVideoFormatDescription`; Annex-B → AVCC
|
|
||||||
`CMSampleBuffer` with `DisplayImmediately` set.
|
|
||||||
- `StreamView.swift` — SwiftUI `NSViewRepresentable` over `AVSampleBufferDisplayLayer`
|
|
||||||
(stage-1 presenter: the layer hardware-decodes compressed HEVC itself). One pump
|
|
||||||
thread per view, token-cancelled so reconnects can't double-pump.
|
|
||||||
- `InputCapture.swift` — `GCMouse` raw deltas + `GCKeyboard` HID→VK mapping (the host's
|
|
||||||
`vk_to_evdev` consumes Windows VKs), with fractional-delta accumulation so sub-pixel
|
|
||||||
motion isn't truncated away. Buttons use GameStream ids (1=left … 5=X2). Scroll is
|
|
||||||
WHEEL_DELTA(120)-scaled: macOS via the stream view's `scrollWheel` override, iPad via
|
|
||||||
GCMouse's scroll dpad when pointer-locked and a scroll-only `UIPanGestureRecognizer`
|
|
||||||
otherwise (trackpad gestures never reach GC's scroll dpad).
|
|
||||||
- `GamepadManager.swift` — app-lifetime controller discovery + selection (`.shared`):
|
|
||||||
watches `GCController` connect/disconnect, fingerprints each pad for the Settings UI
|
|
||||||
(name, capabilities, battery), and selects the ONE controller forwarded to the host
|
|
||||||
(user pin via "Use controller", else most recently connected extended gamepad).
|
|
||||||
- `GamepadCapture.swift` — the active controller → wire: snapshot-diff over
|
|
||||||
`GCExtendedGamepad` into incremental `gamepadButton`/`gamepadAxis` events (pad 0),
|
|
||||||
plus DualSense touchpad contacts and ~250 Hz motion samples on the rich-input plane
|
|
||||||
(the GC→DualSense unit conversions live in `GamepadWire`, one place). Held state is
|
|
||||||
released on the wire on controller switch / app deactivation / stop.
|
|
||||||
- `GamepadFeedback.swift` + `DualSenseTriggerEffect.swift` — host feedback → the real
|
|
||||||
controller: one drain thread for `nextRumble()` (→ `CHHapticEngine` per handle
|
|
||||||
locality) and `nextHidOutput()` (lightbar → `GCDeviceLight`, player LEDs →
|
|
||||||
`playerIndex`, adaptive-trigger effect blocks → a total, table-driven parser →
|
|
||||||
`GCDualSenseAdaptiveTrigger`, exact for the 10-zone positional modes).
|
|
||||||
- `HostDiscovery.swift` — LAN auto-discovery: an `NWBrowser` over `_punktfunk._udp`
|
|
||||||
(the host's `crate::discovery` mDNS advert), resolving each service to an IP:port via a
|
|
||||||
throwaway `NWConnection` and parsing the TXT (`fp` advisory cert fingerprint, `pair`,
|
|
||||||
stable `id`). iOS/tvOS need `NSBonjourServices` (`Config/Info.plist`) or the system
|
|
||||||
blocks the browse.
|
|
||||||
- **`PunktfunkClient`** (the app): hosts grid (saved in UserDefaults) with an **On this
|
|
||||||
network** section listing mDNS-discovered hosts (tap to save + connect, or pair if the
|
|
||||||
host requires it), "+" toolbar sheet to add hosts manually, stream mode in Settings (⌘,),
|
|
||||||
two trust flows — the
|
|
||||||
trust-on-first-use fingerprint prompt over the live-but-blurred stream, and SPAKE2 PIN
|
|
||||||
pairing (`PairSheet`, from a host card's context menu or the trust prompt;
|
|
||||||
`ClientIdentityStore` keeps the client identity in the Keychain and presents it on
|
|
||||||
every connect) — then pinned reconnects, fps/Mb-s HUD + a **capture→client-receipt latency**
|
|
||||||
line (`LatencyMeter`, p50/p95): the AU `pts_ns` (host capture clock) to the instant the client
|
|
||||||
received it, **skew-corrected** across machines via `PunktfunkConnection.clockOffsetNs` (the
|
|
||||||
connect-time wall-clock handshake, `punktfunk_connection_clock_offset_ns`). It excludes the
|
|
||||||
layer's decode+present (stage-1 `AVSampleBufferDisplayLayer` has no per-frame present callback);
|
|
||||||
the opt-in **stage-2 presenter** (Settings → Presenter) adds a **capture→present**
|
|
||||||
(glass-to-glass) line via explicit decode + a Metal/display-link present. Settings also picks the HOST
|
|
||||||
compositor (KWin/wlroots/Mutter/gamescope, default automatic — the host honors it
|
|
||||||
only if that backend is available there) and has a **Controllers** section: every
|
|
||||||
detected controller (capability glyphs, battery, "In use" badge), which one to forward
|
|
||||||
("Use controller", default automatic), and the virtual pad type the host creates
|
|
||||||
("Controller type": Automatic / Xbox 360 / DualSense — Automatic matches the physical
|
|
||||||
pad; resolved at connect time, the host pad is fixed per session). Gamepad capture +
|
|
||||||
feedback run with streaming (`SessionModel` owns them, same trust gate as audio).
|
|
||||||
Settings also sets the **Bitrate** (Automatic toggle = host default; manual is a
|
|
||||||
log-scale slider, 2 Mbps – 3 Gbps, snapped to two significant figures — above 1 Gbps
|
|
||||||
an inline warning says to run a speed test first; tvOS uses a preset picker instead,
|
|
||||||
Slider doesn't exist there; negotiated via the Hello on every connect), and a host
|
|
||||||
card's context menu offers **"Test Network Speed…"** (`SpeedTestSheet`): connects, has
|
|
||||||
the host burst probe filler over the real data plane (up to the host's 3 Gbps probe
|
|
||||||
ceiling for 2 s, roadmap §9),
|
|
||||||
shows measured goodput · loss · a recommended bitrate (≈70% of measured), and applies
|
|
||||||
it in one tap. The streaming **statistics overlay** can be turned off and moved to any
|
|
||||||
corner (Settings → Display → Statistics, `DefaultsKey.hudEnabled`/`hudPlacement`), and
|
|
||||||
toggled live with **⌘⇧S** — a Scene-level **"Stream" menu** (`StreamCommands`) that also
|
|
||||||
carries **Disconnect ⌘D**, so disconnect survives the HUD being hidden (on iOS a small
|
|
||||||
exit chip appears instead; on tvOS the Siri-Remote Menu button still disconnects). The
|
|
||||||
macOS Settings window is a **tabbed preferences pane** (General / Display / Audio /
|
|
||||||
Controllers / Advanced) — the sections are shared with the iOS single-Form layout and the
|
|
||||||
tvOS pushed-picker layout, defined once each.
|
|
||||||
- **Tests** (`swift test`): byte-level Annex-B units; a real-codec round trip
|
|
||||||
(VTCompressionSession-encoded HEVC rebuilt as the host's wire shape → `AnnexB` →
|
|
||||||
VTDecompressionSession → pixels); table-driven DualSense trigger-effect parsing
|
|
||||||
(`DualSenseTriggerEffectTests`) and the gamepad wire conversions
|
|
||||||
(`GamepadWireTests`); loopback integration against real local hosts
|
|
||||||
(`test-loopback.sh` — stream round trip incl. gamepad/touchpad/motion sends, a
|
|
||||||
host-scripted feedback burst asserted on the rumble + HID-output planes
|
|
||||||
(`PUNKTFUNK_TEST_FEEDBACK=1`), the bitrate-negotiation echo and a real 20 Mbps
|
|
||||||
bandwidth probe, plus the PIN pairing ceremony and the `--require-pairing` gate
|
|
||||||
against a second, armed host); the remote first-light test above.
|
|
||||||
|
|
||||||
## Build / run / test (on a Mac)
|
## Build / run / test (on a Mac)
|
||||||
|
|
||||||
|
Requires Xcode 26.5 / Swift 6.3. First build the Rust core into an xcframework, then build the app:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
bash scripts/build-xcframework.sh # → clients/apple/PunktfunkCore.xcframework
|
bash scripts/build-xcframework.sh # → clients/apple/PunktfunkCore.xcframework
|
||||||
# + BUILD_IOS=1 for the iOS slices (rustup target add aarch64-apple-ios{,-sim} x86_64-apple-ios)
|
# BUILD_IOS=1 also builds the iOS slices (add the ios rustup targets)
|
||||||
# + BUILD_TVOS=1 for tvOS — TIER-3 Rust targets, built from source:
|
# BUILD_TVOS=1 also builds tvOS (tier-3 targets, built from source — see below)
|
||||||
# rustup toolchain install nightly && rustup component add rust-src --toolchain nightly
|
|
||||||
cd clients/apple
|
cd clients/apple
|
||||||
swift build && swift test # loopback/remote tests self-skip without a host
|
|
||||||
swift run PunktfunkClient # the unbundled dev shell (CLI)
|
|
||||||
open Punktfunk.xcodeproj # the real app: ⌘R builds + runs Punktfunk.app
|
open Punktfunk.xcodeproj # the real app: ⌘R builds + runs Punktfunk.app
|
||||||
|
swift run PunktfunkClient # or the unbundled dev shell (CLI)
|
||||||
|
swift build && swift test # unit + loopback/remote tests (self-skip w/o a host)
|
||||||
|
```
|
||||||
|
|
||||||
bash test-loopback.sh # full loopback proof: builds punktfunk-host
|
tvOS slices are tier-3 Rust targets, built from source:
|
||||||
# (synthetic source — runs on macOS), streams
|
`rustup toolchain install nightly && rustup component add rust-src --toolchain nightly`.
|
||||||
# byte-verified frames into the Swift client
|
|
||||||
|
|
||||||
# against the real host (Linux box, see CLAUDE.md "Running on this box") — punktfunk1-host is a
|
### Test against a host
|
||||||
# persistent listener, reconnect at will:
|
|
||||||
# PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
|
```sh
|
||||||
# cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 60
|
# full loopback proof — builds punktfunk-host (synthetic source, runs on macOS) and streams
|
||||||
|
# byte-verified frames into the Swift client, incl. the PIN pairing ceremony:
|
||||||
|
bash test-loopback.sh
|
||||||
|
|
||||||
|
# against a real Linux host on the LAN (see the repo README "Running on this box"):
|
||||||
PUNKTFUNK_REMOTE_HOST=<box-ip> swift test --filter RemoteFirstLightTests # headless
|
PUNKTFUNK_REMOTE_HOST=<box-ip> swift test --filter RemoteFirstLightTests # headless
|
||||||
# (+ PUNKTFUNK_REMOTE_PORT / PUNKTFUNK_REMOTE_COMPOSITOR=gamescope|kwin|… /
|
|
||||||
# PUNKTFUNK_REMOTE_PIN=<arming-pin> for the remote pairing test)
|
|
||||||
PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkClient # on glass
|
PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkClient # on glass
|
||||||
```
|
```
|
||||||
|
|
||||||
## Xcode project (`Punktfunk.xcodeproj`)
|
## Project layout
|
||||||
|
|
||||||
The app target **Punktfunk** wraps the same sources as the `swift run` shell
|
- **`PunktfunkKit`** (library) — the reusable pieces:
|
||||||
(`Sources/PunktfunkClient`, a synchronized folder — no duplication) plus `App/` (asset
|
- `PunktfunkConnection` — the wrapper over the C ABI (thread-safe `close()`, per-plane locks,
|
||||||
catalog) and links `PunktfunkKit` from the local package. Generated Info.plist, ad-hoc
|
pinning + TOFU).
|
||||||
signing, bundle id `io.unom.punktfunk`. Notes:
|
- `AnnexB` / `StreamView` / `VideoDecoder` / `MetalVideoPresenter` — format handling, the stage-1
|
||||||
|
(`AVSampleBufferDisplayLayer`) and stage-2 (`VTDecompressionSession` → `CAMetalLayer`) presenters.
|
||||||
|
- `InputCapture` — `GCMouse`/`GCKeyboard` → host VK/mouse, with fractional-delta accumulation.
|
||||||
|
- `GamepadManager` / `GamepadCapture` / `GamepadFeedback` / `DualSenseTriggerEffect` — controller
|
||||||
|
discovery + selection, capture (buttons/axes/touchpad/motion), and host-feedback rendering.
|
||||||
|
- `HostDiscovery` — `NWBrowser` over `_punktfunk._udp`.
|
||||||
|
- **`PunktfunkClient`** (the app) — hosts grid with an *On this network* section, add-host sheet,
|
||||||
|
the two trust flows (TOFU prompt + SPAKE2 `PairSheet`), the stream view with the HUD, a
|
||||||
|
tabbed Settings pane (General / Display / Audio / Controllers / Advanced), and the network speed
|
||||||
|
test. A Scene-level **Stream** menu carries Disconnect (⌘D) and the HUD toggle (⌘⇧S).
|
||||||
|
On iOS/iPadOS **and macOS** a connected controller swaps the whole home for the **gamepad UI**
|
||||||
|
(`Home/Gamepad*`, `Settings/GamepadSettingsView`): a console-style host carousel (A connect · Y
|
||||||
|
library · X settings), a controller-navigable settings screen, an add-host flow with an
|
||||||
|
on-screen controller keyboard (no touch required anywhere), and the coverflow library browser —
|
||||||
|
all driven by the shared `GamepadMenuInput` poller + `GamepadCarousel`/`GamepadMenuList` focus
|
||||||
|
machinery, with dual-channel haptics (device Taptic + controller `MenuHaptics`), over an
|
||||||
|
animated "aurora" backdrop (`GamepadScreenBackground` — TimelineView-driven drifting color
|
||||||
|
blobs; deliberately pure SwiftUI, since a .metal library only reliably bundles in one of the
|
||||||
|
two build systems these sources compile under). macOS presents the settings/add-host screens as
|
||||||
|
sheets (no `fullScreenCover` there); `PUNKTFUNK_FORCE_GAMEPAD_UI=1` forces the mode without a
|
||||||
|
physical pad (dev/screenshots).
|
||||||
|
- **Tests** (`swift test`) — Annex-B units, a real-codec VideoToolbox round trip, DualSense
|
||||||
|
trigger-effect and gamepad-wire conversions, loopback integration against real local hosts, and the
|
||||||
|
remote first-light test.
|
||||||
|
|
||||||
- **Entitlements (sandbox)**: the macOS target uses
|
## Notes for contributors
|
||||||
`Config/Punktfunk-macOS.entitlements`; iOS/tvOS use the shared
|
|
||||||
`Config/Punktfunk.entitlements`. The macOS app is **App-Sandboxed** (mandatory for the Mac
|
|
||||||
App Store/TestFlight, and used for the Developer ID DMG too so the local build matches what
|
|
||||||
ships): `com.apple.security.app-sandbox`, `network.client` + **`network.server`** (the
|
|
||||||
sandbox gates `bind()`; quinn + the raw-UDP plane both bind, so receive breaks without it),
|
|
||||||
`device.audio-input` (mic), `device.bluetooth` + `device.usb` (GameController over BT/USB),
|
|
||||||
and the existing `keychain-access-groups`. `app-sandbox` is macOS-only — keep it OUT of the
|
|
||||||
shared iOS/tvOS file (it fails upload validation there). Verify a build is sandboxed with
|
|
||||||
`codesign -d --entitlements :- <built .app>`. Heads-up: `device.usb` draws some App Review
|
|
||||||
scrutiny — justify it in the review notes ("reads input from USB game controllers").
|
|
||||||
- **App icon**: `App/Assets.xcassets` ships an empty `AppIcon` slot. For an Icon Composer
|
|
||||||
`.icon`: add the file to the project (target Punktfunk), set it as the App Icon in the
|
|
||||||
target's General tab, and delete the placeholder `AppIcon.appiconset`. Heads-up: CLI
|
|
||||||
`actool` (Xcode 26.5) crashed compiling `punktfunk_Logo.icon` — if Xcode does the same,
|
|
||||||
suspect the icon bundle (it has a duplicate-named layer, "…Layer-3 2.svg"), not the
|
|
||||||
project.
|
|
||||||
- **Tests from Xcode**: the package tests run with `swift test`; to get them on ⌘U, add
|
|
||||||
`PunktfunkKitTests` once via Edit Scheme → Test → + (Xcode persists it into the shared
|
|
||||||
scheme — a hand-written package-test reference doesn't resolve headlessly).
|
|
||||||
- `xcodebuild -project Punktfunk.xcodeproj -scheme Punktfunk build` works headlessly;
|
|
||||||
same for `-scheme Punktfunk-iOS -destination 'generic/platform=iOS Simulator'` (run it
|
|
||||||
in a simulator via `xcrun simctl install/launch` — `SIMCTL_CHILD_PUNKTFUNK_AUTOCONNECT=…`
|
|
||||||
passes the dev autoconnect env through).
|
|
||||||
|
|
||||||
## Notes for whoever picks this up next
|
- **Xcode project** (`Punktfunk.xcodeproj`) wraps the same sources as the `swift run` shell (a
|
||||||
|
synchronized folder — no duplication). The macOS target is **App-Sandboxed** (needs
|
||||||
|
`network.server` — the raw-UDP plane and quinn both `bind()`); iOS/tvOS use the shared
|
||||||
|
entitlements file (keep `app-sandbox` **out** of it). Verify with
|
||||||
|
`codesign -d --entitlements :- <built .app>`.
|
||||||
|
- **Decode flow**: the host opens every stream with an IDR carrying VPS/SPS/PPS in-band, and recovery
|
||||||
|
keyframes re-send them — refresh the format description on every IDR; there is no out-of-band
|
||||||
|
extradata, ever.
|
||||||
|
- **ABI threading**: one video pump thread per connection, one optional audio drain thread, and one
|
||||||
|
optional feedback drain thread (rumble + HID-output). `send()` is enqueue-only and safe alongside
|
||||||
|
all of them. The wrapper's per-plane locks make `close()` safe from anywhere.
|
||||||
|
- **DualSense motion scale** (`GamepadWire`) is derived from hid-playstation's math, not yet
|
||||||
|
live-verified — if gyro/accel feel wrong in a game, correct sign/scale there and `evtest` the
|
||||||
|
host's virtual pad.
|
||||||
|
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
|
||||||
|
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
|
||||||
|
every main push. See the script header for details.
|
||||||
|
- Deeper design notes live in [`design/apple-stage2-presenter.md`](../../design/apple-stage2-presenter.md).
|
||||||
|
|
||||||
1. **cbindgen import quirk** (the predicted "small compile fixes", now fixed): the
|
## Related
|
||||||
C17-compatible header spells `PunktfunkStatus`/`PunktfunkInputKind` as integer typedefs while
|
|
||||||
the enum *constants* import into Swift as a distinct same-named type — bridge with
|
|
||||||
`.rawValue` (see the top of `PunktfunkConnection.swift`). Don't fight the generated header.
|
|
||||||
2. **ABI contract**: one video pump thread per connection, plus optionally one *separate*
|
|
||||||
audio drain thread for `nextAudio()` and one feedback drain thread for
|
|
||||||
`nextRumble()`/`nextHidOutput()` (the core keeps per-plane borrow slots, so the planes
|
|
||||||
never alias; rumble + HID-output are two planes drained sequentially by the one
|
|
||||||
feedback thread); `send()` is enqueue-only and safe alongside all of them. The
|
|
||||||
wrapper's per-plane locks make `close()` safe from anywhere (it waits out in-flight
|
|
||||||
polls, ≤ their timeouts).
|
|
||||||
3. **Decode flow**: the host opens every stream with an IDR carrying VPS/SPS/PPS in-band
|
|
||||||
and recovery keyframes re-send them — "refresh the format description on every IDR"
|
|
||||||
(what `StreamView` does) is sufficient; there is no out-of-band extradata, ever.
|
|
||||||
4. **Stage 2 — built, opt-in (`punktfunk.presenter == "stage2"`, default stage 1).** Explicit
|
|
||||||
`VTDecompressionSession` decode (`VideoDecoder`) → a `CAMetalLayer` + display-link present
|
|
||||||
(`MetalVideoPresenter`/`Stage2Pipeline`), hosted as a sublayer by the same `StreamView`s with
|
|
||||||
input capture + HUD unchanged. It adds a **capture→present** (glass-to-glass, modulo the host
|
|
||||||
render→capture term) HUD line, skew-corrected via `PunktfunkConnection.clockOffsetNs`. The
|
|
||||||
decode half is unit-tested (`testVideoDecoderAsyncCallbackDeliversPixels`); the Metal present
|
|
||||||
is display-bound — **validate live** (flip the Settings "Presenter" picker, watch the HUD
|
|
||||||
number and that the image looks right) before making it the default. 10-bit/HDR + a smoothing
|
|
||||||
pacer are later. Plan: `docs-site/content/docs/apple-stage2-presenter.md`.
|
|
||||||
5. **Audio — wired, both directions.** Playback: `SessionAudio` drains `nextAudio()`
|
|
||||||
on its own thread, decodes through CoreAudio's built-in Opus codec (`OpusCodec.swift`
|
|
||||||
— kAudioFormatOpus, no bundled libopus; round-trip unit-tested) into a priming
|
|
||||||
jitter ring feeding an `AVAudioSourceNode`. Mic: a second engine taps the input
|
|
||||||
device, resamples to 48 kHz stereo, Opus-encodes 20 ms chunks and `sendMic()`s them
|
|
||||||
(the host's virtual PipeWire source accepts any frame size ≤ 120 ms). Speaker/mic
|
|
||||||
are chosen in Settings (`AudioDevices.swift` — persisted by UID; "System default"
|
|
||||||
leaves the engines unpinned so they follow macOS device changes), mic on/off toggle
|
|
||||||
included; the app asks for mic permission on first use
|
|
||||||
(NSMicrophoneUsageDescription is in the Xcode target). A/V sync and packet-loss
|
|
||||||
concealment beyond silence-fill are still open (AudioPacket.seq/ptsNs carry what's
|
|
||||||
needed). Decode with libopus or `AVAudioConverter`/`kAudioFormatOpus` into an
|
|
||||||
`AVAudioEngine` source node; conceal gaps (drop/dup) rather than blocking — the Rust
|
|
||||||
side buffers 320 ms and drops the newest packet when the puller lags. Wall-clock
|
|
||||||
`ptsNs` shares the host clock with video AUs for A/V sync. Wiring this into
|
|
||||||
`PunktfunkClient` is the next app-side task.
|
|
||||||
6. **Gamepads — wired end to end.** Exactly ONE controller (the `GamepadManager`
|
|
||||||
selection) forwards as pad 0; the host accumulates the incremental events into a
|
|
||||||
virtual pad whose TYPE the client negotiates in the Hello (`gamepad:` connect
|
|
||||||
parameter, echoed resolved in `resolvedGamepad` — Automatic resolves from the physical
|
|
||||||
pad at connect time; host precedence: explicit client choice > host `PUNKTFUNK_GAMEPAD`
|
|
||||||
env > Xbox 360). A DualSense session carries the full feel: adaptive-trigger blocks
|
|
||||||
(`DualSenseTriggerEffect.parse` — mode bytes per the community convention
|
|
||||||
(Nielk1/ds5w/inputtino), total, unknown → `.off`), lightbar, player LEDs, touchpad,
|
|
||||||
motion. **Motion scale constants** (`GamepadWire.gyroLSBPerRadS` = 20 LSB per deg/s,
|
|
||||||
`accelLSBPerG` = 10000) are derived from hid-playstation's math over the host's fixed
|
|
||||||
calibration blob, not yet live-verified — if gyro/accel feel wrong in a real game,
|
|
||||||
correct sign/scale in `GamepadCapture.forwardMotion`/`GamepadWire` and `evtest` the
|
|
||||||
host's virtual pad. Twin identical controllers share a fingerprint base, so a manual
|
|
||||||
pin can swap between them across reconnects (documented in the Settings footer).
|
|
||||||
7. **Trust — the full ceremony exists now (SPAKE2).** `generateIdentity()` once (persist
|
|
||||||
both PEMs in the Keychain), then `pair(host:identity:pin:name:)` with the 4-digit PIN
|
|
||||||
the host prints when it ARMS pairing (`--allow-pairing`/`--require-pairing`; one PIN
|
|
||||||
per arming window, surfaced in the host's web console — port 3000 → Pairing — and
|
|
||||||
printed at startup; the user reads it before pairing). Returns the
|
|
||||||
host's VERIFIED fingerprint; persist it and pass `pinSHA256:` + `identity:` to every
|
|
||||||
connect. Pairing is a real PAKE: a wrong PIN gets ONE online guess (no offline
|
|
||||||
dictionary attack), throwing `.wrongPIN`; a wrong-size pin throws `.invalidPin`. `PunktfunkClient` implements both flows:
|
|
||||||
the TOFU fingerprint sheet keeps working against hosts not running
|
|
||||||
`--require-pairing`, and the PIN ceremony is wired in — `ClientIdentityStore`
|
|
||||||
(Keychain) on every connect, `PairSheet` from a host card's context menu or the trust
|
|
||||||
prompt's "Pair with PIN instead…" (the host's accept loop is sequential, so that path
|
|
||||||
drops the live session before pairing). With `--require-pairing` the host now
|
|
||||||
authorizes clients too (the "other direction" is no longer open, opt-in per host);
|
|
||||||
the whole gate is regression-tested in `testPairingCeremonyAndRequirePairingGate`.
|
|
||||||
7b. **Resize without reconnect**: `requestMode(width:height:refreshHz:)` mid-stream —
|
|
||||||
the host rebuilds at the new mode in ~90 ms; the first new-mode AU is an IDR with
|
|
||||||
fresh parameter sets (the refresh-on-IDR decode flow handles it untouched) and
|
|
||||||
`currentMode()` reflects the switch. Wire it to window-resize events.
|
|
||||||
8. **Input capture** (stage 1): capture is a deliberate, reversible STATE owned by
|
|
||||||
`StreamLayerView`, Moonlight-style. Engaged when the stream starts / trust is
|
|
||||||
confirmed and when the user clicks into the video (that click is suppressed toward
|
|
||||||
the host); released by ⌘⎋ (toggles) or focus loss; NEVER engaged by mere app
|
|
||||||
activation — activating clicks may be title-bar drags or resizes, which used to get
|
|
||||||
their cursor warped away mid-drag. While captured: the local cursor is hidden +
|
|
||||||
frozen mid-view (the host renders its own), all input is forwarded, and the view
|
|
||||||
consumes key events as first responder so unhandled keyDowns don't beep — ⌘-combos
|
|
||||||
still work locally (⌘D disconnect, ⌘Q) *and* reach the host via GC. While released:
|
|
||||||
nothing is forwarded (`InputCapture.forwarding` gates the GC handlers; held
|
|
||||||
keys/buttons are flushed host-side on release so nothing sticks down), the cursor is
|
|
||||||
free, and the HUD shows "Click the stream to capture input". GC handlers only fire
|
|
||||||
while the app has focus, and focus loss also auto-releases everything held. One live capture per process (the GC
|
|
||||||
mouse/keyboard singletons have a single handler slot — ownership is tracked so a stale
|
|
||||||
capture's stop() can't clobber a newer one).
|
|
||||||
9. **iOS/iPadOS — ported and first-lit** (iPad simulator ↔ the real host, 60 fps).
|
|
||||||
`BUILD_IOS=1 bash scripts/build-xcframework.sh` builds device + universal-simulator
|
|
||||||
slices; the Xcode project has a second target, **Punktfunk-iOS**, sharing the same
|
|
||||||
synchronized sources. The iOS `StreamView` (StreamViewIOS.swift — same name/signature
|
|
||||||
as the macOS one, so the SwiftUI shell is identical) hosts the shared `StreamPump` in
|
|
||||||
a view controller for `prefersPointerLocked`: with a hardware mouse/trackpad that is
|
|
||||||
the iPadOS cursor capture (system honors it fullscreen-and-frontmost; in Stage
|
|
||||||
Manager it degrades to absolute-mouse forwarding). Input is routed by kind: DIRECT
|
|
||||||
fingers / Pencil are touches (each gets a wire touch id, coordinates mapped through the
|
|
||||||
aspect-fit letterbox into host-mode pixels — surface == host mode, so the host rescale is
|
|
||||||
the identity), while a mouse/trackpad is a MOUSE — pointer-LOCKED it is GCMouse relative
|
|
||||||
deltas; unlocked it is absolute moves + buttons + scroll over the UIKit pointer path
|
|
||||||
(hover + `.indirectPointer` touches), the local cursor staying visible so you can aim. An
|
|
||||||
indirect pointer is never sent as a touch. Touch is gated on trust (not forwarded under
|
|
||||||
the TOFU prompt), and returning to the foreground restores the capture you had on leaving.
|
|
||||||
`InputCapture` is cross-platform (GC works the same on iPadOS; ⌘⎋ is detected from
|
|
||||||
the HID stream there); audio routes via `AVAudioSession` (the Settings device
|
|
||||||
pickers are macOS-only). For the iPad-with-external-display setup: the target
|
|
||||||
enables multiple scenes + indirect input events — on Stage Manager iPads, drag the
|
|
||||||
punktfunk window onto the external screen and the stream runs there with full
|
|
||||||
keyboard/mouse/touch. While streaming the session is immersive (edge-to-edge,
|
|
||||||
status bar + home indicator hidden) and the iPadOS cursor is hidden over the video only
|
|
||||||
while the scene is actually pointer-LOCKED (`UIPointerInteraction` `.hidden()`); when the
|
|
||||||
lock isn't held it stays visible and the mouse forwards as an absolute cursor instead; on
|
|
||||||
iOS first run the stream mode defaults to the device's native screen so the video
|
|
||||||
fills the display. **tvOS** runs the same app (target **Punktfunk-tvOS**, first-lit
|
|
||||||
in the Apple TV simulator at 720p60): playback-only audio (no mic on tvOS),
|
|
||||||
focus-driven UI (`.card` host tiles), no kb/mouse capture yet — input lands with
|
|
||||||
gamepad support, the natural tvOS input anyway. While streaming there is NO focusable
|
|
||||||
control (a focusable Disconnect button would let the focus engine eat the controller's A
|
|
||||||
before the host sees it); the Siri Remote's **Menu** button disconnects (`.onExitCommand`).
|
|
||||||
Core slices are tier-3 Rust targets (see Build above). Known gaps: true pointer LOCK (`prefersPointerLocked`) isn't
|
|
||||||
consulted through UIHostingController, so the hidden cursor can still drift onto a
|
|
||||||
second screen (fixing it means putting the controller into the UIKit presentation
|
|
||||||
chain); and
|
|
||||||
AVAudioSession interruptions (calls, Siri) don't auto-restart the audio engines yet
|
|
||||||
(reconnect recovers).
|
|
||||||
|
|
||||||
## Known limitations of the current host (relevant to client UX)
|
- **[Documentation](https://docs.punktfunk.unom.io)** — quick start, pairing, troubleshooting
|
||||||
|
- **[Project README](../../README.md)** — the host, the other clients, and how it all fits together
|
||||||
- One session **at a time** (the listener is persistent, but a second concurrent client
|
|
||||||
waits in the accept queue until the current session ends — the virtual output and
|
|
||||||
encoder are single-tenant).
|
|
||||||
- Mid-stream renegotiation (resolution change without reconnect) is designed-for but not
|
|
||||||
implemented (the Welcome is one-shot today).
|
|
||||||
- Host-side gamepad injection needs `/dev/uinput` access on the box (udev rule from
|
|
||||||
`docs/linux-setup.md`).
|
|
||||||
|
|||||||
@@ -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,16 +27,44 @@ 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.codec) private var codec = "auto"
|
||||||
|
@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
|
||||||
|
/// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto).
|
||||||
|
private var preferredCodecByte: UInt8 {
|
||||||
|
switch codec {
|
||||||
|
case "h264": return PunktfunkConnection.codecH264
|
||||||
|
case "hevc": return PunktfunkConnection.codecHEVC
|
||||||
|
case "av1": return PunktfunkConnection.codecAV1
|
||||||
|
default: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@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)
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
#endif
|
#endif
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
// A connected controller (+ the Settings toggle) swaps the whole home screen for
|
||||||
|
// GamepadHomeView instead of retrofitting HomeView's touch/desktop UI — see `home` below.
|
||||||
|
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||||
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
|
private var gamepadUIActive: Bool {
|
||||||
|
GamepadUIEnvironment.isActive(
|
||||||
|
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -54,10 +84,31 @@ struct ContentView: View {
|
|||||||
autoConnectIfAsked()
|
autoConnectIfAsked()
|
||||||
}
|
}
|
||||||
.onChange(of: model.phase) { _, phase in
|
.onChange(of: model.phase) { _, phase in
|
||||||
|
switch phase {
|
||||||
|
case .streaming:
|
||||||
// A session actually started — remember it on the card ("Connected … ago"
|
// A session actually started — remember it on the card ("Connected … ago"
|
||||||
// plus the accent ring on the most recent host).
|
// plus the accent ring on the most recent host).
|
||||||
if case .streaming = phase, let host = model.activeHost {
|
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)
|
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)
|
||||||
@@ -83,22 +134,117 @@ struct ContentView: View {
|
|||||||
.sheet(item: $speedTestTarget) { host in
|
.sheet(item: $speedTestTarget) { host in
|
||||||
SpeedTestSheet(host: host)
|
SpeedTestSheet(host: host)
|
||||||
}
|
}
|
||||||
|
// The library is a full-screen presentation, not a sheet: on iPad a sheet is a centered page
|
||||||
|
// card, but the gamepad coverflow is meant to be an immersive, full-bleed screen (and the
|
||||||
|
// launcher behind it stops consuming the controller — see GamepadHomeView's `isActive`).
|
||||||
|
// macOS has no `fullScreenCover`, so it keeps the sheet there — with an explicit size: a
|
||||||
|
// macOS sheet takes its content's IDEAL size, and both library layouts are geometry-driven
|
||||||
|
// (the coverflow is a GeometryReader, ideal ≈ zero), so without a frame it collapses to a
|
||||||
|
// tiny panel.
|
||||||
|
#if os(macOS)
|
||||||
.sheet(item: $libraryTarget) { host in
|
.sheet(item: $libraryTarget) { host in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||||||
}
|
}
|
||||||
|
.frame(minWidth: 940, minHeight: 620)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
.fullScreenCover(item: $libraryTarget) { host in
|
||||||
|
NavigationStack {
|
||||||
|
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#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.")
|
||||||
|
}
|
||||||
|
// One "Connection failed" surface for every home screen (touch grid, gamepad launcher) and
|
||||||
|
// platform — SessionModel funnels all connect/session errors into `errorMessage`.
|
||||||
|
.alert(
|
||||||
|
"Connection failed",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { model.errorMessage != nil },
|
||||||
|
set: { if !$0 { model.errorMessage = nil } })
|
||||||
|
) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(model.errorMessage ?? "")
|
||||||
|
}
|
||||||
|
// 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 {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
Group {
|
||||||
|
if gamepadUIActive {
|
||||||
|
GamepadHomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
libraryTarget: $libraryTarget,
|
||||||
|
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||||
|
} else {
|
||||||
HomeView(
|
HomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#elseif os(iOS)
|
||||||
|
Group {
|
||||||
|
if gamepadUIActive {
|
||||||
|
GamepadHomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
libraryTarget: $libraryTarget,
|
||||||
|
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||||
|
} else {
|
||||||
|
HomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||||
|
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||||
|
showSettings: $showSettings,
|
||||||
|
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||||
|
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
HomeView(
|
HomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
@@ -187,7 +333,8 @@ struct ContentView: View {
|
|||||||
onSessionEnd: { [weak model] in
|
onSessionEnd: { [weak model] in
|
||||||
Task { @MainActor in model?.sessionEnded() }
|
Task { @MainActor in model?.sessionEnded() }
|
||||||
},
|
},
|
||||||
presentMeter: model.presentLatency
|
presentMeter: model.presentLatency,
|
||||||
|
presentTailMeter: model.presentTail
|
||||||
)
|
)
|
||||||
.overlay(alignment: placement.alignment) {
|
.overlay(alignment: placement.alignment) {
|
||||||
if captureEnabled && hudEnabled {
|
if captureEnabled && hudEnabled {
|
||||||
@@ -229,19 +376,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 +412,26 @@ 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,
|
||||||
|
preferredCodec: preferredCodecByte,
|
||||||
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 +444,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 +454,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 +470,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 +556,9 @@ struct ContentView: View {
|
|||||||
compositor: pref,
|
compositor: pref,
|
||||||
gamepad: pad,
|
gamepad: pad,
|
||||||
bitrateKbps: bitrate,
|
bitrateKbps: bitrate,
|
||||||
|
audioChannels: UInt8(clamping: audioChannels),
|
||||||
|
hdrEnabled: hdrEnabled,
|
||||||
|
preferredCodec: preferredCodecByte,
|
||||||
autoTrust: true)
|
autoTrust: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,3 +583,11 @@ 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?
|
||||||
|
}
|
||||||
|
|||||||
+7
-2
@@ -80,6 +80,11 @@ struct AddHostSheet: View {
|
|||||||
}
|
}
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
|
#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
|
#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
|
||||||
@@ -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,234 @@
|
|||||||
|
// The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS) — the controller counterpart of
|
||||||
|
// AddHostSheet, reached from the launcher's Add Host tile. Three field rows (name / address /
|
||||||
|
// port) plus the Add action, navigated with the same vertical focus list as the gamepad settings;
|
||||||
|
// A on a field opens GamepadKeyboard in a bottom tray, so a host can be registered end to end
|
||||||
|
// without touching the screen. Field edits are live (the row shows every keystroke); B closes the
|
||||||
|
// keyboard first, then cancels the screen — the same "back peels one layer" rule as a console UI.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
|
||||||
|
struct GamepadAddHostView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
let onAdd: (StoredHost) -> Void
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// `.compact` in a landscape phone window — tighter chrome so the keyboard tray still fits.
|
||||||
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
|
|
||||||
|
private var compact: Bool { vSizeClass == .compact }
|
||||||
|
#else
|
||||||
|
private let compact = false // no size classes on macOS; the sheet is sized to fit the tray
|
||||||
|
#endif
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var address = ""
|
||||||
|
@State private var port = "9777"
|
||||||
|
@State private var focusID: String?
|
||||||
|
/// The field row the keyboard tray is editing; nil ⇒ the row list owns the controller.
|
||||||
|
@State private var editing: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GamepadMenuList(
|
||||||
|
items: rows,
|
||||||
|
focusID: $focusID,
|
||||||
|
onActivate: { activate(id: $0.id) },
|
||||||
|
onBack: { dismiss() },
|
||||||
|
isActive: editing == nil
|
||||||
|
) { row, focused in
|
||||||
|
rowView(row, focused: focused)
|
||||||
|
.frame(maxWidth: 620)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Add Host")
|
||||||
|
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
if !compact {
|
||||||
|
Text("Hosts on this network appear automatically — add one by address "
|
||||||
|
+ "for everything else.")
|
||||||
|
.font(.geist(13, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 440)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
|
.padding(.bottom, compact ? 4 : 8)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.overlay(alignment: .topTrailing) { closeButton.padding(.trailing, 20) }
|
||||||
|
.background { GamepadTrayScrim(edge: .top) }
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
|
bottomTray
|
||||||
|
.padding(.horizontal, 22)
|
||||||
|
.padding(.vertical, compact ? 6 : 10)
|
||||||
|
.background { GamepadTrayScrim(edge: .bottom) }
|
||||||
|
}
|
||||||
|
.background { GamepadScreenBackground() }
|
||||||
|
// A port can't exceed 5 digits — cap while typing so the row can't grow absurd.
|
||||||
|
.onChange(of: port) { _, value in
|
||||||
|
if value.count > 5 { port = String(value.prefix(5)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keyboard tray while editing, the controls legend otherwise.
|
||||||
|
@ViewBuilder private var bottomTray: some View {
|
||||||
|
if let editing {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
GamepadKeyboard(
|
||||||
|
text: editingBinding(editing),
|
||||||
|
allowed: allowedCharacters(editing),
|
||||||
|
onDone: { closeKeyboard() })
|
||||||
|
// Fresh keyboard per field: a touch user can retarget the tray by tapping
|
||||||
|
// another field row, and the keyboard's input wiring captured the previous
|
||||||
|
// binding on appear — new identity forces a rewire to the new field.
|
||||||
|
.id(editing)
|
||||||
|
GamepadHintBar(hints: [
|
||||||
|
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
||||||
|
.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete"),
|
||||||
|
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||||
|
])
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
} else {
|
||||||
|
GamepadHintBar(hints: [
|
||||||
|
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
|
||||||
|
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
|
||||||
|
])
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||||
|
/// rides the cancel action.
|
||||||
|
private var closeButton: some View {
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
|
.glassBackground(Circle(), interactive: true)
|
||||||
|
.contentShape(Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityLabel("Cancel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rows
|
||||||
|
|
||||||
|
private struct Row: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let label: String
|
||||||
|
var value = ""
|
||||||
|
var placeholder = ""
|
||||||
|
var isAction = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rows: [Row] {
|
||||||
|
[
|
||||||
|
Row(id: "name", label: "Name", value: name, placeholder: "Optional — e.g. Living Room"),
|
||||||
|
Row(id: "address", label: "Address", value: address, placeholder: "IP or hostname"),
|
||||||
|
Row(id: "port", label: "Port", value: port, placeholder: "9777"),
|
||||||
|
Row(id: "add", label: "Add Host", isAction: true),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
if row.isAction {
|
||||||
|
Label("Add Host", systemImage: "plus.circle.fill")
|
||||||
|
.font(.geist(16, .semibold, relativeTo: .body))
|
||||||
|
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
Text(row.label)
|
||||||
|
.font(.geist(16, .semibold, relativeTo: .body))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
Text(row.value.isEmpty ? row.placeholder : row.value)
|
||||||
|
.font(.geistFixed(15, .medium))
|
||||||
|
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.head) // keep the end of a long address visible while typing
|
||||||
|
if editing == row.id {
|
||||||
|
// The live-edit caret: this row is what the keyboard tray is typing into.
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.brand)
|
||||||
|
.frame(width: 2, height: 18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 13)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.fill(.white.opacity(focused || editing == row.id ? 0.1 : 0))
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.strokeBorder(
|
||||||
|
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.22 : 0),
|
||||||
|
lineWidth: 1)
|
||||||
|
}
|
||||||
|
.scaleEffect(focused ? 1.0 : 0.98)
|
||||||
|
.animation(.smooth(duration: 0.18), value: focused)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func activate(id: String) {
|
||||||
|
switch id {
|
||||||
|
case "add":
|
||||||
|
guard canAdd else {
|
||||||
|
// Not addable yet — jump straight to what's missing instead of a dead press.
|
||||||
|
focusID = "address"
|
||||||
|
openKeyboard("address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onAdd(StoredHost(
|
||||||
|
name: name.trimmingCharacters(in: .whitespaces),
|
||||||
|
address: address.trimmingCharacters(in: .whitespaces),
|
||||||
|
port: UInt16(port) ?? 9777))
|
||||||
|
dismiss()
|
||||||
|
default:
|
||||||
|
openKeyboard(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canAdd: Bool {
|
||||||
|
!address.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& UInt16(port).map { $0 > 0 } == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openKeyboard(_ id: String) {
|
||||||
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeKeyboard() {
|
||||||
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func editingBinding(_ id: String) -> Binding<String> {
|
||||||
|
switch id {
|
||||||
|
case "name": return $name
|
||||||
|
case "port": return $port
|
||||||
|
default: return $address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the keyboard may type per field: a port is digits, an address never contains spaces;
|
||||||
|
/// a name is free-form.
|
||||||
|
private func allowedCharacters(_ id: String) -> CharacterSet? {
|
||||||
|
switch id {
|
||||||
|
case "port": return CharacterSet(charactersIn: "0123456789")
|
||||||
|
case "address": return CharacterSet(charactersIn: " ").inverted
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
|
||||||
|
// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely
|
||||||
|
// by a controller (iOS/iPadOS/macOS).
|
||||||
|
//
|
||||||
|
// The scrolling is pure native SwiftUI — `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
|
||||||
|
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
|
||||||
|
// container width, so it's correct in an iPad split view too) lets the first and last item reach the
|
||||||
|
// middle. The CALLER owns each card's look, including its own `.scrollTransition` — this component
|
||||||
|
// deliberately applies none, so a screen can chain the VisualEffect-only transition modifiers without
|
||||||
|
// the generic wrapper here pushing the type-checker onto an overload it can't satisfy.
|
||||||
|
//
|
||||||
|
// Navigation authority: an internal `cursor` (an index), NOT the scroll-position binding, is the
|
||||||
|
// source of truth for where the gamepad is. `.scrollPosition(id:)` is a two-way binding and the
|
||||||
|
// scroll view WRITES intermediate ids into it while a programmatic animation is in flight — so
|
||||||
|
// reading the "current" item back out of it to compute the next one desyncs badly on a fast held
|
||||||
|
// stick (each move reads a lagging value and the cursor stalls before the last item). Instead a move
|
||||||
|
// advances `cursor` synchronously and points the scroll view at `items[cursor]`; scroll read-back is
|
||||||
|
// only allowed to move the cursor when the gamepad hasn't driven recently (i.e. a touch drag).
|
||||||
|
//
|
||||||
|
// Feedback is dual-channel by design: `.sensoryFeedback` ticks the DEVICE Taptic engine (for a
|
||||||
|
// handheld/touch user) and `MenuHaptics` ticks the CONTROLLER (for a couch user holding the pad).
|
||||||
|
// Both fire on a move, on confirm, and — for a non-wrapping list — a duller bump plus a short visual
|
||||||
|
// recoil when a move is refused at either end.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
|
||||||
|
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
|
||||||
|
let items: [Item]
|
||||||
|
/// Output only: the carousel WRITES the focused item's id here for the caller's detail panel.
|
||||||
|
/// It is deliberately not what drives the scroll (see the file header).
|
||||||
|
@Binding var selection: Item.ID?
|
||||||
|
/// Every card is laid out at this fixed width so `.viewAligned` snapping + symmetric side
|
||||||
|
/// insets center exactly one at a time.
|
||||||
|
let itemWidth: CGFloat
|
||||||
|
let spacing: CGFloat
|
||||||
|
/// A → activate the centered item.
|
||||||
|
let onActivate: (Item) -> Void
|
||||||
|
/// Y → the screen's secondary action (e.g. open a host's library); nil disables it.
|
||||||
|
var onSecondary: (() -> Void)?
|
||||||
|
/// X → the screen's tertiary action (e.g. open settings); nil disables it.
|
||||||
|
var onTertiary: (() -> Void)?
|
||||||
|
/// B → back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to).
|
||||||
|
var onBack: (() -> Void)?
|
||||||
|
/// L1/R1 → jump this many items at once (clamped to the ends); 0 disables the shoulders.
|
||||||
|
var shoulderJump: Int = 0
|
||||||
|
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
|
||||||
|
/// launcher) stays mounted behind a presented one (e.g. the library), and both carousels would
|
||||||
|
/// otherwise poll the SAME controller at once — driving both. The parent sets this false while
|
||||||
|
/// something is presented on top so only the front-most carousel consumes the gamepad.
|
||||||
|
var isActive: Bool = true
|
||||||
|
@ViewBuilder let card: (Item) -> Card
|
||||||
|
|
||||||
|
@State private var input = GamepadMenuInput(manager: .shared)
|
||||||
|
@State private var haptics = MenuHaptics(manager: .shared)
|
||||||
|
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
|
||||||
|
/// while the gamepad is driving — that's the whole desync fix.
|
||||||
|
@State private var cursor = 0
|
||||||
|
/// The id the scroll view is aligned to — its own two-way `.scrollPosition` state.
|
||||||
|
@State private var scrolledID: Item.ID?
|
||||||
|
/// When the gamepad last moved the cursor; gates scroll read-back so a mid-animation write can't
|
||||||
|
/// drag the cursor backward during a fast held direction.
|
||||||
|
@State private var lastNav = Date.distantPast
|
||||||
|
/// True while a programmatic scroll animation is in flight. `.scrollPosition(id:)` DROPS a new
|
||||||
|
/// write that lands mid-animation — the scroll view stays stuck on the old item even though the
|
||||||
|
/// binding updated — so we never issue one until the previous animation reports complete, then
|
||||||
|
/// `commitScroll` re-targets the current cursor (coalescing a fast burst; see `commitScroll`).
|
||||||
|
@State private var isScrolling = false
|
||||||
|
/// A short horizontal recoil when a move is refused at a list end.
|
||||||
|
@State private var bumpOffset: CGFloat = 0
|
||||||
|
/// `.sensoryFeedback` fires on a change of its trigger; counters request a device tick for the
|
||||||
|
/// confirm and end-stop events (moves trigger on `cursor`).
|
||||||
|
@State private var activateTick = 0
|
||||||
|
@State private var boundaryTick = 0
|
||||||
|
|
||||||
|
/// Read-back from a touch drag is honoured only once the gamepad has been quiet this long
|
||||||
|
/// (longer than a move animation, so overlapping held-stick moves never let it through).
|
||||||
|
private let navSettle: TimeInterval = 0.4
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let inset = max(0, (geo.size.width - itemWidth) / 2)
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack(spacing: spacing) {
|
||||||
|
ForEach(items) { item in
|
||||||
|
card(item)
|
||||||
|
.frame(width: itemWidth)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { tap(item) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: geo.size.height) // fill so shorter cards center vertically
|
||||||
|
.scrollTargetLayout()
|
||||||
|
}
|
||||||
|
.scrollPosition(id: $scrolledID)
|
||||||
|
.scrollTargetBehavior(.viewAligned)
|
||||||
|
// .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden
|
||||||
|
// and paints a scroller across the console strip.
|
||||||
|
.scrollIndicators(.never)
|
||||||
|
.scrollClipDisabled() // let the focused card scale up past the strip bounds
|
||||||
|
.safeAreaPadding(.horizontal, inset)
|
||||||
|
.offset(x: bumpOffset)
|
||||||
|
}
|
||||||
|
.sensoryFeedback(.selection, trigger: cursor)
|
||||||
|
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||||
|
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||||
|
.onAppear {
|
||||||
|
reconcile()
|
||||||
|
wire()
|
||||||
|
if isActive { input.start() }
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
input.stop()
|
||||||
|
haptics.stop()
|
||||||
|
}
|
||||||
|
// Hand controller input to/from a screen presented on top (see `isActive`): a covered
|
||||||
|
// carousel stops polling so it can't navigate behind the front-most one.
|
||||||
|
.onChange(of: isActive) { _, active in
|
||||||
|
if active {
|
||||||
|
wire()
|
||||||
|
input.start()
|
||||||
|
} else {
|
||||||
|
input.stop()
|
||||||
|
haptics.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a
|
||||||
|
// programmatic scroll is animating (its own intermediate id write-backs would regress the
|
||||||
|
// cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag
|
||||||
|
// — which never sets `isScrolling` — moves the cursor here.
|
||||||
|
.onChange(of: scrolledID) { _, newValue in
|
||||||
|
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
|
||||||
|
guard let idx = index(of: newValue), idx != cursor else { return }
|
||||||
|
cursor = idx
|
||||||
|
selection = newValue
|
||||||
|
}
|
||||||
|
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
|
||||||
|
// current `items` value (a plain array — unlike an observed object it would otherwise go
|
||||||
|
// stale in the closures stored on `input`).
|
||||||
|
.onChange(of: items.map(\.id)) { _, _ in
|
||||||
|
reconcile()
|
||||||
|
wire()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Input wiring
|
||||||
|
|
||||||
|
private func wire() {
|
||||||
|
input.onMove = { move($0) }
|
||||||
|
input.onConfirm = { activate() }
|
||||||
|
input.onSecondary = onSecondary
|
||||||
|
input.onTertiary = onTertiary
|
||||||
|
input.onBack = onBack
|
||||||
|
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||||
|
let forward = direction == .right || direction == .down
|
||||||
|
step(by: forward ? 1 : -1, clampAtEnds: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shoulder(right: Bool) {
|
||||||
|
step(by: right ? shoulderJump : -shoulderJump, clampAtEnds: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance the cursor by `delta`. A single move (`clampAtEnds: false`) that would leave the list
|
||||||
|
/// recoils + bumps; a shoulder jump (`clampAtEnds: true`) lands on the end item, bumping only if
|
||||||
|
/// already there. The cursor is the authority — the scroll view is pointed at it, never read for it.
|
||||||
|
private func step(by delta: Int, clampAtEnds: Bool) {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
var target = cursor + delta
|
||||||
|
if target < 0 || target >= items.count {
|
||||||
|
guard clampAtEnds else { return boundaryBump(forward: delta > 0) }
|
||||||
|
target = min(max(target, 0), items.count - 1)
|
||||||
|
}
|
||||||
|
guard target != cursor else { return boundaryBump(forward: delta > 0) }
|
||||||
|
cursor = target
|
||||||
|
lastNav = Date()
|
||||||
|
haptics.move()
|
||||||
|
selection = items[target].id // text/detail updates immediately; the scroll chases
|
||||||
|
commitScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private let scrollAnim: TimeInterval = 0.24
|
||||||
|
/// A hair past `scrollAnim` — long enough that the scroll has actually settled before the next
|
||||||
|
/// write, short enough to stay responsive.
|
||||||
|
private var scrollSettle: TimeInterval { scrollAnim + 0.05 }
|
||||||
|
|
||||||
|
/// Drive the scroll toward the current cursor, one honoured write at a time. `.scrollPosition(id:)`
|
||||||
|
/// DROPS a write that lands while a scroll is still animating, so we issue at most one at a time and
|
||||||
|
/// re-target the LATEST cursor once it settles — coalescing a fast burst (hold OR quick flicks) and
|
||||||
|
/// always converging on the final item, instead of getting stuck on the old card.
|
||||||
|
///
|
||||||
|
/// The settle is timed by a plain timer rather than `withAnimation`'s completion: `scrolledID` is a
|
||||||
|
/// discrete id, not an animatable value, so `withAnimation` has no tracked animation to fire a
|
||||||
|
/// reliable completion against (it can fire early — which is exactly what let quick flicks slip a
|
||||||
|
/// write through mid-scroll and stick). `asyncAfter` always fires, so `isScrolling` can never latch.
|
||||||
|
private func commitScroll() {
|
||||||
|
guard !isScrolling, cursor >= 0, cursor < items.count else { return }
|
||||||
|
let id = items[cursor].id
|
||||||
|
guard scrolledID != id else { return }
|
||||||
|
isScrolling = true
|
||||||
|
withAnimation(.easeOut(duration: scrollAnim)) { scrolledID = id }
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + scrollSettle) {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
isScrolling = false
|
||||||
|
commitScroll() // the cursor may have advanced while this scroll ran — chase it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activate() {
|
||||||
|
guard cursor >= 0, cursor < items.count else { return }
|
||||||
|
activateTick &+= 1
|
||||||
|
haptics.confirm()
|
||||||
|
onActivate(items[cursor])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
|
||||||
|
/// any other re-centers on it.
|
||||||
|
private func tap(_ item: Item) {
|
||||||
|
if let idx = index(of: item.id), idx == cursor {
|
||||||
|
activate()
|
||||||
|
} else if let idx = index(of: item.id) {
|
||||||
|
cursor = idx
|
||||||
|
lastNav = Date()
|
||||||
|
haptics.move()
|
||||||
|
selection = item.id
|
||||||
|
commitScroll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selection housekeeping
|
||||||
|
|
||||||
|
private func index(of id: Item.ID?) -> Int? {
|
||||||
|
guard let id else { return nil }
|
||||||
|
return items.firstIndex { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep `cursor`/`scrolledID`/`selection` consistent with `items`: seed on appear, and on a list
|
||||||
|
/// change keep the same focused item when it survives, else clamp the cursor into range.
|
||||||
|
private func reconcile() {
|
||||||
|
guard !items.isEmpty else {
|
||||||
|
cursor = 0
|
||||||
|
if scrolledID != nil { scrolledID = nil }
|
||||||
|
if selection != nil { selection = nil }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let sid = scrolledID, let idx = index(of: sid) {
|
||||||
|
cursor = idx
|
||||||
|
if selection != sid { selection = sid }
|
||||||
|
} else {
|
||||||
|
let idx = min(max(cursor, 0), items.count - 1)
|
||||||
|
cursor = idx
|
||||||
|
let id = items[idx].id
|
||||||
|
scrolledID = id
|
||||||
|
selection = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func boundaryBump(forward: Bool) {
|
||||||
|
boundaryTick &+= 1
|
||||||
|
haptics.boundary()
|
||||||
|
let recoil: CGFloat = forward ? -16 : 16
|
||||||
|
withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bumpOffset = recoil }
|
||||||
|
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// Chrome shared by the gamepad-driven screens (GamepadHomeView, GamepadSettingsView,
|
||||||
|
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
|
||||||
|
// controller-glyph hint bar, and the connected-controller status chip. One look across every
|
||||||
|
// screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages.
|
||||||
|
// iOS/iPadOS and macOS (the couch Mac-mini case); tvOS keeps its native focus engine instead.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
import GameController
|
||||||
|
|
||||||
|
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
||||||
|
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
|
||||||
|
/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit).
|
||||||
|
@MainActor
|
||||||
|
func buttonGlyph(
|
||||||
|
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
|
||||||
|
) -> String {
|
||||||
|
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
|
||||||
|
?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance — the launcher
|
||||||
|
/// title sits right under the window titlebar and the settings/add-host sheets have no titlebar
|
||||||
|
/// at all, so the iOS value hugs the top edge there.
|
||||||
|
func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
|
||||||
|
#if os(macOS)
|
||||||
|
26
|
||||||
|
#else
|
||||||
|
compact ? 4 : 10
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One glyph + label cell in a hint bar.
|
||||||
|
struct GamepadHint: Identifiable {
|
||||||
|
let glyph: String
|
||||||
|
let text: String
|
||||||
|
var id: String { glyph + text }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pinned controls legend every gamepad screen shows bottom-leading (via `.safeAreaInset`).
|
||||||
|
/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration.
|
||||||
|
struct GamepadHintBar: View {
|
||||||
|
let hints: [GamepadHint]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 18) {
|
||||||
|
ForEach(hints) { hint in
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
Image(systemName: hint.glyph)
|
||||||
|
.font(.system(size: 19))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text(hint.text)
|
||||||
|
}
|
||||||
|
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
||||||
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The console backdrop: a living "aurora" field in the brand's violet family — soft color blobs
|
||||||
|
/// drifting on slow Lissajous paths over black, in the direction of Apple Music's animated player
|
||||||
|
/// background but calmer (long 30–90 s periods, muted opacities, a legibility scrim on top, so it
|
||||||
|
/// reads as ambience behind the cards, never as content). Deliberately pure SwiftUI rather than a
|
||||||
|
/// .metal shader: these sources are built both by SwiftPM (`swift run`/tests) and by the Xcode
|
||||||
|
/// project's synchronized folders, and a compiled metallib is only reliably bundled in one of the
|
||||||
|
/// two — radial gradients driven by a TimelineView give the same look with none of that risk.
|
||||||
|
///
|
||||||
|
/// Applied via `.background { }` — NOT as a ZStack sibling — so the `.ignoresSafeArea()` here
|
||||||
|
/// can't inflate the caller's layout past the safe area (see the layout discipline note in
|
||||||
|
/// GamepadHomeView's header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||||
|
struct GamepadScreenBackground: View {
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
|
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular
|
||||||
|
/// speeds (rad/s — periods of 30–90 s), and a radius that slowly breathes.
|
||||||
|
private struct Blob {
|
||||||
|
let color: Color
|
||||||
|
let center: CGPoint
|
||||||
|
let drift: CGSize
|
||||||
|
let speed: (x: Double, y: Double)
|
||||||
|
let phase: (x: Double, y: Double)
|
||||||
|
/// Radius as a fraction of the view's larger dimension (+ breathing amplitude/speed).
|
||||||
|
let radius: CGFloat
|
||||||
|
let breathe: (amount: CGFloat, speed: Double)
|
||||||
|
let opacity: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The brand violet, a deeper indigo, a warmer plum, and a cool blue — related hues so the
|
||||||
|
/// field shifts within one temperature instead of strobing through the rainbow.
|
||||||
|
private static let blobs: [Blob] = [
|
||||||
|
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
|
||||||
|
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
|
||||||
|
speed: (0.111, 0.083), phase: (0.0, 1.9),
|
||||||
|
radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52),
|
||||||
|
Blob(color: Color(red: 0.24, green: 0.20, blue: 0.72), // deep indigo
|
||||||
|
center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
|
||||||
|
speed: (0.071, 0.096), phase: (2.4, 0.7),
|
||||||
|
radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55),
|
||||||
|
Blob(color: Color(red: 0.62, green: 0.30, blue: 0.80), // plum
|
||||||
|
center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
|
||||||
|
speed: (0.089, 0.067), phase: (4.1, 3.2),
|
||||||
|
radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42),
|
||||||
|
Blob(color: Color(red: 0.22, green: 0.38, blue: 0.86), // cool blue
|
||||||
|
center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
|
||||||
|
speed: (0.059, 0.104), phase: (1.2, 5.0),
|
||||||
|
radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if reduceMotion {
|
||||||
|
field(at: 0)
|
||||||
|
} else {
|
||||||
|
// 30 Hz is plenty for centimeters-per-minute drift, and halves the redraw cost
|
||||||
|
// of a battery-fed couch device vs. the default display rate.
|
||||||
|
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||||||
|
field(at: context.date.timeIntervalSinceReferenceDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func field(at t: TimeInterval) -> some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let side = max(geo.size.width, geo.size.height)
|
||||||
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
ZStack {
|
||||||
|
ForEach(Self.blobs.indices, id: \.self) { i in
|
||||||
|
blobView(Self.blobs[i], at: t, in: geo.size, side: side)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ±10° over ~5 min — the whole field very slowly warms and cools.
|
||||||
|
.hueRotation(.degrees(sin(t * 0.021) * 10))
|
||||||
|
// Composite the additive blobs offscreen once instead of per-layer.
|
||||||
|
.drawingGroup()
|
||||||
|
// Legibility scrim: the title (top) and detail/hints (bottom) always sit on
|
||||||
|
// near-black, whatever the blobs are doing behind them.
|
||||||
|
LinearGradient(
|
||||||
|
stops: [
|
||||||
|
.init(color: .black.opacity(0.55), location: 0),
|
||||||
|
.init(color: .black.opacity(0.15), location: 0.35),
|
||||||
|
.init(color: .black.opacity(0.20), location: 0.65),
|
||||||
|
.init(color: .black.opacity(0.60), location: 1),
|
||||||
|
],
|
||||||
|
startPoint: .top, endPoint: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func blobView(_ blob: Blob, at t: TimeInterval, in size: CGSize, side: CGFloat) -> some View {
|
||||||
|
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
|
||||||
|
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
|
||||||
|
let r = side * blob.radius
|
||||||
|
* (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x)))
|
||||||
|
return Circle()
|
||||||
|
.fill(RadialGradient(
|
||||||
|
colors: [blob.color, blob.color.opacity(0)],
|
||||||
|
center: .center, startRadius: 0, endRadius: r / 2))
|
||||||
|
.frame(width: r, height: r)
|
||||||
|
.position(x: x * size.width, y: y * size.height)
|
||||||
|
.opacity(blob.opacity)
|
||||||
|
.blendMode(.plusLighter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A darkening scrim behind a pinned tray (a screen title, the hints/detail bar, the keyboard
|
||||||
|
/// tray): scrollable rows pass beneath those insets, so without this the tray text and the row
|
||||||
|
/// underneath render interleaved. Fades toward the content so it reads as depth, not a bar.
|
||||||
|
struct GamepadTrayScrim: View {
|
||||||
|
let edge: VerticalEdge
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LinearGradient(
|
||||||
|
stops: [
|
||||||
|
.init(color: .black.opacity(0.92), location: 0),
|
||||||
|
.init(color: .black.opacity(0.85), location: 0.55),
|
||||||
|
.init(color: .black.opacity(0), location: 1),
|
||||||
|
],
|
||||||
|
startPoint: edge == .top ? .top : .bottom,
|
||||||
|
endPoint: edge == .top ? .bottom : .top)
|
||||||
|
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own
|
||||||
|
// text always sits on the near-opaque part, rows dim before they reach it.
|
||||||
|
.padding(edge == .top ? .bottom : .top, -32)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet
|
||||||
|
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
||||||
|
/// when the pad or its battery state changes.
|
||||||
|
struct ControllerStatusChip: View {
|
||||||
|
let controller: GamepadManager.DiscoveredController
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
Image(systemName: controller.hasTouchpadAndMotion
|
||||||
|
? "playstation.logo" : "gamecontroller.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Text(controller.name)
|
||||||
|
.lineLimit(1)
|
||||||
|
if let level = controller.batteryLevel {
|
||||||
|
Image(systemName: batterySymbol(level))
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(level <= 0.2 && !controller.isCharging
|
||||||
|
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.geist(12, .medium, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(Capsule().fill(.white.opacity(0.08)))
|
||||||
|
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func batterySymbol(_ level: Float) -> String {
|
||||||
|
if controller.isCharging { return "battery.100.bolt" }
|
||||||
|
switch level {
|
||||||
|
case ..<0.125: return "battery.0"
|
||||||
|
case ..<0.375: return "battery.25"
|
||||||
|
case ..<0.625: return "battery.50"
|
||||||
|
case ..<0.875: return "battery.75"
|
||||||
|
default: return "battery.100"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
// The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host
|
||||||
|
// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active — a separate screen built
|
||||||
|
// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is
|
||||||
|
// required anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the
|
||||||
|
// gamepad settings screen, and the carousel always ends in an Add Host tile that opens the
|
||||||
|
// controller-keyboard add flow. (A tap still works as a fallback for all of it.)
|
||||||
|
//
|
||||||
|
// All the scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the launcher's
|
||||||
|
// chrome. Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a
|
||||||
|
// `.background` modifier — NOT a ZStack sibling — because an `.ignoresSafeArea()` sibling expands the
|
||||||
|
// stack to full-screen and hands the GeometryReader the full height, laying content out under the
|
||||||
|
// status bar / home indicator. As a background it draws behind without affecting layout, so the
|
||||||
|
// GeometryReader is sized to the safe area. The title and the controller-glyph hints are pinned with
|
||||||
|
// `.safeAreaInset` (top / bottom-leading) — guaranteed inside the safe area and out of the carousel's
|
||||||
|
// vertical budget — and the card is sized off the remaining height. macOS mounts it too (the
|
||||||
|
// couch Mac-mini case) — same screen, with the settings/add-host covers presented as sheets
|
||||||
|
// (macOS has no fullScreenCover). tvOS never mounts this view (native focus engine instead).
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
import GameController
|
||||||
|
|
||||||
|
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
|
||||||
|
/// action. Hashable so it can be the carousel's scroll-position identity.
|
||||||
|
private enum GamepadHomeTarget: Hashable {
|
||||||
|
case saved(UUID)
|
||||||
|
case discovered(String)
|
||||||
|
case addHost
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fully-resolved launcher tile — display fields + the activate action, built fresh each render
|
||||||
|
/// from the live stores so nothing goes stale.
|
||||||
|
private struct HomeTile: Identifiable {
|
||||||
|
let id: GamepadHomeTarget
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
var isOnline = false
|
||||||
|
var isPaired = false
|
||||||
|
var isConnecting = false
|
||||||
|
/// Saved (solid monogram) vs. discovered-but-unsaved / action (tinted outline).
|
||||||
|
var filled = false
|
||||||
|
/// Only saved hosts have a library (matches the touch grid's context-menu gate).
|
||||||
|
var hasLibrary = false
|
||||||
|
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
|
||||||
|
var icon: String?
|
||||||
|
/// Whether the detail panel shows the online/paired pill (hosts yes, actions no).
|
||||||
|
var showsStatus = true
|
||||||
|
let activate: () -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GamepadHomeView: View {
|
||||||
|
@ObservedObject var store: HostStore
|
||||||
|
@ObservedObject var model: SessionModel
|
||||||
|
@ObservedObject var discovery: HostDiscovery
|
||||||
|
@Binding var libraryTarget: StoredHost?
|
||||||
|
let connect: (StoredHost) -> Void
|
||||||
|
let connectDiscovered: (DiscoveredHost) -> Void
|
||||||
|
|
||||||
|
/// Same experimental gate the touch grid's "Browse Library…" context-menu item uses.
|
||||||
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||||
|
#if os(iOS)
|
||||||
|
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
||||||
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
|
|
||||||
|
private var compact: Bool { vSizeClass == .compact }
|
||||||
|
#else
|
||||||
|
private let compact = false // no size classes on macOS; the window minimum keeps room
|
||||||
|
#endif
|
||||||
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
@State private var selection: GamepadHomeTarget?
|
||||||
|
@State private var showSettings = false
|
||||||
|
@State private var showAddHost = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
hero(for: geo.size)
|
||||||
|
}
|
||||||
|
// Pinned inside the safe area, out of the carousel's vertical budget — never clipped.
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
titleBar
|
||||||
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
|
.padding(.bottom, compact ? 4 : 8)
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||||
|
GamepadHintBar(hints: hints)
|
||||||
|
.padding(.leading, 22)
|
||||||
|
.padding(.vertical, compact ? 6 : 10)
|
||||||
|
}
|
||||||
|
.background { GamepadScreenBackground() }
|
||||||
|
.onAppear { discovery.start() }
|
||||||
|
.onDisappear { discovery.stop() }
|
||||||
|
// The settings / add-host screens take over the controller (the carousel's `isActive`
|
||||||
|
// gate above). iOS presents them full screen — the immersive console feel; macOS has no
|
||||||
|
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
||||||
|
#if os(macOS)
|
||||||
|
.sheet(isPresented: $showSettings) {
|
||||||
|
GamepadSettingsView()
|
||||||
|
.frame(width: 720, height: 640)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAddHost) {
|
||||||
|
GamepadAddHostView { store.add($0) }
|
||||||
|
.frame(width: 660, height: 620)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 640, minHeight: 420)
|
||||||
|
#else
|
||||||
|
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() }
|
||||||
|
.fullScreenCover(isPresented: $showAddHost) {
|
||||||
|
GamepadAddHostView { store.add($0) }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
|
||||||
|
|
||||||
|
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
||||||
|
let cardWidth = min(340, size.width * 0.84)
|
||||||
|
// 96 ≈ the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so
|
||||||
|
// the strip + detail always fit the region the safe-area insets leave.
|
||||||
|
let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96))
|
||||||
|
VStack(spacing: compact ? 8 : 10) {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
||||||
|
detailPanel
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chrome
|
||||||
|
|
||||||
|
private var titleBar: some View {
|
||||||
|
Text("Select a Host")
|
||||||
|
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
|
// Which pad is driving this UI (name + battery) — quiet, and only where there's
|
||||||
|
// room; a compact-height phone gives the pixels to the carousel instead.
|
||||||
|
if !compact, let active = gamepads.active {
|
||||||
|
ControllerStatusChip(controller: active)
|
||||||
|
.padding(.trailing, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Carousel
|
||||||
|
|
||||||
|
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
|
||||||
|
GamepadCarousel(
|
||||||
|
items: tiles,
|
||||||
|
selection: $selection,
|
||||||
|
itemWidth: cardWidth,
|
||||||
|
spacing: 30,
|
||||||
|
onActivate: { $0.activate() },
|
||||||
|
onSecondary: { openLibraryForSelected() },
|
||||||
|
onTertiary: { showSettings = true },
|
||||||
|
// Stop consuming the controller while another screen is presented on top — otherwise
|
||||||
|
// the launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet).
|
||||||
|
isActive: libraryTarget == nil && !showSettings && !showAddHost
|
||||||
|
) { tile in
|
||||||
|
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
||||||
|
}
|
||||||
|
.frame(height: cardHeight + 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host tile plus its focus treatment. Every continuous visual reads the scroll view's own
|
||||||
|
/// per-frame `phase` (real distance-from-centered), so the look always matches what's on screen
|
||||||
|
/// mid-scroll. `.shadow`/`.overlay` aren't part of `VisualEffect`, so the focus pop is scale +
|
||||||
|
/// brightness/saturation + a depth blur on the recessed neighbors.
|
||||||
|
private func hostCard(_ tile: HomeTile, size: CGSize) -> some View {
|
||||||
|
GamepadHostTile(tile: tile, size: size)
|
||||||
|
.scrollTransition { content, phase in
|
||||||
|
let d = CGFloat(min(abs(phase.value), 1))
|
||||||
|
let scale = 1 - d * 0.12
|
||||||
|
let bright = Double(-d * 0.24)
|
||||||
|
let sat = Double(1 - d * 0.42)
|
||||||
|
let soft = d * 3
|
||||||
|
let fade = Double(1 - d * 0.22)
|
||||||
|
return content
|
||||||
|
.scaleEffect(scale)
|
||||||
|
.brightness(bright)
|
||||||
|
.saturation(sat)
|
||||||
|
.blur(radius: soft)
|
||||||
|
.opacity(fade)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The "now focused" host, spelled out below the strip — empty (not hidden) so the layout
|
||||||
|
/// doesn't jump as the selection changes.
|
||||||
|
@ViewBuilder private var detailPanel: some View {
|
||||||
|
let tile = tiles.first { $0.id == selection }
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text(tile?.title ?? " ")
|
||||||
|
.font(.geist(22, .bold, relativeTo: .title2))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(tile?.subtitle ?? " ")
|
||||||
|
.font(.geist(13, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
if let tile, tile.showsStatus {
|
||||||
|
statusPill(online: tile.isOnline, paired: tile.isPaired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.animation(.smooth(duration: 0.25), value: selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusPill(online: Bool, paired: Bool) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(online ? Color.green : Color.white.opacity(0.35))
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
Text(online ? "ONLINE" : "OFFLINE")
|
||||||
|
if paired { Text("· PAIRED") }
|
||||||
|
}
|
||||||
|
.font(.geist(11, .medium, relativeTo: .caption2))
|
||||||
|
.tracking(0.8)
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
||||||
|
|
||||||
|
private var hints: [GamepadHint] {
|
||||||
|
let selected = tiles.first { $0.id == selection }
|
||||||
|
var hints = [GamepadHint(
|
||||||
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
||||||
|
text: selected?.id == .addHost ? "Add Host" : "Connect")]
|
||||||
|
if libraryEnabled, selected?.hasLibrary == true {
|
||||||
|
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
||||||
|
}
|
||||||
|
hints.append(.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings"))
|
||||||
|
return hints
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data + actions
|
||||||
|
|
||||||
|
/// Built fresh each render from the live stores (no stale value capture) — saved hosts first,
|
||||||
|
/// then discovered-but-unsaved ones, then the Add Host action tile (so the strip is never
|
||||||
|
/// empty and manual entry is always one press away).
|
||||||
|
private var tiles: [HomeTile] {
|
||||||
|
let saved = store.hosts.map { host in
|
||||||
|
HomeTile(
|
||||||
|
id: .saved(host.id),
|
||||||
|
title: host.displayName,
|
||||||
|
subtitle: "\(host.address):\(String(host.port))",
|
||||||
|
isOnline: discovery.advertises(host),
|
||||||
|
isPaired: host.pinnedSHA256 != nil,
|
||||||
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
|
filled: true,
|
||||||
|
hasLibrary: true,
|
||||||
|
activate: { connect(host) })
|
||||||
|
}
|
||||||
|
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
||||||
|
HomeTile(
|
||||||
|
id: .discovered(d.id),
|
||||||
|
title: d.name,
|
||||||
|
subtitle: "\(d.host):\(String(d.port))",
|
||||||
|
isOnline: true,
|
||||||
|
activate: { connectDiscovered(d) })
|
||||||
|
}
|
||||||
|
let add = HomeTile(
|
||||||
|
id: .addHost,
|
||||||
|
title: "Add Host",
|
||||||
|
subtitle: "Register a host by address",
|
||||||
|
icon: "plus",
|
||||||
|
showsStatus: false,
|
||||||
|
activate: { showAddHost = true })
|
||||||
|
return saved + discovered + [add]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a
|
||||||
|
/// `HostCardView`-only action never offered on `DiscoveredCardView`.
|
||||||
|
private func openLibraryForSelected() {
|
||||||
|
guard libraryEnabled, case .saved(let id) = selection,
|
||||||
|
let host = store.hosts.first(where: { $0.id == id })
|
||||||
|
else { return }
|
||||||
|
libraryTarget = host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One "console tile" in the host carousel — a dark-glass landscape card, bigger and bolder than the
|
||||||
|
/// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by
|
||||||
|
/// the caller's `.scrollTransition` so it always tracks the real scroll position.
|
||||||
|
private struct GamepadHostTile: View {
|
||||||
|
let tile: HomeTile
|
||||||
|
let size: CGSize
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
monogramBadge
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if tile.isOnline {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.green)
|
||||||
|
.frame(width: 9, height: 9)
|
||||||
|
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(tile.title)
|
||||||
|
.font(.geist(23, .bold, relativeTo: .title2))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
Text(tile.subtitle)
|
||||||
|
.font(.geist(13, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||||
|
.strokeBorder(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.white.opacity(0.22), .white.opacity(0.04)],
|
||||||
|
startPoint: .top, endPoint: .bottom),
|
||||||
|
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var monogramBadge: some View {
|
||||||
|
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous)
|
||||||
|
return ZStack {
|
||||||
|
shape.fill(tile.filled
|
||||||
|
? AnyShapeStyle(LinearGradient(
|
||||||
|
colors: [Color.brand, Color.brand.opacity(0.68)],
|
||||||
|
startPoint: .top, endPoint: .bottom))
|
||||||
|
: AnyShapeStyle(Color.brand.opacity(0.16)))
|
||||||
|
if tile.isConnecting {
|
||||||
|
ProgressView().tint(.white)
|
||||||
|
} else if let icon = tile.icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 24, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.brand)
|
||||||
|
} else {
|
||||||
|
Text(monogram(tile.title))
|
||||||
|
.font(.geistFixed(25, .bold))
|
||||||
|
.foregroundStyle(tile.filled ? .white : Color.brand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 52, height: 52)
|
||||||
|
.overlay {
|
||||||
|
if !tile.filled {
|
||||||
|
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func monogram(_ name: String) -> String {
|
||||||
|
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
|
||||||
|
return String(first).uppercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// A controller-driven on-screen keyboard for the gamepad UI's text fields (iOS/iPadOS only) —
|
||||||
|
// iOS has no system keyboard a game controller can drive (the tvOS fullscreen entry doesn't
|
||||||
|
// exist here), so without this, adding a host from the couch would end with "now touch the
|
||||||
|
// screen". Dpad/stick moves a key cursor over a fixed grid, A types, X backspaces, B/Y confirms.
|
||||||
|
// Lowercase + digits + the hostname/address punctuation is deliberately the whole character set:
|
||||||
|
// these fields hold names, addresses and ports, not prose.
|
||||||
|
//
|
||||||
|
// Edits are applied to the binding live (the caller's field row shows every keystroke), so
|
||||||
|
// closing the keyboard is always "done" — there is no separate cancel/commit step to get wrong.
|
||||||
|
// Touch stays a fallback: every keycap is tappable.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
|
||||||
|
struct GamepadKeyboard: View {
|
||||||
|
@Binding var text: String
|
||||||
|
/// Restricts typed characters (e.g. digits for a port field); backspace always works.
|
||||||
|
var allowed: CharacterSet?
|
||||||
|
/// B / Y / the Done key — the binding already holds the final text.
|
||||||
|
let onDone: () -> Void
|
||||||
|
|
||||||
|
@State private var input = GamepadMenuInput(manager: .shared)
|
||||||
|
@State private var haptics = MenuHaptics(manager: .shared)
|
||||||
|
@State private var cursor = GridPos(row: 1, col: 0) // opens on "q"
|
||||||
|
@State private var pressTick = 0
|
||||||
|
@State private var boundaryTick = 0
|
||||||
|
#if os(iOS)
|
||||||
|
/// `.compact` (landscape phone): shorter keycaps so the tray leaves room for the field rows.
|
||||||
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
|
|
||||||
|
private var compact: Bool { vSizeClass == .compact }
|
||||||
|
#else
|
||||||
|
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private struct GridPos: Hashable {
|
||||||
|
var row: Int
|
||||||
|
var col: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Key: Hashable {
|
||||||
|
case char(Character)
|
||||||
|
case space
|
||||||
|
case backspace
|
||||||
|
case done
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Digits first (addresses/ports), then letters; the last char column carries the
|
||||||
|
/// hostname/address punctuation.
|
||||||
|
private static let rows: [[Key]] = [
|
||||||
|
Array("1234567890").map(Key.char),
|
||||||
|
Array("qwertyuiop").map(Key.char),
|
||||||
|
Array("asdfghjkl-").map(Key.char),
|
||||||
|
Array("zxcvbnm._:").map(Key.char),
|
||||||
|
[.space, .backspace, .done],
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: compact ? 5 : 7) {
|
||||||
|
ForEach(Self.rows.indices, id: \.self) { r in
|
||||||
|
HStack(spacing: compact ? 5 : 7) {
|
||||||
|
ForEach(Self.rows[r].indices, id: \.self) { c in
|
||||||
|
keycap(Self.rows[r][c], focused: cursor == GridPos(row: r, col: c))
|
||||||
|
.onTapGesture {
|
||||||
|
cursor = GridPos(row: r, col: c)
|
||||||
|
press(Self.rows[r][c])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 560)
|
||||||
|
.padding(compact ? 10 : 14)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
|
||||||
|
}
|
||||||
|
.sensoryFeedback(.selection, trigger: cursor)
|
||||||
|
.sensoryFeedback(.impact(weight: .light), trigger: pressTick)
|
||||||
|
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||||
|
.onAppear {
|
||||||
|
wire()
|
||||||
|
input.start()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
input.stop()
|
||||||
|
haptics.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keycaps
|
||||||
|
|
||||||
|
@ViewBuilder private func keycap(_ key: Key, focused: Bool) -> some View {
|
||||||
|
Group {
|
||||||
|
switch key {
|
||||||
|
case .char(let c):
|
||||||
|
Text(String(c)).font(.geistFixed(18, .medium))
|
||||||
|
case .space:
|
||||||
|
Image(systemName: "space")
|
||||||
|
case .backspace:
|
||||||
|
Image(systemName: "delete.left")
|
||||||
|
case .done:
|
||||||
|
Label("Done", systemImage: "checkmark")
|
||||||
|
.font(.geist(15, .semibold, relativeTo: .callout))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(focused ? Color.black : .white)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: compact ? 34 : 42)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||||
|
.fill(focused ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.white.opacity(0.08)))
|
||||||
|
}
|
||||||
|
.animation(.smooth(duration: 0.12), value: focused)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Input
|
||||||
|
|
||||||
|
private func wire() {
|
||||||
|
input.onMove = { move($0) }
|
||||||
|
input.onConfirm = { press(Self.rows[cursor.row][cursor.col]) }
|
||||||
|
input.onTertiary = { press(.backspace) }
|
||||||
|
input.onSecondary = onDone
|
||||||
|
input.onBack = onDone
|
||||||
|
}
|
||||||
|
|
||||||
|
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||||
|
var next = cursor
|
||||||
|
switch direction {
|
||||||
|
case .left: next.col -= 1
|
||||||
|
case .right: next.col += 1
|
||||||
|
case .up, .down:
|
||||||
|
let row = cursor.row + (direction == .down ? 1 : -1)
|
||||||
|
guard row >= 0, row < Self.rows.count else { return refuse() }
|
||||||
|
// Map the column proportionally between rows of different widths, so e.g. Done
|
||||||
|
// (rightmost of 3) goes up to the rightmost letters, not to "e".
|
||||||
|
let from = max(1, Self.rows[cursor.row].count - 1)
|
||||||
|
let to = Self.rows[row].count - 1
|
||||||
|
next = GridPos(
|
||||||
|
row: row,
|
||||||
|
col: Int((Double(cursor.col) * Double(to) / Double(from)).rounded()))
|
||||||
|
}
|
||||||
|
guard next.row >= 0, next.row < Self.rows.count,
|
||||||
|
next.col >= 0, next.col < Self.rows[next.row].count
|
||||||
|
else { return refuse() }
|
||||||
|
cursor = next
|
||||||
|
haptics.move()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func press(_ key: Key) {
|
||||||
|
switch key {
|
||||||
|
case .char(let c):
|
||||||
|
if let allowed, !c.unicodeScalars.allSatisfy(allowed.contains) { return refuse() }
|
||||||
|
text.append(c)
|
||||||
|
case .space:
|
||||||
|
if let allowed, !allowed.contains(" ") { return refuse() }
|
||||||
|
text.append(" ")
|
||||||
|
case .backspace:
|
||||||
|
guard !text.isEmpty else { return refuse() }
|
||||||
|
text.removeLast()
|
||||||
|
case .done:
|
||||||
|
haptics.confirm()
|
||||||
|
onDone()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pressTick &+= 1
|
||||||
|
haptics.move()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refused input (edge of the grid, a disallowed character, deleting nothing).
|
||||||
|
private func refuse() {
|
||||||
|
boundaryTick &+= 1
|
||||||
|
haptics.boundary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS): a controller-driven focus list for
|
||||||
|
// the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves a
|
||||||
|
// focus bar through the rows, left/right adjusts the focused row's value, A activates it, B backs
|
||||||
|
// out. The CALLER owns each row's look (it gets the focused flag); this component owns the focus
|
||||||
|
// cursor, controller polling, haptics, and keeping the focused row scrolled into view.
|
||||||
|
//
|
||||||
|
// Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the
|
||||||
|
// cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a
|
||||||
|
// first-class fallback — tapping a row focuses AND activates it (rows are always fully visible, so
|
||||||
|
// the carousel's "first tap re-centers" step would only add friction here), and free finger
|
||||||
|
// scrolling is never hijacked back to the focused row until the next controller move.
|
||||||
|
//
|
||||||
|
// Feedback is dual-channel like the carousel: `.sensoryFeedback` ticks the DEVICE Taptic engine,
|
||||||
|
// `MenuHaptics` ticks the CONTROLLER. Moves and value changes get the crisp detent; a refused
|
||||||
|
// move at either end gets the dull boundary thud plus a short vertical recoil.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
|
||||||
|
struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable {
|
||||||
|
let items: [Item]
|
||||||
|
/// Output only: the list WRITES the focused item's id here (e.g. for a caller's hint bar).
|
||||||
|
@Binding var focusID: Item.ID?
|
||||||
|
/// Left/right on the focused row. Return whether the value actually changed — true plays the
|
||||||
|
/// move detent, false the boundary thud (end of a clamped range, or nothing to adjust).
|
||||||
|
var onAdjust: ((Item, Int) -> Bool)?
|
||||||
|
/// A → activate the focused row (toggle it, open it, run it — the caller decides).
|
||||||
|
let onActivate: (Item) -> Void
|
||||||
|
/// B → back/dismiss; nil disables it.
|
||||||
|
var onBack: (() -> Void)?
|
||||||
|
/// Whether this list currently owns controller input — same handoff contract as
|
||||||
|
/// GamepadCarousel's `isActive` (a covered screen must stop polling the shared pad).
|
||||||
|
var isActive: Bool = true
|
||||||
|
@ViewBuilder let row: (Item, _ focused: Bool) -> Row
|
||||||
|
|
||||||
|
@State private var input = GamepadMenuInput(manager: .shared)
|
||||||
|
@State private var haptics = MenuHaptics(manager: .shared)
|
||||||
|
/// Authoritative focus cursor (index into `items`).
|
||||||
|
@State private var cursor = 0
|
||||||
|
/// A short vertical recoil when a move is refused at a list end.
|
||||||
|
@State private var bumpOffset: CGFloat = 0
|
||||||
|
/// `.sensoryFeedback` counters (see GamepadCarousel): device ticks for activate / value-change
|
||||||
|
/// / end-stop events; moves trigger on `cursor` itself.
|
||||||
|
@State private var activateTick = 0
|
||||||
|
@State private var adjustTick = 0
|
||||||
|
@State private var boundaryTick = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView(.vertical) {
|
||||||
|
LazyVStack(spacing: 6) {
|
||||||
|
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
||||||
|
row(item, idx == cursor && isActive)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { tap(idx) }
|
||||||
|
.id(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
// .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden.
|
||||||
|
.scrollIndicators(.never)
|
||||||
|
.offset(y: bumpOffset)
|
||||||
|
.onChange(of: cursor) { _, newValue in
|
||||||
|
guard newValue >= 0, newValue < items.count else { return }
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
proxy.scrollTo(items[newValue].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sensoryFeedback(.selection, trigger: cursor)
|
||||||
|
.sensoryFeedback(.selection, trigger: adjustTick)
|
||||||
|
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||||
|
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||||
|
.onAppear {
|
||||||
|
reconcile()
|
||||||
|
wire()
|
||||||
|
if isActive { input.start() }
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
input.stop()
|
||||||
|
haptics.stop()
|
||||||
|
}
|
||||||
|
.onChange(of: isActive) { _, active in
|
||||||
|
if active {
|
||||||
|
wire()
|
||||||
|
input.start()
|
||||||
|
} else {
|
||||||
|
input.stop()
|
||||||
|
haptics.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-seed a dropped focus AND re-wire the input callbacks so they capture the current
|
||||||
|
// `items` value (a plain array — it would otherwise go stale in the stored closures).
|
||||||
|
.onChange(of: items.map(\.id)) { _, _ in
|
||||||
|
reconcile()
|
||||||
|
wire()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Input wiring
|
||||||
|
|
||||||
|
private func wire() {
|
||||||
|
input.onMove = { direction in
|
||||||
|
switch direction {
|
||||||
|
case .up: step(by: -1)
|
||||||
|
case .down: step(by: 1)
|
||||||
|
case .left: adjust(by: -1)
|
||||||
|
case .right: adjust(by: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.onConfirm = { activate() }
|
||||||
|
input.onBack = onBack
|
||||||
|
}
|
||||||
|
|
||||||
|
private func step(by delta: Int) {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
let target = cursor + delta
|
||||||
|
guard target >= 0, target < items.count else { return boundaryBump(forward: delta > 0) }
|
||||||
|
cursor = target
|
||||||
|
focusID = items[target].id
|
||||||
|
haptics.move()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func adjust(by delta: Int) {
|
||||||
|
guard let onAdjust, cursor >= 0, cursor < items.count else { return }
|
||||||
|
if onAdjust(items[cursor], delta) {
|
||||||
|
adjustTick &+= 1
|
||||||
|
haptics.move()
|
||||||
|
} else {
|
||||||
|
boundaryTick &+= 1
|
||||||
|
haptics.boundary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activate() {
|
||||||
|
guard cursor >= 0, cursor < items.count else { return }
|
||||||
|
activateTick &+= 1
|
||||||
|
haptics.confirm()
|
||||||
|
onActivate(items[cursor])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Touch fallback: a tap focuses the row and activates it in one go.
|
||||||
|
private func tap(_ idx: Int) {
|
||||||
|
guard idx >= 0, idx < items.count else { return }
|
||||||
|
if cursor != idx {
|
||||||
|
cursor = idx
|
||||||
|
focusID = items[idx].id
|
||||||
|
}
|
||||||
|
activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep `cursor`/`focusID` consistent with `items`: seed on appear; on a list change keep the
|
||||||
|
/// same focused item when it survives, else clamp the cursor into range.
|
||||||
|
private func reconcile() {
|
||||||
|
guard !items.isEmpty else {
|
||||||
|
cursor = 0
|
||||||
|
if focusID != nil { focusID = nil }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let id = focusID, let idx = items.firstIndex(where: { $0.id == id }) {
|
||||||
|
cursor = idx
|
||||||
|
} else {
|
||||||
|
cursor = min(max(cursor, 0), items.count - 1)
|
||||||
|
focusID = items[cursor].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func boundaryBump(forward: Bool) {
|
||||||
|
boundaryTick &+= 1
|
||||||
|
haptics.boundary()
|
||||||
|
let recoil: CGFloat = forward ? -14 : 14
|
||||||
|
withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bumpOffset = recoil }
|
||||||
|
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
+13
-31
@@ -127,28 +127,16 @@ 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()
|
||||||
.navigationTitle("Settings")
|
.settingsSheetSizing()
|
||||||
.toolbar {
|
|
||||||
Button("Done") { showSettings = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
.alert(
|
|
||||||
"Connection failed",
|
|
||||||
isPresented: Binding(
|
|
||||||
get: { model.errorMessage != nil },
|
|
||||||
set: { if !$0 { model.errorMessage = nil } }
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Button("OK", role: .cancel) {}
|
|
||||||
} message: {
|
|
||||||
Text(model.errorMessage ?? "")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cards
|
// MARK: - Cards
|
||||||
@@ -157,7 +145,7 @@ struct HomeView: View {
|
|||||||
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
|
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
|
||||||
return HostCardView(
|
return HostCardView(
|
||||||
host: host,
|
host: host,
|
||||||
isOnline: isOnline(host),
|
isOnline: discovery.advertises(host),
|
||||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
isMostRecent: host.id == mostRecentHostID,
|
isMostRecent: host.id == mostRecentHostID,
|
||||||
isBusy: model.isBusy,
|
isBusy: model.isBusy,
|
||||||
@@ -172,7 +160,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) {
|
||||||
@@ -187,18 +175,10 @@ struct HomeView: View {
|
|||||||
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A saved host is "online" iff a live mDNS advert currently matches it (see
|
/// Discovered hosts not already saved (see `HostDiscovery.unsaved` — shared with the gamepad
|
||||||
/// `StoredHost.matches`). Recomputed on every discovery change (the @Published set), so the
|
/// launcher so both screens classify hosts identically).
|
||||||
/// dot tracks hosts appearing/leaving the network live.
|
|
||||||
private func isOnline(_ host: StoredHost) -> Bool {
|
|
||||||
discovery.hosts.contains { host.matches($0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Discovered hosts not already saved — the saved grid shows the rest, so this section only
|
|
||||||
/// surfaces genuinely-new hosts on the network. Same match as the online dot, so a saved host
|
|
||||||
/// whose IP changed (still fingerprint-matched) doesn't also appear here as a stranger.
|
|
||||||
private var discoveredUnsaved: [DiscoveredHost] {
|
private var discoveredUnsaved: [DiscoveredHost] {
|
||||||
discovery.hosts.filter { d in !store.hosts.contains { $0.matches(d) } }
|
discovery.unsaved(among: store.hosts)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The host of the most recent session — its card carries the accent ring.
|
/// The host of the most recent session — its card carries the accent ring.
|
||||||
@@ -249,8 +229,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
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// 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 "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 SwiftUI
|
||||||
|
|
||||||
|
/// Shared host-card sizing — touch-first on iOS, compact on macOS, roomy on tvOS.
|
||||||
|
private struct CardMetrics {
|
||||||
|
let tile: CGFloat // monogram tile side
|
||||||
|
let monogram: CGFloat // monogram letter point size
|
||||||
|
let name: CGFloat // host-name point size
|
||||||
|
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 {
|
||||||
|
#if os(iOS)
|
||||||
|
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
|
||||||
|
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
||||||
|
padding: 13, spacing: 12, radius: 10)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
struct HostCardView: View {
|
||||||
|
let host: StoredHost
|
||||||
|
/// Currently advertising on the LAN (matched against live mDNS discovery). False means
|
||||||
|
/// "not seen on this network" — off, or a remote/cross-subnet host we can't observe.
|
||||||
|
let isOnline: Bool
|
||||||
|
let isConnecting: Bool
|
||||||
|
let isMostRecent: Bool
|
||||||
|
let isBusy: Bool
|
||||||
|
let onConnect: () -> Void
|
||||||
|
let onPair: () -> Void
|
||||||
|
let onSpeedTest: () -> Void
|
||||||
|
let onForget: () -> Void
|
||||||
|
let onRemove: () -> Void
|
||||||
|
/// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
|
||||||
|
var onBrowseLibrary: (() -> Void)? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let m = CardMetrics.current
|
||||||
|
return Button(action: onConnect) {
|
||||||
|
HStack(spacing: m.spacing) {
|
||||||
|
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(host.displayName)
|
||||||
|
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("\(host.address):\(String(host.port))")
|
||||||
|
.font(.geist(m.meta, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
statusRow(m)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(m.padding)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
#if !os(tvOS)
|
||||||
|
// tvOS: the .card button style owns platter + focus motion; extra chrome mutes it.
|
||||||
|
// Elsewhere: a flat material panel with a hairline border (industrial, not a soft blob),
|
||||||
|
// and a brand accent bar down the leading edge for the most-recent host.
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
if isMostRecent {
|
||||||
|
Rectangle().fill(Color.brand).frame(width: 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||||
|
.strokeBorder(.quaternary, lineWidth: 1)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.buttonStyle(.card)
|
||||||
|
#elseif os(iOS)
|
||||||
|
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||||
|
#else
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
|
.disabled(isBusy)
|
||||||
|
.contextMenu {
|
||||||
|
Button("Pair with PIN…", action: onPair)
|
||||||
|
Button("Test Network Speed…", action: onSpeedTest)
|
||||||
|
if let onBrowseLibrary {
|
||||||
|
Button("Browse Library…", action: onBrowseLibrary)
|
||||||
|
}
|
||||||
|
if host.pinnedSHA256 != nil {
|
||||||
|
// Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via
|
||||||
|
// PIN (unless the host advertises pair=optional). Wording reflects that.
|
||||||
|
Button("Forget Identity (re-pair to reconnect)", action: onForget)
|
||||||
|
}
|
||||||
|
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 tinted-outline monogram + dashed panel border
|
||||||
|
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
|
||||||
|
struct DiscoveredCardView: View {
|
||||||
|
let discovered: DiscoveredHost
|
||||||
|
let isBusy: Bool
|
||||||
|
let onConnect: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let m = CardMetrics.current
|
||||||
|
return Button(action: onConnect) {
|
||||||
|
HStack(spacing: m.spacing) {
|
||||||
|
monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(discovered.name)
|
||||||
|
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("\(discovered.host):\(String(discovered.port))")
|
||||||
|
.font(.geist(m.meta, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: discovered.requiresPairing
|
||||||
|
? "lock.fill" : "antenna.radiowaves.left.and.right")
|
||||||
|
.font(.system(size: m.status))
|
||||||
|
.accessibilityHidden(true) // decorative; the adjacent text says the state
|
||||||
|
Text(discovered.requiresPairing ? "PAIRING REQUIRED" : "DISCOVERED")
|
||||||
|
}
|
||||||
|
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||||
|
.tracking(0.8)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(m.padding)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
#if !os(tvOS)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||||
|
.strokeBorder(
|
||||||
|
Color.secondary.opacity(0.3),
|
||||||
|
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.buttonStyle(.card)
|
||||||
|
#elseif os(iOS)
|
||||||
|
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||||
|
#else
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
|
.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
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS — see LibraryView's
|
||||||
|
// `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the
|
||||||
|
// scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card
|
||||||
|
// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
|
||||||
|
// the controller-glyph hints. A steps through covers, A launches the centered title, B closes, and
|
||||||
|
// the shoulders (L1/R1) jump a handful at a time through a long library.
|
||||||
|
//
|
||||||
|
// Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a
|
||||||
|
// `.background` modifier — NOT a ZStack sibling — because an `.ignoresSafeArea()` sibling expands the
|
||||||
|
// stack to full-screen and hands the GeometryReader the full height, laying content out under the
|
||||||
|
// status bar / home indicator. As a background it draws behind without affecting layout, so the
|
||||||
|
// GeometryReader is sized to the safe area, and the controller-glyph hints are pinned inside it with
|
||||||
|
// `.safeAreaInset(.bottom, alignment: .leading)`. Cover size is then derived from the height that
|
||||||
|
// remains, so a tall 2:3 poster + the detail line always fit.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
import GameController
|
||||||
|
|
||||||
|
struct LibraryCoverflowView: View {
|
||||||
|
let games: [GameEntry]
|
||||||
|
let imageSession: URLSession?
|
||||||
|
var onLaunch: ((String) -> Void)?
|
||||||
|
/// Button B (back) — dismisses the library screen. No touch equivalent needed here (the toolbar
|
||||||
|
/// Close button already covers that); this is what makes gamepad-only exit possible.
|
||||||
|
var onDismiss: (() -> Void)?
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// `.compact` in a landscape phone window — drives a tighter poster so everything still fits.
|
||||||
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
|
|
||||||
|
private var compact: Bool { vSizeClass == .compact }
|
||||||
|
#else
|
||||||
|
private let compact = false // no size classes on macOS
|
||||||
|
#endif
|
||||||
|
@State private var selection: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
content(for: geo.size)
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||||
|
GamepadHintBar(hints: hints)
|
||||||
|
.padding(.leading, 22)
|
||||||
|
.padding(.vertical, compact ? 6 : 10)
|
||||||
|
}
|
||||||
|
.background { GamepadScreenBackground() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func content(for size: CGSize) -> some View {
|
||||||
|
// Fit the tallest poster into the height the detail line + paddings leave (the hints are a
|
||||||
|
// safe-area inset, already out of this budget) — capped so it never dwarfs a large iPad and
|
||||||
|
// clamped by width on a narrow screen.
|
||||||
|
let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers
|
||||||
|
let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9))
|
||||||
|
let coverWidth = coverHeight * 2 / 3
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
carousel(coverWidth: coverWidth, coverHeight: coverHeight)
|
||||||
|
detailPanel
|
||||||
|
.padding(.top, 12)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func carousel(coverWidth: CGFloat, coverHeight: CGFloat) -> some View {
|
||||||
|
GamepadCarousel(
|
||||||
|
items: games,
|
||||||
|
selection: $selection,
|
||||||
|
itemWidth: coverWidth,
|
||||||
|
spacing: 34,
|
||||||
|
onActivate: { onLaunch?($0.id) },
|
||||||
|
onBack: { onDismiss?() },
|
||||||
|
shoulderJump: 5
|
||||||
|
) { game in
|
||||||
|
cover(game, width: coverWidth, height: coverHeight)
|
||||||
|
}
|
||||||
|
.frame(height: coverHeight + 44)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One cover + the coverflow recede. Every continuous visual reads the scroll view's own
|
||||||
|
/// per-frame `phase` (real distance-from-centered), so the tilt tracks what's actually on screen
|
||||||
|
/// mid-scroll. `.shadow` isn't a `VisualEffect`, so it's baked constant into the card; the
|
||||||
|
/// scale/rotation/opacity ramp already makes the centered cover prominent.
|
||||||
|
private func cover(_ game: GameEntry, width: CGFloat, height: CGFloat) -> some View {
|
||||||
|
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||||
|
.frame(width: width, height: height)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
|
||||||
|
}
|
||||||
|
.shadow(color: .black.opacity(0.5), radius: 16, y: 12)
|
||||||
|
.scrollTransition { content, phase in
|
||||||
|
let v = phase.value
|
||||||
|
let d = CGFloat(min(abs(v), 1))
|
||||||
|
let scale = 1 - d * 0.24
|
||||||
|
let rot = v * -38
|
||||||
|
let anchor: UnitPoint = v < 0 ? .trailing : .leading
|
||||||
|
let bright = Double(-d * 0.22)
|
||||||
|
let fade = Double(1 - d * 0.38)
|
||||||
|
return content
|
||||||
|
.scaleEffect(scale)
|
||||||
|
.rotation3DEffect(
|
||||||
|
.degrees(rot), axis: (x: 0, y: 1, z: 0), anchor: anchor, perspective: 0.55)
|
||||||
|
.brightness(bright)
|
||||||
|
.opacity(fade)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The centered title + store tag — empty (not hidden) so the layout doesn't jump.
|
||||||
|
@ViewBuilder private var detailPanel: some View {
|
||||||
|
let game = games.first { $0.id == selection }
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text(game?.title ?? " ")
|
||||||
|
.font(.geist(compact ? 22 : 25, .bold, relativeTo: .title))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.75)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
if let game {
|
||||||
|
Text(game.isCustom ? "CUSTOM" : "STEAM")
|
||||||
|
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||||
|
.tracking(1.2)
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.animation(.smooth(duration: 0.25), value: selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
||||||
|
|
||||||
|
private var hints: [GamepadHint] {
|
||||||
|
var hints: [GamepadHint] = []
|
||||||
|
if onLaunch != nil {
|
||||||
|
hints.append(.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch"))
|
||||||
|
}
|
||||||
|
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
|
||||||
|
return hints
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
+50
-53
@@ -12,10 +12,25 @@ struct LibraryView: View {
|
|||||||
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
|
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
|
||||||
/// through). `nil` ⇒ browse-only (cards aren't tappable).
|
/// through). `nil` ⇒ browse-only (cards aren't tappable).
|
||||||
var onLaunch: ((String) -> Void)? = nil
|
var onLaunch: ((String) -> Void)? = nil
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var games: [GameEntry] = []
|
@State private var games: [GameEntry] = []
|
||||||
@State private var loading = false
|
@State private var loading = false
|
||||||
@State private var errorText: String?
|
@State private var errorText: String?
|
||||||
|
/// Authenticated session for cover-art fetches (the same paired identity + host pinning as the
|
||||||
|
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
|
||||||
|
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
|
||||||
|
@State private var imageSession: URLSession?
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
// Gamepad-driven browsing (iOS/iPadOS/macOS) — see ContentView's identical gate. tvOS keeps
|
||||||
|
// its existing plain-grid presentation of this same view unchanged.
|
||||||
|
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||||
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
|
private var gamepadUIActive: Bool {
|
||||||
|
GamepadUIEnvironment.isActive(
|
||||||
|
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
content
|
content
|
||||||
@@ -29,8 +44,20 @@ struct LibraryView: View {
|
|||||||
#else
|
#else
|
||||||
ToolbarItem(placement: .primaryAction) { reloadButton }
|
ToolbarItem(placement: .primaryAction) { reloadButton }
|
||||||
#endif
|
#endif
|
||||||
|
// A gamepad-only user can't swipe-to-dismiss the sheet this view is presented in
|
||||||
|
// (ContentView's `.sheet(item: $libraryTarget)`) — give it a focusable, dpad-reachable
|
||||||
|
// Close action. tvOS already has its own pushed-navigation back (Menu button).
|
||||||
|
#if !os(tvOS)
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
|
.onDisappear {
|
||||||
|
imageSession?.finishTasksAndInvalidate()
|
||||||
|
imageSession = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private var content: some View {
|
@ViewBuilder private var content: some View {
|
||||||
@@ -41,9 +68,19 @@ struct LibraryView: View {
|
|||||||
errorState(errorText)
|
errorState(errorText)
|
||||||
} else if games.isEmpty {
|
} else if games.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
|
} else {
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
if gamepadUIActive {
|
||||||
|
LibraryCoverflowView(
|
||||||
|
games: games, imageSession: imageSession, onLaunch: onLaunch,
|
||||||
|
onDismiss: { dismiss() })
|
||||||
} else {
|
} else {
|
||||||
grid
|
grid
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
grid
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var grid: some View {
|
private var grid: some View {
|
||||||
@@ -51,10 +88,10 @@ struct LibraryView: View {
|
|||||||
LazyVGrid(columns: columns, spacing: 18) {
|
LazyVGrid(columns: columns, spacing: 18) {
|
||||||
ForEach(games) { game in
|
ForEach(games) { game in
|
||||||
if let onLaunch {
|
if let onLaunch {
|
||||||
Button { onLaunch(game.id) } label: { GameCard(game: game) }
|
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
} else {
|
} else {
|
||||||
GameCard(game: game)
|
GameCard(game: game, imageSession: imageSession)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +162,13 @@ struct LibraryView: View {
|
|||||||
certPEM: identity.certPEM,
|
certPEM: identity.certPEM,
|
||||||
keyPEM: identity.keyPEM,
|
keyPEM: identity.keyPEM,
|
||||||
hostFingerprint: current.pinnedSHA256)
|
hostFingerprint: current.pinnedSHA256)
|
||||||
|
imageSession?.finishTasksAndInvalidate()
|
||||||
|
imageSession = try LibraryImageLoader.session(
|
||||||
|
address: current.address,
|
||||||
|
port: current.effectiveMgmtPort,
|
||||||
|
certPEM: identity.certPEM,
|
||||||
|
keyPEM: identity.keyPEM,
|
||||||
|
hostFingerprint: current.pinnedSHA256)
|
||||||
} catch {
|
} catch {
|
||||||
games = []
|
games = []
|
||||||
errorText = (error as? LibraryError)?.errorDescription ?? error.localizedDescription
|
errorText = (error as? LibraryError)?.errorDescription ?? error.localizedDescription
|
||||||
@@ -137,66 +181,19 @@ struct LibraryView: View {
|
|||||||
/// (portrait → header → hero) and finally a text placeholder.
|
/// (portrait → header → hero) and finally a text placeholder.
|
||||||
private struct GameCard: View {
|
private struct GameCard: View {
|
||||||
let game: GameEntry
|
let game: GameEntry
|
||||||
|
let imageSession: URLSession?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
PosterImage(candidates: game.art.posterCandidates, title: game.title)
|
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
.overlay(alignment: .topLeading) { storeBadge }
|
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||||
Text(game.title)
|
Text(game.title)
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var storeBadge: some View {
|
|
||||||
Text(game.isCustom ? "Custom" : "Steam")
|
|
||||||
.font(.caption2.weight(.semibold))
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(.ultraThinMaterial, in: Capsule())
|
|
||||||
.padding(6)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sequentially tries cover-art URLs, advancing past any that fail to load, then a placeholder.
|
|
||||||
private struct PosterImage: View {
|
|
||||||
let candidates: [URL]
|
|
||||||
let title: String
|
|
||||||
@State private var index = 0
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if index < candidates.count {
|
|
||||||
AsyncImage(url: candidates[index]) { phase in
|
|
||||||
switch phase {
|
|
||||||
case .success(let image):
|
|
||||||
image.resizable().scaledToFill()
|
|
||||||
case .failure:
|
|
||||||
// Advance to the next candidate on the next render pass.
|
|
||||||
Color.clear.onAppear { index += 1 }
|
|
||||||
case .empty:
|
|
||||||
ZStack { placeholder; ProgressView() }
|
|
||||||
@unknown default:
|
|
||||||
placeholder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.id(index) // recreate AsyncImage so it loads the newly-selected URL
|
|
||||||
} else {
|
|
||||||
placeholder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var placeholder: some View {
|
|
||||||
ZStack {
|
|
||||||
Rectangle().fill(.quaternary)
|
|
||||||
Text(title)
|
|
||||||
.font(.headline)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Reusable library widgets, shared by the touch grid (LibraryView's `GameCard`) and the gamepad
|
||||||
|
// coverflow (LibraryCoverflowView's cover cell).
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster —
|
||||||
|
/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell.
|
||||||
|
struct StoreBadge: View {
|
||||||
|
let isCustom: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(isCustom ? "Custom" : "Steam")
|
||||||
|
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
private typealias PlatformImage = UIImage
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
private typealias PlatformImage = NSImage
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private extension Image {
|
||||||
|
init(platformImage: PlatformImage) {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
self.init(uiImage: platformImage)
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
self.init(nsImage: platformImage)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sequentially tries cover-art URLs over `session` (so a paired client can reach the host's own
|
||||||
|
/// art proxy, not just public CDNs — see `LibraryImageLoader`), advancing past any that fail to
|
||||||
|
/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame
|
||||||
|
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback
|
||||||
|
/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report
|
||||||
|
/// a much wider intrinsic size than the card and overflow into neighboring cards. Not `private` —
|
||||||
|
/// the gamepad coverflow (`LibraryCoverflowView`) reuses it directly rather than re-fetching art.
|
||||||
|
struct PosterImage: View {
|
||||||
|
let candidates: [URL]
|
||||||
|
let title: String
|
||||||
|
let session: URLSession?
|
||||||
|
@State private var index = 0
|
||||||
|
@State private var image: PlatformImage?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let image {
|
||||||
|
Image(platformImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
} else if index < candidates.count {
|
||||||
|
ZStack { placeholder; ProgressView() }
|
||||||
|
} else {
|
||||||
|
placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.clipped()
|
||||||
|
.task(id: index) { await loadCurrent() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadCurrent() async {
|
||||||
|
guard index < candidates.count else { return }
|
||||||
|
guard let session, let data = try? await session.data(from: candidates[index]).0,
|
||||||
|
let loaded = PlatformImage(data: data)
|
||||||
|
else {
|
||||||
|
index += 1 // advance to the next candidate (or past the end → placeholder)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
image = loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholder: some View {
|
||||||
|
ZStack {
|
||||||
|
Rectangle().fill(.quaternary)
|
||||||
|
Text(title)
|
||||||
|
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
// 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.
|
|
||||||
|
|
||||||
import PunktfunkKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// Shared host-card sizing — touch-first on iOS, compact on macOS/tvOS.
|
|
||||||
private struct CardMetrics {
|
|
||||||
let iconSize: CGFloat
|
|
||||||
let iconBox: CGFloat
|
|
||||||
let cardPadding: CGFloat
|
|
||||||
let nameFont: Font
|
|
||||||
|
|
||||||
static var current: CardMetrics {
|
|
||||||
#if os(iOS)
|
|
||||||
CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold))
|
|
||||||
#else
|
|
||||||
CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A saved host. The accent ring marks the most-recently-connected one; the context menu
|
|
||||||
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
|
||||||
struct HostCardView: View {
|
|
||||||
let host: StoredHost
|
|
||||||
/// Currently advertising on the LAN (matched against live mDNS discovery). False means
|
|
||||||
/// "not seen on this network" — off, or a remote/cross-subnet host we can't observe.
|
|
||||||
let isOnline: Bool
|
|
||||||
let isConnecting: Bool
|
|
||||||
let isMostRecent: Bool
|
|
||||||
let isBusy: Bool
|
|
||||||
let onConnect: () -> Void
|
|
||||||
let onPair: () -> Void
|
|
||||||
let onSpeedTest: () -> Void
|
|
||||||
let onForget: () -> Void
|
|
||||||
let onRemove: () -> Void
|
|
||||||
/// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
|
|
||||||
var onBrowseLibrary: (() -> Void)? = nil
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let m = CardMetrics.current
|
|
||||||
return Button(action: onConnect) {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
ZStack {
|
|
||||||
Image(systemName: "play.display")
|
|
||||||
.font(.system(size: m.iconSize, weight: .light))
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
.opacity(isConnecting ? 0.3 : 1)
|
|
||||||
if isConnecting {
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: m.iconBox)
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
// Presence dot: green = advertising on the LAN now; grey = not seen.
|
|
||||||
Circle()
|
|
||||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.35))
|
|
||||||
.frame(width: 7, height: 7)
|
|
||||||
.accessibilityLabel(isOnline ? "Online" : "Offline")
|
|
||||||
Text(host.displayName)
|
|
||||||
.font(m.nameFont)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
.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 {
|
|
||||||
if isMostRecent {
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#if os(tvOS)
|
|
||||||
.buttonStyle(.card)
|
|
||||||
#else
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
#endif
|
|
||||||
.disabled(isBusy)
|
|
||||||
.contextMenu {
|
|
||||||
Button("Pair with PIN…", action: onPair)
|
|
||||||
Button("Test Network Speed…", action: onSpeedTest)
|
|
||||||
if let onBrowseLibrary {
|
|
||||||
Button("Browse Library…", action: onBrowseLibrary)
|
|
||||||
}
|
|
||||||
if host.pinnedSHA256 != nil {
|
|
||||||
// Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via
|
|
||||||
// PIN (unless the host advertises pair=optional). Wording reflects that.
|
|
||||||
Button("Forget Identity (re-pair to reconnect)", action: onForget)
|
|
||||||
}
|
|
||||||
Button("Remove", role: .destructive, action: onRemove)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A host found on the LAN but not yet saved. A dashed ring distinguishes it from saved cards;
|
|
||||||
/// tapping saves it and connects (or pairs, if the host requires it).
|
|
||||||
struct DiscoveredCardView: View {
|
|
||||||
let discovered: DiscoveredHost
|
|
||||||
let isBusy: Bool
|
|
||||||
let onConnect: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let m = CardMetrics.current
|
|
||||||
return Button(action: onConnect) {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Image(systemName: "play.display")
|
|
||||||
.font(.system(size: m.iconSize, weight: .light))
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
.frame(height: m.iconBox)
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
Text(discovered.name)
|
|
||||||
.font(m.nameFont)
|
|
||||||
.lineLimit(1)
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text("\(discovered.host):\(String(discovered.port))")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
Text(discovered.requiresPairing ? "Pairing required" : "Discovered")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, m.cardPadding)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
#if !os(tvOS)
|
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.strokeBorder(
|
|
||||||
Color.secondary.opacity(0.25),
|
|
||||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#if os(tvOS)
|
|
||||||
.buttonStyle(.card)
|
|
||||||
#else
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
#endif
|
|
||||||
.disabled(isBusy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,10 +12,37 @@ 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") {
|
||||||
|
// 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()
|
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.
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// The HUD-corner model persisted by Settings and read wherever the overlay is placed
|
||||||
|
// (ContentView, StreamHUDView).
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Which corner the HUD overlay occupies (persisted as `DefaultsKey.hudPlacement`). The raw
|
||||||
|
/// values are stable on disk — rename the cases freely, never the strings.
|
||||||
|
enum HUDPlacement: String, CaseIterable, Identifiable {
|
||||||
|
case topLeading, topTrailing, bottomLeading, bottomTrailing
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// SwiftUI overlay alignment for `.overlay(alignment:)`.
|
||||||
|
var alignment: Alignment {
|
||||||
|
switch self {
|
||||||
|
case .topLeading: return .topLeading
|
||||||
|
case .topTrailing: return .topTrailing
|
||||||
|
case .bottomLeading: return .bottomLeading
|
||||||
|
case .bottomTrailing: return .bottomTrailing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The HUD's own stack hugs the screen edge it sits against, so its text aligns outward.
|
||||||
|
var isTrailing: Bool { self == .topTrailing || self == .bottomTrailing }
|
||||||
|
|
||||||
|
/// User-facing corner label.
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .topLeading: return "Top Left"
|
||||||
|
case .topTrailing: return "Top Right"
|
||||||
|
case .bottomLeading: return "Bottom Left"
|
||||||
|
case .bottomTrailing: return "Bottom Right"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-4
@@ -74,6 +74,11 @@ final class SessionModel: ObservableObject {
|
|||||||
@Published var presentLatencyP95Ms = 0.0
|
@Published var presentLatencyP95Ms = 0.0
|
||||||
@Published var presentLatencyValid = false
|
@Published var presentLatencyValid = false
|
||||||
@Published var presentLatencySkewCorrected = false
|
@Published var presentLatencySkewCorrected = false
|
||||||
|
/// Decode-completion→present (the "present tail": ring wait + render + vsync) — the term the
|
||||||
|
/// stage-2 presenter exists to shorten. Both instants are client-side, so no skew applies.
|
||||||
|
@Published var presentTailP50Ms = 0.0
|
||||||
|
@Published var presentTailP95Ms = 0.0
|
||||||
|
@Published var presentTailValid = false
|
||||||
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
||||||
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
||||||
@Published var mouseCaptured = false
|
@Published var mouseCaptured = false
|
||||||
@@ -82,6 +87,8 @@ final class SessionModel: ObservableObject {
|
|||||||
let latency = LatencyMeter()
|
let latency = LatencyMeter()
|
||||||
/// Fed by the stage-2 presenter's display link (capture→present). Passed to StreamView.
|
/// Fed by the stage-2 presenter's display link (capture→present). Passed to StreamView.
|
||||||
let presentLatency = LatencyMeter()
|
let presentLatency = LatencyMeter()
|
||||||
|
/// Fed by the same present stamp (decode-completion→present). Passed to StreamView.
|
||||||
|
let presentTail = LatencyMeter()
|
||||||
private var statsTimer: Timer?
|
private var statsTimer: Timer?
|
||||||
private var audio: SessionAudio?
|
private var audio: SessionAudio?
|
||||||
private var gamepadCapture: GamepadCapture?
|
private var gamepadCapture: GamepadCapture?
|
||||||
@@ -95,14 +102,24 @@ final class SessionModel: ObservableObject {
|
|||||||
/// field — TOFU is forbidden (rule 3b): the connect refuses rather than offering trust, and
|
/// field — TOFU is forbidden (rule 3b): the connect refuses rather than offering trust, and
|
||||||
/// the user is routed to PIN pairing by the caller. (A pinned host connects regardless: its
|
/// the user is routed to PIN pairing by the caller. (A pinned host connects regardless: its
|
||||||
/// stored fingerprint is the trust decision.)
|
/// stored fingerprint is the trust decision.)
|
||||||
|
///
|
||||||
|
/// `requestAccess` is the no-PIN delegated-approval path: open an identified connect the host
|
||||||
|
/// PARKS until the operator clicks Approve in its console, then admits the SAME connection (no
|
||||||
|
/// reconnect). The handshake budget is widened to exceed the host's park window, and a
|
||||||
|
/// successful connect streams directly (the approval IS the trust decision) — the caller pins
|
||||||
|
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
|
||||||
|
/// for the wait; nil = trust-on-first-use.
|
||||||
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
||||||
compositor: PunktfunkConnection.Compositor = .auto,
|
compositor: PunktfunkConnection.Compositor = .auto,
|
||||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||||
bitrateKbps: UInt32 = 0,
|
bitrateKbps: UInt32 = 0,
|
||||||
|
audioChannels: UInt8 = 2,
|
||||||
hdrEnabled: Bool = true,
|
hdrEnabled: Bool = true,
|
||||||
|
preferredCodec: UInt8 = 0,
|
||||||
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
|
||||||
@@ -120,6 +137,8 @@ final class SessionModel: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
}()
|
}()
|
||||||
let hdrCapable = hdrEnabled && displayHDR
|
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
|
||||||
@@ -129,15 +148,36 @@ final class SessionModel: ObservableObject {
|
|||||||
// Advertise 10-bit + HDR10 when enabled: the host upgrades to a BT.2020 PQ Main10 stream
|
// 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
|
// 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.
|
// HDR-capable (P010 + itur_2100_PQ + EDR). 0 keeps the 8-bit BT.709 SDR stream.
|
||||||
let videoCaps: UInt8 = hdrCapable
|
var videoCaps: UInt8 = hdrCapable
|
||||||
? (PunktfunkConnection.videoCap10Bit | PunktfunkConnection.videoCapHDR)
|
? (PunktfunkConnection.videoCap10Bit | PunktfunkConnection.videoCapHDR)
|
||||||
: 0
|
: 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
|
||||||
|
}
|
||||||
|
// This client's VideoToolbox path decodes H.264 and HEVC (AV1 isn't wired — hosts don't
|
||||||
|
// emit it on the native path yet). The host resolves the emitted codec from these + the
|
||||||
|
// soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
||||||
|
let videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
||||||
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, videoCaps: videoCaps,
|
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||||
launchID: launchID) }
|
audioChannels: audioChannels,
|
||||||
|
videoCodecs: videoCodecs, preferredCodec: preferredCodec, launchID: launchID,
|
||||||
|
// Delegated approval: the host holds this connect open until the operator approves
|
||||||
|
// it (~180 s) — outwait that window so a slow approval still lands here. Normal
|
||||||
|
// connects keep the snappy default.
|
||||||
|
timeoutMs: requestAccess ? 185_000 : 10_000) }
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// The user may have abandoned this attempt (window closed, another host
|
// The user may have abandoned this attempt (window closed, another host
|
||||||
@@ -151,7 +191,9 @@ final class SessionModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let conn):
|
case .success(let conn):
|
||||||
if pin != nil || autoTrust {
|
if pin != nil || autoTrust || requestAccess {
|
||||||
|
// requestAccess: the operator approved this device on the host, so the
|
||||||
|
// session is trusted — stream directly (the caller pins it as paired).
|
||||||
self.connection = conn
|
self.connection = conn
|
||||||
self.startStatsTimer()
|
self.startStatsTimer()
|
||||||
self.beginStreaming()
|
self.beginStreaming()
|
||||||
@@ -173,6 +215,14 @@ final class SessionModel: ObservableObject {
|
|||||||
case .failure:
|
case .failure:
|
||||||
self.phase = .idle
|
self.phase = .idle
|
||||||
self.activeHost = nil
|
self.activeHost = nil
|
||||||
|
if requestAccess {
|
||||||
|
// The delegated-approval connect ended without being admitted: the
|
||||||
|
// operator didn't approve it before the host's park window elapsed (or
|
||||||
|
// the host was unreachable).
|
||||||
|
self.errorMessage = "\(host.displayName) didn't let this device in. "
|
||||||
|
+ "Approve it in the host's web console (port 3000 → Pairing), then "
|
||||||
|
+ "request access again — the request expires after a few minutes."
|
||||||
|
} else {
|
||||||
self.errorMessage = pin != nil
|
self.errorMessage = pin != nil
|
||||||
? "Could not connect to \(host.displayName) — host unreachable, "
|
? "Could not connect to \(host.displayName) — host unreachable, "
|
||||||
+ "not running, its identity no longer matches the pinned "
|
+ "not running, its identity no longer matches the pinned "
|
||||||
@@ -187,6 +237,7 @@ final class SessionModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
|
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
|
||||||
func confirmTrust() -> Data? {
|
func confirmTrust() -> Data? {
|
||||||
@@ -293,6 +344,13 @@ final class SessionModel: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.presentLatencyValid = false
|
self.presentLatencyValid = false
|
||||||
}
|
}
|
||||||
|
if let t = self.presentTail.drain() {
|
||||||
|
self.presentTailP50Ms = t.p50Ms
|
||||||
|
self.presentTailP95Ms = t.p95Ms
|
||||||
|
self.presentTailValid = true
|
||||||
|
} else {
|
||||||
|
self.presentTailValid = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// .common so the HUD keeps updating during window drags / menu tracking.
|
// .common so the HUD keeps updating during window drags / menu tracking.
|
||||||
+11
-40
@@ -4,37 +4,6 @@
|
|||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Which corner the HUD overlay occupies (persisted as `DefaultsKey.hudPlacement`). The raw
|
|
||||||
/// values are stable on disk — rename the cases freely, never the strings.
|
|
||||||
enum HUDPlacement: String, CaseIterable, Identifiable {
|
|
||||||
case topLeading, topTrailing, bottomLeading, bottomTrailing
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
/// SwiftUI overlay alignment for `.overlay(alignment:)`.
|
|
||||||
var alignment: Alignment {
|
|
||||||
switch self {
|
|
||||||
case .topLeading: return .topLeading
|
|
||||||
case .topTrailing: return .topTrailing
|
|
||||||
case .bottomLeading: return .bottomLeading
|
|
||||||
case .bottomTrailing: return .bottomTrailing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The HUD's own stack hugs the screen edge it sits against, so its text aligns outward.
|
|
||||||
var isTrailing: Bool { self == .topTrailing || self == .bottomTrailing }
|
|
||||||
|
|
||||||
/// User-facing corner label.
|
|
||||||
var label: String {
|
|
||||||
switch self {
|
|
||||||
case .topLeading: return "Top Left"
|
|
||||||
case .topTrailing: return "Top Right"
|
|
||||||
case .bottomLeading: return "Bottom Left"
|
|
||||||
case .bottomTrailing: return "Bottom Right"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StreamHUDView: View {
|
struct StreamHUDView: View {
|
||||||
@ObservedObject var model: SessionModel
|
@ObservedObject var model: SessionModel
|
||||||
let connection: PunktfunkConnection
|
let connection: PunktfunkConnection
|
||||||
@@ -63,25 +32,27 @@ struct StreamHUDView: View {
|
|||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
if model.presentTailValid {
|
||||||
|
// Decode→present (the client-local "present tail": ring wait + render + vsync) —
|
||||||
|
// the term the stage-2 presenter shortens; no skew applies (one clock).
|
||||||
|
Text("decode→present \(model.presentTailP50Ms, specifier: "%.1f")/\(model.presentTailP95Ms, specifier: "%.1f") ms p50/p95")
|
||||||
|
.font(.system(.caption2, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
// While captured the cursor is hidden+frozen, so the button is keyboard-only
|
// While captured the cursor is hidden+frozen, so the button is keyboard-only
|
||||||
// (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again).
|
// (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again).
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
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)
|
|
||||||
// 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.
|
|
||||||
Text("⌘⇧C toggles the on-screen cursor")
|
|
||||||
.font(.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 +60,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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
// The gamepad-driven settings screen (iOS/iPadOS/macOS): the couch-relevant subset of SettingsView,
|
||||||
|
// restyled as a console settings page and fully navigable with a controller — up/down moves the
|
||||||
|
// focus bar, left/right steps the focused value, A cycles/toggles it, B closes. Shown from the
|
||||||
|
// gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom
|
||||||
|
// resolutions, the log bitrate slider, debug tools), and both write the same DefaultsKey storage,
|
||||||
|
// so values round-trip freely between the two.
|
||||||
|
//
|
||||||
|
// Rows are rebuilt from live @AppStorage on every render; the focus list dispatches adjust/
|
||||||
|
// activate back here BY ROW ID (see `adjust`/`activate`), so a stored input callback can never act
|
||||||
|
// on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells
|
||||||
|
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
|
||||||
|
// with one button. Toggles read left = off, right = on — refusing a no-op with the same thud.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
import GameController
|
||||||
|
|
||||||
|
struct GamepadSettingsView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||||
|
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||||
|
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||||
|
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||||
|
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||||
|
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||||
|
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||||
|
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||||
|
@AppStorage(DefaultsKey.enable444) private var enable444 = true
|
||||||
|
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||||
|
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||||
|
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||||
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||||
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// `.compact` in a landscape phone window — tighter chrome so more rows fit.
|
||||||
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
|
|
||||||
|
private var compact: Bool { vSizeClass == .compact }
|
||||||
|
#else
|
||||||
|
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||||
|
#endif
|
||||||
|
@State private var focusID: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GamepadMenuList(
|
||||||
|
items: rows,
|
||||||
|
focusID: $focusID,
|
||||||
|
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
|
||||||
|
onActivate: { activate(id: $0.id) },
|
||||||
|
onBack: { dismiss() }
|
||||||
|
) { row, focused in
|
||||||
|
rowView(row, focused: focused)
|
||||||
|
.frame(maxWidth: 620)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
Text("Settings")
|
||||||
|
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
|
.padding(.bottom, compact ? 4 : 8)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
|
||||||
|
.background { GamepadTrayScrim(edge: .top) }
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(focusedDetail)
|
||||||
|
.font(.geist(13, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
.lineLimit(2, reservesSpace: true)
|
||||||
|
.animation(.smooth(duration: 0.2), value: focusID)
|
||||||
|
GamepadHintBar(hints: [
|
||||||
|
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||||
|
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||||
|
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
.padding(.leading, 22)
|
||||||
|
.padding(.trailing, 22)
|
||||||
|
.padding(.vertical, compact ? 6 : 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background { GamepadTrayScrim(edge: .bottom) }
|
||||||
|
}
|
||||||
|
.background { GamepadScreenBackground() }
|
||||||
|
.onAppear {
|
||||||
|
gamepads.refresh()
|
||||||
|
gamepads.startDiscovery()
|
||||||
|
}
|
||||||
|
.onDisappear { gamepads.stopDiscovery() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||||
|
/// rides the cancel action.
|
||||||
|
private var closeButton: some View {
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
|
.glassBackground(Circle(), interactive: true)
|
||||||
|
.contentShape(Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityLabel("Close settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Row rendering
|
||||||
|
|
||||||
|
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if let header = row.header {
|
||||||
|
Text(header)
|
||||||
|
.font(.geist(12, .semibold, relativeTo: .caption))
|
||||||
|
.tracking(1.4)
|
||||||
|
.foregroundStyle(.white.opacity(0.45))
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.padding(.top, 14)
|
||||||
|
}
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: row.icon)
|
||||||
|
.font(.system(size: 17))
|
||||||
|
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
|
||||||
|
.frame(width: 28)
|
||||||
|
Text(row.label)
|
||||||
|
.font(.geist(16, .semibold, relativeTo: .body))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
HStack(spacing: 9) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||||
|
Text(row.value)
|
||||||
|
.font(.geist(15, .medium, relativeTo: .callout))
|
||||||
|
.foregroundStyle(focused ? .white : .white.opacity(0.6))
|
||||||
|
.lineLimit(1)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 13)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.fill(.white.opacity(focused ? 0.1 : 0))
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(focused ? 0.22 : 0), lineWidth: 1)
|
||||||
|
}
|
||||||
|
.scaleEffect(focused ? 1.0 : 0.98)
|
||||||
|
.animation(.smooth(duration: 0.18), value: focused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var focusedDetail: String {
|
||||||
|
rows.first { $0.id == focusID }?.detail ?? " "
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Row model
|
||||||
|
|
||||||
|
private struct Row: Identifiable {
|
||||||
|
let id: String
|
||||||
|
/// Section header drawn above this row (the first row of each group carries it).
|
||||||
|
var header: String?
|
||||||
|
let icon: String
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
/// One-line explanation shown near the hint bar while this row is focused.
|
||||||
|
let detail: String
|
||||||
|
/// Left/right step; returns whether the value actually changed (false ⇒ boundary thud).
|
||||||
|
let adjust: (Int) -> Bool
|
||||||
|
/// A — cycle forward (wrapping) / flip.
|
||||||
|
let activate: () -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
|
||||||
|
/// (never on state captured at wire time).
|
||||||
|
private func adjust(id: String, by delta: Int) -> Bool {
|
||||||
|
rows.first { $0.id == id }?.adjust(delta) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activate(id: String) {
|
||||||
|
rows.first { $0.id == id }?.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rows: [Row] {
|
||||||
|
let resolution = resolutionOptions
|
||||||
|
let refresh = SettingsOptions.refreshRates(including: hz)
|
||||||
|
.map { (label: "\($0) Hz", tag: $0) }
|
||||||
|
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
||||||
|
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||||||
|
return [
|
||||||
|
choiceRow(
|
||||||
|
id: "resolution", header: "Stream", icon: "aspectratio",
|
||||||
|
label: "Resolution",
|
||||||
|
detail: "The host creates a virtual display at exactly this size — no scaling.",
|
||||||
|
options: resolution, current: "\(width)x\(height)"
|
||||||
|
) { tag in
|
||||||
|
let parts = tag.split(separator: "x").compactMap { Int($0) }
|
||||||
|
guard parts.count == 2 else { return }
|
||||||
|
width = parts[0]
|
||||||
|
height = parts[1]
|
||||||
|
},
|
||||||
|
choiceRow(
|
||||||
|
id: "refresh", icon: "gauge.with.needle", label: "Refresh rate",
|
||||||
|
detail: "Rates this display can actually show.",
|
||||||
|
options: refresh, current: hz
|
||||||
|
) { hz = $0 },
|
||||||
|
choiceRow(
|
||||||
|
id: "bitrate", icon: "speedometer", label: "Bitrate",
|
||||||
|
detail: "Automatic uses the host's default (20 Mbps). "
|
||||||
|
+ "Run a speed test from the touch UI for an informed value.",
|
||||||
|
options: bitrate, current: bitrateKbps
|
||||||
|
) { bitrateKbps = $0 },
|
||||||
|
choiceRow(
|
||||||
|
id: "compositor", icon: "macwindow", label: "Compositor",
|
||||||
|
detail: "Which compositor drives the virtual output — honored only if "
|
||||||
|
+ "available on the host.",
|
||||||
|
options: SettingsOptions.compositors, current: compositor
|
||||||
|
) { compositor = $0 },
|
||||||
|
|
||||||
|
choiceRow(
|
||||||
|
id: "codec", header: "Video", icon: "film", label: "Video codec",
|
||||||
|
detail: "A preference — the host falls back if it can't encode this one "
|
||||||
|
+ "(10-bit and 4:4:4 are HEVC-only).",
|
||||||
|
options: SettingsOptions.codecs, current: codec
|
||||||
|
) { codec = $0 },
|
||||||
|
toggleRow(
|
||||||
|
id: "hdr", icon: "sun.max", label: "10-bit HDR",
|
||||||
|
detail: "HDR10 — engages when the host sends HDR content and this display "
|
||||||
|
+ "supports it.",
|
||||||
|
value: $hdrEnabled),
|
||||||
|
toggleRow(
|
||||||
|
id: "chroma", icon: "textformat", label: "Full chroma (4:4:4)",
|
||||||
|
detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
|
||||||
|
+ "hardware decode.",
|
||||||
|
value: $enable444),
|
||||||
|
|
||||||
|
choiceRow(
|
||||||
|
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||||
|
detail: "The speaker layout requested from the host.",
|
||||||
|
options: SettingsOptions.audioChannels, current: audioChannels
|
||||||
|
) { audioChannels = $0 },
|
||||||
|
toggleRow(
|
||||||
|
id: "mic", icon: "mic", label: "Microphone",
|
||||||
|
detail: "Send this device's microphone to the host's virtual mic.",
|
||||||
|
value: $micEnabled),
|
||||||
|
|
||||||
|
choiceRow(
|
||||||
|
id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller",
|
||||||
|
detail: "Which pad is forwarded to the host, as player 1.",
|
||||||
|
options: controllers, current: gamepads.preferredID
|
||||||
|
) { gamepads.preferredID = $0 },
|
||||||
|
choiceRow(
|
||||||
|
id: "padType", icon: "dpad", label: "Controller type",
|
||||||
|
detail: "The virtual pad the host creates — Automatic matches this controller.",
|
||||||
|
options: SettingsOptions.padTypes, current: gamepadType
|
||||||
|
) { gamepadType = $0 },
|
||||||
|
|
||||||
|
toggleRow(
|
||||||
|
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||||||
|
detail: "Resolution, frame rate, throughput and latency while streaming.",
|
||||||
|
value: $hudEnabled),
|
||||||
|
choiceRow(
|
||||||
|
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
|
||||||
|
detail: "Which corner the statistics overlay sits in.",
|
||||||
|
options: SettingsOptions.hudPlacements, current: hudPlacement
|
||||||
|
) { hudPlacement = $0 },
|
||||||
|
toggleRow(
|
||||||
|
id: "library", icon: "square.grid.2x2", label: "Game library",
|
||||||
|
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")) "
|
||||||
|
+ "(experimental).",
|
||||||
|
value: $libraryEnabled),
|
||||||
|
toggleRow(
|
||||||
|
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
|
||||||
|
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||||
|
value: $gamepadUIEnabled),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||||||
|
/// (set via the touch settings), so cycling starts from it instead of jumping.
|
||||||
|
private var resolutionOptions: [(label: String, tag: String)] {
|
||||||
|
var options = SettingsOptions.resolutionModes()
|
||||||
|
.map { (label: "\($0.name) · \($0.w) × \($0.h)", tag: "\($0.w)x\($0.h)") }
|
||||||
|
let current = "\(width)x\(height)"
|
||||||
|
if !options.contains(where: { $0.tag == current }) {
|
||||||
|
options.insert((label: "Custom · \(width) × \(height)", tag: current), at: 0)
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active controller's user-facing name for a button (for detail strings).
|
||||||
|
private func buttonName(
|
||||||
|
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, _ fallback: String
|
||||||
|
) -> String {
|
||||||
|
gamepads.active?.controller.extendedGamepad?[keyPath: button].localizedName ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Row builders
|
||||||
|
|
||||||
|
private func choiceRow<T: Equatable>(
|
||||||
|
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||||
|
options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void
|
||||||
|
) -> Row {
|
||||||
|
let index = options.firstIndex { $0.tag == current }
|
||||||
|
return Row(
|
||||||
|
id: id, header: header, icon: icon, label: label,
|
||||||
|
value: index.map { options[$0].label } ?? "—",
|
||||||
|
detail: detail,
|
||||||
|
adjust: { delta in
|
||||||
|
// Unknown current value: snap to the first option on any step.
|
||||||
|
guard let index else {
|
||||||
|
guard let first = options.first else { return false }
|
||||||
|
write(first.tag)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let target = index + delta
|
||||||
|
guard target >= 0, target < options.count else { return false }
|
||||||
|
write(options[target].tag)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
activate: {
|
||||||
|
guard let index else { return write(options.first?.tag ?? current) }
|
||||||
|
write(options[(index + 1) % options.count].tag)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleRow(
|
||||||
|
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||||
|
value: Binding<Bool>
|
||||||
|
) -> Row {
|
||||||
|
Row(
|
||||||
|
id: id, header: header, icon: icon, label: label,
|
||||||
|
value: value.wrappedValue ? "On" : "Off",
|
||||||
|
detail: detail,
|
||||||
|
adjust: { delta in
|
||||||
|
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
|
||||||
|
let target = delta > 0
|
||||||
|
guard value.wrappedValue != target else { return false }
|
||||||
|
value.wrappedValue = target
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
activate: { value.wrappedValue.toggle() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user