Compare commits
284 Commits
bdfab8e0d5
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b57e414618 | |||
| ec40a4062f | |||
| 19c35de3d7 | |||
| aa012c6b45 | |||
| 74c9e46faf | |||
| 95b3496bb5 | |||
| 334f36ce25 | |||
| 88348153f3 | |||
| 4a87cef98c | |||
| fc1e8a8a32 | |||
| 69f4c987f6 | |||
| 468a60c88a | |||
| fad1e01408 | |||
| 04a397be84 | |||
| ccbd7e8880 | |||
| a0546b36b6 | |||
| b71dc94bb2 | |||
| c2bc72a8e9 | |||
| b53710da1a | |||
| c1acfe8b85 | |||
| 2e43fcc27c | |||
| 2aa7ac8c7e | |||
| 6b4f9f86ed | |||
| 8986667b78 | |||
| 62e0367f4b | |||
| 677a4f4cf5 | |||
| fa45608628 | |||
| a7ff1cf312 | |||
| 87435e6547 | |||
| e0f15822ae | |||
| a5dc3134de | |||
| eddcd91f48 | |||
| 23446fa177 | |||
| 980939ed6b | |||
| cfad0cf7ee | |||
| 42b1158ea7 | |||
| 029d1134a9 | |||
| e35b6991e2 | |||
| 913f6ce659 | |||
| d23bd9b0cf | |||
| eda7cac78e | |||
| d73951414c | |||
| b150d79626 | |||
| cb7ddc0411 | |||
| 60816709c4 | |||
| 783c52dfad | |||
| e27718b406 | |||
| 6bc893e394 | |||
| f0d015fc45 | |||
| 2dd17dda80 | |||
| 87f0ce7997 | |||
| bbd98241e4 | |||
| 202f40fd4e | |||
| 8f90563ffd | |||
| 2e6b822fd6 | |||
| f7c5314b5e | |||
| d6669fc3fb | |||
| e292084225 | |||
| c758b0393a | |||
| d6a659a1ee | |||
| 2190dad2ad | |||
| 5b5ec15ead | |||
| c9ff144492 | |||
| 7930d2f0f4 | |||
| 160b67d043 | |||
| 6c4ba77606 | |||
| eeee2782f5 | |||
| b488bd1d99 | |||
| 7e6561aaa2 | |||
| e9c5030190 | |||
| 22c0d92f2e | |||
| 097cc6faf4 | |||
| 8b37badae4 | |||
| 90c2d8b3a0 | |||
| 853e7fe92f | |||
| df496776b0 | |||
| 5310176ab5 | |||
| 76ff616dcf | |||
| ac706ba839 | |||
| 94b5f48d0b | |||
| 139d032e55 | |||
| caa7a1c735 | |||
| 13dc7fc49f | |||
| 57ae00a9c8 | |||
| 882a3d57f6 | |||
| fa28fa19a0 | |||
| 42595b5558 | |||
| 4de543c146 | |||
| 42d1c74663 | |||
| 136f6e8f0e | |||
| 00acf5e44e | |||
| 38078fe7ee | |||
| 69609945a3 | |||
| 8470419433 | |||
| 449a67ce8d | |||
| 09a5957c6d | |||
| c7630ff5dc | |||
| 2c7ded0f3c | |||
| b7048446c4 | |||
| 3039626b87 | |||
| 3f33ed30ae | |||
| 7e31020c1c | |||
| fe54aff658 | |||
| b46aa15afb | |||
| 058630f542 | |||
| e9c1f4083a | |||
| 20f0d2802f | |||
| 6f8fb15c9b | |||
| 89455032a0 | |||
| 0da9d8ec10 | |||
| fbf3fea0c8 | |||
| c52ae119e1 | |||
| 5d7aabe8f0 | |||
| f204a89cef | |||
| 24fa018c70 | |||
| 51a6ca7e02 | |||
| b9fde03f1e | |||
| efb1ba26d7 | |||
| 1320e3dc66 | |||
| 1be83575b6 | |||
| 4d1d20f832 | |||
| 6e875fea44 | |||
| 4f3cd24036 | |||
| af13f0b749 | |||
| d285d4a0b2 | |||
| 04f370999c | |||
| 2c937855b3 | |||
| 8005b11faf | |||
| 01fcb01019 | |||
| 95a08e99c3 | |||
| a3e1ea2b44 | |||
| 6686fcdded | |||
| 31c382fde0 | |||
| d707ee4d4e | |||
| e8196b33b8 | |||
| fd699b3e2c | |||
| 79dd8f58e3 | |||
| be879c946a | |||
| f3646d4e7c | |||
| 396c3453f5 | |||
| 6921e147dd | |||
| 861da54066 | |||
| 0c17343a50 | |||
| 38f8f18fe8 | |||
| 9a58746aa5 | |||
| c21549c136 | |||
| 8af1a15aa6 | |||
| 7ced80c4e3 | |||
| 1a483aae06 | |||
| 49e6021ece | |||
| fa4c798a25 | |||
| fd1086074b | |||
| 12a3944156 | |||
| 73f14bc725 | |||
| 21eded8d88 | |||
| 315eb6ef7c | |||
| a333d5a15b | |||
| 34bdda7d96 | |||
| fbeac16c96 | |||
| bf799b41e3 | |||
| 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 |
+38
-8
@@ -5,16 +5,46 @@
|
|||||||
# means the audit job stops flagging it, so the reasoning must hold up.
|
# means the audit job stops flagging it, so the reasoning must hold up.
|
||||||
#
|
#
|
||||||
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
|
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
|
||||||
# `unmaintained` warnings (audiopus_sys / paste / rustls-pemfile). Those are left visible on purpose
|
# `unmaintained` warnings (audiopus_sys via opus, paste via utoipa-axum). Both are transitive, at
|
||||||
# so we keep getting the maintenance signal — they do not fail CI.
|
# their latest published version with no successor, so there's nothing to bump — left visible on
|
||||||
|
# purpose so we keep getting the maintenance signal; they do not fail CI. (rustls-pemfile was dropped
|
||||||
|
# 2026-06-29 by removing axum-server's unused tls-rustls feature + moving our own PEM parsing to
|
||||||
|
# rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11 bump.)
|
||||||
|
|
||||||
[advisories]
|
[advisories]
|
||||||
ignore = [
|
ignore = [
|
||||||
# rsa "Marvin Attack" — a timing sidechannel in RSA *decryption* (PKCS#1 v1.5 padding oracle).
|
# rsa "Marvin Attack" (RUSTSEC-2023-0071): a timing side-channel in the rsa crate's variable-time
|
||||||
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream), and rsa
|
# modular exponentiation of the SECRET exponent. IMPORTANT — this affects the RSA private-key op in
|
||||||
# is required for GameStream/Moonlight pairing. Crucially, the host uses rsa ONLY for PKCS#1 v1.5
|
# general, INCLUDING signing (m^d mod n), which the host DOES perform (gamestream/pairing.rs
|
||||||
# SIGNING / VERIFYING (gamestream/cert.rs + gamestream/pairing.rs: SigningKey / VerifyingKey /
|
# `signing_key.sign(&serversecret)`). It is NOT, as an earlier version of this note wrongly claimed,
|
||||||
# Signer / Verifier) — it never performs RSA decryption, which is the operation Marvin targets.
|
# limited to decryption — so "the vulnerable path isn't exercised" is false; signing exercises it.
|
||||||
# So the vulnerable code path is not exercised. Revisit if a fixed rsa ships or we add RSA decrypt.
|
# We accept it because the attack is not practically reachable here, NOT because the path is unused:
|
||||||
|
# * No RSA decryption / PKCS#1v1.5 padding oracle exists anywhere (every `decrypt` in the tree is
|
||||||
|
# AES/AES-GCM), so the classic Bleichenbacher/Marvin chosen-ciphertext oracle is absent.
|
||||||
|
# * The only signed message (`serversecret`) is HOST-generated random, never attacker-chosen — so
|
||||||
|
# there's no adaptive chosen-input probing (the lever remote RSA-timing key recovery needs); and
|
||||||
|
# signing is gated behind the operator-entered pairing PIN, ONE signature per ceremony (a
|
||||||
|
# repeated phase-3 is rejected — gamestream/pairing.rs — to deny a passive timing-sample harvester).
|
||||||
|
# * GameStream is OFF by default (bare `serve` is native-only); the secure native QUIC plane uses
|
||||||
|
# rustls' constant-time backend, NOT the rsa crate. RSA is touched only on the opt-in,
|
||||||
|
# trusted-LAN GameStream/Moonlight pairing handshake. Moonlight mandates RSA-2048, so the
|
||||||
|
# GameStream identity cannot move to Ed25519/ECDSA (only the native identity could, and it
|
||||||
|
# already avoids the rsa crate).
|
||||||
|
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream). Revisit if:
|
||||||
|
# a constant-time rsa ships (then drop this), the host ever signs an attacker-chosen message with
|
||||||
|
# this key, or any RSA decryption / key-transport using the private key is added.
|
||||||
"RUSTSEC-2023-0071",
|
"RUSTSEC-2023-0071",
|
||||||
|
|
||||||
|
# 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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Shown on the "new issue" chooser so security reports go to the private channel, not a public issue.
|
||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: 🔒 Report a security vulnerability
|
||||||
|
url: https://git.unom.io/unom/punktfunk/src/branch/main/SECURITY.md
|
||||||
|
about: >-
|
||||||
|
Found a security issue? Please report it privately by email to security@punktfunk.com — do not
|
||||||
|
open a public issue, so other users aren't exposed before a fix ships. See SECURITY.md for the
|
||||||
|
full policy.
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via
|
# Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via
|
||||||
# cargo-ndk for both shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
|
# cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
|
||||||
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
|
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
|
||||||
#
|
#
|
||||||
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
|
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||||
"$RUSTUP" target add aarch64-linux-android x86_64-linux-android
|
"$RUSTUP" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||||
|
|
||||||
- name: Android SDK
|
- name: Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
@@ -78,9 +78,10 @@ jobs:
|
|||||||
- name: Version + channel
|
- name: Version + channel
|
||||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||||
run: |
|
run: |
|
||||||
|
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
||||||
*) VN="0.3.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||||
esac
|
esac
|
||||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||||
@@ -97,7 +98,7 @@ jobs:
|
|||||||
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks
|
echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks
|
||||||
# AAB for Play; a universal APK (both ABIs) for direct sideload/testing — same upload key.
|
# AAB for Play; a universal APK (all ABIs) for direct sideload/testing — same upload key.
|
||||||
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
|
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
|
||||||
|
|
||||||
# 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.
|
||||||
|
|||||||
@@ -32,6 +32,25 @@ jobs:
|
|||||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
|
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
|
|
||||||
|
# `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling
|
||||||
|
# `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a
|
||||||
|
# system Opus — so the xcframework is self-contained (no runtime libopus.dylib on end-user Macs).
|
||||||
|
# CMake must be on PATH; install it self-healing on a fresh runner.
|
||||||
|
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||||
|
run: |
|
||||||
|
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||||
|
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||||
|
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||||
|
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||||
|
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||||
|
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||||
|
command -v cmake >/dev/null || "$BREW" install cmake
|
||||||
|
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||||
|
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||||
|
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||||
|
# inherits this from the env during the xcframework build).
|
||||||
|
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build PunktfunkCore.xcframework
|
- name: Build PunktfunkCore.xcframework
|
||||||
run: bash scripts/build-xcframework.sh
|
run: bash scripts/build-xcframework.sh
|
||||||
|
|
||||||
@@ -71,6 +90,22 @@ jobs:
|
|||||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||||
|
|
||||||
|
# See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake.
|
||||||
|
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||||
|
run: |
|
||||||
|
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||||
|
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||||
|
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||||
|
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||||
|
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||||
|
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||||
|
command -v cmake >/dev/null || "$BREW" install cmake
|
||||||
|
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||||
|
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||||
|
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||||
|
# inherits this from the env during the xcframework build).
|
||||||
|
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
|
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
|
||||||
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from
|
||||||
|
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
|
||||||
|
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
|
||||||
|
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
|
||||||
|
# resolves today — the same sonames an up-to-date Arch box runs.
|
||||||
|
#
|
||||||
|
# Registry (public, unom org) — box setup (once), see packaging/arch/README.md. The registry
|
||||||
|
# SIGNS the DB + packages, so the box imports the registry key first (pacman-key --add +
|
||||||
|
# --lsign-key), then no SigLevel line is needed (pacman's default Required verifies):
|
||||||
|
# [punktfunk] # or [punktfunk-canary] for main-push builds
|
||||||
|
# Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
|
||||||
|
#
|
||||||
|
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
|
||||||
|
# NOTE: this token + the registry-held private key are the trust root — a token holder can
|
||||||
|
# publish a validly-signed package (the signature attests "via the registry", not "built by CI").
|
||||||
|
name: arch
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the
|
||||||
|
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
|
||||||
|
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.unom.io
|
||||||
|
OWNER: unom
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-publish:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
container:
|
||||||
|
image: docker.io/library/archlinux:base-devel
|
||||||
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
CARGO_HOME: /usr/local/cargo
|
||||||
|
steps:
|
||||||
|
# git + nodejs must exist before actions/checkout — base-devel ships neither, and
|
||||||
|
# act_runner runs the action's JS with the CONTAINER's node, it does not inject one.
|
||||||
|
- name: Install build + runtime-dev deps
|
||||||
|
run: |
|
||||||
|
pacman -Syu --noconfirm --needed \
|
||||||
|
git nodejs rust clang cmake nasm pkgconf python \
|
||||||
|
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||||
|
mesa libglvnd unzip libarchive
|
||||||
|
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
||||||
|
# it's AUR-only on Arch, so bootstrap the official binary.
|
||||||
|
command -v bun >/dev/null || {
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||||
|
}
|
||||||
|
bun --version
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Cache cargo's git dir too, not just the registry: the workspace includes
|
||||||
|
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
|
||||||
|
# them (windows-rs is huge) merely to resolve the workspace, even though nothing Windows
|
||||||
|
# is ever compiled here. Cached, that cost is paid once per runner.
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/usr/local/cargo/registry
|
||||||
|
/usr/local/cargo/git
|
||||||
|
key: cargo-home-arch-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-home-arch-
|
||||||
|
|
||||||
|
- name: Version + channel
|
||||||
|
# vX.Y.Z tag -> X.Y.Z-1 in the `punktfunk` repo; main push -> <next-minor>-0.<run#> in
|
||||||
|
# `punktfunk-canary` (pkgrel accepts only digits+dots — the run number carries the
|
||||||
|
# monotonic ordering; the commit sha is stamped into the binary via the workflow log).
|
||||||
|
run: |
|
||||||
|
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
|
||||||
|
case "$GITHUB_REF" in
|
||||||
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||||
|
*) V="$PF_BASE"; R="0.${GITHUB_RUN_NUMBER}"; REPO=punktfunk-canary ;;
|
||||||
|
esac
|
||||||
|
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
|
||||||
|
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
|
||||||
|
echo "REPO=$REPO" >> "$GITHUB_ENV"
|
||||||
|
echo "pacman $V-$R -> repo '$REPO'"
|
||||||
|
|
||||||
|
- name: Build packages (makepkg)
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory "$PWD"
|
||||||
|
# libcuda link stub — same trick as packaging/rpm/build-rpm.sh: the zerocopy FFI
|
||||||
|
# links -lcuda but the builder has no GPU; synthesize every cu* symbol the source
|
||||||
|
# references so a newly-added call can't silently break the link.
|
||||||
|
CU_SYMS="$(grep -rhoE '\bcu[A-Z][A-Za-z0-9_]*' crates/punktfunk-host/src/ | sort -u || true)"
|
||||||
|
if [ -n "$CU_SYMS" ] && [ ! -e /usr/lib/libcuda.so ]; then
|
||||||
|
STUB_C="$(mktemp --suffix=.c)"
|
||||||
|
for s in $CU_SYMS; do printf 'int %s(void){return 0;}\n' "$s" >> "$STUB_C"; done
|
||||||
|
gcc -shared -fPIC -Wl,-soname,libcuda.so.1 -o /usr/lib/libcuda.so.1 "$STUB_C"
|
||||||
|
ln -sf libcuda.so.1 /usr/lib/libcuda.so
|
||||||
|
rm -f "$STUB_C"; ldconfig
|
||||||
|
echo "== libcuda stub: $(printf '%s\n' "$CU_SYMS" | wc -l) symbols =="
|
||||||
|
fi
|
||||||
|
# makepkg refuses to run as root; deps are already installed above (-d skips the
|
||||||
|
# RPM-level check that can't see the script-installed bun anyway).
|
||||||
|
useradd -m builder
|
||||||
|
mkdir -p "$CARGO_HOME" # actions/cache doesn't create it on a cache miss
|
||||||
|
chown -R builder: "$PWD" "$CARGO_HOME"
|
||||||
|
sudo -u builder git config --global --add safe.directory "$PWD"
|
||||||
|
mkdir -p dist && chown builder: dist
|
||||||
|
cd packaging/arch
|
||||||
|
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \
|
||||||
|
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
|
||||||
|
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
|
||||||
|
makepkg -f -d --holdver
|
||||||
|
ls -lh "$GITHUB_WORKSPACE/dist"
|
||||||
|
|
||||||
|
- name: Publish to the Gitea Arch registry
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
for pkg in dist/*.pkg.tar.zst; do
|
||||||
|
echo "uploading $pkg"
|
||||||
|
NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p')
|
||||||
|
VER=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgver = //p')
|
||||||
|
ARCH=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^arch = //p')
|
||||||
|
# A re-tagged release re-fires this workflow and the registry 409s on duplicate
|
||||||
|
# package versions — delete any prior copy first (404 on the first publish is fine).
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO/$NAME/$VER/$ARCH" || true
|
||||||
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$pkg" \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO"
|
||||||
|
done
|
||||||
|
echo "published to $OWNER/arch/$REPO"
|
||||||
|
|
||||||
|
# On a real release, also attach the packages to the unified Gitea Release.
|
||||||
|
- name: Attach packages 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 pkg in dist/*.pkg.tar.zst; do
|
||||||
|
upsert_asset "$RID" "$pkg"
|
||||||
|
done
|
||||||
@@ -28,8 +28,8 @@ jobs:
|
|||||||
|
|
||||||
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
|
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
|
||||||
# registry/git are download caches, target/ the incremental build. The target key
|
# registry/git are download caches, target/ the incremental build. The target key
|
||||||
# carries the rustc version — rust-toolchain.toml pins the floating "stable"
|
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
|
||||||
# channel, so the file alone wouldn't invalidate stale incremental state.
|
# from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
|
||||||
- name: Cache keys
|
- name: Cache keys
|
||||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
|
|||||||
+27
-15
@@ -36,16 +36,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Version + channel
|
- name: Version + channel
|
||||||
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
|
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
|
||||||
# A main push -> 0.3.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
# A main push -> <next-minor>~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||||
# below the eventual 0.3.0 tag, it climbs monotonically by run number, and the canary base
|
# below the eventual tag, it climbs monotonically by run number, and the canary base is
|
||||||
# stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves
|
# derived one minor AHEAD of the latest stable tag (scripts/ci/pf-version.sh) so a
|
||||||
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
# 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).
|
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
||||||
run: |
|
run: |
|
||||||
|
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||||
*) V="0.3.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||||
esac
|
esac
|
||||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||||
@@ -87,12 +88,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
|
||||||
@@ -101,21 +103,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:
|
||||||
@@ -123,6 +127,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
for DEB in dist/*.deb; do
|
for DEB in dist/*.deb; do
|
||||||
echo "uploading $DEB"
|
echo "uploading $DEB"
|
||||||
|
# A re-tagged release re-fires this workflow and the apt registry 409s on duplicate
|
||||||
|
# package versions — delete any prior copy of this exact name/version/arch first
|
||||||
|
# (404 on the first publish is fine).
|
||||||
|
NAME=$(dpkg-deb -f "$DEB" Package)
|
||||||
|
VER=$(dpkg-deb -f "$DEB" Version)
|
||||||
|
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
||||||
# PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login.
|
# PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login.
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||||
|
|||||||
+53
-13
@@ -11,12 +11,18 @@
|
|||||||
# punktfunk.zip
|
# punktfunk.zip
|
||||||
# punktfunk/ <- single top-level dir == plugin.json "name"
|
# punktfunk/ <- single top-level dir == plugin.json "name"
|
||||||
# plugin.json [required]
|
# plugin.json [required]
|
||||||
# package.json [required]
|
# package.json [required; CI stamps "version" — Decky reads the installed version here]
|
||||||
# main.py [required: python backend]
|
# main.py [required: python backend]
|
||||||
# dist/index.js [required: rollup output]
|
# dist/index.js [required: rollup output]
|
||||||
|
# update.json [CI-baked {channel, manifest}: where the plugin's self-update check polls]
|
||||||
# README.md (recommended)
|
# README.md (recommended)
|
||||||
# LICENSE [required by the plugin store]
|
# LICENSE [required by the plugin store]
|
||||||
#
|
#
|
||||||
|
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
|
||||||
|
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
|
||||||
|
# plugin polls it (main.py check_update), and the frontend drives Decky's own install RPC to
|
||||||
|
# apply a newer build. See clients/decky/README.md "Updating".
|
||||||
|
#
|
||||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker).
|
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker).
|
||||||
name: decky
|
name: decky
|
||||||
|
|
||||||
@@ -56,20 +62,30 @@ jobs:
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm run build # rollup -> clients/decky/dist/index.js
|
pnpm run build # rollup -> clients/decky/dist/index.js
|
||||||
|
|
||||||
- name: Version + channel
|
- name: Version + channel + stamp
|
||||||
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.0-ciN.g<sha>
|
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> <next-minor>.<run>
|
||||||
# (`canary/` alias). Used for the registry version path + the zip name (the plugin.json
|
# (base one minor ahead of the latest stable tag via scripts/ci/pf-version.sh)
|
||||||
# version is the source of truth Decky reads after install — bump it in the release commit).
|
# (`canary/` alias). Decky reads a plugin's INSTALLED version from package.json (NOT
|
||||||
|
# plugin.json), and the plugin's own update check (clients/decky/main.py check_update)
|
||||||
|
# compares against it — so the build version is STAMPED into package.json here (mirrored
|
||||||
|
# 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)
|
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_MAJOR/PF_MINOR (base one minor ahead of latest stable)
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;;
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;;
|
||||||
*) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; ALIAS=canary ;;
|
# Canary MUST be a plain monotonic numeric semver (see the note above): <major>.<minor>.<run>,
|
||||||
|
# where major.minor track one minor ahead of the latest stable and the run number climbs.
|
||||||
|
*) V="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"; ALIAS=canary ;;
|
||||||
esac
|
esac
|
||||||
|
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
||||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||||
echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
|
echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
|
||||||
|
echo "BASE=$BASE" >> "$GITHUB_ENV"
|
||||||
echo "decky version $V -> alias '$ALIAS'"
|
echo "decky version $V -> alias '$ALIAS'"
|
||||||
|
VERSION="$V" node -e 'const fs=require("fs");for(const f of ["clients/decky/package.json","clients/decky/plugin.json"]){const j=JSON.parse(fs.readFileSync(f,"utf8"));j.version=process.env.VERSION;fs.writeFileSync(f,JSON.stringify(j,null,2)+"\n");}'
|
||||||
|
|
||||||
- name: Assemble store-layout zip
|
- name: Assemble store-layout zip
|
||||||
working-directory: ${{ gitea.workspace }}
|
working-directory: ${{ gitea.workspace }}
|
||||||
@@ -89,9 +105,20 @@ jobs:
|
|||||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||||
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
|
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
|
||||||
cp LICENSE-MIT "$DEST/LICENSE"
|
cp LICENSE-MIT "$DEST/LICENSE"
|
||||||
|
# Self-update channel pointer the backend reads (main.py check_update). It points at
|
||||||
|
# THIS channel's manifest.json (published below); that manifest in turn points at the
|
||||||
|
# immutable per-version zip, so its sha256 stays valid across future alias re-uploads.
|
||||||
|
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
|
||||||
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||||
ls -lh "$RUNNER_TEMP/punktfunk.zip"
|
ls -lh "$RUNNER_TEMP/punktfunk.zip"
|
||||||
unzip -l "$RUNNER_TEMP/punktfunk.zip"
|
unzip -l "$RUNNER_TEMP/punktfunk.zip"
|
||||||
|
# The update manifest the plugin polls: the immutable per-version artifact + its
|
||||||
|
# sha256 (Decky's installer verifies the download against this hash, aborting on
|
||||||
|
# mismatch — so it MUST be the per-version URL, never the mutable alias).
|
||||||
|
SHA=$(sha256sum "$RUNNER_TEMP/punktfunk.zip" | cut -d' ' -f1)
|
||||||
|
printf '{"version":"%s","artifact":"%s/%s/punktfunk.zip","sha256":"%s"}\n' \
|
||||||
|
"$VERSION" "$BASE" "$VERSION" "$SHA" > "$RUNNER_TEMP/manifest.json"
|
||||||
|
cat "$RUNNER_TEMP/manifest.json"
|
||||||
|
|
||||||
- name: Publish to the Gitea generic registry
|
- name: Publish to the Gitea generic registry
|
||||||
working-directory: ${{ gitea.workspace }}
|
working-directory: ${{ gitea.workspace }}
|
||||||
@@ -99,18 +126,31 @@ 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) Versioned URL + its update manifest (the manifest's `artifact` points here, so the
|
||||||
|
# published sha256 keeps matching what Decky later downloads). A re-tagged release
|
||||||
|
# re-fires this workflow and the registry 409s on duplicate uploads — delete any
|
||||||
|
# prior copy of this version first (404 on the first publish is fine).
|
||||||
|
for f in punktfunk.zip manifest.json; do
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$VERSION/$f" || true
|
||||||
|
done
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
||||||
"$BASE/$VERSION/punktfunk.zip"
|
"$BASE/$VERSION/punktfunk.zip"
|
||||||
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \
|
||||||
|
"$BASE/$VERSION/manifest.json"
|
||||||
echo "published $BASE/$VERSION/punktfunk.zip"
|
echo "published $BASE/$VERSION/punktfunk.zip"
|
||||||
# 2) Channel alias (stable release -> latest/, canary main build -> canary/) — the link
|
# 2) Channel alias (stable release -> latest/, canary main build -> canary/) — the
|
||||||
# to paste into Decky's "install from URL". The generic registry rejects re-uploading
|
# zip is the "install from URL" link; manifest.json is what the installed plugin
|
||||||
# an existing version/file (409), so delete the prior alias first (ignore 404 on run #1).
|
# polls for updates. The generic registry rejects re-uploading an existing
|
||||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
# version/file (409), so delete the prior alias copies first (ignore 404 on run #1).
|
||||||
"$BASE/$ALIAS/punktfunk.zip" || true
|
for f in punktfunk.zip manifest.json; do
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$ALIAS/$f" || true
|
||||||
|
done
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
||||||
"$BASE/$ALIAS/punktfunk.zip"
|
"$BASE/$ALIAS/punktfunk.zip"
|
||||||
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \
|
||||||
|
"$BASE/$ALIAS/manifest.json"
|
||||||
echo "install-from-URL link: $BASE/$ALIAS/punktfunk.zip"
|
echo "install-from-URL link: $BASE/$ALIAS/punktfunk.zip"
|
||||||
|
echo "update manifest: $BASE/$ALIAS/manifest.json"
|
||||||
|
|
||||||
- name: Attach zip to the Gitea release (stable tags only)
|
- name: Attach zip to the Gitea release (stable tags only)
|
||||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
|||||||
@@ -73,15 +73,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Version + channel
|
- name: Version + channel
|
||||||
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
|
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
|
||||||
# 0.3.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo
|
# <next-minor>-ciN.g<sha> on the `canary` branch (base one minor ahead of the latest stable
|
||||||
|
# tag via scripts/ci/pf-version.sh). The two branches live side-by-side in one repo
|
||||||
# (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
|
# (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
|
# on a stable box never jumps to a canary build. The generic-registry version string allows
|
||||||
# letters/dots/hyphens.
|
# letters/dots/hyphens.
|
||||||
run: |
|
run: |
|
||||||
|
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
|
||||||
*) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
*) V="${PF_BASE}-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
||||||
esac
|
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"
|
||||||
@@ -106,6 +108,40 @@ jobs:
|
|||||||
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
|
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
|
||||||
-o packaging/flatpak/cargo-sources.json
|
-o packaging/flatpak/cargo-sources.json
|
||||||
|
|
||||||
|
- name: Seed the local OSTree repo from the live server (keep BOTH channels in the summary)
|
||||||
|
# Each CI run builds only ONE branch (canary on main, stable on a tag). The deploy step's
|
||||||
|
# `flatpak build-update-repo` regenerates the repo SUMMARY from whatever refs are in the
|
||||||
|
# LOCAL repo, and the rsync publishes it (without --delete). A fresh single-branch local
|
||||||
|
# repo therefore produces a single-branch summary that CLOBBERS the other channel on the
|
||||||
|
# server — the exact bug that made `app/io.unom.Punktfunk/x86_64/stable` unresolvable
|
||||||
|
# ("No such ref") after a canary main-push overwrote the post-release summary, even though
|
||||||
|
# the stable commit's objects were still on disk. Fix: mirror the published repo DOWN first,
|
||||||
|
# so the local repo carries every existing branch; the build below then only ADDS this run's
|
||||||
|
# commit and the regenerated+signed summary keeps both channels. No-op on a fresh repo (first
|
||||||
|
# publish) or when the deploy secrets aren't set (the build still produces a valid bundle).
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||||
|
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||||
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_SSH_KEY:-}" ]; then
|
||||||
|
echo "::warning::DEPLOY_* not set — no seed; building a fresh single-branch repo."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
install -d -m700 ~/.ssh
|
||||||
|
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
|
||||||
|
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
|
||||||
|
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||||
|
mkdir -p "$PWD/repo"
|
||||||
|
# Pull the currently-published repo (all channels' objects + refs) into the repo the build
|
||||||
|
# will extend. No --delete: the local repo starts empty, so this only ADDS. A missing
|
||||||
|
# server repo (very first publish) is fine — we continue with a fresh repo.
|
||||||
|
rsync -az --info=stats1 -e "$SSH" "$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/" \
|
||||||
|
|| echo "::warning::no published repo to seed (first publish?) — continuing fresh"
|
||||||
|
echo "seeded refs:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
|
||||||
|
|
||||||
- name: Build the flatpak (install deps from Flathub, offline build)
|
- name: Build the flatpak (install deps from Flathub, offline build)
|
||||||
run: |
|
run: |
|
||||||
# --install-deps-from=flathub pulls everything the manifest declares: the GNOME 50
|
# --install-deps-from=flathub pulls everything the manifest declares: the GNOME 50
|
||||||
@@ -133,7 +169,10 @@ 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) Versioned URL. A re-tagged release re-fires this workflow and the registry 409s on
|
||||||
|
# duplicate uploads — delete any prior copy first (404 on the first publish is fine).
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||||
|
"$BASE/$VERSION/$BUNDLE" || true
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
||||||
"$BASE/$VERSION/$BUNDLE"
|
"$BASE/$VERSION/$BUNDLE"
|
||||||
echo "published $BASE/$VERSION/$BUNDLE"
|
echo "published $BASE/$VERSION/$BUNDLE"
|
||||||
@@ -174,6 +213,10 @@ jobs:
|
|||||||
--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"
|
||||||
|
# The regenerated summary advertises exactly these refs — must include EVERY channel that
|
||||||
|
# has ever published (the seed step ensures the other channel's commit is present). If this
|
||||||
|
# ever shows only one branch on a repo that had two, the seed didn't run — investigate.
|
||||||
|
echo "published summary advertises:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
|
||||||
# 2) Build the install descriptors (GPGKey = the committed public key, base64).
|
# 2) Build the install descriptors (GPGKey = the committed public key, base64).
|
||||||
GPGKEY="$(base64 -w0 packaging/flatpak/unom-flatpak.gpg)"
|
GPGKEY="$(base64 -w0 packaging/flatpak/unom-flatpak.gpg)"
|
||||||
rm -rf site && mkdir -p site
|
rm -rf site && mkdir -p site
|
||||||
@@ -185,9 +228,12 @@ jobs:
|
|||||||
Comment=unom Flatpak applications
|
Comment=unom Flatpak applications
|
||||||
GPGKey=$GPGKEY
|
GPGKey=$GPGKEY
|
||||||
EOF
|
EOF
|
||||||
# Two refs, one per channel — both regenerated every run and rsync'd without --delete, so
|
# Two refs, one per channel. Both descriptor files are regenerated every run and rsync'd
|
||||||
# the server always offers both (the stable ref only resolves once a release has built the
|
# without --delete; the repo SUMMARY carries both branches because the build was seeded
|
||||||
# `stable` branch). A box installs ONE; `flatpak update` then tracks that channel's branch.
|
# from the live repo above (so build-update-repo below re-signs a summary listing every
|
||||||
|
# published channel, not just this run's). The stable ref resolves for good once any
|
||||||
|
# release has built the `stable` branch. A box installs ONE; `flatpak update` then tracks
|
||||||
|
# that channel's branch.
|
||||||
write_ref() { # <filename> <branch> <title>
|
write_ref() { # <filename> <branch> <title>
|
||||||
cat > "site/$1" <<EOF
|
cat > "site/$1" <<EOF
|
||||||
[Flatpak Ref]
|
[Flatpak Ref]
|
||||||
|
|||||||
@@ -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
|
||||||
+106
-55
@@ -14,8 +14,12 @@
|
|||||||
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
|
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
|
||||||
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
|
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
|
||||||
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
|
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
|
||||||
# The Developer ID DMG is codesigned with the SAME macOS entitlements, so what we test
|
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
|
||||||
# locally equals what App Store users get.
|
# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a
|
||||||
|
# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG
|
||||||
|
# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though
|
||||||
|
# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the
|
||||||
|
# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here.
|
||||||
#
|
#
|
||||||
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
|
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
|
||||||
# step is continue-on-error until they exist):
|
# step is continue-on-error until they exist):
|
||||||
@@ -27,6 +31,15 @@
|
|||||||
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
|
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
|
||||||
# .pkg is installer-signed with it.
|
# .pkg is installer-signed with it.
|
||||||
#
|
#
|
||||||
|
# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it):
|
||||||
|
# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID,
|
||||||
|
# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner
|
||||||
|
# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the
|
||||||
|
# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch
|
||||||
|
# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app
|
||||||
|
# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships
|
||||||
|
# a launchable app.
|
||||||
|
#
|
||||||
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
|
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
|
||||||
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
|
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
|
||||||
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
|
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
|
||||||
@@ -99,13 +112,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Version from tag
|
- name: Version from tag
|
||||||
run: |
|
run: |
|
||||||
|
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
||||||
*) V="0.3.0" ;; # canary marketing version; the build number disambiguates
|
*) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
|
||||||
esac
|
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"
|
||||||
echo "version $V build $GITHUB_RUN_NUMBER"
|
echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
|
||||||
|
|
||||||
- name: Rust toolchain (mac + iOS + tvOS slices)
|
- name: Rust toolchain (mac + iOS + tvOS slices)
|
||||||
run: |
|
run: |
|
||||||
@@ -118,6 +132,23 @@ jobs:
|
|||||||
"$RUSTUP" toolchain install nightly --profile minimal
|
"$RUSTUP" toolchain install nightly --profile minimal
|
||||||
"$RUSTUP" component add rust-src --toolchain nightly
|
"$RUSTUP" component add rust-src --toolchain nightly
|
||||||
|
|
||||||
|
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
|
||||||
|
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
|
||||||
|
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||||
|
run: |
|
||||||
|
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||||
|
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||||
|
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||||
|
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||||
|
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||||
|
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||||
|
command -v cmake >/dev/null || "$BREW" install cmake
|
||||||
|
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||||
|
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||||
|
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||||
|
# inherits this from the env during the xcframework build).
|
||||||
|
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||||
@@ -138,9 +169,8 @@ jobs:
|
|||||||
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
|
||||||
# provisioning-profile gate; codesign just needs the (now valid) identity + the
|
# provisioning-profile gate at archive time; we re-assert that authorization below by
|
||||||
# team-prefixed entitlements, no profile (App Sandbox + the network/device
|
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
|
||||||
# capabilities are self-asserted for Developer ID — no profile entry needed).
|
|
||||||
# Bundle is a single static binary.
|
# Bundle is a single static binary.
|
||||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
@@ -155,6 +185,35 @@ jobs:
|
|||||||
RESOLVED="$RUNNER_TEMP/macos.entitlements"
|
RESOLVED="$RUNNER_TEMP/macos.entitlements"
|
||||||
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
|
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
|
||||||
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
|
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
|
||||||
|
|
||||||
|
# keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the
|
||||||
|
# network/device keys are self-asserted for Developer ID, but a keychain access group
|
||||||
|
# must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to
|
||||||
|
# spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163),
|
||||||
|
# SIGKILL before main() — even though the bundle is validly signed and notarized. Embed
|
||||||
|
# a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its
|
||||||
|
# entitlements authorize the access group, exactly like the App Store build's profile
|
||||||
|
# does. Located by profile Name among the profiles installed on the runner (see header).
|
||||||
|
DEVID_PROFILE_NAME="Punktfunk macOS Developer ID"
|
||||||
|
PROFILE_SRC=""
|
||||||
|
for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \
|
||||||
|
"$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do
|
||||||
|
[ -e "$p" ] || continue
|
||||||
|
NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true)
|
||||||
|
[ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break
|
||||||
|
done
|
||||||
|
if [ -n "$PROFILE_SRC" ]; then
|
||||||
|
# Must land BEFORE codesign so it's sealed into the bundle.
|
||||||
|
cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile"
|
||||||
|
echo "embedded Developer ID profile: $PROFILE_SRC"
|
||||||
|
else
|
||||||
|
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
|
||||||
|
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
|
||||||
|
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
|
||||||
|
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
|
||||||
|
/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
codesign --force --options runtime --timestamp \
|
codesign --force --options runtime --timestamp \
|
||||||
--entitlements "$RESOLVED" \
|
--entitlements "$RESOLVED" \
|
||||||
--sign "Developer ID Application" "$APP"
|
--sign "Developer ID Application" "$APP"
|
||||||
@@ -190,10 +249,20 @@ jobs:
|
|||||||
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
|
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
# Separate archive from the Developer ID one above: App Store needs a profile-signed
|
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
|
||||||
# archive (manual signing), not the unsigned-then-codesign DMG path. Same App-Manager
|
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
|
||||||
# ASC-key constraint as iOS/tvOS — MANUAL signing, NOT -allowProvisioningUpdates
|
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
|
||||||
# (cloud signing the key can't do). Quit Xcode so it can't prune the dropped profile.
|
# profile (as this step used to do): the in-app license screens added a SwiftPM resource
|
||||||
|
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
|
||||||
|
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
|
||||||
|
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
||||||
|
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
||||||
|
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
||||||
|
# entitlements in. No -allowProvisioningUpdates → it stays OFFLINE and never cloud-signs
|
||||||
|
# (the App-Manager ASC key can't), so the runner must have a macOS *development* profile
|
||||||
|
# for io.unom.punktfunk installed. DISTRIBUTION signing happens in the export step below
|
||||||
|
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
||||||
|
# distribution profile that export needs.
|
||||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk macOS App Store Distribution"
|
PROFILE="Punktfunk macOS App Store Distribution"
|
||||||
@@ -201,11 +270,10 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||||
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
CODE_SIGN_STYLE=Manual \
|
CODE_SIGN_STYLE=Automatic \
|
||||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
|
||||||
PROVISIONING_PROFILE_SPECIFIER="$PROFILE"
|
|
||||||
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
|
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
@@ -235,35 +303,27 @@ jobs:
|
|||||||
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
|
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
# MANUAL App Store signing: the local (valid) Apple Distribution identity + the App
|
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
|
||||||
# Store provisioning profile. NOT -allowProvisioningUpdates — with an App-Manager-role
|
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
|
||||||
# ASC key that forces Xcode's CLOUD-managed signing, which the role can't do ("Cloud
|
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
||||||
# signing permission error"). The profile must be installed on the runner under
|
# this step used to set matched it and failed the archive ("does not support provisioning
|
||||||
# ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ (install it once with
|
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
||||||
# Xcode.app quit, or it prunes the manually-dropped distribution profile).
|
# the macOS-host macro plugins) alone. No -allowProvisioningUpdates → OFFLINE, never
|
||||||
# A running Xcode.app prunes unrecognized profiles from that dir — quit it so the App
|
# cloud-signs (the App-Manager ASC key can't), so the runner needs an iOS *development*
|
||||||
# Store profile survives this build; headless xcodebuild doesn't need the GUI app.
|
# profile for io.unom.punktfunk installed. DISTRIBUTION signing is the export step below
|
||||||
|
# (manual, via the plist). A running Xcode.app prunes unrecognized profiles — quit it so the
|
||||||
|
# manually-installed App Store distribution profile survives for export.
|
||||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk iOS App Store Distribution"
|
PROFILE="Punktfunk iOS App Store Distribution"
|
||||||
# Scope signing to the iOS device SDK via an xcconfig — see the tvOS step below for the
|
|
||||||
# full rationale. A global (CLI) profile specifier would also be forced onto the shared
|
|
||||||
# macOS-host SwiftPM macro plugins, which reject it and fail the archive; [sdk=iphoneos*]
|
|
||||||
# in an xcconfig lands it on the app/framework slices only.
|
|
||||||
SIGN_XCCONFIG="$RUNNER_TEMP/sign-ios.xcconfig"
|
|
||||||
cat > "$SIGN_XCCONFIG" <<XCCONF
|
|
||||||
CODE_SIGN_STYLE = Manual
|
|
||||||
DEVELOPMENT_TEAM = $TEAM_ID
|
|
||||||
CODE_SIGN_IDENTITY[sdk=iphoneos*] = Apple Distribution
|
|
||||||
PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*] = $PROFILE
|
|
||||||
XCCONF
|
|
||||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||||
-destination 'generic/platform=iOS' \
|
-destination 'generic/platform=iOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-xcconfig "$SIGN_XCCONFIG" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM"
|
CODE_SIGN_STYLE=Automatic \
|
||||||
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF
|
cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
@@ -295,33 +355,24 @@ jobs:
|
|||||||
# on the runner (xcodebuild -downloadPlatform tvOS).
|
# on the runner (xcodebuild -downloadPlatform tvOS).
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
# Same manual App Store signing as iOS (the App-Manager ASC key can't cloud-sign).
|
# Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
|
||||||
|
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
||||||
|
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
||||||
|
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
||||||
|
# AssociationMacro) alone. No -allowProvisioningUpdates → OFFLINE, never cloud-signs (the
|
||||||
|
# App-Manager ASC key can't), so the runner needs a tvOS *development* profile for
|
||||||
|
# io.unom.punktfunk installed. DISTRIBUTION signing is the export step below (manual, plist).
|
||||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk tvOS App Store Distribution"
|
PROFILE="Punktfunk tvOS App Store Distribution"
|
||||||
# Scope signing to the tvOS device SDK via an xcconfig. A global (CLI) profile specifier
|
|
||||||
# hits EVERY target, including the shared SwiftPM macro plugins (OnceMacro/SwizzlingMacro/
|
|
||||||
# AssociationMacro) which build for the macOS host and reject a provisioning profile
|
|
||||||
# ("<macro> does not support provisioning profiles"), failing the archive. Conditionals
|
|
||||||
# work only in an xcconfig (xcodebuild mis-parses a CLI "SETTING[sdk=..]=val"), and a
|
|
||||||
# command-line -xcconfig outranks target settings, so [sdk=appletvos*] puts the profile on
|
|
||||||
# the app/framework slices only — the macosx-host macros get nothing. (The macOS archive
|
|
||||||
# above is immune: its host-SDK macros are CODE_SIGNING_ALLOWED=NO, so a global specifier
|
|
||||||
# is ignored there.)
|
|
||||||
SIGN_XCCONFIG="$RUNNER_TEMP/sign-tvos.xcconfig"
|
|
||||||
cat > "$SIGN_XCCONFIG" <<XCCONF
|
|
||||||
CODE_SIGN_STYLE = Manual
|
|
||||||
DEVELOPMENT_TEAM = $TEAM_ID
|
|
||||||
CODE_SIGN_IDENTITY[sdk=appletvos*] = Apple Distribution
|
|
||||||
PROVISIONING_PROFILE_SPECIFIER[sdk=appletvos*] = $PROFILE
|
|
||||||
XCCONF
|
|
||||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||||
-destination 'generic/platform=tvOS' \
|
-destination 'generic/platform=tvOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-xcconfig "$SIGN_XCCONFIG" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM"
|
CODE_SIGN_STYLE=Automatic \
|
||||||
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF
|
cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- image: punktfunk-fedora-rpm # Fedora 43 == Bazzite base
|
- image: punktfunk-fedora-rpm # Fedora 43 == Bazzite base
|
||||||
group: bazzite
|
group: bazzite
|
||||||
|
fedver: 43
|
||||||
- image: punktfunk-fedora44-rpm # Fedora 44 == Fedora KDE spin
|
- image: punktfunk-fedora44-rpm # Fedora 44 == Fedora KDE spin
|
||||||
group: fedora-44
|
group: fedora-44
|
||||||
|
fedver: 44
|
||||||
container:
|
container:
|
||||||
image: git.unom.io/unom/${{ matrix.image }}:latest
|
image: git.unom.io/unom/${{ matrix.image }}:latest
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
@@ -53,6 +55,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
git config --global --add safe.directory "$PWD"
|
git config --global --add safe.directory "$PWD"
|
||||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
||||||
|
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||||
|
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
||||||
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
|
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
|
||||||
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
|
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
|
||||||
command -v bun >/dev/null || {
|
command -v bun >/dev/null || {
|
||||||
@@ -68,16 +72,17 @@ jobs:
|
|||||||
restore-keys: cargo-home-
|
restore-keys: cargo-home-
|
||||||
|
|
||||||
- name: Version + channel
|
- name: Version + channel
|
||||||
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.3.0-0.ciN.g<sha>
|
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> <next-minor>-0.ciN.g<sha>
|
||||||
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.3.0-1 yet
|
# in the `<base>-canary` group, whose "0." release sorts below the eventual <next-minor>-1 yet
|
||||||
# climbs by run number. The canary base stays one minor ahead of the latest stable so a
|
# climbs by run number. The canary base is derived one minor ahead of the latest stable tag
|
||||||
# stable->canary box re-point still moves forward. The spec %build stamps
|
# (scripts/ci/pf-version.sh) so a stable->canary box re-point still moves forward. The spec %build stamps
|
||||||
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
||||||
run: |
|
run: |
|
||||||
|
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
|
||||||
*) V="0.3.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
*) V="$PF_BASE"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
||||||
esac
|
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"
|
||||||
@@ -103,11 +108,40 @@ jobs:
|
|||||||
for rpm in dist/*.rpm; do
|
for rpm in dist/*.rpm; do
|
||||||
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"
|
||||||
|
# A re-tagged release re-fires this workflow and the rpm registry 409s on duplicate
|
||||||
|
# package versions — delete any prior copy of this exact name/version-release/arch
|
||||||
|
# first (404 on the first publish is fine).
|
||||||
|
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
|
||||||
|
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
|
||||||
|
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
||||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
||||||
done
|
done
|
||||||
echo "published to $OWNER/rpm/$GROUP"
|
echo "published to $OWNER/rpm/$GROUP"
|
||||||
|
|
||||||
|
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
|
||||||
|
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
|
||||||
|
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
|
||||||
|
- name: Build the sysext image
|
||||||
|
run: |
|
||||||
|
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
|
||||||
|
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
|
||||||
|
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||||
|
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||||
|
|
||||||
|
- name: Publish the sysext feed
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
case "$GROUP" in
|
||||||
|
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up
|
||||||
|
*) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release
|
||||||
|
esac
|
||||||
|
KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \
|
||||||
|
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
|
||||||
|
|
||||||
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
|
# 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
|
# (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).
|
# both on the release; canary builds live in the `*-canary` rpm groups (no release page).
|
||||||
@@ -123,3 +157,6 @@ jobs:
|
|||||||
base="$(basename "$rpm" .rpm)"
|
base="$(basename "$rpm" .rpm)"
|
||||||
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
|
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
|
||||||
done
|
done
|
||||||
|
for raw in dist-sysext/*.raw; do
|
||||||
|
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
|
||||||
|
done
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# One-shot provisioning of the WDK + cargo-wdk onto the persistent self-hosted windows-amd64 runner, so
|
|
||||||
# the all-Rust UMDF drivers can build there (design/windows-host-rewrite.md, M0). The runner has the base
|
|
||||||
# Windows SDK + MSVC + LLVM + Rust but NOT the WDK (no km/wdf/iddcx headers) or cargo-wdk.
|
|
||||||
#
|
|
||||||
# Dispatch manually (workflow_dispatch). Idempotent: re-running is a near no-op once provisioned. The
|
|
||||||
# install persists on the runner (real box, not an ephemeral container), so this runs once, not per build.
|
|
||||||
name: windows-drivers-provision
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'scripts/ci/provision-windows-wdk.ps1'
|
|
||||||
- '.gitea/workflows/windows-drivers-provision.yml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
provision:
|
|
||||||
runs-on: windows-amd64
|
|
||||||
timeout-minutes: 60
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: pwsh
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install WDK + cargo-wdk on the runner
|
|
||||||
run: ./scripts/ci/provision-windows-wdk.ps1
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Windows driver workspace CI — runs on the self-hosted Windows runner (home-windows-1, host mode;
|
# Windows driver workspace CI — runs on a self-hosted Windows runner (home-windows-runner-1, host
|
||||||
# label windows-amd64). Part of the Windows-host rewrite (design/windows-host-rewrite.md, M0).
|
# 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
|
# 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,
|
# inf2cat/stampinf/devgen/signtool tools) so we know what's provisioned BEFORE writing driver code,
|
||||||
@@ -26,7 +26,8 @@ on:
|
|||||||
- 'crates/pf-driver-proto/**'
|
- 'crates/pf-driver-proto/**'
|
||||||
- 'packaging/windows/drivers/**'
|
- 'packaging/windows/drivers/**'
|
||||||
|
|
||||||
# Driver builds need the WDK on the runner (provision once via windows-drivers-provision.yml).
|
# 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:
|
jobs:
|
||||||
probe-and-proto:
|
probe-and-proto:
|
||||||
@@ -76,7 +77,7 @@ jobs:
|
|||||||
head "EWDK"
|
head "EWDK"
|
||||||
Write-Host ("EWDKROOT = " + ($env:EWDKROOT ?? '<unset>'))
|
Write-Host ("EWDKROOT = " + ($env:EWDKROOT ?? '<unset>'))
|
||||||
|
|
||||||
head "LLVM / clang (README pins 21.1.2 for wdk-sys bindgen)"
|
head "LLVM / clang (bindgen 0.72 builds on the runner default clang)"
|
||||||
Write-Host ("LIBCLANG_PATH = " + ($env:LIBCLANG_PATH ?? '<unset>'))
|
Write-Host ("LIBCLANG_PATH = " + ($env:LIBCLANG_PATH ?? '<unset>'))
|
||||||
$clang = Get-Command clang -ErrorAction SilentlyContinue
|
$clang = Get-Command clang -ErrorAction SilentlyContinue
|
||||||
if ($clang) { & clang --version } else { Write-Host "clang: NOT on PATH" }
|
if ($clang) { & clang --version } else { Write-Host "clang: NOT on PATH" }
|
||||||
@@ -119,21 +120,32 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
# wdk-build otherwise picks 10.0.28000.0 (no km/crt) and bindgen fails — pin the WDK SDK version.
|
# 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'
|
Version_Number: '10.0.26100.0'
|
||||||
# wdk-sys bindgen layout tests overflow (E0080) on the runner's default LLVM (ToT/22-dev); point at
|
# No LIBCLANG_PATH pin: the vendored bindgen 0.72 builds clean on the runner's default clang 22
|
||||||
# the pinned LLVM 21.1.2 that windows-drivers-rs builds clean against (provisioned to C:\llvm-21).
|
# (the shipping pack proves it). A 0.71-era layout-test overflow once needed LLVM 21; the 0.72 bump
|
||||||
LIBCLANG_PATH: 'C:\llvm-21\bin'
|
# retired that — see design/windows-build-and-packaging.md.
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Ensure WDK + cargo-wdk + LLVM 21.1.2 (idempotent self-provision)
|
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||||
# Run the provisioning script here too so driver-build is self-sufficient and never races a
|
# Shared self-provision step (also used by windows.yml/windows-msix.yml/windows-host.yml) so
|
||||||
# separate provision run on the single runner. Path is relative to the job working-directory
|
# driver-build is self-sufficient on any windows-amd64 runner and never races a manually
|
||||||
# (packaging/windows/drivers). Near-noop once the toolchain is present.
|
# dispatched provisioning workflow landing on a different one. Path is relative to the job
|
||||||
run: ../../../scripts/ci/provision-windows-wdk.ps1
|
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
|
||||||
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay)
|
run: ../../../scripts/ci/ensure-windows-toolchain.ps1
|
||||||
|
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay + gamepad drivers)
|
||||||
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
|
# 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
|
# pf-vdisplay (the real IddCx driver) + pf-umdf-util (the safe UMDF primitive layer) + the two
|
||||||
# against IddCxStub end-to-end (M1 step 2 gate).
|
# gamepad drivers. pf-vdisplay linking proves the IddCx call sites resolve against IddCxStub
|
||||||
|
# end-to-end (M1 step 2 gate); the gamepad drivers prove pf-umdf-util's WDF dispatch links.
|
||||||
run: cargo build -v
|
run: cargo build -v
|
||||||
|
- name: cargo clippy the shipped drivers (-D warnings — enforces the unsafe-audit gates)
|
||||||
|
# The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited
|
||||||
|
# unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a
|
||||||
|
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
||||||
|
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
||||||
|
# toolchain-only probe crate and is excluded.)
|
||||||
|
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||||
|
- name: cargo fmt --check the safe-layer + gamepad drivers
|
||||||
|
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
|
||||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||||
run: |
|
run: |
|
||||||
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||||
|
|||||||
@@ -1,8 +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
|
||||||
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
|
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
|
||||||
# bun) from one signed setup.exe. Runs on the self-hosted Windows runner
|
# bun) from one signed setup.exe. Runs on a self-hosted windows-amd64 runner
|
||||||
# (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml.
|
# (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
|
||||||
@@ -15,17 +16,20 @@
|
|||||||
# Versioning (free-form; not MSIX's 4-part rule) — single project version:
|
# Versioning (free-form; not MSIX's 4-part rule) — single project version:
|
||||||
# vX.Y.Z tag -> X.Y.Z (THE release; published + stable `latest/` alias + attached to the
|
# vX.Y.Z tag -> X.Y.Z (THE release; published + stable `latest/` alias + attached to the
|
||||||
# unified Gitea Release).
|
# unified Gitea Release).
|
||||||
# main push / dispatch -> 0.3.<run_number> (canary; `canary/` alias; climbs by run number).
|
# main push / dispatch -> <next-minor>.<run_number> (canary; `canary/` alias; base one minor
|
||||||
|
# ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs).
|
||||||
#
|
#
|
||||||
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
|
# 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.
|
||||||
#
|
#
|
||||||
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
||||||
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export
|
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
|
||||||
# .def with llvm-dlltool (no GPU/SDK at build time).
|
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
|
||||||
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN gpl-shared
|
# AMD/Intel-only boxes before main).
|
||||||
|
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
|
||||||
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
||||||
|
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
||||||
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
|
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
|
||||||
name: windows-host
|
name: windows-host
|
||||||
|
|
||||||
@@ -35,6 +39,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'crates/punktfunk-host/**'
|
- 'crates/punktfunk-host/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
|
- 'crates/punktfunk-tray/**'
|
||||||
- 'packaging/windows/**'
|
- 'packaging/windows/**'
|
||||||
- 'scripts/windows/**'
|
- 'scripts/windows/**'
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
@@ -56,6 +61,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: Locale-safety gate (installer-run scripts must be ASCII)
|
- name: Locale-safety gate (installer-run scripts must be ASCII)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
# The installer runs these via powershell.exe (Windows PowerShell 5.1) and cmd.exe on the END
|
# The installer runs these via powershell.exe (Windows PowerShell 5.1) and cmd.exe on the END
|
||||||
@@ -80,37 +89,47 @@ jobs:
|
|||||||
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
|
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
|
||||||
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
# FFMPEG_DIR: the same BtbN gpl-shared x64 tree the Windows CLIENT links against (provisioned
|
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
|
||||||
# by scripts/ci/setup-windows-runner.ps1). The host's AMD/Intel AMF/QSV encode backend
|
# by scripts/ci/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
|
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
||||||
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
||||||
if (-not $env:FFMPEG_DIR) {
|
if (-not $env:FFMPEG_DIR) {
|
||||||
"FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"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
|
||||||
|
}
|
||||||
|
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||||
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||||
$env:GITHUB_REF_NAME -replace '^v', ''
|
$env:GITHUB_REF_NAME -replace '^v', ''
|
||||||
} else {
|
} else {
|
||||||
"0.3.$($env:GITHUB_RUN_NUMBER)"
|
# Canary: <major>.<minor>.<run> — major.minor track one minor ahead of stable, run climbs monotonically.
|
||||||
|
"$($pf.PF_MAJOR).$($pf.PF_MINOR).$($env:GITHUB_RUN_NUMBER)"
|
||||||
}
|
}
|
||||||
"HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"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
|
||||||
Write-Output "host version $v"
|
Write-Output "host version $v"
|
||||||
|
|
||||||
- name: Generate NVENC import lib
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
|
|
||||||
"PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
|
||||||
|
|
||||||
- name: Build (release, nvenc + amf-qsv)
|
- name: Build (release, nvenc + amf-qsv)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
|
# 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
|
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
|
||||||
|
|
||||||
- name: Clippy (host, Windows)
|
- name: Build (release, status tray)
|
||||||
|
shell: pwsh
|
||||||
|
# The per-user notification-area companion the installer bundles (punktfunk-tray.exe).
|
||||||
|
run: cargo build --release -p punktfunk-tray
|
||||||
|
|
||||||
|
- name: Clippy (host + tray, Windows)
|
||||||
shell: pwsh
|
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,amf-qsv -- -D warnings
|
run: |
|
||||||
|
cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||||
|
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||||
|
|
||||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -124,14 +143,6 @@ jobs:
|
|||||||
cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" }
|
cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" }
|
||||||
Pop-Location
|
Pop-Location
|
||||||
|
|
||||||
- name: Ensure Inno Setup
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
if (-not (Test-Path 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe') -and -not (Get-Command iscc -ErrorAction SilentlyContinue)) {
|
|
||||||
Write-Output "installing Inno Setup via choco"
|
|
||||||
choco install innosetup -y --no-progress
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Fetch portable bun runtime (build tool + bundled to run the console)
|
- name: Fetch portable bun runtime (build tool + bundled to run the console)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
@@ -170,8 +181,8 @@ jobs:
|
|||||||
Push-Location web
|
Push-Location web
|
||||||
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
||||||
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
|
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
|
||||||
if (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet) {
|
if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
|
||||||
throw "web build is a bun bundle (Bun.serve) - need the node-server preset"
|
throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
|
||||||
}
|
}
|
||||||
Pop-Location
|
Pop-Location
|
||||||
# Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login.
|
# Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login.
|
||||||
@@ -202,9 +213,15 @@ 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" }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -15,7 +16,8 @@
|
|||||||
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
|
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
|
||||||
# Published to the generic registry + the stable `latest/` alias + attached to the
|
# Published to the generic registry + the stable `latest/` alias + attached to the
|
||||||
# unified Gitea Release alongside every other platform's artifact.
|
# unified Gitea Release alongside every other platform's artifact.
|
||||||
# main push / dispatch -> 0.3.<run_number>.0 (canary; climbs monotonically by run number).
|
# main push / dispatch -> <next-minor>.<run_number>.0 (canary; base is one minor ahead of the
|
||||||
|
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
|
||||||
# Published to the generic registry + the `canary/` alias.
|
# 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).
|
||||||
#
|
#
|
||||||
@@ -62,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 + version
|
- name: Configure + version
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
@@ -73,11 +79,13 @@ 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 }}
|
||||||
|
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||||
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
|
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
|
||||||
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
|
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
|
||||||
} else {
|
} else {
|
||||||
@('0', '3', $env:GITHUB_RUN_NUMBER)
|
# Canary: <major>.<minor>.<run>.0 — major.minor track one minor ahead of stable, run climbs monotonically.
|
||||||
|
@($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER)
|
||||||
}
|
}
|
||||||
while ($parts.Count -lt 4) { $parts += '0' }
|
while ($parts.Count -lt 4) { $parts += '0' }
|
||||||
$v = ($parts[0..3] -join '.')
|
$v = ($parts[0..3] -join '.')
|
||||||
@@ -115,9 +123,15 @@ jobs:
|
|||||||
$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) {
|
function Put($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"
|
||||||
}
|
}
|
||||||
foreach ($f in $files) {
|
foreach ($f in $files) {
|
||||||
$name = Split-Path $f -Leaf
|
$name = Split-Path $f -Leaf
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ clients/apple/PunktfunkCore.xcframework/
|
|||||||
clients/apple/.swiftpm/
|
clients/apple/.swiftpm/
|
||||||
# Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact)
|
# Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact)
|
||||||
clients/apple/screenshots/
|
clients/apple/screenshots/
|
||||||
|
clients/linux/screenshots/
|
||||||
# Xcode per-user state
|
# Xcode per-user state
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
|
||||||
@@ -30,3 +31,6 @@ xcuserdata/
|
|||||||
# Python bytecode (e.g. clients/android/ci tooling)
|
# Python bytecode (e.g. clients/android/ci tooling)
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
# Claude Code project instructions — local to each dev box, not part of the repo.
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
@@ -1,357 +0,0 @@
|
|||||||
# CLAUDE.md — punktfunk
|
|
||||||
|
|
||||||
Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core
|
|
||||||
(`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design:
|
|
||||||
[`design/implementation-plan.md`](design/implementation-plan.md). Status table: `README.md`.
|
|
||||||
|
|
||||||
## Where the work stands
|
|
||||||
|
|
||||||
- **Core (`punktfunk-core` + C ABI): complete and hardened.** FEC recovery, loopback-under-loss,
|
|
||||||
proptests, C ABI harness all green; 13 adversarial-review findings fixed +
|
|
||||||
regression-tested (`a913042`).
|
|
||||||
- **GameStream host: working end-to-end with a stock Moonlight client.** Validated live
|
|
||||||
on this box: pairing (persists across restarts), serverinfo/applist (app catalog from
|
|
||||||
`~/.config/punktfunk/apps.json` → each entry picks a compositor + nested command), RTSP, ENet
|
|
||||||
control, audio, and video at the **client's native resolution and refresh** — the host
|
|
||||||
creates a per-session virtual output via per-compositor `VirtualDisplay` backends:
|
|
||||||
**KWin** (`zkde_screencast stream_virtual_output`, needs KWin ≥ 6.5.6 headless; >60 Hz via
|
|
||||||
custom modes), **gamescope** (spawned headless at WxH@Hz, its PipeWire node captured, needs
|
|
||||||
gamescope ≥ 3.16.22 — older deadlocks on PipeWire ≥ 1.6), **Mutter** (D-Bus
|
|
||||||
`RecordVirtual` virtual monitor; validated live on headless GNOME Shell 50, zero-copy),
|
|
||||||
**Sway/wlroots** (`swaymsg create_output` + custom mode, xdpw portal capture with a
|
|
||||||
managed chooser config; validated live on sway 1.11, zero-copy).
|
|
||||||
Performance work landed and measured: GPU **zero-copy** on all paths (tiled dmabuf →
|
|
||||||
EGL/GL → CUDA; LINEAR dmabuf → **Vulkan bridge** → CUDA → NVENC), auto 2-way NVENC
|
|
||||||
split-encode above ~1 Gpix/s (5K@240), infinite GOP + RFI keyframes (killed the periodic
|
|
||||||
freeze), encode|send thread split with `sendmmsg` batching. Stable 240 fps at 5120×1440.
|
|
||||||
Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS
|
|
||||||
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
|
|
||||||
back-channel; validated live — pad created/destroyed with the session). Management REST API +
|
|
||||||
checked-in OpenAPI doc (`mgmt.rs`). **Web-console performance capture** (`stats_recorder.rs`,
|
|
||||||
design: [`design/stats-capture-plan.md`](design/stats-capture-plan.md)): the operator arms stats
|
|
||||||
recording from the web console, plays, stops, and reviews the run as graphs (per-stage latency
|
|
||||||
breakdown · fps new/repeat · goodput · loss/FEC). A shared `Arc<StatsRecorder>` ring (the hot-path
|
|
||||||
gate is a runtime `AtomicBool`, replacing the startup-only `PUNKTFUNK_PERF`) is fed by **both** the
|
|
||||||
native `virtual_stream` and the GameStream encode loop at their existing ~2 s/~1 s aggregation
|
|
||||||
boundary, and finished captures are saved as on-disk recordings
|
|
||||||
(`~/.config/punktfunk/captures/*.json`) browsable/exportable from the console's **Performance** page
|
|
||||||
(recharts). Endpoints `/api/v1/stats/*` (bearer-only). *Implemented; not yet on-glass validated.*
|
|
||||||
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
|
|
||||||
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
|
|
||||||
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
|
||||||
(inexpressible in GameStream), host creates the native virtual output at the client's
|
|
||||||
requested mode. `punktfunk1-host` is a **persistent listener** (sessions back to back;
|
|
||||||
`--max-sessions`). QUIC datagrams carry the side planes, demuxed by first byte: input
|
|
||||||
0xC8 (incl. **gamepads** — incremental events accumulated into the uinput xpad), **Opus
|
|
||||||
audio** 0xC9 (48 kHz stereo, 5 ms, host→client), **rumble** 0xCA (host→client). **Trust:**
|
|
||||||
host serves its persistent identity (`~/.config/punktfunk/cert.pem`, shared with GameStream
|
|
||||||
pairing) and logs the SHA-256 fingerprint; clients pin it, established by a **SPAKE2 PIN pairing
|
|
||||||
ceremony** (host arms pairing and displays a 4-digit PIN; a PAKE binds both cert fingerprints so an
|
|
||||||
attacker gets one online guess, no offline dictionary attack) — PIN pairing is the default for new
|
|
||||||
hosts. **TOFU on first connect** (`endpoint::client_pinned`) stays as an explicit host opt-in
|
|
||||||
(`punktfunk1-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs;
|
|
||||||
clients only offer the TOFU "Trust" path for a host that advertised `pair=optional`, route every
|
|
||||||
other new host straight to the PIN ceremony, and on a pinned-fingerprint change force re-pairing
|
|
||||||
(no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores
|
|
||||||
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
|
|
||||||
default; `--allow-tofu`/`--open` accept unpaired clients).
|
|
||||||
**LAN auto-discovery**: both `serve` and `punktfunk1-host` advertise the native service over
|
|
||||||
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
|
|
||||||
pin)/`pair`(required|optional)/`id`; `punktfunk-probe --discover` lists hosts, Apple clients
|
|
||||||
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
|
|
||||||
**Mid-stream mode renegotiation**: `Reconfigure` on the still-open control stream — the
|
|
||||||
host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on
|
|
||||||
(validated live: one .h265 with 720p and 1080p segments). Measured on-box at 720p120: 1680/1680 frames, **p50 0.83 ms**
|
|
||||||
capture→…→reassembled; audio measured live (~200 pkts/s). A **wall-clock skew handshake**
|
|
||||||
(`ClockProbe`/`ClockEcho`, 8 NTP rounds after `Start`, `clock_offset_ns`) aligns the client to the
|
|
||||||
host clock, so that latency is now valid **cross-machine** (`skew_corrected=true`) — measured GNOME
|
|
||||||
box → dev box over the LAN: **p50 1.30 ms** (the −1.57 ms inter-box clock offset removed).
|
|
||||||
`punktfunk-probe` is the
|
|
||||||
working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad).
|
|
||||||
The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect`
|
|
||||||
(pin/TOFU) + `next_au`/`next_audio`/`next_rumble`/`next_hidout`/`send_input`/
|
|
||||||
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
|
|
||||||
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
|
|
||||||
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
|
|
||||||
env > uinput Xbox 360. Backends: **Xbox 360** (uinput / ViGEm), **Xbox One/Series** (the same
|
|
||||||
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
|
|
||||||
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
|
|
||||||
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
|
|
||||||
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
|
|
||||||
LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows
|
|
||||||
(UMDF minidriver)** backend — `inject/dualsense_windows.rs` + `inject/dualshock4_windows.rs`, one
|
|
||||||
driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4
|
|
||||||
reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays
|
|
||||||
Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller
|
|
||||||
(DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad
|
|
||||||
dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver
|
|
||||||
(`packaging/windows/xusb-driver/`, `inject/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB`
|
|
||||||
and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState`
|
|
||||||
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
|
|
||||||
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are bundled + pnputil-installed
|
|
||||||
by the Inno Setup installer (`packaging/windows/gamepad-drivers/` + `install-gamepad-drivers.ps1`).
|
|
||||||
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
|
|
||||||
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
|
|
||||||
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
|
|
||||||
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
|
|
||||||
the remaining piece.)
|
|
||||||
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
|
|
||||||
behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA**
|
|
||||||
virtual display per session (`vdisplay/sudovda.rs`), GPU encode (NVENC `--features nvenc`; AMD/Intel
|
|
||||||
`--features amf-qsv`), SendInput + **ViGEm** gamepads (`inject/gamepad_windows.rs`), WASAPI loopback
|
|
||||||
+ virtual mic (`audio/wasapi_*`). 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 (`service.rs`), bundles the SudoVDA driver + the FFmpeg DLLs, 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) detects the DXGI adapter vendor → **NVENC** (NVIDIA,
|
|
||||||
direct SDK, `encode/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec
|
|
||||||
(`encode/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`,
|
|
||||||
system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a
|
|
||||||
system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is
|
|
||||||
probed per-GPU on AMF/QSV (`windows_codec_support` → `serverinfo`, AV1 gated). **HDR (10-bit)**: WGC
|
|
||||||
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
|
|
||||||
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
|
|
||||||
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
|
|
||||||
host only** (the Linux host stays 8-bit, blocked upstream). **Vulkan-game HDR over the virtual
|
|
||||||
display**: NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an IddCx
|
|
||||||
indirect display (so Vulkan games — Doom: The Dark Ages, id Tech, etc. — say "device does not support
|
|
||||||
HDR"), even though the ICD happily *accepts + presents* a forced HDR swapchain there. A tiny always-on
|
|
||||||
Vulkan **implicit layer** (`packaging/windows/pf-vkhdr-layer/`, `VK_LAYER_PUNKTFUNK_hdr_inject`)
|
|
||||||
injects the `HDR10_ST2084`/scRGB surface formats into `vkGetPhysicalDeviceSurfaceFormats[2]KHR`,
|
|
||||||
self-gated on the display's actual advanced-color state (no-op on SDR / real monitors); bundled +
|
|
||||||
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
|
|
||||||
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
|
|
||||||
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
|
|
||||||
|
|
||||||
## What's left
|
|
||||||
|
|
||||||
1. **Native clients — decode + present: macOS stage 1 done, first light achieved
|
|
||||||
(2026-06-10).** PunktfunkKit compiles and is tested on macOS (AnnexB → VideoToolbox →
|
|
||||||
`AVSampleBufferDisplayLayer`, GCMouse/GCKeyboard capture, `PunktfunkClient` app shell);
|
|
||||||
validated live Mac ↔ this box at 720p60 — vkcube on glass, input injected via gamescope
|
|
||||||
EIS. The app speaks the full ABI v2 trust surface: Keychain-persisted client identity
|
|
||||||
presented on every connect, SPAKE2 PIN pairing UI (host-card context menu + the trust
|
|
||||||
prompt's "Pair with PIN instead…"), TOFU fingerprint prompt. **Gamepads (2026-06-11):**
|
|
||||||
controller discovery + selection in Settings (`GamepadManager` — exactly one pad
|
|
||||||
forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical
|
|
||||||
controller, user-overridable), capture incl. DualSense touchpad/motion
|
|
||||||
(`GamepadCapture`/`GamepadWire`), feedback rendering (rumble → CoreHaptics; lightbar /
|
|
||||||
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
|
|
||||||
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
|
|
||||||
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
|
|
||||||
motion sign/scale derived, not yet live-verified. Tests: `swift test` in
|
|
||||||
`clients/apple` (unit + real-codec round trip),
|
|
||||||
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
|
|
||||||
includes the pairing ceremony + `--require-pairing` gate),
|
|
||||||
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
|
||||||
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter**
|
|
||||||
(`VTDecompressionSession` + `CAMetalLayer`) is built and live-validated on glass behind the opt-in
|
|
||||||
`punktfunk.presenter` flag (~11 ms p50 capture→present), to become the default after a few
|
|
||||||
resolution/HDR checks. Next: make stage 2 the default, glass-to-glass numbers via
|
|
||||||
`tools/latency-probe`, iOS/iPadOS/tvOS variants.
|
|
||||||
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
|
|
||||||
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
|
||||||
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
|
||||||
PIN dialogs (identity shared with client-rs), FFmpeg software HEVC decode (LOW_DELAY,
|
|
||||||
slice threads) → `GtkGraphicsOffload`-wrapped picture, PipeWire playback (mic-player
|
|
||||||
jitter ring inverted), SDL3 gamepad capture + rumble/lightbar feedback, keyboard via
|
|
||||||
exact inverse of the host VK table, absolute mouse + 120-unit scroll. Validated live
|
|
||||||
against `serve` on this box: 1080p60, steady 60 fps, capture→decoded p50
|
|
||||||
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
|
|
||||||
stage 1.5 (2026-06-12 evening)**: capture state machine (click-to-capture,
|
|
||||||
Ctrl+Alt+Shift+Q / focus-loss release, held-state flush), app-lifetime SDL gamepad
|
|
||||||
service (pad pin UI, auto type from the physical pad, DualSense touchpad/motion 0xCC +
|
|
||||||
raw-DS5-effects trigger/player-LED replay — needs a physical pad to live-verify), mic
|
|
||||||
uplink (validated live), per-host speed test, compositor pref, native-display mode
|
|
||||||
default, saved-hosts list, .deb + RPM-subpackage CI (deb.yml/rpm.yml). **VAAPI decode
|
|
||||||
→ DRM-PRIME dmabuf → `GdkDmabufTexture`** (BT.709 color state; Tier-1 zero-copy on
|
|
||||||
Intel/AMD, `PUNKTFUNK_DECODER=software|vaapi` override) with a proven fallback ladder —
|
|
||||||
no VAAPI device (NVIDIA) or mid-session VAAPI error → software decode. **First AMD test
|
|
||||||
(Steam Deck) hit a green-screen bug, fixed:** FFmpeg's VAAPI export uses
|
|
||||||
`SEPARATE_LAYERS`, so NV12 arrives as two single-plane layers (R8 luma + GR88 chroma,
|
|
||||||
one shared fd); the mapper took `layers[0]` only → GTK got a luma-only R8 texture, chroma
|
|
||||||
read as 0 → green field / red whites. Fix derives the combined fourcc from the decoder
|
|
||||||
`sw_format` (→ `DRM_FORMAT_NV12`) and flattens all planes across all layers (mpv's
|
|
||||||
pattern); a first-frame descriptor dump logs the real layout. Awaiting Steam Deck
|
|
||||||
reconfirm. Next: the stage-2 raw-Wayland
|
|
||||||
presenter (wp_presentation feedback, tearing-control, Vulkan Video on NVIDIA) —
|
|
||||||
**wgpu/winit rejected** (no dmabuf import / presentation feedback / shortcuts-inhibit).
|
|
||||||
**Windows stage 1 done 2026-06-15** (`clients/windows`, binary
|
|
||||||
`punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like
|
|
||||||
framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The
|
|
||||||
video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain** (WARP fallback for
|
|
||||||
the GPU-less dev box; runtime-compiled fullscreen-triangle shaders, Contain-fit letterbox),
|
|
||||||
driven by reactor's per-frame `on_rendering`. **FFmpeg HEVC decode with a D3D11VA
|
|
||||||
zero-copy hardware path** (`gpu.rs` shares one D3D11 device — hardware+`VIDEO_SUPPORT`, WARP
|
|
||||||
fallback, multithread-protected — between the decoder and presenter; the decoder outputs
|
|
||||||
NV12/P010 `ID3D11Texture2D` array slices with `BIND_SHADER_RESOURCE` and the presenter samples
|
|
||||||
them via per-plane SRVs + YUV→RGB shaders — NV12/BT.709, P010/BT.2020-PQ; **software CPU decode
|
|
||||||
stays as the robust fallback**, auto-selected with a `DecoderPref` override). **HDR10**: the
|
|
||||||
client advertises 10-bit/HDR (Settings toggle), detects PQ in-band (`transfer == SMPTE2084`),
|
|
||||||
and flips the swapchain to `R10G10B10A2` + ST.2084 with HDR10 metadata. **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 cards w/ monogram + status pills,
|
|
||||||
`InfoBar` errors/hints, `ToggleSwitch` settings, status-chip stream HUD showing GPU/CPU decode +
|
|
||||||
HDR), host list (live mDNS + saved + manual), settings (resolution/refresh/decoder/bitrate/HDR/
|
|
||||||
mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch re-pair. **(D3D11VA + HDR present + the
|
|
||||||
GUI polish are written against the windows-rs/reactor APIs but not yet on-glass validated — the
|
|
||||||
dev VM is headless/WARP; needs the RTX box.)** **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor
|
|
||||||
exposes no raw key/pointer events; native Windows VK + absolute mouse (client-rect Contain-fit) +
|
|
||||||
wheel, Ctrl+Alt+Shift+Q capture toggle. `--headless`/`--discover` keep CLI paths. Builds + clippy
|
|
||||||
+ fmt green on **`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`** — the latter
|
|
||||||
**cross-compiled off the one x64 runner** (no ARM64 runner; the x64 MSVC toolset's ARM64 cross
|
|
||||||
compiler + a per-arch `FFMPEG_DIR` ARM64 tree, SDL3/libopus build-from-source cross-compile
|
|
||||||
cleanly), and both ship as signed MSIX (`windows-msix.yml` matrix → `..._x64.msix`/`..._arm64.msix`,
|
|
||||||
verified: ARM64 binaries + manifest arch). **windows-reactor is unpublished** (git
|
|
||||||
dep pinned to commit `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`
|
|
||||||
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
|
|
||||||
validation** of the D3D11VA decode + HDR present + GUI on the RTX box (the dev VM is
|
|
||||||
headless/Session-0/WARP → the WinUI window + hardware decode need a real display+GPU: RDP or the
|
|
||||||
RTX box), then RAWINPUT relative-mouse pointer-lock and a per-host speed test in the UI.
|
|
||||||
**Android stage 1 done** (`clients/android`, Kotlin app + `native/` Rust JNI core linking
|
|
||||||
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
|
|
||||||
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
|
|
||||||
Opus/Oboe audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
|
|
||||||
(`feedback.rs`), **native `mdns-sd` mDNS discovery** (`discovery.rs`, polled over JNI — the same
|
|
||||||
browse the Linux/Windows clients use, replacing the flaky per-OEM `NsdManager`; Kotlin keeps only
|
|
||||||
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
|
|
||||||
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
|
|
||||||
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
|
|
||||||
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish.
|
|
||||||
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
|
||||||
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
|
||||||
at high res).
|
|
||||||
3. **punktfunk/1 protocol growth.** **Done:** unified host (`serve --gamestream` runs GameStream + the
|
|
||||||
punktfunk/1 QUIC host in one process; bare `serve` is the secure native-only default — GameStream is
|
|
||||||
opt-in, trusted-LAN only, security-review #5/#9) with native pairing driven over the mgmt API /
|
|
||||||
web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
|
|
||||||
**Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
|
|
||||||
`pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts
|
|
||||||
unpaired clients); clients render TOFU only for a `pair=optional` host and force re-pairing on a
|
|
||||||
fingerprint change. **Done:** concurrent sessions — the accept loop spawns each session
|
|
||||||
(`--max-concurrent`, default 4, an NVENC bound), each with its own virtual output + encoder, sharing
|
|
||||||
the host-lifetime input/audio/mic services (shared-desktop multi-view on kwin/mutter/wlroots).
|
|
||||||
**Done:** delegated pairing approval (§8b-1) — an unpaired device shows up as a pending request in
|
|
||||||
the web console, one click approves + pins it. Next (see roadmap): gamescope multi-user isolation
|
|
||||||
(per-session input/audio = independent desktops); §8b-2 peer-push approval from a paired device's
|
|
||||||
own app.
|
|
||||||
4. **GameStream host polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
|
|
||||||
-highbitdepth 1` already encodes Main10 from 8-bit input on this box),
|
|
||||||
reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented
|
|
||||||
and unit/live-capture tested — both still need a live Moonlight confirmation (select
|
|
||||||
AV1 in a stock client; a real 5.1/7.1 listen incl. FEC under loss).
|
|
||||||
|
|
||||||
Box one-time setup is complete: udev rule + `input` group (gamepads validated live),
|
|
||||||
gamescope 3.16.22 installed system-wide (no PATH override), gnome-shell installed (Mutter
|
|
||||||
backend validated live). All three compositor backends are live-validated.
|
|
||||||
|
|
||||||
## Build / test / run
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo build --workspace # green on Linux and macOS
|
|
||||||
cargo test --workspace # unit + loopback + proptest + C ABI harness (~100 tests)
|
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
cargo fmt --all --check
|
|
||||||
|
|
||||||
cargo run -p loss-harness # FEC loss-resilience sweep (no network needed)
|
|
||||||
bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip proof
|
|
||||||
```
|
|
||||||
|
|
||||||
Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h`
|
|
||||||
(cbindgen from `punktfunk-core/src/abi.rs`) and `api/openapi.json` (regenerate with
|
|
||||||
`cargo run -p punktfunk-host -- openapi > api/openapi.json`; spec lives in `mgmt.rs`).
|
|
||||||
|
|
||||||
CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the
|
|
||||||
workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site
|
|
||||||
build+typecheck; `docker.yml` builds+pushes the web/docs/rust-ci images (host and native
|
|
||||||
clients are deliberately NOT containerized); `apple.yml` builds the xcframework and runs
|
|
||||||
`swift build`/`swift test` on the `macos-arm64` host-mode runner (home-mac-mini-1,
|
|
||||||
provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows:
|
|
||||||
`deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml`
|
|
||||||
(Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG +
|
|
||||||
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated)
|
|
||||||
crates/punktfunk-host/
|
|
||||||
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
|
|
||||||
vdisplay/{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)
|
|
||||||
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
|
|
||||||
encode/{nvenc,linux,vaapi,ffmpeg_win,sw}.rs per-GPU encoders (NVENC · Linux NVENC/CUDA · VAAPI · AMF/QSV · openh264)
|
|
||||||
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs
|
|
||||||
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
|
||||||
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
|
||||||
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
|
|
||||||
clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController)
|
|
||||||
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
|
|
||||||
clients/decky/ Steam Deck Decky plugin
|
|
||||||
crates/punktfunk-host/src/{capture/dxgi,vdisplay/sudovda,encode/ffmpeg_win,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends
|
|
||||||
web/ TanStack web console over the mgmt API (status · devices · pairing · performance graphs)
|
|
||||||
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
|
|
||||||
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
|
||||||
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
|
|
||||||
include/punktfunk_core.h generated C header
|
|
||||||
```
|
|
||||||
|
|
||||||
## Design invariants — do not regress
|
|
||||||
|
|
||||||
- **One core, linked everywhere.** Protocol/FEC/crypto live only in `punktfunk-core`, behind a
|
|
||||||
stable, versioned C ABI. `tokio`/`quinn` exist only behind the `quic` feature (control
|
|
||||||
plane); **no async on the per-frame path** — native threads only.
|
|
||||||
- **Native client resolution, no scaling.** A session gets a virtual output at exactly the
|
|
||||||
client's WxH@Hz via the `VirtualDisplay` trait (`create(mode) → VirtualOutput { node_id,
|
|
||||||
remote_fd, preferred_mode, keepalive }`, RAII teardown). There is no cross-compositor
|
|
||||||
protocol for this — each compositor keeps its own backend.
|
|
||||||
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block, Moonlight-compatible) and GF(2¹⁶)
|
|
||||||
Leopard (≤65535 shards/block) — punktfunk/1 negotiates the latter, removing the ~1 Gbps
|
|
||||||
ceiling.
|
|
||||||
- **Core security hardening stays intact**: reassembler bounds attacker-controlled fields
|
|
||||||
before allocating (`ReassemblerLimits`); AES-GCM per-direction nonce salts + seq-as-AAD;
|
|
||||||
ABI `struct_size` checks. Regression tests exist — keep them green.
|
|
||||||
- **PipeWire consumer discipline**: our capture streams set `node.dont-reconnect` and tear
|
|
||||||
down promptly on negotiation timeout — one wedged link head-blocks the daemon's shared
|
|
||||||
work queue system-wide.
|
|
||||||
|
|
||||||
## Running on this box
|
|
||||||
|
|
||||||
Headless QEMU VM (Ubuntu 26.04, kernel 7.0), passthrough RTX 5070 Ti (driver 595 **open**
|
|
||||||
module — a kernel update silently drops it; reinstall `nvidia-driver-595-open`), no KMS
|
|
||||||
scanout → KWin `--drm` impossible; everything renders offscreen via `renderD128`.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# compositor session (shell 1, or the systemd unit in scripts/): full headless Plasma.
|
|
||||||
# The script sets XDG_MENU_PREFIX=plasma- & co. — without it plasmashell runs but the
|
|
||||||
# launcher menu is EMPTY (no apps, no System Settings).
|
|
||||||
bash scripts/headless/run-headless-kde.sh 1920x1080
|
|
||||||
|
|
||||||
# host (shell 2): bare `serve` is native-only (secure default); add --gamestream for Moonlight compat.
|
|
||||||
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_VIDEO_SOURCE=virtual \
|
|
||||||
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve --gamestream
|
|
||||||
|
|
||||||
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
|
|
||||||
# across sessions — bound it with --max-sessions):
|
|
||||||
cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 10 --max-sessions 1
|
|
||||||
cargo run -rp punktfunk-probe -- --mode 1280x720x120 --out /tmp/a.h265 --input-test # + --pin HEX
|
|
||||||
```
|
|
||||||
|
|
||||||
Pinned crate facts: `ashpd` 0.13 + `pipewire` 0.9 (must match ashpd's) + `ffmpeg-next` 8.x
|
|
||||||
(`ffmpeg-sys-next` auto-detects the system FFmpeg, so it builds against **FFmpeg 7.x/libavcodec 61
|
|
||||||
or 8.x/libavcodec 62** — validated live on Ubuntu 26.04 (8) and Bazzite F43 (7.1); the zero-copy
|
|
||||||
FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKTFUNK_VIDEO_SOURCE=virtual|portal`,
|
|
||||||
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1`, `PUNKTFUNK_GAMESCOPE_APP=...`,
|
|
||||||
`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 +
|
|
||||||
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy).
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- Rust 2021, `rustfmt` + `clippy -D warnings` clean before commit.
|
|
||||||
- Match the surrounding code's comment density and naming.
|
|
||||||
- Commit messages end with the Co-Authored-By trailer (see `git log`).
|
|
||||||
- `pkill` caution on this box: match exact comm names (`pkill -x gamescope-wl`,
|
|
||||||
`pkill -x punktfunk-host`) — `pkill -f` self-matches the invoking shell.
|
|
||||||
@@ -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
+414
-332
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/punktfunk-tray",
|
||||||
"crates/pf-driver-proto",
|
"crates/pf-driver-proto",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
@@ -11,9 +13,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.8.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
# punktfunk
|
<p align="center">
|
||||||
|
<img src="assets/punktfunk-logo.svg" alt="punktfunk" width="320" />
|
||||||
|
</p>
|
||||||
|
|
||||||
**Low-latency desktop and game streaming with first-class Linux and Windows hosts.** Run the host on
|
<p align="center"><b>Low-latency desktop and game streaming with first-class Linux and Windows hosts.</b></p>
|
||||||
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.
|
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
|
||||||
@@ -11,6 +15,9 @@ or games — each device at its **own native resolution and refresh rate**, over
|
|||||||
💬 **Community: [Discord](https://discord.gg/kaPNvzMuGU)** — chat, support, and **Android beta
|
💬 **Community: [Discord](https://discord.gg/kaPNvzMuGU)** — chat, support, and **Android beta
|
||||||
access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
|
access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
|
||||||
|
|
||||||
|
🔒 **Security:** found a vulnerability? Report it privately to **security@punktfunk.com** — see
|
||||||
|
[SECURITY.md](SECURITY.md). Please don't open a public issue.
|
||||||
|
|
||||||
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
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
|
||||||
@@ -22,6 +29,11 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
|||||||
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display
|
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display
|
||||||
sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No
|
sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No
|
||||||
letterboxing, no scaling, no rearranging your real monitors.
|
letterboxing, no scaling, no rearranging your real monitors.
|
||||||
|
- **Displays you configure, not just create.** Keep a game's display (and the game) alive across
|
||||||
|
disconnects so a reconnect drops straight back in; make the stream your sole desktop or extend
|
||||||
|
alongside your monitors; let several devices become monitors of one desktop; keep each client's
|
||||||
|
scaling. One-click presets in the console — a dedicated couch box, a shared desktop, a multi-monitor
|
||||||
|
workstation. See [Virtual displays](docs-site/content/docs/virtual-displays.md).
|
||||||
- **A real virtual display on Windows, too.** On Linux the host uses per-compositor virtual outputs;
|
- **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
|
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
|
monitor or dummy HDMI plug, even on the secure desktop (UAC / lock screen). It also has **its own
|
||||||
@@ -29,7 +41,9 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
|||||||
a screen — tight, push-based integration that's unusual for a Windows streaming host.
|
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
|
||||||
@@ -43,19 +57,19 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
|||||||
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
| **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** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
|
| **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) | ✅ Streaming live: D3D11VA hardware decode on all GPU vendors (NVIDIA + Intel validated on glass) with software fallback, WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
|
||||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing |
|
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
|
||||||
|
|
||||||
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
|
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
|
||||||
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
|
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
|
||||||
and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin,
|
and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin,
|
||||||
gamescope, Mutter, and Sway/wlroots backends), encoded with GPU **zero-copy** (dmabuf → CUDA/Vulkan →
|
gamescope, Mutter, and Sway/wlroots backends), encoded with GPU **zero-copy** (dmabuf → CUDA/Vulkan →
|
||||||
NVENC) up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a
|
NVENC) up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a
|
||||||
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→reassembled at 720p120), with
|
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→received at 720p120), with
|
||||||
mid-stream mode renegotiation and a wall-clock skew handshake so latency stays valid across machines.
|
mid-stream mode renegotiation and a wall-clock skew handshake so latency stays valid across machines.
|
||||||
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
|
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
|
||||||
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
|
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
|
||||||
@@ -69,14 +83,15 @@ 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) |
|
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [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) |
|
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
|
||||||
| **Windows** (NVIDIA, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||||
|
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||||
|
|
||||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
`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;
|
||||||
@@ -121,18 +136,17 @@ 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 · GPUs · performance · logs
|
||||||
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
|
||||||
design/ design notes & deep-dive plans
|
|
||||||
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)
|
||||||
```
|
```
|
||||||
@@ -151,4 +165,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.
|
||||||
|
|||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
|
||||||
|
machine, so we take security reports seriously and appreciate responsible disclosure.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Please report security issues privately by email to security@punktfunk.com.**
|
||||||
|
|
||||||
|
Do **not** open a public issue, pull request, or chat/forum post for a suspected vulnerability — that
|
||||||
|
exposes other users before a fix exists.
|
||||||
|
|
||||||
|
### What to include
|
||||||
|
|
||||||
|
The more of this you can give us, the faster we can act:
|
||||||
|
|
||||||
|
- The component and version (e.g. `punktfunk-host 0.6.0`, Windows or Linux, which client).
|
||||||
|
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
|
||||||
|
admin, a paired client, …).
|
||||||
|
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
|
||||||
|
- Any suggested fix or mitigation (optional).
|
||||||
|
|
||||||
|
## What to expect
|
||||||
|
|
||||||
|
We're a small team, so timelines are best-effort, but we commit to:
|
||||||
|
|
||||||
|
- **Acknowledge** your report within **3 business days**.
|
||||||
|
- Give an **initial assessment** (severity + whether we can reproduce) within about **7 days**.
|
||||||
|
- Keep you updated, and tell you when a fix ships.
|
||||||
|
- **Credit** you in the advisory / release notes when the fix is public — unless you'd rather stay
|
||||||
|
anonymous.
|
||||||
|
|
||||||
|
We practice **coordinated disclosure**: please give us reasonable time to release a fix before
|
||||||
|
publishing details. We aim to resolve valid issues within **90 days** and will agree a disclosure
|
||||||
|
date with you.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope — the code in this repository:
|
||||||
|
|
||||||
|
- The host (`punktfunk-host`), its Windows drivers, and the protocol/crypto core (`punktfunk-core`).
|
||||||
|
- The native clients (Apple, Linux, Windows, Android), the web management console, and the management
|
||||||
|
API.
|
||||||
|
|
||||||
|
Known limits — documented behavior, not vulnerabilities (see
|
||||||
|
https://docs.punktfunk.unom.io/docs/security):
|
||||||
|
|
||||||
|
- **Admin/SYSTEM already on the host = out of scope.** An attacker who is already administrator or
|
||||||
|
SYSTEM on the host owns the machine regardless of punktfunk.
|
||||||
|
- **The virtual display is a real monitor** — any process already in the interactive desktop session
|
||||||
|
can capture it via the normal OS screen-capture APIs, exactly as it could a physical monitor.
|
||||||
|
- **GameStream/Moonlight compatibility** (`--gamestream`) uses legacy encryption and is documented as
|
||||||
|
opt-in, trusted-LAN-only.
|
||||||
|
- **Public-internet exposure is unsupported** — issues that only arise from exposing the host to the
|
||||||
|
WAN are expected; keep the host on a trusted LAN or a VPN.
|
||||||
|
|
||||||
|
If you're unsure whether something is in scope, report it anyway — we'd rather hear about it.
|
||||||
|
|
||||||
|
## Safe harbor
|
||||||
|
|
||||||
|
We consider good-faith security research that follows this policy to be authorized, and we won't
|
||||||
|
pursue legal action against researchers who:
|
||||||
|
|
||||||
|
- make a good-faith effort to avoid privacy violations, data loss, and service disruption,
|
||||||
|
- only test systems they own or have explicit permission to test,
|
||||||
|
- give us reasonable time to remediate before public disclosure,
|
||||||
|
- don't exfiltrate more data than needed to demonstrate the issue.
|
||||||
|
|
||||||
|
Thank you for helping keep punktfunk and its users safe.
|
||||||
+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"
|
||||||
+1223
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 579 298" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<style>
|
||||||
|
/* Theme-adaptive so the logo stays readable on both light and dark README
|
||||||
|
backgrounds: deep violet (the brand-mark palette) on light, the original
|
||||||
|
light violet on dark. Evaluated by the viewer's color scheme. */
|
||||||
|
.pf-wm { fill: #6c5bf3; }
|
||||||
|
.pf-back { fill: #a79ff8; }
|
||||||
|
.pf-deep { fill: #6c5bf3; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.pf-wm { fill: #cec9fb; }
|
||||||
|
.pf-back { fill: #f2f1fe; }
|
||||||
|
.pf-deep { fill: #8c7ef5; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="pf-wm" style="fill-rule:nonzero;" d="M21.144,176.635l0,102.687l31.253,0l0,-35.563l73.436,0l0,-23.555l-73.436,0l0,-19.398l77.285,0l0,-24.171l-108.537,0Z"/>
|
||||||
|
<path class="pf-wm" style="fill-rule:nonzero;" d="M136.148,176.635l0,47.264c0.154,16.627 0.154,16.627 0.308,20.014c0.77,15.087 2.463,21.4 7.544,26.634c7.698,8.16 20.014,10.315 59.272,10.315c23.863,0 34.178,-0.616 43.415,-2.463c11.7,-2.463 19.552,-10.623 21.246,-22.323c0.924,-7.236 1.078,-8.929 1.54,-32.176l0,-47.264l-31.253,0l0,47.264c0,2.155 -0.154,7.082 -0.308,10.623c-0.462,9.699 -1.232,12.47 -3.695,15.087c-3.387,3.695 -9.853,4.619 -31.407,4.619c-26.634,0 -32.638,-1.693 -34.332,-9.853c-0.77,-4.157 -0.77,-4.311 -1.078,-20.476l0,-47.264l-31.253,0Z"/>
|
||||||
|
<path class="pf-wm" style="fill-rule:nonzero;" d="M275.938,176.527l0,102.687l31.868,0l-0.77,-76.669l3.387,0l54.038,76.669l54.346,0l0,-102.687l-31.868,0l0.77,76.515l-3.233,0l-53.73,-76.515l-54.808,0Z"/>
|
||||||
|
<path class="pf-wm" style="fill-rule:nonzero;" d="M425.273,176.527l0,102.687l31.253,0l0,-39.258l17.089,0l46.032,39.258l47.418,0l-64.353,-52.344l59.426,-50.959l-47.88,0l-40.644,37.873l-17.089,0l0,-37.257l-31.253,0Z"/>
|
||||||
|
</g>
|
||||||
|
<path class="pf-back" style="fill-rule:nonzero;" d="M65.442,150.143c24.514,0 44.298,-19.784 44.298,-44.298c0,-24.514 -19.784,-44.298 -44.298,-44.298c-24.514,0 -44.298,19.784 -44.298,44.298c0,24.514 19.784,44.298 44.298,44.298Z"/>
|
||||||
|
<path class="pf-deep" style="fill-rule:nonzero;" d="M141.063,92.871c17.334,-17.334 17.334,-45.312 0,-62.647c-17.334,-17.334 -45.312,-17.334 -62.647,-0c-17.334,17.334 -17.334,45.312 0,62.647c17.334,17.334 45.312,17.334 62.647,-0Z"/>
|
||||||
|
<path style="fill:url(#_Linear1);" d="M121.228,104.359c-14.777,3.965 -31.187,0.136 -42.811,-11.488c-11.624,-11.624 -15.453,-28.034 -11.488,-42.811c14.777,-3.965 31.187,-0.136 42.811,11.488c11.624,11.624 15.453,28.034 11.488,42.811Z"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(31.323323,-31.323323,31.323323,31.323323,78.416832,92.870811)">
|
||||||
|
<stop offset="0" style="stop-color:#cec9fb;stop-opacity:0"/>
|
||||||
|
<stop offset="1" style="stop-color:#fcfcff;stop-opacity:1"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -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
|
||||||
|
|||||||
+57
-59
@@ -1,83 +1,81 @@
|
|||||||
# 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` + `armeabi-v7a` + `x86_64` — the 32-bit `armeabi-v7a` slice is what keeps the
|
||||||
|
app installable on the many 32-bit Google TV / Android TV streamers (Walmart onn. 4K, Chromecast with
|
||||||
|
Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "not compatible".
|
||||||
|
|
||||||
|
## 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, **mDNS discovery** (`mdns-sd`, the same browse the Linux/Windows clients use) |
|
| **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, the Wi-Fi `MulticastLock` + permission UX, 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 (native mdns-sd, polled) · 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 armv7-linux-androideabi 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** — native `mdns-sd` mDNS host list (polled over JNI; the same browse the
|
|
||||||
Linux/Windows clients use, not `NsdManager`), SPAKE2 PIN pairing and TOFU, with a
|
|
||||||
Keystore-wrapped client identity and a known-host store.
|
|
||||||
- **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.
|
|
||||||
|
|||||||
@@ -22,14 +22,34 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applicationId = "io.unom.punktfunk"
|
applicationId = "io.unom.punktfunk"
|
||||||
minSdk = 31
|
// Android 9. Reaches older Android TV boxes (e.g. Amlogic streamers still on Android 9–11);
|
||||||
|
// the handful of API 31+ APIs we use are runtime-gated (Material You → brand palette, rumble
|
||||||
|
// → legacy Vibrator, NEARBY_WIFI/lights/ADPF already gated), so nothing is lost above 28.
|
||||||
|
minSdk = 28
|
||||||
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 is the single project version, threaded from CI (a vX.Y.Z release or a
|
// 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).
|
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
|
||||||
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2"
|
// Local dev (no VERSION_NAME) falls back to the workspace version from the root Cargo.toml —
|
||||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
// the single source of truth — so an on-device build shows the real current version, not a
|
||||||
|
// stale placeholder.
|
||||||
|
val workspaceVersion = runCatching {
|
||||||
|
project.rootProject.file("../../Cargo.toml").readLines()
|
||||||
|
.dropWhile { !it.trim().startsWith("[workspace.package]") }
|
||||||
|
.firstOrNull { it.trim().startsWith("version") }
|
||||||
|
?.substringAfter('=')?.trim()?.trim('"')
|
||||||
|
}.getOrNull()
|
||||||
|
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME"))
|
||||||
|
?: workspaceVersion ?: "0.0.0"
|
||||||
|
// Ship 32-bit armeabi-v7a alongside 64-bit arm64-v8a: many Google TV / Android TV streamers
|
||||||
|
// (Walmart onn. 4K, Chromecast with Google TV, budget Amlogic boxes) run a 32-bit Android
|
||||||
|
// userspace, and because this app carries native code, Google Play (and a sideload installer)
|
||||||
|
// filters it as "not compatible" on those devices unless an armeabi-v7a variant is present.
|
||||||
|
// x86_64 stays for the emulator. Google keeps delivering to 32-bit TV devices (see the Aug
|
||||||
|
// 2025 "64-bit app compatibility for Google TV and Android TV" post) — the 64-bit lib is the
|
||||||
|
// required half; the 32-bit lib is what actually reaches the boxes people report failing.
|
||||||
|
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -62,6 +82,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
|
||||||
@@ -93,10 +117,36 @@ dependencies {
|
|||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation("androidx.compose.foundation:foundation")
|
implementation("androidx.compose.foundation:foundation")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.compose.material:material-icons-core") // bottom-bar tab icons
|
implementation("androidx.compose.material:material-icons-core") // bottom-bar / rail tab icons
|
||||||
|
implementation("androidx.compose.material:material-icons-extended") // settings-category icons
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
|
// Cover-art loading for the game-library coverflow. Coil 2.x uses OkHttp under the hood, so we
|
||||||
|
// feed it the same mTLS OkHttpClient the library fetch uses (reaching the host's own art proxy).
|
||||||
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
|
|
||||||
|
// Real backdrop blur for the floating console legends (RenderEffect on API 31+, a translucent
|
||||||
|
// scrim below). The gamepad UI's frosted pills sample + blur whatever scrolls behind them.
|
||||||
|
implementation("dev.chrisbanes.haze:haze:1.6.0")
|
||||||
|
|
||||||
// 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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,16 @@
|
|||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||||
|
|
||||||
|
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
|
||||||
|
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
|
||||||
|
eligible for the vendor's game performance profile — the aggressive CPU/GPU governor and
|
||||||
|
scheduler treatment games get — which, together with the ADPF hints in the native decode
|
||||||
|
path, is what keeps clocks up for low, consistent decode latency. Also groups it correctly
|
||||||
|
under Games in battery/data usage. Advisory: devices without Game Mode ignore it. -->
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
android:appCategory="game"
|
||||||
|
android:banner="@drawable/tv_banner"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,40 +2,62 @@ package io.unom.punktfunk
|
|||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
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.layout.systemBarsPadding
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationRail
|
||||||
|
import androidx.compose.material3.NavigationRailItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import io.unom.punktfunk.models.Tab
|
import io.unom.punktfunk.models.Tab
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App(forceGamepadUi: Boolean = false) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val settingsStore = remember { SettingsStore(context) }
|
val settingsStore = remember { SettingsStore(context) }
|
||||||
var settings by remember { mutableStateOf(settingsStore.load()) }
|
var settings by remember { mutableStateOf(settingsStore.load()) }
|
||||||
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
|
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
|
||||||
var tab by remember { mutableStateOf(Tab.Connect) }
|
var tab by remember { mutableStateOf(Tab.Connect) }
|
||||||
|
|
||||||
|
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
|
||||||
|
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
|
||||||
|
val tv = remember { isTvDevice(context) }
|
||||||
|
val controllerConnected by rememberControllerConnected()
|
||||||
|
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = streamHandle != 0L,
|
targetState = streamHandle != 0L,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
@@ -46,46 +68,154 @@ fun App() {
|
|||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
// Immersive: the stream takes the whole screen, no bottom bar.
|
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||||
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
|
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
|
||||||
|
} else if (gamepadUi) {
|
||||||
|
GamepadShell(
|
||||||
|
settings = settings,
|
||||||
|
onSettingsChange = { settings = it; settingsStore.save(it) },
|
||||||
|
onConnected = { streamHandle = it },
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Scaffold(
|
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||||
bottomBar = {
|
// with its items centred vertically (the common Android tablet idiom, mirroring iPad's
|
||||||
NavigationBar {
|
// side navigation). A short landscape phone keeps the bottom bar (rail needs height too).
|
||||||
Tab.entries.forEach { t ->
|
// Tabs slide along the axis the nav sits on: horizontally with the bottom bar (phone),
|
||||||
NavigationBarItem(
|
// vertically with the side rail (tablet), so the motion tracks the direction you moved.
|
||||||
selected = tab == t,
|
val tabContent: @Composable (vertical: Boolean) -> Unit = { vertical ->
|
||||||
onClick = { tab = t },
|
AnimatedContent(
|
||||||
icon = { Icon(t.icon, contentDescription = t.label) },
|
targetState = tab,
|
||||||
label = { Text(t.label) },
|
transitionSpec = {
|
||||||
)
|
val forward = targetState.ordinal > initialState.ordinal
|
||||||
}
|
when {
|
||||||
}
|
vertical && forward ->
|
||||||
},
|
slideInVertically { it } + fadeIn() togetherWith
|
||||||
) { innerPadding ->
|
slideOutVertically { -it } + fadeOut()
|
||||||
Box(Modifier.fillMaxSize().padding(innerPadding)) {
|
vertical ->
|
||||||
AnimatedContent(
|
slideInVertically { -it } + fadeIn() togetherWith
|
||||||
targetState = tab,
|
slideOutVertically { it } + fadeOut()
|
||||||
transitionSpec = {
|
forward ->
|
||||||
if (targetState.ordinal > initialState.ordinal) {
|
|
||||||
slideInHorizontally { it } + fadeIn() togetherWith
|
slideInHorizontally { it } + fadeIn() togetherWith
|
||||||
slideOutHorizontally { -it } + fadeOut()
|
slideOutHorizontally { -it } + fadeOut()
|
||||||
} else {
|
else ->
|
||||||
slideInHorizontally { -it } + fadeIn() togetherWith
|
slideInHorizontally { -it } + fadeIn() togetherWith
|
||||||
slideOutHorizontally { it } + fadeOut()
|
slideOutHorizontally { it } + fadeOut()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = "TabTransition"
|
||||||
|
) { targetTab ->
|
||||||
|
when (targetTab) {
|
||||||
|
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
|
||||||
|
Tab.Settings -> SettingsScreen(
|
||||||
|
initial = settings,
|
||||||
|
onChange = { settings = it; settingsStore.save(it) },
|
||||||
|
onBack = { tab = Tab.Connect },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
if (maxWidth >= 600.dp && maxHeight >= 480.dp) {
|
||||||
|
Row(Modifier.fillMaxSize()) {
|
||||||
|
NavigationRail(Modifier.fillMaxHeight()) {
|
||||||
|
Spacer(Modifier.weight(1f)) // centre the rail items vertically
|
||||||
|
Tab.entries.forEach { t ->
|
||||||
|
NavigationRailItem(
|
||||||
|
selected = tab == t,
|
||||||
|
onClick = { tab = t },
|
||||||
|
icon = { Icon(t.icon, contentDescription = t.label) },
|
||||||
|
label = { Text(t.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
// The rail handles its own insets; the content pane insets itself (the screens
|
||||||
|
// don't, since they used to rely on the Scaffold's padding).
|
||||||
|
Box(Modifier.weight(1f).fillMaxHeight().systemBarsPadding()) { tabContent(true) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
NavigationBar {
|
||||||
|
Tab.entries.forEach { t ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = tab == t,
|
||||||
|
onClick = { tab = t },
|
||||||
|
icon = { Icon(t.icon, contentDescription = t.label) },
|
||||||
|
label = { Text(t.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
label = "TabTransition"
|
) { innerPadding ->
|
||||||
) { targetTab ->
|
Box(Modifier.fillMaxSize().padding(innerPadding)) { tabContent(false) }
|
||||||
when (targetTab) {
|
|
||||||
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
|
|
||||||
Tab.Settings -> SettingsScreen(
|
|
||||||
initial = settings,
|
|
||||||
onChange = { settings = it; settingsStore.save(it) },
|
|
||||||
onBack = { tab = Tab.Connect },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which console screen the gamepad shell is showing. */
|
||||||
|
private enum class GamepadScreen { Home, Settings, Library }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console (gamepad) shell — the Android mirror of the Apple client's ContentView gamepad branch:
|
||||||
|
* a full-screen host carousel with X → Settings and Y → a saved host's library, all sharing
|
||||||
|
* [ConnectScreen]'s connect logic. No bottom bar; navigation is button-driven.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadShell(
|
||||||
|
settings: Settings,
|
||||||
|
onSettingsChange: (Settings) -> Unit,
|
||||||
|
onConnected: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||||
|
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
|
||||||
|
|
||||||
|
// On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the
|
||||||
|
// effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the
|
||||||
|
// panel reports fewer dp than that; a low-density TV that's already spacious, and every phone /
|
||||||
|
// tablet, keep their real density unchanged. This is the "based on pixel density" scale the layout
|
||||||
|
// wanted — one uniform factor across text, cards, spacing, and insets.
|
||||||
|
val isTv = remember { isTvDevice(context) }
|
||||||
|
val baseDensity = LocalDensity.current
|
||||||
|
val screenWidthPx = LocalConfiguration.current.screenWidthDp * baseDensity.density
|
||||||
|
val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP
|
||||||
|
val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) {
|
||||||
|
// Cross-fade between console screens so switches are smooth. Each slot's controller nav is gated
|
||||||
|
// on being the CURRENT target (`s == screen`), so during the fade only the incoming screen drives
|
||||||
|
// the pad. All screens pin their legend at the same ConsoleLegendInset, so it reads as fixed while
|
||||||
|
// the content behind it fades.
|
||||||
|
Crossfade(targetState = screen, animationSpec = tween(240), label = "consoleScreen") { s ->
|
||||||
|
when (s) {
|
||||||
|
GamepadScreen.Home -> ConnectScreen(
|
||||||
|
settings = settings,
|
||||||
|
onConnected = onConnected,
|
||||||
|
gamepadUi = true,
|
||||||
|
onOpenSettings = { screen = GamepadScreen.Settings },
|
||||||
|
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
|
||||||
|
navGate = s == screen,
|
||||||
|
)
|
||||||
|
GamepadScreen.Settings -> GamepadSettingsScreen(
|
||||||
|
initial = settings,
|
||||||
|
onChange = onSettingsChange,
|
||||||
|
onBack = { screen = GamepadScreen.Home },
|
||||||
|
navActive = s == screen,
|
||||||
|
)
|
||||||
|
GamepadScreen.Library -> libraryHost?.let { host ->
|
||||||
|
LibraryScreen(
|
||||||
|
host = host,
|
||||||
|
onBack = { screen = GamepadScreen.Home; libraryHost = null },
|
||||||
|
navActive = s == screen,
|
||||||
|
)
|
||||||
|
} ?: run { screen = GamepadScreen.Home }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */
|
||||||
|
private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
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.kit.security.KnownHostStore
|
||||||
|
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") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a saved host: name, address, port, and the Wake-on-LAN MAC. The MAC is auto-learned from the
|
||||||
|
* host's mDNS advert while it's online, but this is where you can enter or correct it (e.g. to wake a
|
||||||
|
* host you've only ever reached by address). [suggestedMacs] prefills the field from the live advert
|
||||||
|
* when nothing's been learned yet. Keyed by the host so reopening resets the fields. Mirrors the
|
||||||
|
* Apple client's edit form.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun EditHostDialog(
|
||||||
|
target: KnownHost,
|
||||||
|
suggestedMacs: List<String>,
|
||||||
|
onSave: (KnownHost) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember(target) { mutableStateOf(target.name) }
|
||||||
|
var address by remember(target) { mutableStateOf(target.address) }
|
||||||
|
var port by remember(target) { mutableStateOf(target.port.toString()) }
|
||||||
|
var mac by remember(target) {
|
||||||
|
mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", "))
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Edit host") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
placeholder = { Text(target.address) },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = address,
|
||||||
|
onValueChange = { address = it },
|
||||||
|
label = { Text("Address") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = port,
|
||||||
|
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
||||||
|
label = { Text("Port") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = mac,
|
||||||
|
onValueChange = { mac = it },
|
||||||
|
label = { Text("Wake-on-LAN MAC") },
|
||||||
|
placeholder = { Text("auto-filled when the host is seen") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = address.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
onSave(
|
||||||
|
target.copy(
|
||||||
|
name = name.trim().ifEmpty { target.address },
|
||||||
|
address = address.trim(),
|
||||||
|
port = port.toIntOrNull() ?: target.port,
|
||||||
|
mac = KnownHostStore.parseMacs(mac),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { 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,7 +41,6 @@ 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
|
||||||
@@ -74,13 +58,43 @@ 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,
|
||||||
|
// Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this
|
||||||
|
// screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the
|
||||||
|
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
|
||||||
|
gamepadUi: Boolean = false,
|
||||||
|
onOpenSettings: () -> Unit = {},
|
||||||
|
onOpenLibrary: (KnownHost) -> Unit = {},
|
||||||
|
navGate: Boolean = true, // false while the console home is cross-fading out
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var host by remember { mutableStateOf("") }
|
var host by remember { mutableStateOf("") }
|
||||||
@@ -120,6 +134,29 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
val identityStore = remember { IdentityStore(context) }
|
val identityStore = remember { IdentityStore(context) }
|
||||||
val knownHostStore = remember { KnownHostStore(context) }
|
val knownHostStore = remember { KnownHostStore(context) }
|
||||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||||
|
// Wakes a sleeping saved host and waits for it to reappear on mDNS before dialing (its overlay
|
||||||
|
// rides over both the touch and console home). Fire-and-forget WoL isn't enough — a cold boot can
|
||||||
|
// take a minute-plus to advertise again.
|
||||||
|
val waker = remember { WakeController(scope) }
|
||||||
|
// Learn wake MAC(s) from live adverts for hosts we've saved (parity with the desktop clients),
|
||||||
|
// so we can Wake-on-LAN them once they sleep. Runs only when the discovered set changes; the
|
||||||
|
// prefs write is guarded (no-op when unchanged), and we refresh the saved list only if a MAC
|
||||||
|
// was actually newly learned.
|
||||||
|
LaunchedEffect(discovered) {
|
||||||
|
val learned = withContext(Dispatchers.IO) {
|
||||||
|
var any = false
|
||||||
|
discovered.forEach { dh ->
|
||||||
|
if (dh.mac.isNotEmpty() &&
|
||||||
|
knownHostStore.get(dh.host, dh.port)?.let { it.mac != dh.mac } == true
|
||||||
|
) {
|
||||||
|
knownHostStore.learnMac(dh.host, dh.port, dh.mac)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
any
|
||||||
|
}
|
||||||
|
if (learned) savedHosts = knownHostStore.all()
|
||||||
|
}
|
||||||
// Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and
|
// Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and
|
||||||
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
|
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
|
||||||
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
|
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
|
||||||
@@ -128,22 +165,47 @@ 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 saved host whose label is being edited (the Rename dialog).
|
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
||||||
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
||||||
|
// A saved host being edited (name / address / port / MAC).
|
||||||
|
var editTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||||
|
// A saved host whose console options menu (Wake / Edit / Forget) is open — reached with Up on the
|
||||||
|
// carousel (the console counterpart of the touch host card's overflow menu).
|
||||||
|
var optionsTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||||
|
|
||||||
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
|
// 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
|
// 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.
|
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
|
||||||
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
|
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
|
||||||
|
|
||||||
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
|
// The one place the full nativeConnect is issued (shared by the normal connect and the
|
||||||
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
|
// request-access path), including the HDR/gamepad derivation both need.
|
||||||
// straight through and it appears in the saved-hosts list.
|
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long {
|
||||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
// Advertise HDR only when the user enabled it AND this device's display can present it
|
||||||
val id = identity
|
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||||
if (id == null) {
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||||
|
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||||
|
// appears in the saved-hosts list.
|
||||||
|
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||||
|
val id = identity ?: run {
|
||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -151,21 +213,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)
|
|
||||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
|
|
||||||
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
|
|
||||||
// explicit choice is passed through unchanged.
|
|
||||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
|
||||||
val handle = withContext(Dispatchers.IO) {
|
|
||||||
NativeBridge.nativeConnect(
|
|
||||||
targetHost, targetPort, w, h, hz,
|
|
||||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
|
||||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
|
||||||
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)
|
||||||
@@ -182,10 +230,98 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust state is
|
// Wake-aware connect. If the target is a saved host with a learned MAC that ISN'T currently
|
||||||
|
// advertising (asleep/off), wake it and WAIT for it to reappear on mDNS (WakeController shows the
|
||||||
|
// "Waking…" overlay) before dialing — discovery stays running meanwhile so we can see it come
|
||||||
|
// back. A fire-and-forget packet + the connect timeout wasn't enough for a cold boot. Otherwise
|
||||||
|
// dial straight through.
|
||||||
|
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||||
|
if (identity == null) {
|
||||||
|
status = "Identity not ready yet — try again in a moment"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val kh = knownHostStore.get(targetHost, targetPort)
|
||||||
|
val macs = kh?.mac ?: emptyList()
|
||||||
|
// "Up" = a live advert that is THIS host — matched by fingerprint first (so it survives a DHCP
|
||||||
|
// address change on a cold boot), else by address:port. Returns the CURRENT advert so we can
|
||||||
|
// dial its live address rather than the stale saved one.
|
||||||
|
fun liveAdvert(): DiscoveredHost? =
|
||||||
|
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
||||||
|
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
||||||
|
if (macs.isNotEmpty() && liveAdvert() == null) {
|
||||||
|
waker.start(
|
||||||
|
hostName = name,
|
||||||
|
connectsAfter = true,
|
||||||
|
macs = macs,
|
||||||
|
lastIp = targetHost,
|
||||||
|
isOnline = { liveAdvert() != null },
|
||||||
|
onOnline = {
|
||||||
|
val live = liveAdvert()
|
||||||
|
// Woke back on a new address? Re-key the saved record so it (and future connects)
|
||||||
|
// point at the live one, then dial there.
|
||||||
|
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
|
||||||
|
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
}
|
||||||
|
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
doConnectDirect(targetHost, targetPort, name, pinHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// access (approve in the console) or by the SPAKE2 PIN ceremony.
|
||||||
fun connect(
|
fun connect(
|
||||||
targetHost: String,
|
targetHost: String,
|
||||||
targetPort: Int,
|
targetPort: Int,
|
||||||
@@ -208,16 +344,71 @@ 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()) {
|
if (gamepadUi) {
|
||||||
|
// Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares
|
||||||
|
// every action above; the trailing Add Host tile opens the same manual-entry sheet.
|
||||||
|
val tiles = buildList {
|
||||||
|
savedHosts.forEach { kh ->
|
||||||
|
add(
|
||||||
|
HomeTile(
|
||||||
|
id = "saved-${kh.address}:${kh.port}",
|
||||||
|
title = kh.name,
|
||||||
|
subtitle = "${kh.address}:${kh.port}",
|
||||||
|
filled = true,
|
||||||
|
online = discovered.any { it.host == kh.address && it.port == kh.port },
|
||||||
|
paired = kh.paired,
|
||||||
|
knownHost = kh,
|
||||||
|
activate = { connect(kh.address, kh.port) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
discoveredUnsaved.forEach { dh ->
|
||||||
|
add(
|
||||||
|
HomeTile(
|
||||||
|
id = "disc-${dh.host}:${dh.port}",
|
||||||
|
title = dh.name,
|
||||||
|
subtitle = "${dh.host}:${dh.port}",
|
||||||
|
online = true,
|
||||||
|
activate = { connect(dh.host, dh.port, dh) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
HomeTile(
|
||||||
|
id = "add",
|
||||||
|
title = "Add Host",
|
||||||
|
subtitle = "Register a host by address",
|
||||||
|
isAdd = true,
|
||||||
|
activate = { showManualSheet = true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GamepadHome(
|
||||||
|
tiles = tiles,
|
||||||
|
libraryEnabled = settings.libraryEnabled,
|
||||||
|
controllerName = io.unom.punktfunk.kit.Gamepad.firstPad()?.name,
|
||||||
|
// Stop the carousel from consuming the pad while a sheet/dialog/overlay owns the screen,
|
||||||
|
// while a connect is in flight (else a second A launches a concurrent connect that leaks a
|
||||||
|
// handle — the touch grid guards the same way with enabled=!connecting), or while the whole
|
||||||
|
// console home is cross-fading out.
|
||||||
|
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
|
||||||
|
awaiting == null && editTarget == null && optionsTarget == null && waker.waking == null,
|
||||||
|
onActivate = { it.activate() },
|
||||||
|
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOptions = { it.knownHost?.let { kh -> optionsTarget = kh } },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -298,7 +489,25 @@ 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 },
|
onEdit = { editTarget = kh },
|
||||||
|
// Explicit wake-only: offered when the host is offline and we have a MAC. Runs
|
||||||
|
// through the WakeController so it shows the "Waking…" overlay and waits for
|
||||||
|
// the host to come online (matched by fingerprint, so a new DHCP address on a
|
||||||
|
// cold boot still counts as "up") rather than firing a single silent packet.
|
||||||
|
onWake = if (kh.mac.isNotEmpty() && discovered.none { kh.matches(it) }) {
|
||||||
|
{
|
||||||
|
waker.start(
|
||||||
|
hostName = kh.name,
|
||||||
|
connectsAfter = false,
|
||||||
|
macs = kh.mac,
|
||||||
|
lastIp = kh.address,
|
||||||
|
isOnline = { discovered.any { kh.matches(it) } },
|
||||||
|
onOnline = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,228 +555,143 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
ExtendedFloatingActionButton(
|
||||||
visible = true, // Static for now, could be based on scroll if needed
|
onClick = { showManualSheet = true },
|
||||||
enter = scaleIn() + fadeIn(),
|
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||||
exit = scaleOut() + fadeOut(),
|
text = { Text("Add host") },
|
||||||
|
expanded = !connecting,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(20.dp)
|
.padding(20.dp),
|
||||||
) {
|
)
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
onClick = { showManualSheet = true },
|
|
||||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
|
||||||
text = { Text("Add host") },
|
|
||||||
expanded = !connecting,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showManualSheet) {
|
if (showManualSheet) {
|
||||||
ModalBottomSheet(
|
if (gamepadUi) {
|
||||||
onDismissRequest = { showManualSheet = false },
|
// Console add-host: field list + on-screen controller keyboard. "Add" connects (which
|
||||||
sheetState = sheetState,
|
// saves the host on TOFU/pair), exactly like the touch sheet's Connect.
|
||||||
) {
|
GamepadAddHostScreen(
|
||||||
Column(
|
onAdd = { n, addr, p ->
|
||||||
modifier = Modifier
|
showManualSheet = false
|
||||||
.fillMaxWidth()
|
connect(addr, p, manualName = n)
|
||||||
.padding(horizontal = 24.dp)
|
},
|
||||||
.padding(bottom = 32.dp),
|
onDismiss = { showManualSheet = false },
|
||||||
) {
|
)
|
||||||
Text("Add a host", style = MaterialTheme.typography.titleLarge)
|
} else {
|
||||||
Spacer(Modifier.height(4.dp))
|
AddHostSheet(
|
||||||
Text(
|
hostName = hostName,
|
||||||
"Enter its address. You'll pair with the host's PIN on first connect.",
|
onHostNameChange = { hostName = it },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
host = host,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
onHostChange = { host = it },
|
||||||
)
|
port = port,
|
||||||
Spacer(Modifier.height(20.dp))
|
onPortChange = { port = it },
|
||||||
OutlinedTextField(
|
connecting = connecting,
|
||||||
value = hostName,
|
modeLabel = "$w×$h@$hz",
|
||||||
onValueChange = { hostName = it },
|
onDismiss = { showManualSheet = false },
|
||||||
label = { Text("Name (optional)") },
|
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
|
||||||
placeholder = { Text("e.g. Living Room") },
|
)
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = host,
|
|
||||||
onValueChange = { host = it },
|
|
||||||
label = { Text("Host") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.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
|
|
||||||
val n = hostName
|
|
||||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
|
||||||
showManualSheet = false
|
|
||||||
connect(h, p, manualName = n)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { Text("Connect ($w×$h@$hz)") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingTrust?.let { pt ->
|
pendingTrust?.let { pt ->
|
||||||
|
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
|
||||||
|
val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
|
||||||
|
val onSavePaired = { fp: String ->
|
||||||
|
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
pendingTrust = null
|
||||||
|
doConnect(pt.host, pt.port, pt.name, fp)
|
||||||
|
}
|
||||||
when (pt.kind) {
|
when (pt.kind) {
|
||||||
PendingTrust.Kind.TRUST_NEW -> AlertDialog(
|
PendingTrust.Kind.TRUST_NEW ->
|
||||||
onDismissRequest = { pendingTrust = null },
|
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
|
||||||
title = { Text("Trust this host?") },
|
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
|
||||||
text = {
|
PendingTrust.Kind.FP_CHANGED ->
|
||||||
Column {
|
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||||
Text("First connection to ${pt.host}:${pt.port}.")
|
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||||
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}…") }
|
PendingTrust.Kind.REQUEST_ACCESS ->
|
||||||
Text(
|
if (gamepadUi) GamepadRequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
|
||||||
"This host allows trust-on-first-use, but that can't tell an impostor " +
|
else RequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
|
||||||
"from the real host. Pairing with a PIN is stronger — it proves both sides.",
|
PendingTrust.Kind.PAIR ->
|
||||||
)
|
if (gamepadUi) GamepadPairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
|
||||||
}
|
else PairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton({ pendingTrust = null; doConnect(pt.host, pt.port, pt.name, 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(
|
|
||||||
onDismissRequest = { pendingTrust = null },
|
|
||||||
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({ 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),
|
|
||||||
)
|
|
||||||
savedHosts = knownHostStore.all()
|
|
||||||
pendingTrust = null
|
|
||||||
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") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
awaiting?.let { req ->
|
||||||
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
val onCancel = {
|
||||||
renameTarget?.let { kh ->
|
req.cancelled.set(true)
|
||||||
var newName by remember(kh) { mutableStateOf(kh.name) }
|
awaiting = null
|
||||||
AlertDialog(
|
connecting = false
|
||||||
onDismissRequest = { renameTarget = null },
|
discovery.start() // the request may still be pending on the host; keep scanning
|
||||||
title = { Text("Rename host") },
|
}
|
||||||
text = {
|
if (gamepadUi) GamepadAwaitingApprovalDialog(req.target.name, onCancel)
|
||||||
OutlinedTextField(
|
else AwaitingApprovalDialog(hostLabel = req.target.name, onCancel = onCancel)
|
||||||
value = newName,
|
}
|
||||||
onValueChange = { newName = it },
|
|
||||||
label = { Text("Name") },
|
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
|
||||||
placeholder = { Text(kh.address) },
|
optionsTarget?.let { kh ->
|
||||||
singleLine = true,
|
val offline = discovered.none { kh.matches(it) }
|
||||||
|
GamepadHostOptionsDialog(
|
||||||
|
hostName = kh.name,
|
||||||
|
canWake = kh.mac.isNotEmpty() && offline,
|
||||||
|
onWake = {
|
||||||
|
optionsTarget = null
|
||||||
|
waker.start(
|
||||||
|
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
|
||||||
|
isOnline = { discovered.any { kh.matches(it) } },
|
||||||
|
onOnline = {},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
|
||||||
TextButton(
|
// so a TV remote reaches the library here instead of via the Y face button.
|
||||||
enabled = newName.isNotBlank(),
|
onLibrary = if (settings.libraryEnabled) {
|
||||||
onClick = {
|
{ optionsTarget = null; onOpenLibrary(kh) }
|
||||||
knownHostStore.rename(kh.address, kh.port, newName.trim())
|
} else {
|
||||||
savedHosts = knownHostStore.all()
|
null
|
||||||
renameTarget = null
|
|
||||||
},
|
|
||||||
) { Text("Save") }
|
|
||||||
},
|
},
|
||||||
dismissButton = {
|
onEdit = { optionsTarget = null; editTarget = kh },
|
||||||
TextButton(onClick = { renameTarget = null }) { Text("Cancel") }
|
onForget = {
|
||||||
|
knownHostStore.remove(kh.address, kh.port)
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
optionsTarget = null
|
||||||
},
|
},
|
||||||
|
onDismiss = { optionsTarget = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
editTarget?.let { kh ->
|
||||||
|
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
|
||||||
|
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
|
||||||
|
val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
|
||||||
|
val onSaveHost: (KnownHost) -> Unit = { updated ->
|
||||||
|
knownHostStore.update(kh.address, kh.port, updated)
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
editTarget = null
|
||||||
|
}
|
||||||
|
if (gamepadUi) {
|
||||||
|
// Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the
|
||||||
|
// host with an extra MAC row; the action SAVES instead of connecting.
|
||||||
|
GamepadAddHostScreen(
|
||||||
|
onAdd = { _, _, _ -> },
|
||||||
|
onDismiss = { editTarget = null },
|
||||||
|
editHost = kh,
|
||||||
|
suggestedMacs = suggested,
|
||||||
|
onSave = onSaveHost,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EditHostDialog(
|
||||||
|
target = kh,
|
||||||
|
suggestedMacs = suggested,
|
||||||
|
onSave = onSaveHost,
|
||||||
|
onDismiss = { editTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
|
||||||
|
WakeOverlay(waker, gamepadUi)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.hardware.input.InputManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.CombinedVibration
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connected-controllers debug view (Settings → Host → Connected controllers): everything the app
|
||||||
|
* can see about attached input devices, plus a live input test. This exists for exactly the support
|
||||||
|
* case where a pad "doesn't work" — adapters and BT-to-USB dongles often enumerate with a different
|
||||||
|
* identity than the physical pad, or not as a gamepad at all, and punktfunk only forwards devices
|
||||||
|
* Android classifies as gamepad/joystick. This screen makes that visible on the device itself.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = context as? MainActivity
|
||||||
|
|
||||||
|
// Device list, re-read on every hot-plug event.
|
||||||
|
var generation by remember { mutableIntStateOf(0) }
|
||||||
|
val pads = remember(generation) { Gamepad.pads() }
|
||||||
|
val others = remember(generation) {
|
||||||
|
InputDevice.getDeviceIds()
|
||||||
|
.toList()
|
||||||
|
.mapNotNull { InputDevice.getDevice(it) }
|
||||||
|
.filter { !it.isVirtual && !Gamepad.isPad(it) }
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val im = context.getSystemService(InputManager::class.java)
|
||||||
|
val listener = object : InputManager.InputDeviceListener {
|
||||||
|
override fun onInputDeviceAdded(deviceId: Int) { generation++ }
|
||||||
|
override fun onInputDeviceRemoved(deviceId: Int) { generation++ }
|
||||||
|
override fun onInputDeviceChanged(deviceId: Int) { generation++ }
|
||||||
|
}
|
||||||
|
im.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
|
||||||
|
onDispose { im.unregisterInputDeviceListener(listener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live input test. While `testing`, the MainActivity probes consume pad events (so they show up
|
||||||
|
// here instead of driving focus navigation); holding B releases, since the pad can no longer
|
||||||
|
// reach the Switch. Events are observed (not consumed) even when the test is off, so the
|
||||||
|
// "last input" line works while browsing.
|
||||||
|
var testing by remember { mutableStateOf(false) }
|
||||||
|
val held = remember { mutableStateMapOf<Int, Boolean>() }
|
||||||
|
val axes = remember { mutableStateMapOf<String, Float>() }
|
||||||
|
var lastInput by remember { mutableStateOf<String?>(null) }
|
||||||
|
var bHeld by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
activity?.padKeyProbe = probe@{ event ->
|
||||||
|
if (!Gamepad.isPad(event.device)) return@probe false
|
||||||
|
when (event.action) {
|
||||||
|
KeyEvent.ACTION_DOWN -> {
|
||||||
|
held[event.keyCode] = true
|
||||||
|
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
|
||||||
|
}
|
||||||
|
KeyEvent.ACTION_UP -> {
|
||||||
|
held[event.keyCode] = false
|
||||||
|
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
|
||||||
|
testing
|
||||||
|
}
|
||||||
|
activity?.padMotionProbe = probe@{ event ->
|
||||||
|
if (!Gamepad.isPad(event.device)) return@probe false
|
||||||
|
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
|
||||||
|
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
|
||||||
|
axes["RX"] = event.getAxisValue(MotionEvent.AXIS_Z)
|
||||||
|
axes["RY"] = event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||||
|
axes["LT"] = maxOf(
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||||
|
)
|
||||||
|
axes["RT"] = maxOf(
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||||
|
)
|
||||||
|
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||||
|
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||||
|
testing
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
activity?.padKeyProbe = null
|
||||||
|
activity?.padMotionProbe = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the
|
||||||
|
// test instead (touch still works). A short tap cancels the effect before the delay fires.
|
||||||
|
LaunchedEffect(bHeld) {
|
||||||
|
if (bHeld && testing) {
|
||||||
|
delay(1_200)
|
||||||
|
testing = false
|
||||||
|
held.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
|
||||||
|
Group("Gamepads") {
|
||||||
|
if (pads.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"No controller detected. punktfunk can only forward devices Android " +
|
||||||
|
"classifies as a gamepad or joystick — a pad connected through an adapter " +
|
||||||
|
"or hub may show up under \"Other input devices\" below with the adapter's " +
|
||||||
|
"identity, or not at all.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pads.forEachIndexed { i, dev ->
|
||||||
|
PadRow(dev, forwarded = i == 0, gamepadSetting = gamepadSetting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Group("Input test") {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text("Test inputs", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(
|
||||||
|
if (testing) "Controller input stays on this screen — hold B to finish"
|
||||||
|
else "Show button presses and stick motion live",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(checked = testing, onCheckedChange = { testing = it; if (!it) held.clear() })
|
||||||
|
}
|
||||||
|
if (testing) {
|
||||||
|
ButtonGrid(held)
|
||||||
|
AXIS_LABELS.forEach { label -> AxisBar(label, axes[label] ?: 0f) }
|
||||||
|
}
|
||||||
|
lastInput?.let {
|
||||||
|
Text(
|
||||||
|
"Last input — $it",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Group("Other input devices") {
|
||||||
|
if (others.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"None",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
others.forEach { dev ->
|
||||||
|
Column {
|
||||||
|
Text(dev.name, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(
|
||||||
|
deviceDetail(dev),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
||||||
|
@Composable
|
||||||
|
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||||
|
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||||
|
if (forwarded) {
|
||||||
|
Text(
|
||||||
|
"forwarded to host",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
deviceDetail(dev),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
val resolved = Gamepad.prefFor(dev)
|
||||||
|
Text(
|
||||||
|
if (gamepadSetting == Gamepad.PREF_AUTO) {
|
||||||
|
"Streams as: ${prefLabel(resolved)} (automatic)"
|
||||||
|
} else {
|
||||||
|
"Streams as: ${prefLabel(gamepadSetting)} (set in Settings; " +
|
||||||
|
"automatic would pick ${prefLabel(resolved)})"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
val canRumble = deviceHasVibrator(dev)
|
||||||
|
if (canRumble) {
|
||||||
|
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"No rumble motors reported — host rumble will be silent",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The forwarded buttons as chips that light up while held. */
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun ButtonGrid(held: Map<Int, Boolean>) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
TEST_BUTTONS.forEach { (label, keyCode) ->
|
||||||
|
val active = held[keyCode] == true
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = if (active) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
if (active) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
RoundedCornerShape(6.dp),
|
||||||
|
)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A labelled live axis bar; sticks/HAT are −1..1 (centre = half), triggers 0..1. */
|
||||||
|
@Composable
|
||||||
|
private fun AxisBar(label: String, value: Float) {
|
||||||
|
val progress = if (label == "LT" || label == "RT") value else (value + 1f) / 2f
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(32.dp))
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress.coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"%+.2f".format(value),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A titled section — same look as the Settings groups. */
|
||||||
|
@Composable
|
||||||
|
private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 4.dp),
|
||||||
|
)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp), content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
||||||
|
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
dev.vibratorManager.vibratorIds.isNotEmpty()
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
dev.vibrator.hasVibrator()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun testRumble(dev: InputDevice) {
|
||||||
|
runCatching {
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
val vm = dev.vibratorManager
|
||||||
|
if (vm.vibratorIds.isEmpty()) return
|
||||||
|
vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200)))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val v = dev.vibrator
|
||||||
|
if (!v.hasVibrator()) return
|
||||||
|
v.vibrate(VibrationEffect.createOneShot(300, 200))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identity line: VID:PID + the source classes Android assigned. */
|
||||||
|
private fun deviceDetail(dev: InputDevice): String =
|
||||||
|
"%04X:%04X · %s".format(dev.vendorId, dev.productId, sourcesLabel(dev.sources))
|
||||||
|
|
||||||
|
private fun sourcesLabel(sources: Int): String {
|
||||||
|
fun has(flag: Int) = sources and flag == flag
|
||||||
|
val names = buildList {
|
||||||
|
if (has(InputDevice.SOURCE_GAMEPAD)) add("gamepad")
|
||||||
|
if (has(InputDevice.SOURCE_JOYSTICK)) add("joystick")
|
||||||
|
if (has(InputDevice.SOURCE_DPAD)) add("dpad")
|
||||||
|
if (has(InputDevice.SOURCE_KEYBOARD)) add("keyboard")
|
||||||
|
if (has(InputDevice.SOURCE_MOUSE)) add("mouse")
|
||||||
|
if (has(InputDevice.SOURCE_TOUCHSCREEN)) add("touchscreen")
|
||||||
|
if (has(InputDevice.SOURCE_TOUCHPAD)) add("touchpad")
|
||||||
|
if (has(InputDevice.SOURCE_STYLUS)) add("stylus")
|
||||||
|
if (has(InputDevice.SOURCE_ROTARY_ENCODER)) add("rotary")
|
||||||
|
}
|
||||||
|
return if (names.isEmpty()) "sources 0x%08X".format(sources) else names.joinToString(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [Gamepad] PREF_* wire byte → user-facing label (mirrors GAMEPAD_OPTIONS, plus the Steam types). */
|
||||||
|
private fun prefLabel(pref: Int): String = when (pref) {
|
||||||
|
Gamepad.PREF_XBOX360 -> "Xbox 360"
|
||||||
|
Gamepad.PREF_DUALSENSE -> "DualSense"
|
||||||
|
Gamepad.PREF_XBOXONE -> "Xbox One"
|
||||||
|
Gamepad.PREF_DUALSHOCK4 -> "DualShock 4"
|
||||||
|
Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller"
|
||||||
|
Gamepad.PREF_STEAMDECK -> "Steam Deck"
|
||||||
|
else -> "Automatic"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buttons shown in the test grid (label → Android keycode). */
|
||||||
|
private val TEST_BUTTONS = listOf(
|
||||||
|
"A" to KeyEvent.KEYCODE_BUTTON_A,
|
||||||
|
"B" to KeyEvent.KEYCODE_BUTTON_B,
|
||||||
|
"X" to KeyEvent.KEYCODE_BUTTON_X,
|
||||||
|
"Y" to KeyEvent.KEYCODE_BUTTON_Y,
|
||||||
|
"LB" to KeyEvent.KEYCODE_BUTTON_L1,
|
||||||
|
"RB" to KeyEvent.KEYCODE_BUTTON_R1,
|
||||||
|
"L2" to KeyEvent.KEYCODE_BUTTON_L2,
|
||||||
|
"R2" to KeyEvent.KEYCODE_BUTTON_R2,
|
||||||
|
"LS" to KeyEvent.KEYCODE_BUTTON_THUMBL,
|
||||||
|
"RS" to KeyEvent.KEYCODE_BUTTON_THUMBR,
|
||||||
|
"Select" to KeyEvent.KEYCODE_BUTTON_SELECT,
|
||||||
|
"Start" to KeyEvent.KEYCODE_BUTTON_START,
|
||||||
|
"Guide" to KeyEvent.KEYCODE_BUTTON_MODE,
|
||||||
|
"↑" to KeyEvent.KEYCODE_DPAD_UP,
|
||||||
|
"↓" to KeyEvent.KEYCODE_DPAD_DOWN,
|
||||||
|
"←" to KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
|
"→" to KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Axis bars shown in the test view, in display order. */
|
||||||
|
private val AXIS_LABELS = listOf("LX", "LY", "RX", "RY", "LT", "RT", "HX", "HY")
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
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.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
// The gamepad-driven "Add Host" screen — the Android mirror of the Apple client's GamepadAddHostView
|
||||||
|
// + GamepadKeyboard: three field rows (name / address / port) plus an Add action, navigated with the
|
||||||
|
// vertical focus list; A on a field opens the on-screen keyboard so a host can be registered end to
|
||||||
|
// end from the couch. One GamepadNavEffect2D owns BOTH modes (list vs keyboard) so they never fight
|
||||||
|
// over the shared input probes. B peels one layer: close the keyboard, then cancel the screen.
|
||||||
|
|
||||||
|
// Keyboard grid: digits, qwerty letters, hostname/address punctuation, then space / delete / done.
|
||||||
|
private val KB_CHAR_ROWS = listOf("1234567890", "qwertyuiop", "asdfghjkl-", "zxcvbnm._:")
|
||||||
|
private const val KB_ACTIONS_ROW = 4 // index of the [space, delete, done] row
|
||||||
|
private const val KB_ROWS = 5
|
||||||
|
|
||||||
|
private class Field(val id: String, val label: String, val value: String, val placeholder: String)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadAddHostScreen(
|
||||||
|
onAdd: (name: String, address: String, port: Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
// Non-null → EDIT mode: fields seed from this host, a MAC row is added, and the action SAVES the
|
||||||
|
// edited record via [onSave] instead of connecting. [suggestedMacs] prefills a not-yet-learned MAC.
|
||||||
|
editHost: KnownHost? = null,
|
||||||
|
suggestedMacs: List<String> = emptyList(),
|
||||||
|
onSave: ((KnownHost) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val isTv = remember { isTvDevice(context) }
|
||||||
|
val isEdit = editHost != null
|
||||||
|
val title = if (isEdit) "Edit Host" else "Add Host"
|
||||||
|
val actionLabel = if (isEdit) "Save" else "Add Host"
|
||||||
|
var name by remember { mutableStateOf(editHost?.name ?: "") }
|
||||||
|
var address by remember { mutableStateOf(editHost?.address ?: "") }
|
||||||
|
var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") }
|
||||||
|
var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") }
|
||||||
|
val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0
|
||||||
|
fun commit() {
|
||||||
|
if (isEdit && editHost != null && onSave != null) {
|
||||||
|
onSave(
|
||||||
|
editHost.copy(
|
||||||
|
name = name.trim().ifEmpty { editHost.address },
|
||||||
|
address = address.trim(),
|
||||||
|
port = port.toIntOrNull() ?: editHost.port,
|
||||||
|
mac = KnownHostStore.parseMacs(mac),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
onAdd(name.trim(), address.trim(), port.toIntOrNull() ?: 9777)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a TV the OS provides a leanback on-screen keyboard for text fields, so use real (focusable)
|
||||||
|
// text fields + the system IME there. Our controller keyboard is for a phone-with-controller,
|
||||||
|
// where the phone's own soft keyboard needs a touch a pad can't provide.
|
||||||
|
if (isTv) {
|
||||||
|
TvAddHostForm(
|
||||||
|
title = title, actionLabel = actionLabel,
|
||||||
|
name = name, onName = { name = it },
|
||||||
|
address = address, onAddress = { address = it },
|
||||||
|
port = port, onPort = { port = it.filter(Char::isDigit).take(5) },
|
||||||
|
mac = if (isEdit) mac else null, onMac = { mac = it },
|
||||||
|
canAdd = canAdd,
|
||||||
|
onAdd = { commit() },
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var focus by remember { mutableIntStateOf(1) } // start on Address
|
||||||
|
var editing by remember { mutableStateOf<String?>(null) } // field id being typed, or null
|
||||||
|
var kbRow by remember { mutableIntStateOf(1) }
|
||||||
|
var kbCol by remember { mutableIntStateOf(0) }
|
||||||
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
|
||||||
|
val fields = buildList {
|
||||||
|
add(Field("name", "Name", name, "Optional — e.g. Living Room"))
|
||||||
|
add(Field("address", "Address", address, "IP or hostname"))
|
||||||
|
add(Field("port", "Port", port, "9777"))
|
||||||
|
if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen"))
|
||||||
|
}
|
||||||
|
val actionIndex = fields.size // the Save/Add action sits just after the last field
|
||||||
|
|
||||||
|
fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 }
|
||||||
|
fun closeKeyboard() { editing = null }
|
||||||
|
fun editField(id: String, transform: (String) -> String) {
|
||||||
|
when (id) {
|
||||||
|
"name" -> name = transform(name)
|
||||||
|
"address" -> address = transform(address)
|
||||||
|
"port" -> port = transform(port).take(5)
|
||||||
|
"mac" -> mac = transform(mac)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun allowed(id: String, c: Char): Boolean = when (id) {
|
||||||
|
"port" -> c.isDigit()
|
||||||
|
"address" -> c != ' '
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
fun activateField() {
|
||||||
|
if (focus == actionIndex) {
|
||||||
|
if (canAdd) commit() else { focus = 1; openKeyboard("address") }
|
||||||
|
} else {
|
||||||
|
openKeyboard(fields[focus].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun pressKey() {
|
||||||
|
val id = editing ?: return
|
||||||
|
if (kbRow < KB_ACTIONS_ROW) {
|
||||||
|
val c = KB_CHAR_ROWS[kbRow][kbCol.coerceIn(0, KB_CHAR_ROWS[kbRow].lastIndex)]
|
||||||
|
if (allowed(id, c)) editField(id) { it + c }
|
||||||
|
} else when (kbCol) {
|
||||||
|
0 -> if (allowed(id, ' ')) editField(id) { "$it " }
|
||||||
|
1 -> editField(id) { it.dropLast(1) }
|
||||||
|
else -> closeKeyboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler { if (editing != null) closeKeyboard() else onDismiss() }
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = true,
|
||||||
|
onDirection = { dir ->
|
||||||
|
if (editing == null) {
|
||||||
|
when (dir) {
|
||||||
|
NavDir.UP -> if (focus > 0) focus--
|
||||||
|
NavDir.DOWN -> if (focus < actionIndex) focus++
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when (dir) {
|
||||||
|
NavDir.UP -> if (kbRow > 0) { kbRow--; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
|
||||||
|
NavDir.DOWN -> if (kbRow < KB_ROWS - 1) { kbRow++; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
|
||||||
|
NavDir.LEFT -> if (kbCol > 0) kbCol--
|
||||||
|
NavDir.RIGHT -> if (kbCol < rowCols(kbRow) - 1) kbCol++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { if (editing == null) activateField() else pressKey() },
|
||||||
|
onTertiary = { if (editing != null) editField(editing!!) { it.dropLast(1) } },
|
||||||
|
onSecondary = { if (editing != null) closeKeyboard() },
|
||||||
|
)
|
||||||
|
|
||||||
|
val onFieldClick: (Int) -> Unit = { i -> if (focus == i) activateField() else focus = i }
|
||||||
|
val onAddClick: () -> Unit = { if (focus == actionIndex) activateField() else focus = actionIndex }
|
||||||
|
// Tappable (touch escape hatch): the legend doubles as buttons when there's no working controller.
|
||||||
|
val typeHints = listOf(
|
||||||
|
PadGlyph.hint('A', "Type") { pressKey() },
|
||||||
|
PadGlyph.hint('X', "Delete") { editing?.let { id -> editField(id) { it.dropLast(1) } } },
|
||||||
|
PadGlyph.hint('B', "Done") { closeKeyboard() },
|
||||||
|
)
|
||||||
|
val sideBySide = landscape && editing != null
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||||
|
GamepadFormBackground(Modifier.fillMaxSize())
|
||||||
|
|
||||||
|
if (sideBySide) {
|
||||||
|
// Landscape + typing: fields and keyboard SIDE BY SIDE so the field being edited stays
|
||||||
|
// visible (stacked, the keyboard covered the whole short screen). The legend is NOT put
|
||||||
|
// under the keyboard here — it floats at the same fixed bottom-left spot as everywhere.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxSize().systemBarsPadding().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
ConsoleHeader(title, horizontalInset = false)
|
||||||
|
fields.forEachIndexed { i, f -> FieldRow(f, focused = false, editing = editing == f.id) { onFieldClick(i) } }
|
||||||
|
AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() }
|
||||||
|
Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1.15f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
KeyboardGrid(kbRow, kbCol, compact = true) { r, c -> kbRow = r; kbCol = c; pressKey() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Portrait (or landscape not typing): the FORM SCROLLS so the Add button is never
|
||||||
|
// compressed by the keyboard; the keyboard sits below it; the legend floats (fixed).
|
||||||
|
Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) {
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
ConsoleHeader(title, horizontalInset = false)
|
||||||
|
if (editing == null && !landscape) {
|
||||||
|
Text(
|
||||||
|
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.55f),
|
||||||
|
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } }
|
||||||
|
AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() }
|
||||||
|
Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled
|
||||||
|
}
|
||||||
|
if (editing != null) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// The keyboard fills to the bottom; its bottom frame is padded so the fixed
|
||||||
|
// legend sits OVER that frame (bottom-left corner) rather than in a gap below.
|
||||||
|
KeyboardGrid(kbRow, kbCol, compact = false, bottomInset = 52.dp) { r, c -> kbRow = r; kbCol = c; pressKey() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating legend — ALWAYS at the same fixed bottom-start spot (portrait or landscape, keyboard
|
||||||
|
// open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred.
|
||||||
|
Box(
|
||||||
|
Modifier.align(Alignment.BottomStart)
|
||||||
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
|
.padding(ConsoleLegendInset),
|
||||||
|
) {
|
||||||
|
GamepadHintBar(
|
||||||
|
if (editing != null) {
|
||||||
|
typeHints
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
PadGlyph.hint('A', "Select") { activateField() },
|
||||||
|
PadGlyph.hint('B', "Cancel", onClick = onDismiss),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
hazeState = hazeState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add-Host on a TV: real focusable text fields + the system (leanback) IME, driven by the OS. No
|
||||||
|
* custom keyboard or input probes — the native focus engine moves between fields and the Add button,
|
||||||
|
* and focusing a field pops the OS keyboard. B backs out.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun TvAddHostForm(
|
||||||
|
title: String,
|
||||||
|
actionLabel: String,
|
||||||
|
name: String,
|
||||||
|
onName: (String) -> Unit,
|
||||||
|
address: String,
|
||||||
|
onAddress: (String) -> Unit,
|
||||||
|
port: String,
|
||||||
|
onPort: (String) -> Unit,
|
||||||
|
mac: String?, // non-null only in edit mode
|
||||||
|
onMac: (String) -> Unit,
|
||||||
|
canAdd: Boolean,
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = onDismiss)
|
||||||
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
GamepadFormBackground(Modifier.fillMaxSize())
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.systemBarsPadding()
|
||||||
|
.padding(horizontal = 56.dp, vertical = 36.dp)
|
||||||
|
.widthIn(max = 720.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
Text(
|
||||||
|
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.55f),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name, onValueChange = onName, singleLine = true,
|
||||||
|
label = { Text("Name (optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth().focusRequester(firstFocus),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = address, onValueChange = onAddress, singleLine = true,
|
||||||
|
label = { Text("Address") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = port, onValueChange = onPort, singleLine = true,
|
||||||
|
label = { Text("Port") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
if (mac != null) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = mac, onValueChange = onMac, singleLine = true,
|
||||||
|
label = { Text("Wake-on-LAN MAC") },
|
||||||
|
placeholder = { Text("auto-filled when the host is seen") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(onClick = onAdd, enabled = canAdd, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(actionLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row].length else 3
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
|
||||||
|
val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale")
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
|
.clip(shape)
|
||||||
|
.background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||||
|
.border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
f.value.ifEmpty { f.placeholder },
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (editing) Text(" |", color = Color(0xFF8678F5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||||
|
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale")
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
|
.clip(shape)
|
||||||
|
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||||
|
.padding(vertical = 14.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KeyboardGrid(
|
||||||
|
cursorRow: Int,
|
||||||
|
cursorCol: Int,
|
||||||
|
compact: Boolean,
|
||||||
|
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
|
||||||
|
onKey: (Int, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(20.dp)
|
||||||
|
val gap = if (compact) 5.dp else 7.dp
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.widthIn(max = 640.dp)
|
||||||
|
.clip(shape)
|
||||||
|
.background(Color(0x1FFFFFFF))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.12f), shape)
|
||||||
|
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(gap),
|
||||||
|
) {
|
||||||
|
KB_CHAR_ROWS.forEachIndexed { r, chars ->
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
|
||||||
|
chars.forEachIndexed { c, ch ->
|
||||||
|
Keycap(ch.toString(), focused = cursorRow == r && cursorCol == c, compact = compact, modifier = Modifier.weight(1f)) { onKey(r, c) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
|
||||||
|
Keycap("space", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 0, compact = compact, modifier = Modifier.weight(2f)) { onKey(KB_ACTIONS_ROW, 0) }
|
||||||
|
Keycap("⌫", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 1, compact = compact, modifier = Modifier.weight(1f)) { onKey(KB_ACTIONS_ROW, 1) }
|
||||||
|
Keycap("Done", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 2, compact = compact, modifier = Modifier.weight(1.5f)) { onKey(KB_ACTIONS_ROW, 2) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.height(if (compact) 34.dp else 44.dp)
|
||||||
|
.clip(RoundedCornerShape(9.dp))
|
||||||
|
.background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF))
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (focused) Color.Black else Color.White,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.SportsEsports
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
|
||||||
|
// GamepadChrome.swift: a slow-drifting violet aurora backdrop, a bottom button-glyph hint bar, and a
|
||||||
|
// connected-controller status chip. One look across every screen is what makes the console UI read
|
||||||
|
// as a coherent mode rather than a set of themed pages.
|
||||||
|
|
||||||
|
/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */
|
||||||
|
private class AuroraBlob(
|
||||||
|
val color: Color,
|
||||||
|
val baseX: Float,
|
||||||
|
val baseY: Float,
|
||||||
|
val driftX: Float,
|
||||||
|
val driftY: Float,
|
||||||
|
val sx: Int,
|
||||||
|
val sy: Int,
|
||||||
|
val phase: Float,
|
||||||
|
val radiusFrac: Float,
|
||||||
|
val alpha: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val auroraBlobs = listOf(
|
||||||
|
AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet
|
||||||
|
AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo
|
||||||
|
AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum
|
||||||
|
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops,
|
||||||
|
* finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation
|
||||||
|
* of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||||
|
val transition = rememberInfiniteTransition(label = "aurora")
|
||||||
|
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap
|
||||||
|
// so the field never visibly jumps when the animation restarts.
|
||||||
|
val angle by transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = (2 * PI).toFloat(),
|
||||||
|
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
|
||||||
|
label = "angle",
|
||||||
|
)
|
||||||
|
Canvas(modifier) {
|
||||||
|
drawRect(Color.Black)
|
||||||
|
val span = max(size.width, size.height)
|
||||||
|
for (b in auroraBlobs) {
|
||||||
|
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
|
||||||
|
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
|
||||||
|
val r = span * b.radiusFrac
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent),
|
||||||
|
center = Offset(cx, cy),
|
||||||
|
radius = r,
|
||||||
|
),
|
||||||
|
center = Offset(cx, cy),
|
||||||
|
radius = r,
|
||||||
|
blendMode = BlendMode.Plus,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Cinematic vignette: pool light centre, sink the corners.
|
||||||
|
drawRect(
|
||||||
|
Brush.radialGradient(
|
||||||
|
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
|
||||||
|
center = Offset(size.width / 2, size.height / 2),
|
||||||
|
radius = span * 0.92f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Top/bottom legibility scrim for the pinned title + hint bar.
|
||||||
|
drawRect(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0.0f to Color.Black.copy(alpha = 0.40f),
|
||||||
|
0.30f to Color.Black.copy(alpha = 0.05f),
|
||||||
|
0.70f to Color.Black.copy(alpha = 0.06f),
|
||||||
|
1.0f to Color.Black.copy(alpha = 0.42f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet
|
||||||
|
* (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass
|
||||||
|
* rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||||
|
Canvas(modifier) {
|
||||||
|
val span = max(size.width, size.height)
|
||||||
|
drawRect(Color(0xFF131126))
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(Color(0xE6635AAE), Color.Transparent),
|
||||||
|
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||||
|
radius = span * 0.7f,
|
||||||
|
),
|
||||||
|
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||||
|
radius = span * 0.7f,
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(Color(0xBF343E96), Color.Transparent),
|
||||||
|
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||||
|
radius = span * 0.7f,
|
||||||
|
),
|
||||||
|
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||||
|
radius = span * 0.7f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact inset every console screen places its floating legend at (bottom-start), so the legend
|
||||||
|
* sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind
|
||||||
|
* it cross-fades between screens.
|
||||||
|
*/
|
||||||
|
val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp)
|
||||||
|
|
||||||
|
/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */
|
||||||
|
val ConsoleEdgeInset = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heading every console screen uses — one style, one inset, so titles line up across Home /
|
||||||
|
* Settings / Add-Host / Library. Callers place it at the top of their content (or float it, on Home).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) {
|
||||||
|
// `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a
|
||||||
|
// LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way.
|
||||||
|
val h = if (horizontalInset) ConsoleEdgeInset else 0.dp
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue.
|
||||||
|
* [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working
|
||||||
|
* controller can still drive the console UI (and reach Settings to switch it off).
|
||||||
|
*/
|
||||||
|
class GamepadHint(
|
||||||
|
val glyph: Char,
|
||||||
|
val color: Color,
|
||||||
|
val text: String,
|
||||||
|
val onClick: (() -> Unit)? = null,
|
||||||
|
// Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc —
|
||||||
|
// for a TV remote, which has no A/B/X/Y.
|
||||||
|
val select: Boolean = false,
|
||||||
|
// Render as the gamepad Select/View button (a small capsule).
|
||||||
|
val viewButton: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */
|
||||||
|
object PadGlyph {
|
||||||
|
val A = Color(0xFF6BBE45)
|
||||||
|
val B = Color(0xFFD14B4B)
|
||||||
|
val X = Color(0xFF4B7BD1)
|
||||||
|
val Y = Color(0xFFE0B23C)
|
||||||
|
fun hint(glyph: Char, text: String, onClick: (() -> Unit)? = null) = GamepadHint(
|
||||||
|
glyph, when (glyph) { 'A' -> A; 'B' -> B; 'X' -> X; 'Y' -> Y; else -> Color(0xFF9A93C7) }, text, onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
|
||||||
|
@Composable
|
||||||
|
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
glyph.toString(),
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = (size.value * 0.52f).sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */
|
||||||
|
@Composable
|
||||||
|
private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The remote's "Back" button — a back-arrow disc; the TV-remote glyph for B (back / cancel / done). */
|
||||||
|
@Composable
|
||||||
|
private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
|
GamepadButtonGlyph('↩', PadGlyph.B, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */
|
||||||
|
@Composable
|
||||||
|
private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
|
Box(Modifier.size(size), contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(width = size * 0.74f, height = size * 0.46f)
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pinned controls legend every gamepad screen shows along the bottom — worn as a self-contained
|
||||||
|
* translucent pill so it floats over the aurora rather than dissolving into it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
|
||||||
|
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
|
||||||
|
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
|
||||||
|
// home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests).
|
||||||
|
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||||
|
val shape = RoundedCornerShape(50)
|
||||||
|
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
|
||||||
|
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
|
||||||
|
val frosted = if (hazeState != null) {
|
||||||
|
modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A))
|
||||||
|
} else {
|
||||||
|
modifier.clip(shape).background(Color(0x8C14122A))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = frosted
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||||
|
) {
|
||||||
|
for (h in hints) {
|
||||||
|
val cb = h.onClick
|
||||||
|
val cell = if (cb != null) {
|
||||||
|
Modifier.clip(RoundedCornerShape(50)).clickable(onClick = cb).padding(horizontal = 4.dp, vertical = 5.dp)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
when {
|
||||||
|
h.viewButton -> ViewButtonGlyph()
|
||||||
|
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
|
||||||
|
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
|
||||||
|
else -> GamepadButtonGlyph(h.glyph, h.color)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
h.text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false, // never char-wrap a label when several hints crowd a narrow pill
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */
|
||||||
|
@Composable
|
||||||
|
fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(Color.White.copy(alpha = 0.08f))
|
||||||
|
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.SportsEsports,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.75f),
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(7.dp))
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.75f),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
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.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||||
|
import io.unom.punktfunk.models.PendingTrust
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
// Console-styled trust/pairing dialogs — the controller-navigable counterparts of the touch
|
||||||
|
// AlertDialogs in ConnectDialogs.kt, shown while the gamepad UI is active. A dark glass card over a
|
||||||
|
// scrim with focusable action buttons: D-pad left/right moves the focus, A activates it, B dismisses.
|
||||||
|
|
||||||
|
/** One dialog action button. */
|
||||||
|
class DialogAction(
|
||||||
|
val label: String,
|
||||||
|
val primary: Boolean = false,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val onClick: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared console-dialog scaffold: scrim + glass card with a title, [body], and a row of focusable
|
||||||
|
* [actions]. Owns its own controller nav (the presenting carousel drops its probes while a dialog is
|
||||||
|
* up, via ConnectScreen's `navActive`). B → [onDismiss].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadDialog(
|
||||||
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
actions: List<DialogAction>,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
|
||||||
|
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
|
||||||
|
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
|
||||||
|
BackHandler(onBack = onDismiss)
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = true,
|
||||||
|
onDirection = { dir ->
|
||||||
|
when (dir) {
|
||||||
|
NavDir.UP -> if (focus > 0) focus--
|
||||||
|
NavDir.DOWN -> if (focus < actions.lastIndex) focus++
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
|
||||||
|
)
|
||||||
|
// Cap the card to most of the screen and let the BODY scroll — in a short landscape window the
|
||||||
|
// title + body + buttons would otherwise overflow and compress/clip the bottom button.
|
||||||
|
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(24.dp)
|
||||||
|
.widthIn(max = 520.dp)
|
||||||
|
.heightIn(max = maxCardHeight)
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.background(Color(0xF01A1730))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||||
|
.padding(28.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
// The body scrolls; the title above and the buttons below stay pinned + always visible.
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
body()
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
actions.forEachIndexed { i, a ->
|
||||||
|
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
||||||
|
val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale")
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
val bg = when {
|
||||||
|
focused -> Color(0xFF6656F2)
|
||||||
|
primary -> Color(0x336656F2)
|
||||||
|
else -> Color(0x14FFFFFF)
|
||||||
|
}
|
||||||
|
val fg = when {
|
||||||
|
!enabled -> Color.White.copy(alpha = 0.35f)
|
||||||
|
focused -> Color.White
|
||||||
|
primary -> Color(0xFF8678F5)
|
||||||
|
else -> Color.White.copy(alpha = 0.85f)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
|
.clip(shape)
|
||||||
|
.background(bg)
|
||||||
|
.border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape)
|
||||||
|
.clickable(
|
||||||
|
enabled = enabled,
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 13.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body text helper — a dimmed paragraph. */
|
||||||
|
@Composable
|
||||||
|
private fun DialogText(text: String) {
|
||||||
|
Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Edit,
|
||||||
|
* Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of
|
||||||
|
* the touch host card's overflow menu.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadHostOptionsDialog(
|
||||||
|
hostName: String,
|
||||||
|
canWake: Boolean,
|
||||||
|
onWake: () -> Unit,
|
||||||
|
onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onForget: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = hostName,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = buildList {
|
||||||
|
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
|
||||||
|
if (canWake) add(DialogAction("Wake host", onClick = onWake))
|
||||||
|
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
|
||||||
|
add(DialogAction("Forget", onClick = onForget))
|
||||||
|
add(DialogAction("Cancel", onClick = onDismiss))
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
DialogText("Manage this saved host.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = "Trust this host?",
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = listOf(
|
||||||
|
DialogAction("Cancel", onClick = onDismiss),
|
||||||
|
DialogAction("Pair with PIN", onClick = onPairInstead),
|
||||||
|
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
DialogText("First connection to ${pt.host}:${pt.port}.")
|
||||||
|
pt.advertisedFp?.let { DialogText("Fingerprint ${it.take(16)}…") }
|
||||||
|
DialogText(
|
||||||
|
"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.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadFingerprintChangedDialog(pt: PendingTrust, onRepair: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = "Host identity changed",
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = listOf(
|
||||||
|
DialogAction("Cancel", onClick = onDismiss),
|
||||||
|
DialogAction("Re-pair", primary = true, onClick = onRepair),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
DialogText(
|
||||||
|
"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.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, onUsePin: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = "Pairing required",
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = listOf(
|
||||||
|
DialogAction("Cancel", onClick = onDismiss),
|
||||||
|
DialogAction("Use a PIN", onClick = onUsePin),
|
||||||
|
DialogAction("Request access", primary = true, onClick = onRequestAccess),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
DialogText("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||||
|
DialogText(
|
||||||
|
"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.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = "Waiting for approval",
|
||||||
|
onDismiss = onCancel,
|
||||||
|
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
|
||||||
|
) {
|
||||||
|
val deviceName = Build.MODEL ?: "this device"
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
|
||||||
|
Text("Approve this device on $hostLabel.", color = Color.White)
|
||||||
|
}
|
||||||
|
DialogText(
|
||||||
|
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
|
||||||
|
"once you approve — no PIN needed.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Console PIN pairing: four digit slots set with the D-pad (left/right selects a slot, up/down changes
|
||||||
|
* 0–9), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified
|
||||||
|
* fingerprint to [onPaired]. No text keyboard needed — a PIN is four digits.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
|
||||||
|
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
|
||||||
|
var pairing by remember(pt) { mutableStateOf(false) }
|
||||||
|
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||||
|
val name = remember { Build.MODEL ?: "Android" }
|
||||||
|
|
||||||
|
fun pair() {
|
||||||
|
val id = identity ?: return
|
||||||
|
pairing = true
|
||||||
|
err = null
|
||||||
|
val pin = digits.joinToString("")
|
||||||
|
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) else err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(onBack = { if (!pairing) onDismiss() })
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = !pairing,
|
||||||
|
onDirection = { dir ->
|
||||||
|
when (dir) {
|
||||||
|
NavDir.LEFT -> if (slot > 0) slot--
|
||||||
|
NavDir.RIGHT -> if (slot < 4) slot++
|
||||||
|
NavDir.UP -> if (slot < 4) digits[slot] = (digits[slot] + 1) % 10
|
||||||
|
NavDir.DOWN -> if (slot < 4) digits[slot] = (digits[slot] + 9) % 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { if (slot == 4 && identity != null) pair() },
|
||||||
|
)
|
||||||
|
|
||||||
|
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||||
|
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
|
) {
|
||||||
|
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
Text(
|
||||||
|
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
|
||||||
|
}
|
||||||
|
err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) }
|
||||||
|
DialogButton(
|
||||||
|
label = if (pairing) "Pairing…" else "Pair",
|
||||||
|
focused = slot == 4 && !pairing,
|
||||||
|
primary = true,
|
||||||
|
enabled = !pairing && identity != null,
|
||||||
|
onClick = { if (identity != null) pair() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PinSlot(value: Int, focused: Boolean) {
|
||||||
|
val shape = RoundedCornerShape(12.dp)
|
||||||
|
Box(
|
||||||
|
Modifier.size(54.dp, 66.dp).clip(shape)
|
||||||
|
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||||
|
.border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PageSize
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.draw.BlurredEdgeTreatment
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.util.lerp
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct,
|
||||||
|
// "10-foot" console-style host launcher shown INSTEAD of the touch grid while the console UI is
|
||||||
|
// active. A center-snapping carousel of hosts (saved first, then discovered, then a trailing Add
|
||||||
|
// Host tile), driven from the couch: A connects, X opens Settings, Y opens a saved host's library.
|
||||||
|
|
||||||
|
/** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */
|
||||||
|
class HomeTile(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val filled: Boolean = false, // saved (solid monogram) vs discovered / action (tinted outline)
|
||||||
|
val online: Boolean = false, // advertising on the LAN right now
|
||||||
|
val paired: Boolean = false, // pinned identity (shows a lock)
|
||||||
|
val connecting: Boolean = false,
|
||||||
|
val isAdd: Boolean = false, // the trailing Add Host tile (plus icon, not a monogram)
|
||||||
|
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
|
||||||
|
val activate: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
|
||||||
|
// first" message if the host hasn't authorized this device for its management API.
|
||||||
|
val hasLibrary: Boolean get() = knownHost != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console home. [tiles] is rebuilt by the caller from the live host stores; [onActivate] runs a
|
||||||
|
* tile's action, [onOpenLibrary]/[onOpenSettings] are the Y/X actions. Fully driven by D-pad / stick
|
||||||
|
* / face buttons (MainActivity already maps a pad's A→center, B→back, sticks→D-pad) and by touch.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadHome(
|
||||||
|
tiles: List<HomeTile>,
|
||||||
|
libraryEnabled: Boolean,
|
||||||
|
controllerName: String?,
|
||||||
|
// False while a sheet/dialog is on top → the carousel stops consuming the pad so the overlay
|
||||||
|
// can be driven instead.
|
||||||
|
navActive: Boolean,
|
||||||
|
onActivate: (HomeTile) -> Unit,
|
||||||
|
onOpenLibrary: (HomeTile) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
// Up on a saved host opens its options (Wake / Edit / Forget). Only saved tiles carry a knownHost.
|
||||||
|
onOptions: (HomeTile) -> Unit = {},
|
||||||
|
) {
|
||||||
|
// Equal inset for the pinned title + hint bar, measured from the safe-area edges (so the legend
|
||||||
|
// sits the same distance from the left and the bottom).
|
||||||
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
|
||||||
|
val pagerState = rememberPagerState(pageCount = { tiles.size })
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// navTarget is the navigation authority — a controller move steps THIS, and the pager is pointed
|
||||||
|
// at it, so a fast repeat coalesces to the latest target instead of reading a lagging currentPage
|
||||||
|
// mid-animation (which is what let a flick overshoot by two).
|
||||||
|
var navTarget by remember { mutableStateOf(0) }
|
||||||
|
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||||
|
val current = tiles.getOrNull(navTarget)
|
||||||
|
|
||||||
|
GamepadNavEffect(
|
||||||
|
active = navActive && tiles.isNotEmpty(),
|
||||||
|
onMove = { dir ->
|
||||||
|
val target = (navTarget + dir).coerceIn(0, tiles.lastIndex)
|
||||||
|
if (target != navTarget) {
|
||||||
|
navTarget = target
|
||||||
|
scope.launch { pagerState.animateScrollToPage(target) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect
|
||||||
|
onSecondary = { // Y (gamepad) → Library
|
||||||
|
tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary)
|
||||||
|
},
|
||||||
|
onTertiary = onOpenSettings, // X (gamepad) → Settings
|
||||||
|
// A TV remote has no A/B/X/Y: Up → Settings, Down → a saved host's Options (Wake / Library /
|
||||||
|
// Edit / Forget). A gamepad instead opens Options on its Select/View button.
|
||||||
|
onUp = onOpenSettings,
|
||||||
|
onDown = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
|
||||||
|
onOptions = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// The legend follows the LAST-USED input: a real gamepad shows its A/X/Y face buttons + the
|
||||||
|
// Select/View button for Options; a TV D-pad remote (no face buttons) shows a select ring + Up
|
||||||
|
// (Settings) / Down (Options) arrows, with Library folded into Options. Input is universal either
|
||||||
|
// way. Each hint is also TAPPABLE (touch hatch).
|
||||||
|
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: false
|
||||||
|
val connectLabel = if (current?.isAdd == true) "Add Host" else "Connect"
|
||||||
|
val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) }
|
||||||
|
val optionsAction: () -> Unit = { current?.let(onOptions) }
|
||||||
|
val arrowTint = Color(0xFF9A93C7)
|
||||||
|
val hints = buildList {
|
||||||
|
if (padIsGamepad) {
|
||||||
|
add(PadGlyph.hint('A', connectLabel, onClick = connectAction))
|
||||||
|
if (libraryEnabled && current?.hasLibrary == true) add(PadGlyph.hint('Y', "Library") {
|
||||||
|
tiles.getOrNull(navTarget)?.takeIf { it.hasLibrary }?.let(onOpenLibrary)
|
||||||
|
})
|
||||||
|
add(PadGlyph.hint('X', "Settings", onClick = onOpenSettings))
|
||||||
|
// The pad's Select/View button (drawn as its capsule glyph) opens host options.
|
||||||
|
if (current?.knownHost != null) add(GamepadHint(' ', arrowTint, "Options", onClick = optionsAction, viewButton = true))
|
||||||
|
} else {
|
||||||
|
add(GamepadHint(' ', PadGlyph.A, connectLabel, onClick = connectAction, select = true))
|
||||||
|
add(GamepadHint('↑', arrowTint, "Settings", onClick = { onOpenSettings() }))
|
||||||
|
if (current?.knownHost != null) add(GamepadHint('↓', arrowTint, "Options", onClick = optionsAction))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
// The whole backdrop (aurora + carousel) is the haze source, so the floating legend can blur
|
||||||
|
// whatever scrolls under it.
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||||
|
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||||
|
|
||||||
|
// Carousel centred on the FULL screen — the title + legend FLOAT over it (below), so they
|
||||||
|
// no longer push the cards below the true centre.
|
||||||
|
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
|
||||||
|
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
|
||||||
|
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
|
||||||
|
Box(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
pageSize = PageSize.Fixed(cardWidth),
|
||||||
|
contentPadding = PaddingValues(horizontal = sidePad),
|
||||||
|
pageSpacing = 22.dp,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) { page ->
|
||||||
|
val tile = tiles[page]
|
||||||
|
// Real distance-from-centered (page + fractional drag), so the pop tracks the
|
||||||
|
// live scroll: centered tile at full scale/brightness, neighbours recede + blur.
|
||||||
|
val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
|
||||||
|
.absoluteValue.coerceIn(0f, 1f)
|
||||||
|
GamepadHostTile(
|
||||||
|
tile = tile,
|
||||||
|
modifier = Modifier
|
||||||
|
.graphicsLayer {
|
||||||
|
val s = lerp(1f, 0.86f, offset)
|
||||||
|
scaleX = s
|
||||||
|
scaleY = s
|
||||||
|
alpha = lerp(1f, 0.5f, offset)
|
||||||
|
}
|
||||||
|
// Unbounded so the depth blur isn't hard-clipped at the card's rectangle
|
||||||
|
// (the cut-off edge). No-op below API 31; a soft blur above.
|
||||||
|
.blur(radius = (offset * 12f).dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
|
||||||
|
.height(cardHeight)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) {
|
||||||
|
if (page == navTarget) {
|
||||||
|
onActivate(tile)
|
||||||
|
} else {
|
||||||
|
navTarget = page
|
||||||
|
scope.launch { pagerState.animateScrollToPage(page) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title floats over the top (out of the carousel's layout, so the cards stay centred). Uses
|
||||||
|
// the shared ConsoleHeader so it lines up with every other screen's heading.
|
||||||
|
Row(
|
||||||
|
Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding()
|
||||||
|
.padding(end = ConsoleEdgeInset),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
ConsoleHeader("Select a Host", modifier = Modifier.weight(1f))
|
||||||
|
if (controllerName != null) ControllerStatusChip(controllerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE
|
||||||
|
// it ignores the safe area (the nav-bar inset made the bottom gap look oversized).
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
|
.padding(ConsoleLegendInset),
|
||||||
|
) {
|
||||||
|
GamepadHintBar(hints, hazeState = hazeState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
|
||||||
|
@Composable
|
||||||
|
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||||
|
val shape = RoundedCornerShape(26.dp)
|
||||||
|
val wash = if (tile.filled) {
|
||||||
|
Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A)))
|
||||||
|
} else {
|
||||||
|
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.background(wash)
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
|
||||||
|
.padding(22.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||||
|
MonogramBadge(tile)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (tile.paired) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Lock,
|
||||||
|
contentDescription = "Paired",
|
||||||
|
tint = Color.White.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.padding(end = 6.dp).size(15.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (tile.online) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(10.dp).clip(androidx.compose.foundation.shape.CircleShape)
|
||||||
|
.background(Color(0xFF3CD070)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
tile.title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
tile.subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.55f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonogramBadge(tile: HomeTile) {
|
||||||
|
val shape = RoundedCornerShape(15.dp)
|
||||||
|
val fill = if (tile.filled) {
|
||||||
|
Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5)))
|
||||||
|
} else {
|
||||||
|
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(52.dp).clip(shape).background(fill),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
tile.connecting -> CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
tile.isAdd -> Icon(
|
||||||
|
Icons.Filled.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||||
|
)
|
||||||
|
else -> Text(
|
||||||
|
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "•",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
|
// Controller navigation for the console carousels (host launcher + library coverflow). It taps the
|
||||||
|
// SAME MainActivity input probes the Controllers debug screen uses (padMotionProbe / padKeyProbe) so
|
||||||
|
// it sees the raw analog stick and consumes it BEFORE MainActivity's stick→D-pad focus synthesis —
|
||||||
|
// which is what made carousel scrolling feel wrong: that path is edge-only (no hold-to-repeat, so a
|
||||||
|
// held stick did nothing) and a flick could cross the threshold twice (double-move). Here the left
|
||||||
|
// stick drives discrete moves with hysteresis (fire once when it crosses HIGH; re-arm only after it
|
||||||
|
// falls back under LOW → a flick is exactly one move) and auto-repeat while held. The caller coalesces
|
||||||
|
// the moves against a target index so a fast repeat walks smoothly instead of overshooting.
|
||||||
|
|
||||||
|
private const val STICK_HIGH = 0.6f // cross this to commit a move
|
||||||
|
private const val STICK_LOW = 0.3f // fall back under this to re-arm (hysteresis)
|
||||||
|
private const val INITIAL_DELAY_MS = 420L // hold this long before the first auto-repeat
|
||||||
|
private const val REPEAT_MS = 150L // then repeat this often while held
|
||||||
|
|
||||||
|
private class NavInputState {
|
||||||
|
@Volatile var stickX = 0f
|
||||||
|
@Volatile var stickY = 0f
|
||||||
|
@Volatile var hatX = 0f
|
||||||
|
@Volatile var hatY = 0f
|
||||||
|
@Volatile var dpadX = 0
|
||||||
|
@Volatile var dpadY = 0
|
||||||
|
fun reset() { stickX = 0f; stickY = 0f; hatX = 0f; hatY = 0f; dpadX = 0; dpadY = 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A committed navigation direction from the stick / D-pad / HAT. */
|
||||||
|
enum class NavDir { UP, DOWN, LEFT, RIGHT }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs controller navigation for a console screen while [active]. [onMove] gets -1 (left) / +1
|
||||||
|
* (right) for each committed step; [onActivate] is A / D-pad-center / Enter, [onTertiary] is X,
|
||||||
|
* [onSecondary] is Y. B and the shoulders fall through to MainActivity (B → its BACK remap → the
|
||||||
|
* screen's BackHandler). [active] is set false while a sheet/dialog is on top so the carousel stops
|
||||||
|
* consuming the pad and the overlay can be navigated.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadNavEffect(
|
||||||
|
active: Boolean,
|
||||||
|
onMove: (Int) -> Unit,
|
||||||
|
onActivate: () -> Unit,
|
||||||
|
onSecondary: () -> Unit = {},
|
||||||
|
onTertiary: () -> Unit = {},
|
||||||
|
// D-pad Up (the carousel is horizontal) → e.g. Settings, since a TV remote has no X face button.
|
||||||
|
onUp: () -> Unit = {},
|
||||||
|
onDown: () -> Unit = {},
|
||||||
|
// Context/options menu — fired by the gamepad Select/View button OR a long-press of the select/OK
|
||||||
|
// button (the Android-TV context-menu convention). A short OK press is [onActivate].
|
||||||
|
onOptions: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val activity = LocalContext.current as? MainActivity ?: return
|
||||||
|
val state = remember { NavInputState() }
|
||||||
|
// The effects below are keyed on `active` only (they must NOT restart on every recomposition), so
|
||||||
|
// they'd otherwise capture the FIRST callbacks — closing over a stale `tiles` (fewer hosts than are
|
||||||
|
// discovered later, which clamped navigation to that old count). rememberUpdatedState keeps the
|
||||||
|
// long-lived coroutine/probes pointed at the CURRENT callbacks.
|
||||||
|
val currentOnMove by rememberUpdatedState(onMove)
|
||||||
|
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||||
|
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||||
|
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||||
|
val currentOnUp by rememberUpdatedState(onUp)
|
||||||
|
val currentOnDown by rememberUpdatedState(onDown)
|
||||||
|
val currentOnOptions by rememberUpdatedState(onOptions)
|
||||||
|
|
||||||
|
DisposableEffect(active) {
|
||||||
|
// Stable probe refs (see GamepadNavEffect2D) so onDispose only releases the slot if we still
|
||||||
|
// own it — a cross-fading-out screen mustn't null the incoming screen's probes.
|
||||||
|
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||||
|
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||||
|
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||||
|
state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||||
|
return@probe true // consume → MainActivity's stick→D-pad synthesis stays out of it
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||||
|
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||||
|
val edge = down && ev.repeatCount == 0
|
||||||
|
when (ev.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||||
|
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> { if (edge) currentOnUp(); true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> { if (edge) currentOnDown(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
|
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||||
|
// The gamepad Select / View / Share button → context options (a remote uses Down).
|
||||||
|
KeyEvent.KEYCODE_BUTTON_SELECT -> { if (edge) currentOnOptions(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||||
|
else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
activity.padMotionProbe = motionProbe
|
||||||
|
activity.padKeyProbe = keyProbe
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||||
|
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||||
|
state.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(active) {
|
||||||
|
if (!active) return@LaunchedEffect
|
||||||
|
var committed = 0 // the direction currently held (hysteresis + repeat authority)
|
||||||
|
var fireAt = 0L // uptime at/after which the next auto-repeat may fire
|
||||||
|
while (isActive) {
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
val hat = if (state.hatX <= -0.5f) -1 else if (state.hatX >= 0.5f) 1 else 0
|
||||||
|
val dir = when {
|
||||||
|
state.dpadX != 0 -> state.dpadX
|
||||||
|
hat != 0 -> hat
|
||||||
|
else -> {
|
||||||
|
val x = state.stickX
|
||||||
|
when {
|
||||||
|
x >= STICK_HIGH -> 1
|
||||||
|
x <= -STICK_HIGH -> -1
|
||||||
|
abs(x) < STICK_LOW -> 0
|
||||||
|
else -> committed // inside the hysteresis band → hold the committed value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
dir == 0 -> committed = 0
|
||||||
|
dir != committed -> { currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS }
|
||||||
|
now >= fireAt -> { currentOnMove(dir); fireAt = now + REPEAT_MS }
|
||||||
|
}
|
||||||
|
delay(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2-D controller navigation for the console form screens (settings focus list, add-host, on-screen
|
||||||
|
* keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant
|
||||||
|
* stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick
|
||||||
|
* returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X,
|
||||||
|
* [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels
|
||||||
|
* one layer": close the keyboard, then the screen).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadNavEffect2D(
|
||||||
|
active: Boolean,
|
||||||
|
onDirection: (NavDir) -> Unit,
|
||||||
|
onActivate: () -> Unit,
|
||||||
|
onTertiary: () -> Unit = {},
|
||||||
|
onSecondary: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val activity = LocalContext.current as? MainActivity ?: return
|
||||||
|
val state = remember { NavInputState() }
|
||||||
|
val currentOnDirection by rememberUpdatedState(onDirection)
|
||||||
|
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||||
|
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||||
|
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||||
|
|
||||||
|
DisposableEffect(active) {
|
||||||
|
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
|
||||||
|
// cross-fade both the outgoing and incoming screen are briefly composed, and the outgoing's
|
||||||
|
// teardown must not null out the incoming screen's just-installed probes.
|
||||||
|
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||||
|
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||||
|
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||||
|
state.stickY = ev.getAxisValue(MotionEvent.AXIS_Y)
|
||||||
|
state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||||
|
state.hatY = ev.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||||
|
return@probe true
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||||
|
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||||
|
val edge = down && ev.repeatCount == 0
|
||||||
|
when (ev.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> { state.dpadY = if (down) 1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
|
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||||
|
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
activity.padMotionProbe = motionProbe
|
||||||
|
activity.padKeyProbe = keyProbe
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||||
|
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||||
|
state.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(active) {
|
||||||
|
if (!active) return@LaunchedEffect
|
||||||
|
var committed: NavDir? = null
|
||||||
|
var fireAt = 0L
|
||||||
|
while (isActive) {
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
val raw = resolveDir(state)
|
||||||
|
val nearCentre = state.dpadX == 0 && state.dpadY == 0 &&
|
||||||
|
abs(state.hatX) < 0.5f && abs(state.hatY) < 0.5f &&
|
||||||
|
abs(state.stickX) < STICK_LOW && abs(state.stickY) < STICK_LOW
|
||||||
|
when {
|
||||||
|
raw == null && nearCentre -> committed = null
|
||||||
|
raw == null -> { /* in the hysteresis band → hold, don't fire */ }
|
||||||
|
raw != committed -> { currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS }
|
||||||
|
now >= fireAt -> { currentOnDirection(raw); fireAt = now + REPEAT_MS }
|
||||||
|
}
|
||||||
|
delay(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The direction currently past the commit threshold (D-pad/HAT first, then the dominant stick axis). */
|
||||||
|
private fun resolveDir(s: NavInputState): NavDir? {
|
||||||
|
if (s.dpadY < 0) return NavDir.UP
|
||||||
|
if (s.dpadY > 0) return NavDir.DOWN
|
||||||
|
if (s.dpadX < 0) return NavDir.LEFT
|
||||||
|
if (s.dpadX > 0) return NavDir.RIGHT
|
||||||
|
if (s.hatY <= -0.5f) return NavDir.UP
|
||||||
|
if (s.hatY >= 0.5f) return NavDir.DOWN
|
||||||
|
if (s.hatX <= -0.5f) return NavDir.LEFT
|
||||||
|
if (s.hatX >= 0.5f) return NavDir.RIGHT
|
||||||
|
return if (abs(s.stickY) >= abs(s.stickX)) {
|
||||||
|
when {
|
||||||
|
s.stickY <= -STICK_HIGH -> NavDir.UP
|
||||||
|
s.stickY >= STICK_HIGH -> NavDir.DOWN
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when {
|
||||||
|
s.stickX <= -STICK_HIGH -> NavDir.LEFT
|
||||||
|
s.stickX >= STICK_HIGH -> NavDir.RIGHT
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
|
||||||
|
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||||
|
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||||
|
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
|
||||||
|
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
|
||||||
|
|
||||||
|
private class GpRow(
|
||||||
|
val id: String,
|
||||||
|
val header: String?,
|
||||||
|
val label: String,
|
||||||
|
val value: String,
|
||||||
|
val detail: String,
|
||||||
|
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||||
|
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadSettingsScreen(
|
||||||
|
initial: Settings,
|
||||||
|
onChange: (Settings) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
navActive: Boolean = true, // false while this screen is cross-fading out, so it drops the pad
|
||||||
|
) {
|
||||||
|
var s by remember { mutableStateOf(initial) }
|
||||||
|
fun update(next: Settings) { s = next; onChange(next) }
|
||||||
|
|
||||||
|
val rows = buildSettingsRows(s, ::update)
|
||||||
|
var focus by remember { mutableIntStateOf(0) }
|
||||||
|
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = navActive,
|
||||||
|
onDirection = { dir ->
|
||||||
|
when (dir) {
|
||||||
|
NavDir.UP -> if (focus > 0) focus--
|
||||||
|
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||||
|
NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1)
|
||||||
|
NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { rows.getOrNull(focus)?.activate() },
|
||||||
|
)
|
||||||
|
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||||
|
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||||
|
// +1 accounts for the heading being item 0.
|
||||||
|
LaunchedEffect(focus) {
|
||||||
|
runCatching {
|
||||||
|
val itemIndex = focus + 1
|
||||||
|
val info = listState.layoutInfo
|
||||||
|
val item = info.visibleItemsInfo.firstOrNull { it.index == itemIndex }
|
||||||
|
val offScreen = item == null ||
|
||||||
|
item.offset < info.viewportStartOffset ||
|
||||||
|
item.offset + item.size > info.viewportEndOffset - 96 // keep clear of the floating legend
|
||||||
|
if (offScreen) listState.animateScrollToItem(itemIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
// Everything scrolls — including the heading — so nothing is pinned. Vital in landscape,
|
||||||
|
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
|
||||||
|
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||||
|
GamepadFormBackground(Modifier.fillMaxSize())
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize().systemBarsPadding(),
|
||||||
|
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
item(key = "__title") {
|
||||||
|
ConsoleHeader("Settings", horizontalInset = false)
|
||||||
|
}
|
||||||
|
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||||
|
SettingRowView(row, focused = index == focus, onClick = {
|
||||||
|
if (focus == index) row.activate() else focus = index
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating frosted legend — a real backdrop blur of the rows scrolling behind it (no dedicated
|
||||||
|
// strip). In landscape it ignores the safe area so it hugs the corner instead of the nav-bar inset.
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
|
.padding(ConsoleLegendInset),
|
||||||
|
) {
|
||||||
|
GamepadHintBar(
|
||||||
|
listOf(
|
||||||
|
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||||
|
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||||
|
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||||
|
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||||
|
),
|
||||||
|
hazeState = hazeState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
|
||||||
|
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale")
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
Column {
|
||||||
|
if (row.header != null) {
|
||||||
|
Text(
|
||||||
|
row.header.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.45f),
|
||||||
|
letterSpacing = 1.4.sp,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
|
.clip(shape)
|
||||||
|
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
row.label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (focused) Text("‹ ", color = Color.White.copy(alpha = 0.6f))
|
||||||
|
Text(
|
||||||
|
row.value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (focused) Color.White else Color.White.copy(alpha = 0.6f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (focused) Text(" ›", color = Color.White.copy(alpha = 0.6f))
|
||||||
|
}
|
||||||
|
// The focused row carries its own one-line description — no dedicated (space-eating)
|
||||||
|
// detail strip. It appears right where you're looking, and the row grows to fit.
|
||||||
|
if (focused && row.detail.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
row.detail,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.White.copy(alpha = 0.6f),
|
||||||
|
maxLines = 2,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the console settings rows from the current [Settings], writing through [update]. */
|
||||||
|
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
|
||||||
|
fun <T> choice(
|
||||||
|
id: String, header: String?, label: String, detail: String,
|
||||||
|
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||||
|
): GpRow {
|
||||||
|
val idx = options.indexOfFirst { it.first == current }
|
||||||
|
return GpRow(
|
||||||
|
id, header, label,
|
||||||
|
value = options.getOrNull(idx)?.second ?: "—",
|
||||||
|
detail = detail,
|
||||||
|
adjust = { delta ->
|
||||||
|
if (idx < 0) {
|
||||||
|
options.firstOrNull()?.let { write(it.first) } != null
|
||||||
|
} else {
|
||||||
|
val t = idx + delta
|
||||||
|
if (t in options.indices) { write(options[t].first); true } else false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
activate = {
|
||||||
|
val i = if (idx < 0) 0 else (idx + 1) % options.size
|
||||||
|
options.getOrNull(i)?.let { write(it.first) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fun toggle(
|
||||||
|
id: String, header: String?, label: String, detail: String,
|
||||||
|
value: Boolean, write: (Boolean) -> Unit,
|
||||||
|
): GpRow = GpRow(
|
||||||
|
id, header, label,
|
||||||
|
value = if (value) "On" else "Off",
|
||||||
|
detail = detail,
|
||||||
|
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
||||||
|
activate = { write(!value) },
|
||||||
|
)
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
choice(
|
||||||
|
"resolution", "Stream", "Resolution",
|
||||||
|
"The host creates a virtual display at exactly this size — no scaling.",
|
||||||
|
RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to lbl }, s.width to s.height,
|
||||||
|
) { (w, h) -> update(s.copy(width = w, height = h)) },
|
||||||
|
choice(
|
||||||
|
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
|
||||||
|
REFRESH_OPTIONS, s.hz,
|
||||||
|
) { update(s.copy(hz = it)) },
|
||||||
|
choice(
|
||||||
|
"bitrate", null, "Bitrate",
|
||||||
|
"Automatic uses the host's default. Run a speed test from the touch UI for an informed value.",
|
||||||
|
BITRATE_OPTIONS, s.bitrateKbps,
|
||||||
|
) { update(s.copy(bitrateKbps = it)) },
|
||||||
|
choice(
|
||||||
|
"compositor", null, "Compositor",
|
||||||
|
"Which compositor drives the virtual output — honored only if available on the host.",
|
||||||
|
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
|
||||||
|
) { update(s.copy(compositor = it)) },
|
||||||
|
|
||||||
|
choice(
|
||||||
|
"codec", "Video", "Video codec",
|
||||||
|
"A preference — the host falls back if it can't encode this one.",
|
||||||
|
CODEC_OPTIONS, s.codec,
|
||||||
|
) { update(s.copy(codec = it)) },
|
||||||
|
toggle(
|
||||||
|
"hdr", null, "10-bit HDR",
|
||||||
|
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||||
|
s.hdrEnabled,
|
||||||
|
) { update(s.copy(hdrEnabled = it)) },
|
||||||
|
|
||||||
|
choice(
|
||||||
|
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||||
|
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
|
||||||
|
) { update(s.copy(audioChannels = it)) },
|
||||||
|
toggle(
|
||||||
|
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
|
||||||
|
s.micEnabled,
|
||||||
|
) { update(s.copy(micEnabled = it)) },
|
||||||
|
|
||||||
|
choice(
|
||||||
|
"padType", "Controller", "Controller type",
|
||||||
|
"The virtual pad the host creates — Automatic matches this controller.",
|
||||||
|
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||||
|
) { update(s.copy(gamepad = it)) },
|
||||||
|
|
||||||
|
toggle(
|
||||||
|
"hud", "Interface", "Statistics overlay",
|
||||||
|
"Show FPS, throughput and latency while streaming.",
|
||||||
|
s.statsHudEnabled,
|
||||||
|
) { update(s.copy(statsHudEnabled = it)) },
|
||||||
|
toggle(
|
||||||
|
"library", null, "Game library",
|
||||||
|
"Browse a paired host's games with Y (experimental).",
|
||||||
|
s.libraryEnabled,
|
||||||
|
) { update(s.copy(libraryEnabled = it)) },
|
||||||
|
toggle(
|
||||||
|
"gamepadUI", null, "Controller-optimized UI",
|
||||||
|
"Turn off to use the touch interface even with a controller connected.",
|
||||||
|
s.gamepadUiEnabled,
|
||||||
|
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.app.UiModeManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.hardware.input.InputManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
|
||||||
|
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
|
||||||
|
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
|
||||||
|
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
|
||||||
|
* console UI (as long as the setting is on).
|
||||||
|
*/
|
||||||
|
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
|
||||||
|
enabled && (controllerConnected || tv || forced)
|
||||||
|
|
||||||
|
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
|
||||||
|
fun isTvDevice(context: Context): Boolean {
|
||||||
|
val pm = context.packageManager
|
||||||
|
if (pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) ||
|
||||||
|
pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val uiMode = context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager
|
||||||
|
return uiMode?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live "is a game controller attached" state, updated as pads connect/disconnect via
|
||||||
|
* [InputManager]'s device listener — so the home screen flips to the console UI the instant a pad is
|
||||||
|
* plugged in or paired, and back to touch when it's removed. Mirrors the reactivity the Apple client
|
||||||
|
* gets from observing `GamepadManager.shared`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberControllerConnected(): State<Boolean> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||||
|
val listener = object : InputManager.InputDeviceListener {
|
||||||
|
private fun refresh() { connected.value = Gamepad.firstPad() != null }
|
||||||
|
override fun onInputDeviceAdded(deviceId: Int) = refresh()
|
||||||
|
override fun onInputDeviceRemoved(deviceId: Int) = refresh()
|
||||||
|
override fun onInputDeviceChanged(deviceId: Int) = refresh()
|
||||||
|
}
|
||||||
|
im.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
|
||||||
|
connected.value = Gamepad.firstPad() != null
|
||||||
|
onDispose { im.unregisterInputDeviceListener(listener) }
|
||||||
|
}
|
||||||
|
return connected
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
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.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PageSize
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
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.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||||
|
import io.unom.punktfunk.kit.library.GameEntry
|
||||||
|
import io.unom.punktfunk.kit.library.LibraryClient
|
||||||
|
import io.unom.punktfunk.kit.library.LibraryResult
|
||||||
|
import io.unom.punktfunk.kit.library.mtlsHttpClient
|
||||||
|
import io.unom.punktfunk.kit.security.IdentityStore
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
|
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sign
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
// The host game-library browser — the Android mirror of the Apple client's LibraryCoverflowView:
|
||||||
|
// a gamepad-driven poster coverflow (centered cover flat + prominent, neighbours receding on a 3D
|
||||||
|
// Y-tilt) fetched from the host's management API over mTLS. Reached with Y from a saved host.
|
||||||
|
|
||||||
|
private sealed class LibState {
|
||||||
|
object Loading : LibState()
|
||||||
|
data class Ready(val games: List<GameEntry>, val loader: ImageLoader) : LibState()
|
||||||
|
data class Message(val text: String) : LibState() // unauthorized / empty / error
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true) {
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
val context = LocalContext.current
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
|
||||||
|
|
||||||
|
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||||
|
state = LibState.Loading
|
||||||
|
state = withContext(Dispatchers.IO) {
|
||||||
|
val id = runCatching { obtainIdentity(IdentityStore(context)) }.getOrNull()
|
||||||
|
?: return@withContext LibState.Message("Identity unavailable — re-pair may be required.")
|
||||||
|
when (val res = LibraryClient.fetch(
|
||||||
|
address = host.address,
|
||||||
|
mgmtPort = DEFAULT_MGMT_PORT,
|
||||||
|
certPem = id.certPem,
|
||||||
|
keyPem = id.privateKeyPem,
|
||||||
|
fpHex = host.fpHex,
|
||||||
|
)) {
|
||||||
|
is LibraryResult.Ok -> if (res.games.isEmpty()) {
|
||||||
|
LibState.Message("No games found on this host.")
|
||||||
|
} else {
|
||||||
|
val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex)
|
||||||
|
LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build())
|
||||||
|
}
|
||||||
|
is LibraryResult.Unauthorized -> LibState.Message(res.message)
|
||||||
|
is LibraryResult.Error -> LibState.Message(res.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||||
|
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||||
|
Column(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||||
|
ConsoleHeader("${host.name} — Library")
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
when (val s = state) {
|
||||||
|
is LibState.Loading -> LoadingState()
|
||||||
|
is LibState.Message -> MessageState(s.text)
|
||||||
|
is LibState.Ready -> Coverflow(s.games, s.loader, navActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Floating legend at the shared spot — same landscape-aware inset as every other console
|
||||||
|
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
|
||||||
|
Box(
|
||||||
|
Modifier.align(Alignment.BottomStart)
|
||||||
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
|
.padding(ConsoleLegendInset),
|
||||||
|
) {
|
||||||
|
GamepadHintBar(listOf(PadGlyph.hint('B', "Close", onClick = onBack)), hazeState = hazeState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingState() {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageState(text: String) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
color = Color.White.copy(alpha = 0.75f),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Boolean) {
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
|
||||||
|
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
|
||||||
|
val coverWidth = coverHeight * 2f / 3f
|
||||||
|
val sidePad = ((maxWidth - coverWidth) / 2).coerceAtLeast(0.dp)
|
||||||
|
val pagerState = rememberPagerState(pageCount = { games.size })
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var navTarget by remember { mutableIntStateOf(0) }
|
||||||
|
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||||
|
val current = games.getOrNull(navTarget)
|
||||||
|
|
||||||
|
// Controller nav: the pad drives the coverflow (it wasn't captured before). Left/right steps a
|
||||||
|
// coalesced target the pager chases; A is reserved for launch (browse-only for now); B closes
|
||||||
|
// via the screen's BackHandler.
|
||||||
|
GamepadNavEffect(
|
||||||
|
active = navActive && games.isNotEmpty(),
|
||||||
|
onMove = { dir ->
|
||||||
|
val t = (navTarget + dir).coerceIn(0, games.lastIndex)
|
||||||
|
if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } }
|
||||||
|
},
|
||||||
|
onActivate = { /* launch a title — browse-only for now */ },
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
pageSize = PageSize.Fixed(coverWidth),
|
||||||
|
contentPadding = PaddingValues(horizontal = sidePad),
|
||||||
|
pageSpacing = 0.dp, // translationX (below) does the spacing so covers sit closer
|
||||||
|
beyondViewportPageCount = 3, // render more neighbours so a denser fan is visible
|
||||||
|
modifier = Modifier.fillMaxWidth().height(coverHeight + 24.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) { page ->
|
||||||
|
val signed = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
|
||||||
|
val d = signed.absoluteValue
|
||||||
|
Poster(
|
||||||
|
game = games[page],
|
||||||
|
loader = loader,
|
||||||
|
modifier = Modifier
|
||||||
|
.zIndex(-d) // centred cover on top, neighbours stacked behind
|
||||||
|
.width(coverWidth)
|
||||||
|
.height(coverHeight)
|
||||||
|
.graphicsLayer {
|
||||||
|
// Centre at full size; EVERY neighbour settles to one size, so an even pitch
|
||||||
|
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
|
||||||
|
// the "edges spread apart while the centre gets crowded" look.)
|
||||||
|
val scale = 1f - 0.28f * d.coerceAtMost(1f)
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
alpha = (1f - 0.26f * d).coerceAtLeast(0.15f) // depth via fade, not size
|
||||||
|
val rotDeg = signed.coerceIn(-2.5f, 2.5f) * 26f // tilt inward
|
||||||
|
rotationY = rotDeg
|
||||||
|
// Even neighbour pitch (0.8·cover) + a little extra outward push (ramped over
|
||||||
|
// the first step so scrolling stays smooth) so the CENTRE card breathes.
|
||||||
|
val base = signed * size.width * 0.2f - signed.coerceIn(-1f, 1f) * size.width * 0.14f
|
||||||
|
// Counter-balance: a rotated card projects narrower (≈cos θ), which opens its
|
||||||
|
// inner gap — pull it back toward centre by the half-width it loses so the
|
||||||
|
// gaps stay even no matter the tilt.
|
||||||
|
val halfW = size.width * scale * 0.5f
|
||||||
|
val counter = sign(signed) * halfW * (1f - cos(rotDeg * (PI.toFloat() / 180f)))
|
||||||
|
translationX = base + counter
|
||||||
|
// Lower cameraDistance = stronger perspective (CSS `perspective`); the flat
|
||||||
|
// 22 washed the tilt out. 9 makes the same angle read as real depth.
|
||||||
|
cameraDistance = 9f * density
|
||||||
|
transformOrigin = TransformOrigin(0.5f, 0.5f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().padding(top = 14.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
current?.title ?: " ",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (current != null) {
|
||||||
|
Text(
|
||||||
|
if (current.isCustom) "CUSTOM" else "STEAM",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.5f),
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */
|
||||||
|
@Composable
|
||||||
|
private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) {
|
||||||
|
val candidates = game.art.posterCandidates
|
||||||
|
var idx by remember(game.id) { mutableStateOf(0) }
|
||||||
|
val shape = RoundedCornerShape(16.dp)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(Color(0xFF241F3D))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.12f), shape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (idx < candidates.size) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current).data(candidates[idx]).build(),
|
||||||
|
imageLoader = loader,
|
||||||
|
contentDescription = game.title,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
game.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White.copy(alpha = 0.75f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Store badge, top-start.
|
||||||
|
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||||
|
Text(
|
||||||
|
if (game.isCustom) "Custom" else "Steam",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
// The bundled brand typeface (Geist Sans) ships under the SIL Open Font License 1.1. The OFL
|
||||||
|
// requires the license travel with the font, so surface it here (mirrors the Apple client).
|
||||||
|
val fontLicense = remember {
|
||||||
|
runCatching {
|
||||||
|
context.assets.open("GEIST-OFL.txt").bufferedReader().use { it.readText() }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
val version = remember {
|
||||||
|
runCatching {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
// Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler).
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 20.dp)
|
||||||
|
.padding(bottom = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
if (fontLicense != null) {
|
||||||
|
Text("Bundled font", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
fontLicense,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
@@ -10,6 +11,9 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
import io.unom.punktfunk.kit.Keymap
|
import io.unom.punktfunk.kit.Keymap
|
||||||
@@ -26,8 +30,38 @@ class MainActivity : ComponentActivity() {
|
|||||||
/** Joystick-axis state mapper for the active session (built/reset by StreamScreen). */
|
/** Joystick-axis state mapper for the active session (built/reset by StreamScreen). */
|
||||||
var axisMapper: Gamepad.AxisMapper? = null
|
var axisMapper: Gamepad.AxisMapper? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
|
||||||
|
* Called for every key/motion event while not streaming; a `true` return consumes the event —
|
||||||
|
* the screen's "test inputs" mode uses that to keep pad input from also driving focus navigation.
|
||||||
|
*/
|
||||||
|
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
||||||
|
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a
|
||||||
|
* couch user with no keyboard/Back can always leave a stream.
|
||||||
|
*/
|
||||||
|
var requestStreamExit: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/** Currently-held forwarded pad buttons (bitmask of `Gamepad.BTN_*`), for chord detection. */
|
||||||
|
private var heldPadButtons = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
|
||||||
|
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
|
||||||
|
* face buttons, or a select glyph + arrows for a remote. Compose observes it (a snapshot state).
|
||||||
|
*/
|
||||||
|
var lastPadIsGamepad by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||||
|
private var highRefreshModeId = 0
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
resolveHighRefreshMode()
|
||||||
|
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
|
||||||
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
||||||
// the status/nav bars blend with our surface and get light icons. (The no-arg edge-to-edge
|
// the status/nav bars blend with our surface and get light icons. (The no-arg edge-to-edge
|
||||||
// picks the *system* light/dark, which left a black status bar over our dark background.)
|
// picks the *system* light/dark, which left a black status bar over our dark background.)
|
||||||
@@ -35,13 +69,39 @@ class MainActivity : ComponentActivity() {
|
|||||||
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||||
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||||
)
|
)
|
||||||
|
// Dev escape hatch (mirrors the Apple client's PUNKTFUNK_FORCE_GAMEPAD_UI): force the console
|
||||||
|
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
|
||||||
|
// pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV.
|
||||||
|
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
|
||||||
setContent {
|
setContent {
|
||||||
PunktfunkTheme {
|
PunktfunkTheme {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) { App() }
|
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
||||||
|
private fun resolveHighRefreshMode() {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||||
|
highRefreshModeId = disp?.supportedModes?.maxWithOrNull(
|
||||||
|
compareBy({ it.refreshRate }, { it.physicalWidth * it.physicalHeight }),
|
||||||
|
)?.modeId ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
|
||||||
|
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
|
||||||
|
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so
|
||||||
|
* its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead.
|
||||||
|
*/
|
||||||
|
fun setConsoleHighRefreshRate(high: Boolean) {
|
||||||
|
if (highRefreshModeId == 0) return
|
||||||
|
window.attributes = window.attributes.apply {
|
||||||
|
preferredDisplayModeId = if (high) highRefreshModeId else 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
val handle = streamHandle
|
val handle = streamHandle
|
||||||
if (handle != 0L) {
|
if (handle != 0L) {
|
||||||
@@ -52,9 +112,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (bit != 0) {
|
if (bit != 0) {
|
||||||
when (event.action) {
|
when (event.action) {
|
||||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||||
KeyEvent.ACTION_DOWN ->
|
KeyEvent.ACTION_DOWN -> {
|
||||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
|
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
|
||||||
KeyEvent.ACTION_UP -> NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
heldPadButtons = heldPadButtons or bit
|
||||||
|
// Emergency exit: Select + Start + L1 + R1 held together leaves the stream
|
||||||
|
// (a couch user has no keyboard/Back). Fired once per full chord.
|
||||||
|
if (heldPadButtons and STREAM_EXIT_CHORD == STREAM_EXIT_CHORD) {
|
||||||
|
heldPadButtons = 0
|
||||||
|
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyEvent.ACTION_UP -> {
|
||||||
|
NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
||||||
|
heldPadButtons = heldPadButtons and bit.inv()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true // consumed
|
return true // consumed
|
||||||
}
|
}
|
||||||
@@ -72,23 +143,40 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
} else {
|
||||||
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's
|
||||||
// buttons to the navigation keys the focus system understands; D-pad *keys* already move
|
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are).
|
||||||
// focus on their own, so they fall through to super untouched.
|
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
||||||
val mapped = when (event.keyCode) {
|
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||||
KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element
|
}
|
||||||
KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss
|
// The Controllers debug screen sees pad events before the navigation remap below.
|
||||||
else -> 0
|
padKeyProbe?.let { if (it(event)) return true }
|
||||||
|
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||||
|
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
||||||
|
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
|
||||||
|
// already move focus on their own, so they fall through to super untouched.
|
||||||
|
when (event.keyCode) {
|
||||||
|
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
|
||||||
|
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
|
||||||
|
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
|
||||||
|
KeyEvent.KEYCODE_BUTTON_B -> {
|
||||||
|
if (event.action == KeyEvent.ACTION_UP) onBackPressedDispatcher.onBackPressed()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// A → activate the focused element (the focus system understands DPAD_CENTER).
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A ->
|
||||||
|
return super.dispatchKeyEvent(KeyEvent(event.action, KeyEvent.KEYCODE_DPAD_CENTER))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped))
|
|
||||||
}
|
}
|
||||||
return super.dispatchKeyEvent(event)
|
return super.dispatchKeyEvent(event)
|
||||||
}
|
}
|
||||||
@@ -101,6 +189,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (axisMapper?.onMotion(event) == true) return true
|
if (axisMapper?.onMotion(event) == true) return true
|
||||||
return super.dispatchGenericMotionEvent(event)
|
return super.dispatchGenericMotionEvent(event)
|
||||||
}
|
}
|
||||||
|
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
||||||
|
padMotionProbe?.let { if (it(event)) return true }
|
||||||
// Not streaming: turn the gamepad HAT / left stick into discrete D-pad focus moves, so a
|
// Not streaming: turn the gamepad HAT / left stick into discrete D-pad focus moves, so a
|
||||||
// controller navigates the menus even when its D-pad reports as axes (not key events) and
|
// controller navigates the menus even when its D-pad reports as axes (not key events) and
|
||||||
// for stick-based navigation. Edge-detected so a held direction moves focus exactly once.
|
// for stick-based navigation. Edge-detected so a held direction moves focus exactly once.
|
||||||
@@ -121,6 +211,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (dir != lastNavDir) {
|
if (dir != lastNavDir) {
|
||||||
lastNavDir = dir
|
lastNavDir = dir
|
||||||
if (dir != 0) {
|
if (dir != 0) {
|
||||||
|
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
|
||||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
||||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
||||||
return true
|
return true
|
||||||
@@ -131,4 +222,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
return super.dispatchGenericMotionEvent(event)
|
return super.dispatchGenericMotionEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */
|
||||||
|
private fun isConsoleNavKey(kc: Int): Boolean = when (kc) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
|
||||||
|
-> true
|
||||||
|
else -> KeyEvent.isGamepadButton(kc)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together. */
|
||||||
|
val STREAM_EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,51 @@ 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 — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
|
||||||
|
* the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
|
||||||
|
* lift and re-swipe to walk it across), tap to click where it is. [TouchMode.POINTER]: the
|
||||||
|
* cursor jumps to the finger (direct pointing). [TouchMode.TOUCH]: real multi-touch
|
||||||
|
* passthrough — every finger reaches the host as a touchscreen contact, for apps/games that
|
||||||
|
* understand touch. Mirrors the Apple client's TouchInputMode.
|
||||||
|
*/
|
||||||
|
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
||||||
|
/**
|
||||||
|
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
|
||||||
|
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
|
||||||
|
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
|
||||||
|
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||||
|
*/
|
||||||
|
val gamepadUiEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
|
||||||
|
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
|
||||||
|
* client's `libraryEnabled`.
|
||||||
|
*/
|
||||||
|
val libraryEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
|
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
|
||||||
|
|
||||||
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
||||||
class SettingsStore(context: Context) {
|
class SettingsStore(context: Context) {
|
||||||
private val prefs =
|
private val prefs =
|
||||||
@@ -31,10 +69,19 @@ 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),
|
||||||
|
touchMode = prefs.getString(K_TOUCH_MODE, null)
|
||||||
|
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
||||||
|
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||||
|
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||||
|
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||||
|
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: Settings) {
|
fun save(s: Settings) {
|
||||||
@@ -43,10 +90,16 @@ 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)
|
||||||
|
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||||
|
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||||
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +108,19 @@ 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_TOUCH_MODE = "touch_mode"
|
||||||
|
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||||
|
const val K_LIBRARY = "library_enabled"
|
||||||
|
|
||||||
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
|
const val K_TRACKPAD = "trackpad_mode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +186,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,6 +226,13 @@ val COMPOSITOR_OPTIONS = listOf(
|
|||||||
"gamescope",
|
"gamescope",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** (mode, label) for the touch-input model. */
|
||||||
|
val TOUCH_MODE_OPTIONS = listOf(
|
||||||
|
TouchMode.TRACKPAD to "Trackpad",
|
||||||
|
TouchMode.POINTER to "Direct pointer",
|
||||||
|
TouchMode.TOUCH to "Touch passthrough",
|
||||||
|
)
|
||||||
|
|
||||||
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
|
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
|
||||||
val GAMEPAD_OPTIONS = listOf(
|
val GAMEPAD_OPTIONS = listOf(
|
||||||
"Automatic",
|
"Automatic",
|
||||||
|
|||||||
@@ -5,160 +5,454 @@ 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.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.BoxWithConstraints
|
||||||
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
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.SportsEsports
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material.icons.filled.Tv
|
||||||
|
import androidx.compose.material.icons.filled.VolumeUp
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedCard
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.OutlinedTextField
|
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.material3.VerticalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
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.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted
|
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||||
* immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native"
|
* subpages. On a phone the category list pushes to a full-screen detail; on a tablet / large screen
|
||||||
* resolve from the device display at connect time.
|
* it becomes a two-pane list-detail (the list stays on the left, the detail on the right). Edits
|
||||||
|
* persist immediately via [onChange]; [onBack] returns to the connect screen.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
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) }
|
||||||
|
var showControllers by remember { mutableStateOf(false) }
|
||||||
fun update(next: Settings) {
|
fun update(next: Settings) {
|
||||||
s = next
|
s = next
|
||||||
onChange(next)
|
onChange(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
|
|
||||||
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
|
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
|
||||||
val micLauncher = rememberLauncherForActivityResult(
|
val micLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { granted -> update(s.copy(micEnabled = granted)) }
|
) { granted -> update(s.copy(micEnabled = granted)) }
|
||||||
|
val onMicChange: (Boolean) -> Unit = { on ->
|
||||||
|
when {
|
||||||
|
!on -> update(s.copy(micEnabled = false))
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true))
|
||||||
|
else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
// Deep sub-screens replace the whole settings surface (they carry their own back).
|
||||||
modifier = Modifier
|
if (showLicenses) {
|
||||||
.fillMaxSize()
|
LicensesScreen(onBack = { showLicenses = false })
|
||||||
.verticalScroll(rememberScrollState())
|
return
|
||||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
}
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
if (showControllers) {
|
||||||
) {
|
ControllersScreen(gamepadSetting = s.gamepad, onBack = { showControllers = false })
|
||||||
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val (nw, nh, nhz) = nativeDisplayMode(context)
|
// Selected category persists across rotation (stored by name — null = the bare list on a phone).
|
||||||
|
var selectedName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
val selected = selectedName?.let { n -> SettingsCategory.entries.firstOrNull { it.name == n } }
|
||||||
|
|
||||||
SettingsGroup("Display") {
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
SettingDropdown(
|
val twoPane = maxWidth >= 640.dp
|
||||||
label = "Resolution",
|
// A two-column layout must never show an empty detail — land on the first category.
|
||||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
|
LaunchedEffect(twoPane) {
|
||||||
(w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl)
|
if (twoPane && selected == null) selectedName = SettingsCategory.Display.name
|
||||||
},
|
|
||||||
selected = s.width to s.height,
|
|
||||||
) { (w, h) -> update(s.copy(width = w, height = h)) }
|
|
||||||
|
|
||||||
SettingDropdown(
|
|
||||||
label = "Refresh rate",
|
|
||||||
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
|
|
||||||
selected = s.hz,
|
|
||||||
) { hz -> update(s.copy(hz = hz)) }
|
|
||||||
|
|
||||||
SettingDropdown(
|
|
||||||
label = "Bitrate",
|
|
||||||
options = BITRATE_OPTIONS,
|
|
||||||
selected = s.bitrateKbps,
|
|
||||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsGroup("Host") {
|
val detail: @Composable (SettingsCategory, (() -> Unit)?) -> Unit = { cat, back ->
|
||||||
SettingDropdown(
|
CategoryDetail(
|
||||||
label = "Compositor",
|
category = cat,
|
||||||
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
settings = s,
|
||||||
selected = s.compositor,
|
onChange = ::update,
|
||||||
) { c -> update(s.copy(compositor = c)) }
|
context = context,
|
||||||
|
onMicChange = onMicChange,
|
||||||
SettingDropdown(
|
onOpenControllers = { showControllers = true },
|
||||||
label = "Controller type",
|
onOpenLicenses = { showLicenses = true },
|
||||||
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
onBack = back,
|
||||||
selected = s.gamepad,
|
)
|
||||||
) { g -> update(s.copy(gamepad = g)) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsGroup("Audio") {
|
if (twoPane) {
|
||||||
ToggleRow(
|
BackHandler(onBack = onBack)
|
||||||
title = "Microphone",
|
Row(Modifier.fillMaxSize()) {
|
||||||
subtitle = "Send your mic to the host's virtual microphone",
|
CategoryList(
|
||||||
checked = s.micEnabled,
|
selected = selected,
|
||||||
onCheckedChange = { on ->
|
twoPane = true,
|
||||||
when {
|
onSelect = { selectedName = it.name },
|
||||||
!on -> update(s.copy(micEnabled = false))
|
modifier = Modifier.width(300.dp).fillMaxHeight(),
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
)
|
||||||
PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true))
|
VerticalDivider()
|
||||||
else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||||
|
// Cross-fade the detail pane as the selected category changes.
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = selected ?: SettingsCategory.Display,
|
||||||
|
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||||
|
label = "SettingsPane",
|
||||||
|
) { cat -> detail(cat, null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Compact: the category list pushes to a full-screen detail and back, like the iOS /
|
||||||
|
// Android system settings — a horizontal slide that tracks the drill-in direction.
|
||||||
|
BackHandler { if (selected != null) selectedName = null else onBack() }
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = selected,
|
||||||
|
transitionSpec = {
|
||||||
|
if (targetState != null) {
|
||||||
|
slideInHorizontally { it } + fadeIn() togetherWith
|
||||||
|
slideOutHorizontally { -it } + fadeOut()
|
||||||
|
} else {
|
||||||
|
slideInHorizontally { -it } + fadeIn() togetherWith
|
||||||
|
slideOutHorizontally { it } + fadeOut()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
label = "SettingsPush",
|
||||||
}
|
) { sel ->
|
||||||
|
if (sel == null) {
|
||||||
SettingsGroup("Overlay") {
|
CategoryList(
|
||||||
ToggleRow(
|
selected = null,
|
||||||
title = "Stats overlay",
|
twoPane = false,
|
||||||
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
onSelect = { selectedName = it.name },
|
||||||
checked = s.statsHudEnabled,
|
modifier = Modifier.fillMaxSize(),
|
||||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
)
|
||||||
)
|
} else {
|
||||||
|
detail(sel) { selectedName = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A titled group of settings rendered inside an outlined card. */
|
/** The top-level settings groups — each opens its own subpage (list on phone, split on tablet). */
|
||||||
|
enum class SettingsCategory(val title: String, val icon: ImageVector) {
|
||||||
|
Display("Display", Icons.Filled.Tv),
|
||||||
|
Audio("Audio", Icons.Filled.VolumeUp),
|
||||||
|
Controls("Controls", Icons.Filled.SportsEsports),
|
||||||
|
Interface("Interface", Icons.Filled.Tune),
|
||||||
|
About("About", Icons.Filled.Info),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The category list — the settings root. Highlights the [selected] row when it drives a detail pane. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsGroup(title: String, content: @Composable ColumnScope.() -> Unit) {
|
private fun CategoryList(
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
selected: SettingsCategory?,
|
||||||
|
twoPane: Boolean,
|
||||||
|
onSelect: (SettingsCategory) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 12.dp, vertical = 20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
title,
|
"Settings",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
modifier = Modifier.padding(start = 8.dp, bottom = 12.dp),
|
||||||
modifier = Modifier.padding(start = 4.dp),
|
|
||||||
)
|
)
|
||||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
SettingsCategory.entries.forEach { cat ->
|
||||||
Column(
|
val highlighted = twoPane && selected == cat
|
||||||
modifier = Modifier.padding(16.dp),
|
Row(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
modifier = Modifier
|
||||||
content = content,
|
.fillMaxWidth()
|
||||||
)
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent)
|
||||||
|
.clickable { onSelect(cat) }
|
||||||
|
.padding(horizontal = 14.dp, vertical = 15.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
cat.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
)
|
||||||
|
Text(cat.title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||||
|
if (!twoPane) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A title + subtitle on the left, a Switch on the right. */
|
/** One category's controls. [onBack] non-null (phone push) shows a back arrow; null (tablet pane) hides it. */
|
||||||
|
@Composable
|
||||||
|
private fun CategoryDetail(
|
||||||
|
category: SettingsCategory,
|
||||||
|
settings: Settings,
|
||||||
|
onChange: (Settings) -> Unit,
|
||||||
|
context: android.content.Context,
|
||||||
|
onMicChange: (Boolean) -> Unit,
|
||||||
|
onOpenControllers: () -> Unit,
|
||||||
|
onOpenLicenses: () -> Unit,
|
||||||
|
onBack: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (onBack != null) {
|
||||||
|
IconButton(onClick = onBack, modifier = Modifier.padding(end = 4.dp)) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(category.title, style = MaterialTheme.typography.headlineMedium)
|
||||||
|
}
|
||||||
|
when (category) {
|
||||||
|
SettingsCategory.Display -> DisplaySettings(settings, onChange, context)
|
||||||
|
SettingsCategory.Audio -> AudioSettings(settings, onChange, onMicChange)
|
||||||
|
SettingsCategory.Controls -> ControlsSettings(settings, onChange, onOpenControllers)
|
||||||
|
SettingsCategory.Interface -> InterfaceSettings(settings, onChange)
|
||||||
|
SettingsCategory.About -> AboutSettings(onOpenLicenses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
|
||||||
|
val (nw, nh, nhz) = nativeDisplayMode(context)
|
||||||
|
SettingsCard {
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Resolution",
|
||||||
|
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) },
|
||||||
|
selected = s.width to s.height,
|
||||||
|
) { (w, h) -> update(s.copy(width = w, height = h)) }
|
||||||
|
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Refresh rate",
|
||||||
|
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
|
||||||
|
selected = s.hz,
|
||||||
|
) { hz -> update(s.copy(hz = hz)) }
|
||||||
|
|
||||||
|
SettingDropdown(label = "Bitrate", options = BITRATE_OPTIONS, selected = s.bitrateKbps) { 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) so the host doesn't send PQ the panel mis-tone-maps.
|
||||||
|
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)) },
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Compositor",
|
||||||
|
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||||
|
selected = s.compositor,
|
||||||
|
) { c -> update(s.copy(compositor = c)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange: (Boolean) -> Unit) {
|
||||||
|
SettingsCard {
|
||||||
|
SettingDropdown(label = "Audio channels", options = AUDIO_CHANNEL_OPTIONS, selected = s.audioChannels) { ch ->
|
||||||
|
update(s.copy(audioChannels = ch))
|
||||||
|
}
|
||||||
|
ToggleRow(
|
||||||
|
title = "Microphone",
|
||||||
|
subtitle = "Send your mic to the host's virtual microphone",
|
||||||
|
checked = s.micEnabled,
|
||||||
|
onCheckedChange = onMicChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
|
||||||
|
SettingsCard {
|
||||||
|
SettingDropdown(label = "Touch input", options = TOUCH_MODE_OPTIONS, selected = s.touchMode) { mode ->
|
||||||
|
update(s.copy(touchMode = mode))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger tap " +
|
||||||
|
"right-clicks, two fingers scroll, tap-then-drag holds the button. Direct pointer: " +
|
||||||
|
"the cursor jumps to your finger. Touch passthrough: real multi-touch reaches the " +
|
||||||
|
"host, for apps that understand touch.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingsCard {
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Controller type",
|
||||||
|
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||||
|
selected = s.gamepad,
|
||||||
|
) { g -> update(s.copy(gamepad = g)) }
|
||||||
|
ClickableRow(
|
||||||
|
title = "Connected controllers",
|
||||||
|
subtitle = "What the app detects, with a live input test",
|
||||||
|
onClick = onOpenControllers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
|
||||||
|
SettingsCard {
|
||||||
|
ToggleRow(
|
||||||
|
title = "Controller-optimized UI",
|
||||||
|
subtitle = "Switch to the console home (host carousel) when a controller is connected",
|
||||||
|
checked = s.gamepadUiEnabled,
|
||||||
|
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
||||||
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "Game library",
|
||||||
|
subtitle = "Browse a paired host's game library (press Y on a saved host)",
|
||||||
|
checked = s.libraryEnabled,
|
||||||
|
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
|
||||||
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "Stats overlay",
|
||||||
|
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
||||||
|
checked = s.statsHudEnabled,
|
||||||
|
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AboutSettings(onOpenLicenses: () -> Unit) {
|
||||||
|
SettingsCard {
|
||||||
|
ClickableRow(
|
||||||
|
title = "Open-source licenses",
|
||||||
|
subtitle = "Third-party notices and credits",
|
||||||
|
onClick = onOpenLicenses,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A group of settings rendered inside an outlined card. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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(
|
||||||
@@ -167,7 +461,12 @@ private fun ToggleRow(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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 — the unified HUD (`design/stats-unification.md`, Android v1: headline is
|
||||||
|
* `capture→decoded`, tiled by `host+network` + `decode`). Reads the 18-double layout from
|
||||||
|
* [NativeBridge.nativeVideoStats]:
|
||||||
|
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries,
|
||||||
|
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms]`. Indexes 10–13
|
||||||
|
* (present on a current native lib) describe the negotiated video feed and render as a
|
||||||
|
* codec/depth/colour/chroma line; 14/15 render as the stage equation — split into
|
||||||
|
* `host + network + decode` when the Phase-2 terms at 16/17 are nonzero (a current host sends
|
||||||
|
* per-AU 0xCF timings; an old host leaves them 0 and the combined `host+network` term stands);
|
||||||
|
* older layouts just omit those lines.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||||
|
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 lost = 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 clock)"
|
||||||
|
Text(
|
||||||
|
"end-to-end ${"%.1f".format(s[2])} ms p50 · ${"%.1f".format(s[3])} p95 · capture→decoded$tag",
|
||||||
|
color = Color.White,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
if (s.size >= 16) {
|
||||||
|
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
|
||||||
|
// reported its share this window; otherwise the combined term (old host / no
|
||||||
|
// matched 0xCF timing).
|
||||||
|
val equation = if (s.size >= 18 && s[16] > 0) {
|
||||||
|
"= host ${"%.1f".format(s[16])} + network ${"%.1f".format(s[17])} + decode ${"%.1f".format(s[15])}"
|
||||||
|
} else {
|
||||||
|
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
equation,
|
||||||
|
color = Color.White,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lost > 0) {
|
||||||
|
Text(
|
||||||
|
"lost $lost",
|
||||||
|
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,12 +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.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
|
||||||
@@ -40,15 +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
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||||
@@ -65,15 +48,25 @@ 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).
|
||||||
while (true) {
|
val touchMode = initialSettings.touchMode
|
||||||
delay(1000)
|
LaunchedEffect(handle, showStats) {
|
||||||
stats = NativeBridge.nativeVideoStats(handle)
|
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
|
||||||
|
if (showStats) {
|
||||||
|
while (true) {
|
||||||
|
delay(1000)
|
||||||
|
stats = NativeBridge.nativeVideoStats(handle)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stats = null // drop the last snapshot so a re-show never flashes stale numbers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,8 +82,17 @@ 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
|
||||||
|
activity?.requestStreamExit = onDisconnect // Select+Start+L1+R1 chord leaves the stream
|
||||||
|
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||||
onDispose {
|
onDispose {
|
||||||
@@ -99,8 +101,13 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
||||||
activity?.axisMapper = null
|
activity?.axisMapper = null
|
||||||
activity?.streamHandle = 0L
|
activity?.streamHandle = 0L
|
||||||
|
activity?.requestStreamExit = null
|
||||||
|
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||||
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)
|
||||||
@@ -145,156 +152,19 @@ 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 → mouse, absolute "direct pointing" like the Apple client: the host cursor follows
|
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||||
// your finger (MouseMoveAbs, host-normalized against the overlay size — which fills the video,
|
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||||
// so finger position maps straight onto the remote screen). Gestures: tap = left click;
|
|
||||||
// two-finger tap = right click; two-finger drag = scroll; tap-then-press-and-drag = left-drag
|
|
||||||
// (text selection / moving windows); three-finger tap = toggle the stats HUD.
|
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().pointerInput(handle) {
|
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||||
var lastTapUp = 0L
|
when (touchMode) {
|
||||||
var lastTapX = 0f
|
TouchMode.TOUCH -> streamTouchPassthrough(handle)
|
||||||
var lastTapY = 0f
|
else -> streamTouchInput(
|
||||||
fun moveAbs(x: Float, y: Float) {
|
|
||||||
val sw = size.width
|
|
||||||
val sh = size.height
|
|
||||||
if (sw <= 0 || sh <= 0) return
|
|
||||||
NativeBridge.nativeSendPointerAbs(
|
|
||||||
handle,
|
handle,
|
||||||
x.coerceIn(0f, (sw - 1).toFloat()).roundToInt(),
|
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||||
y.coerceIn(0f, (sh - 1).toFloat()).roundToInt(),
|
onToggleStats = { showStats = !showStats },
|
||||||
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
|
|
||||||
moveAbs(startX, startY) // cursor jumps to the finger immediately
|
|
||||||
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
|
|
||||||
|
|
||||||
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 → the cursor follows it (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
|
|
||||||
}
|
|
||||||
moveAbs(p.position.x, p.position.y)
|
|
||||||
}
|
|
||||||
ev.changes.forEach { it.consume() }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDrag) {
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
|
||||||
} else if (!moved) {
|
|
||||||
when {
|
|
||||||
maxFingers >= 3 -> showStats = !showStats // in-stream HUD toggle
|
|
||||||
maxFingers == 2 -> { // two-finger tap → right click
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
|
||||||
}
|
|
||||||
else -> { // tap → left click, and arm tap-and-drag
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, 1, true)
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, 1, false)
|
|
||||||
lastTapUp = upTime
|
|
||||||
lastTapX = startX
|
|
||||||
lastTapY = startY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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),
|
||||||
@@ -39,5 +41,7 @@ fun PunktfunkTheme(content: @Composable () -> Unit) {
|
|||||||
} else {
|
} else {
|
||||||
BrandDark
|
BrandDark
|
||||||
}
|
}
|
||||||
MaterialTheme(colorScheme = scheme, content = content)
|
// Geist Sans across the whole type scale — the brand typeface the website and the Apple client
|
||||||
|
// already ship (see Type.kt).
|
||||||
|
MaterialTheme(colorScheme = scheme, typography = PunktfunkTypography, content = content)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.ui.input.pointer.PointerId
|
||||||
|
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||||
|
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
|
||||||
|
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||||
|
import androidx.compose.ui.input.pointer.positionChanged
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.hypot
|
||||||
|
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).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||||
|
* contact (down/move/up with a stable per-finger id), with NO gesture interpretation — taps,
|
||||||
|
* drags and multi-finger input mean whatever the remote app decides. Coordinates are overlay
|
||||||
|
* pixels with the overlay size as the surface, exactly like the absolute-mouse path (the host
|
||||||
|
* normalizes and maps into the output). On teardown (stream leaves composition) every still-held
|
||||||
|
* contact is lifted so nothing stays stuck on the host.
|
||||||
|
*/
|
||||||
|
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
|
||||||
|
val ids = mutableMapOf<PointerId, Int>()
|
||||||
|
fun alloc(p: PointerId): Int {
|
||||||
|
var id = 0
|
||||||
|
while (ids.containsValue(id)) id++
|
||||||
|
ids[p] = id
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val ev = awaitPointerEvent()
|
||||||
|
val sw = size.width
|
||||||
|
val sh = size.height
|
||||||
|
if (sw <= 0 || sh <= 0) continue
|
||||||
|
for (c in ev.changes) {
|
||||||
|
val x = c.position.x.roundToInt().coerceIn(0, sw - 1)
|
||||||
|
val y = c.position.y.roundToInt().coerceIn(0, sh - 1)
|
||||||
|
when {
|
||||||
|
c.changedToDownIgnoreConsumed() ->
|
||||||
|
NativeBridge.nativeSendTouch(handle, alloc(c.id), 0, x, y, sw, sh)
|
||||||
|
c.changedToUpIgnoreConsumed() ->
|
||||||
|
ids.remove(c.id)?.let {
|
||||||
|
NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, sw, sh)
|
||||||
|
}
|
||||||
|
c.positionChanged() ->
|
||||||
|
ids[c.id]?.let {
|
||||||
|
NativeBridge.nativeSendTouch(handle, it, 1, x, y, sw, sh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.consume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Lift anything still down (composition/session teardown mid-touch).
|
||||||
|
ids.values.forEach { NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, 1, 1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun PointerInputScope.streamTouchInput(
|
||||||
|
handle: Long,
|
||||||
|
trackpad: Boolean,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
|
// Geist — the punktfunk brand typeface (the same family the website and the Apple client ship).
|
||||||
|
// Bundled as static OTF weights in res/font and applied to every Material 3 text style below, so the
|
||||||
|
// Android UI carries the brand type identically to the other clients. Geist Sans only — Geist Mono
|
||||||
|
// is intentionally not shipped (the licenses screen's technical block uses the platform monospace).
|
||||||
|
//
|
||||||
|
// Licensed under the SIL Open Font License 1.1 (see the Geist OFL entry in THIRD-PARTY-NOTICES.txt).
|
||||||
|
val Geist = FontFamily(
|
||||||
|
Font(R.font.geist_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.geist_medium, FontWeight.Medium),
|
||||||
|
Font(R.font.geist_semibold, FontWeight.SemiBold),
|
||||||
|
Font(R.font.geist_bold, FontWeight.Bold),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default Material 3 type scale re-based on [Geist]. Material 3's [Typography] has no
|
||||||
|
* `defaultFontFamily` shortcut (that was Material 2), so each of the 15 roles is re-emitted with the
|
||||||
|
* Geist family while keeping Material's sizes, line heights, letter spacing and per-role weights.
|
||||||
|
*/
|
||||||
|
val PunktfunkTypography: Typography = Typography().run {
|
||||||
|
Typography(
|
||||||
|
displayLarge = displayLarge.copy(fontFamily = Geist),
|
||||||
|
displayMedium = displayMedium.copy(fontFamily = Geist),
|
||||||
|
displaySmall = displaySmall.copy(fontFamily = Geist),
|
||||||
|
headlineLarge = headlineLarge.copy(fontFamily = Geist),
|
||||||
|
headlineMedium = headlineMedium.copy(fontFamily = Geist),
|
||||||
|
headlineSmall = headlineSmall.copy(fontFamily = Geist),
|
||||||
|
titleLarge = titleLarge.copy(fontFamily = Geist),
|
||||||
|
titleMedium = titleMedium.copy(fontFamily = Geist),
|
||||||
|
titleSmall = titleSmall.copy(fontFamily = Geist),
|
||||||
|
bodyLarge = bodyLarge.copy(fontFamily = Geist),
|
||||||
|
bodyMedium = bodyMedium.copy(fontFamily = Geist),
|
||||||
|
bodySmall = bodySmall.copy(fontFamily = Geist),
|
||||||
|
labelLarge = labelLarge.copy(fontFamily = Geist),
|
||||||
|
labelMedium = labelMedium.copy(fontFamily = Geist),
|
||||||
|
labelSmall = labelSmall.copy(fontFamily = Geist),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wake a sleeping host and WAIT for it to come back before proceeding — the Android mirror of the
|
||||||
|
* Apple client's `HostWaker`.
|
||||||
|
*
|
||||||
|
* A magic packet is fire-and-forget, and a cold box can take 20–60 s to POST, boot, and start
|
||||||
|
* advertising on mDNS again — far longer than a connect attempt will sit. So instead of firing one
|
||||||
|
* packet and immediately dialing (which just fails on a genuinely-asleep host), this drives a visible
|
||||||
|
* "Waking…" state: it (re-)sends the packet, polls the host's mDNS presence once a second via
|
||||||
|
* [isOnline], and on success runs [onOnline] (the real connect for a Wake-&-Connect, or nothing for
|
||||||
|
* a wake-only); on timeout it parks in a retry/cancel state. One wake at a time.
|
||||||
|
*
|
||||||
|
* [scope] is the composition's coroutine scope (main-dispatched), so [waking] mutations and the
|
||||||
|
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
|
||||||
|
*/
|
||||||
|
class WakeController(private val scope: CoroutineScope) {
|
||||||
|
/** null = idle; non-null drives [WakeOverlay]. */
|
||||||
|
data class Waking(
|
||||||
|
val hostName: String,
|
||||||
|
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
|
||||||
|
val connectsAfter: Boolean,
|
||||||
|
val seconds: Int = 0,
|
||||||
|
val timedOut: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
var waking by mutableStateOf<Waking?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var loop: Job? = null
|
||||||
|
|
||||||
|
/** Captured so "Try Again" replays the exact same wait. */
|
||||||
|
private var replay: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wake the host and wait for [isOnline] to go true, then run [onOnline]. [macs]/[lastIp] target
|
||||||
|
* the magic packet. No-ops straight to [onOnline] when there's nothing to wake with or the host
|
||||||
|
* is already up (a race between the caller's check and here).
|
||||||
|
*/
|
||||||
|
fun start(
|
||||||
|
hostName: String,
|
||||||
|
connectsAfter: Boolean,
|
||||||
|
macs: List<String>,
|
||||||
|
lastIp: String,
|
||||||
|
isOnline: () -> Boolean,
|
||||||
|
onOnline: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (macs.isEmpty() || isOnline()) {
|
||||||
|
cancel()
|
||||||
|
onOnline()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replay = { run(hostName, connectsAfter, macs, lastIp, isOnline, onOnline) }
|
||||||
|
replay?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop waiting and dismiss the overlay (B / Cancel). */
|
||||||
|
fun cancel() {
|
||||||
|
loop?.cancel()
|
||||||
|
loop = null
|
||||||
|
replay = null
|
||||||
|
waking = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restart the wait after a timeout (A / Try Again). */
|
||||||
|
fun retry() {
|
||||||
|
replay?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun run(
|
||||||
|
hostName: String,
|
||||||
|
connectsAfter: Boolean,
|
||||||
|
macs: List<String>,
|
||||||
|
lastIp: String,
|
||||||
|
isOnline: () -> Boolean,
|
||||||
|
onOnline: () -> Unit,
|
||||||
|
) {
|
||||||
|
loop?.cancel()
|
||||||
|
waking = Waking(hostName = hostName, connectsAfter = connectsAfter)
|
||||||
|
loop = scope.launch {
|
||||||
|
var elapsed = 0
|
||||||
|
while (isActive) {
|
||||||
|
// Re-send periodically: a single packet can be missed, and some NICs only wake on a
|
||||||
|
// fresh packet after dropping into a deeper sleep state.
|
||||||
|
if (elapsed % RESEND_EVERY_S == 0) {
|
||||||
|
val csv = macs.joinToString(",")
|
||||||
|
launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(csv, lastIp) }
|
||||||
|
}
|
||||||
|
if (isOnline()) {
|
||||||
|
waking = null
|
||||||
|
loop = null
|
||||||
|
onOnline()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (elapsed >= TIMEOUT_S) {
|
||||||
|
waking = waking?.copy(timedOut = true)
|
||||||
|
loop = null
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
delay(1000)
|
||||||
|
elapsed++
|
||||||
|
waking = waking?.copy(seconds = elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** How long to wait for the host to reappear before giving up (a cold boot can be a minute+). */
|
||||||
|
const val TIMEOUT_S = 90
|
||||||
|
|
||||||
|
/** Re-send the magic packet this often. */
|
||||||
|
const val RESEND_EVERY_S = 6
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Bedtime
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Waking <host>…" modal shown while [WakeController] brings a sleeping host back — a spinner + a
|
||||||
|
* live elapsed counter, escalating to a retry/cancel prompt on timeout. The Android mirror of the
|
||||||
|
* Apple client's `WakeOverlay`. Rendered over BOTH the touch grid and the console home; it swallows
|
||||||
|
* input to the screen behind it, and in console mode the pad drives it (B cancels, A retries once
|
||||||
|
* timed out) while the touch buttons work for a pointer.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WakeOverlay(waker: WakeController, gamepadUi: Boolean) {
|
||||||
|
val w = waker.waking ?: return
|
||||||
|
|
||||||
|
BackHandler { waker.cancel() } // system Back / pad B (remapped) cancels the wait
|
||||||
|
if (gamepadUi) {
|
||||||
|
// A retries once timed out; B falls through to the BackHandler above.
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = true,
|
||||||
|
onDirection = {},
|
||||||
|
onActivate = { if (w.timedOut) waker.retry() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.6f))
|
||||||
|
// Swallow taps so the home behind can't be touched while waking.
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(40.dp)
|
||||||
|
.widthIn(max = 380.dp)
|
||||||
|
.clip(RoundedCornerShape(22.dp))
|
||||||
|
.background(Color(0xF01A1730))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp))
|
||||||
|
.padding(28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
if (w.timedOut) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Bedtime,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.85f),
|
||||||
|
modifier = Modifier.size(34.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${w.hostName} didn't wake",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 19.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"It may still be booting, or it's powered off / off this network.",
|
||||||
|
color = Color.White.copy(alpha = 0.6f),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = { waker.cancel() }) { Text("Cancel") }
|
||||||
|
Button(onClick = { waker.retry() }) { Text("Try Again") }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
Text(
|
||||||
|
"Waking ${w.hostName}…",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 19.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Waiting for it to come online · ${w.seconds}s",
|
||||||
|
color = Color.White.copy(alpha = 0.6f),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
||||||
|
OutlinedButton(onClick = { waker.cancel() }, modifier = Modifier.padding(top = 6.dp)) {
|
||||||
|
Text(if (w.connectsAfter) "Cancel" else "Stop Waiting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,8 @@ fun HostCard(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onConnect: () -> Unit,
|
onConnect: () -> Unit,
|
||||||
onForget: (() -> Unit)?,
|
onForget: (() -> Unit)?,
|
||||||
onRename: (() -> Unit)? = null,
|
onEdit: (() -> Unit)? = null,
|
||||||
|
onWake: (() -> 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.
|
||||||
@@ -107,7 +108,7 @@ fun HostCard(
|
|||||||
StatusPill(status)
|
StatusPill(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onForget != null || onRename != null) {
|
if (onForget != null || onEdit != null || onWake != 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 }) {
|
||||||
@@ -119,12 +120,21 @@ fun HostCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
if (onRename != null) {
|
if (onWake != null) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Rename") },
|
text = { Text("Wake host") },
|
||||||
onClick = {
|
onClick = {
|
||||||
menu = false
|
menu = false
|
||||||
onRename()
|
onWake()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (onEdit != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Edit…") },
|
||||||
|
onClick = {
|
||||||
|
menu = false
|
||||||
|
onEdit()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,198 @@
|
|||||||
|
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.TouchMode
|
||||||
|
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 = {}, onEdit = {})
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
touchMode = TouchMode.TRACKPAD,
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,13 +37,30 @@ def call(method, url, token=None, data=None, content_type=None, want_json=True):
|
|||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
if content_type:
|
if content_type:
|
||||||
headers["Content-Type"] = content_type
|
headers["Content-Type"] = content_type
|
||||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
# Transient-fault retries: googleapis.com occasionally drops the TLS session ("EOF
|
||||||
try:
|
# occurred in violation of protocol" — failed two release uploads on 2026-07-02) or
|
||||||
with urllib.request.urlopen(req, timeout=300) as r:
|
# answers 5xx. Retry those with backoff; 4xx raises immediately (a real API error).
|
||||||
body = r.read()
|
# The edits API is transactional until commit, so re-sending any of these is safe.
|
||||||
except urllib.error.HTTPError as e:
|
last = None
|
||||||
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
|
for attempt in range(4):
|
||||||
return json.loads(body) if (want_json and body) else body
|
if attempt:
|
||||||
|
delay = 3**attempt
|
||||||
|
print(f"transient Play API failure ({last}); retry {attempt}/3 in {delay}s")
|
||||||
|
time.sleep(delay)
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=300) as r:
|
||||||
|
body = r.read()
|
||||||
|
return json.loads(body) if (want_json and body) else body
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code >= 500:
|
||||||
|
last = f"HTTP {e.code}"
|
||||||
|
continue
|
||||||
|
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
last = str(getattr(e, "reason", e))
|
||||||
|
continue
|
||||||
|
sys.exit(f"ERROR: {method} {url} still failing after retries: {last}")
|
||||||
|
|
||||||
|
|
||||||
def load_sa():
|
def load_sa():
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ android {
|
|||||||
ndkVersion = ndkVer
|
ndkVersion = ndkVer
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 31
|
minSdk = 28 // Android 9 — reaches older TV boxes; API 31+ features are runtime-gated.
|
||||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
// Keep in lockstep with :app — 32-bit armeabi-v7a for the many 32-bit Google TV / Android TV
|
||||||
|
// boxes, 64-bit arm64-v8a for phones + modern TV, x86_64 for the emulator.
|
||||||
|
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
@@ -28,6 +30,9 @@ android {
|
|||||||
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
|
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// mTLS HTTPS client for the host's management API (the game-library fetch + cover-art loads).
|
||||||
|
// OkHttp lets us present the paired client cert and pin the host's self-signed cert by SHA-256.
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser
|
testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +90,11 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
|||||||
// find their subtools.
|
// find their subtools.
|
||||||
val cmd = mutableListOf(
|
val cmd = mutableListOf(
|
||||||
"$cargoBin/cargo", "ndk",
|
"$cargoBin/cargo", "ndk",
|
||||||
"-t", "arm64-v8a", "-t", "x86_64",
|
"-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64",
|
||||||
// Link against the minSdk-31 sysroot so libaaudio (API 26+) is found.
|
// Link against the minSdk-28 sysroot: libaaudio (API 26) is present, and building at the
|
||||||
"--platform", "31",
|
// floor makes the linker reject any accidental >28 hard import (the one API-30 call we
|
||||||
|
// make, ANativeWindow_setFrameRate, is dlsym-resolved — see decode::try_set_frame_rate).
|
||||||
|
"--platform", "28",
|
||||||
"-o", file("src/main/jniLibs").absolutePath,
|
"-o", file("src/main/jniLibs").absolutePath,
|
||||||
"build", "-p", "punktfunk-client-android",
|
"build", "-p", "punktfunk-client-android",
|
||||||
)
|
)
|
||||||
@@ -99,6 +106,12 @@ val cargoNdkDebug = registerCargoNdk("cargoNdkDebug", release = false)
|
|||||||
val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true)
|
val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true)
|
||||||
|
|
||||||
afterEvaluate {
|
afterEvaluate {
|
||||||
tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
|
// `-PskipRustBuild` skips the cargo-ndk native build — for JVM-only tasks (the Roborazzi
|
||||||
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
// screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so
|
||||||
|
// CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired
|
||||||
|
// for every normal APK/AAR build.
|
||||||
|
if (!project.hasProperty("skipRustBuild")) {
|
||||||
|
tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
|
||||||
|
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,15 +50,25 @@ object Gamepad {
|
|||||||
const val PREF_DUALSENSE = 2
|
const val PREF_DUALSENSE = 2
|
||||||
const val PREF_XBOXONE = 3
|
const val PREF_XBOXONE = 3
|
||||||
const val PREF_DUALSHOCK4 = 4
|
const val PREF_DUALSHOCK4 = 4
|
||||||
|
const val PREF_STEAMCONTROLLER = 5
|
||||||
|
const val PREF_STEAMDECK = 6
|
||||||
|
|
||||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||||
private const val VID_SONY = 0x054C
|
private const val VID_SONY = 0x054C
|
||||||
private const val VID_MICROSOFT = 0x045E
|
private const val VID_MICROSOFT = 0x045E
|
||||||
|
private const val VID_VALVE = 0x28DE
|
||||||
|
|
||||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||||
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
||||||
|
|
||||||
|
// Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) /
|
||||||
|
// dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles /
|
||||||
|
// trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard
|
||||||
|
// buttons + sticks reach the host for now — parity with the desktop type resolution.
|
||||||
|
private val PID_STEAMDECK = setOf(0x1205)
|
||||||
|
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
|
||||||
|
|
||||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||||
private val PID_XBOXONE = setOf(
|
private val PID_XBOXONE = setOf(
|
||||||
@@ -82,24 +92,26 @@ object Gamepad {
|
|||||||
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
||||||
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||||
|
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
||||||
|
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
|
||||||
else -> PREF_XBOX360
|
else -> PREF_XBOX360
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
/** True when [dev]'s source classes include gamepad or joystick. */
|
||||||
fun firstPad(): InputDevice? {
|
fun isPad(dev: InputDevice?): Boolean {
|
||||||
for (id in InputDevice.getDeviceIds()) {
|
val s = dev?.sources ?: return false
|
||||||
val d = InputDevice.getDevice(id) ?: continue
|
return s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||||
val s = d.sources
|
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||||
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
|
||||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
|
||||||
) {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */
|
||||||
|
fun pads(): List<InputDevice> =
|
||||||
|
InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) }
|
||||||
|
|
||||||
|
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||||
|
fun firstPad(): InputDevice? = pads().firstOrNull()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A
|
* 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
|
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.hardware.lights.LightsRequest
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.CombinedVibration
|
import android.os.CombinedVibration
|
||||||
import android.os.VibrationEffect
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
import android.os.VibratorManager
|
import android.os.VibratorManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
@@ -16,7 +17,8 @@ import java.nio.ByteBuffer
|
|||||||
/**
|
/**
|
||||||
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
||||||
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
||||||
* `VibratorManager`; HID-output → lightbar / player-LED via `LightsManager` (API 33+); adaptive
|
* `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output → lightbar /
|
||||||
|
* player-LED via `LightsManager` (API 33+); adaptive
|
||||||
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
||||||
*
|
*
|
||||||
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
||||||
@@ -40,6 +42,9 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
private var hidoutThread: Thread? = null
|
private var hidoutThread: Thread? = null
|
||||||
|
|
||||||
private var vm: VibratorManager? = null
|
private var vm: VibratorManager? = null
|
||||||
|
// API 28–30 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager
|
||||||
|
// until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor.
|
||||||
|
private var legacy: Vibrator? = null
|
||||||
private var vibratorIds: IntArray = IntArray(0)
|
private var vibratorIds: IntArray = IntArray(0)
|
||||||
private var amplitudeControlled = false
|
private var amplitudeControlled = false
|
||||||
|
|
||||||
@@ -81,6 +86,7 @@ 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 { legacy?.cancel() }
|
||||||
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
||||||
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
||||||
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
||||||
@@ -98,6 +104,7 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
rgbLight = null
|
rgbLight = null
|
||||||
playerLight = null
|
playerLight = null
|
||||||
vm = null
|
vm = null
|
||||||
|
legacy = null
|
||||||
vibratorIds = IntArray(0)
|
vibratorIds = IntArray(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,39 +118,65 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val m = dev.vibratorManager
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
val ids = m.vibratorIds
|
val m = dev.vibratorManager
|
||||||
if (ids.isEmpty()) {
|
val ids = m.vibratorIds
|
||||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
if (ids.isEmpty()) {
|
||||||
return
|
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vm = m
|
||||||
|
vibratorIds = ids
|
||||||
|
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||||
|
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
||||||
|
} else {
|
||||||
|
// API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val v = dev.vibrator
|
||||||
|
if (!v.hasVibrator()) {
|
||||||
|
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
legacy = v
|
||||||
|
amplitudeControlled = v.hasAmplitudeControl()
|
||||||
|
Log.i(TAG, "rumble: bound legacy vibrator amplitudeControl=$amplitudeControlled")
|
||||||
}
|
}
|
||||||
vm = m
|
|
||||||
vibratorIds = ids
|
|
||||||
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
|
||||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
|
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
|
||||||
private fun renderRumble(low: Int, high: Int) {
|
private fun renderRumble(low: Int, high: Int) {
|
||||||
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
|
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
|
||||||
val m = vm ?: return
|
|
||||||
val lo = toAmplitude(low)
|
val lo = toAmplitude(low)
|
||||||
val hi = toAmplitude(high)
|
val hi = toAmplitude(high)
|
||||||
if (lo == 0 && hi == 0) {
|
val m = vm
|
||||||
m.cancel() // (0,0) = stop
|
if (m != null) {
|
||||||
|
if (lo == 0 && hi == 0) {
|
||||||
|
m.cancel() // (0,0) = stop
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val combo = CombinedVibration.startParallel()
|
||||||
|
if (amplitudeControlled && vibratorIds.size >= 2) {
|
||||||
|
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
||||||
|
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
|
||||||
|
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
|
||||||
|
} else {
|
||||||
|
// Single motor or no amplitude control: blend both into one effect.
|
||||||
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
|
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
||||||
|
}
|
||||||
|
runCatching { m.vibrate(combo.combine()) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val combo = CombinedVibration.startParallel()
|
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||||
if (amplitudeControlled && vibratorIds.size >= 2) {
|
val lv = legacy ?: return
|
||||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
if (lo == 0 && hi == 0) {
|
||||||
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
|
lv.cancel() // (0,0) = stop
|
||||||
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
|
return
|
||||||
} else {
|
}
|
||||||
// Single motor or no amplitude control: blend both into one effect.
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
runCatching {
|
||||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
lv.vibrate(if (amplitudeControlled) oneShot(a) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
}
|
}
|
||||||
runCatching { m.vibrate(combo.combine()) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||||
|
|||||||
@@ -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. */
|
||||||
@@ -80,7 +86,7 @@ object NativeBridge {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The current resolved-host snapshot for [handle]: newline-joined records, each
|
* 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;
|
* `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
|
||||||
* cheap (a lock + string build), safe to call on the main thread.
|
* cheap (a lock + string build), safe to call on the main thread.
|
||||||
*/
|
*/
|
||||||
external fun nativeDiscoveryPoll(handle: Long): String
|
external fun nativeDiscoveryPoll(handle: Long): String
|
||||||
@@ -88,6 +94,15 @@ object NativeBridge {
|
|||||||
/** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */
|
/** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */
|
||||||
external fun nativeDiscoveryStop(handle: Long)
|
external fun nativeDiscoveryStop(handle: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a Wake-on-LAN magic packet to wake a sleeping host. [macsCsv] is comma-separated MAC
|
||||||
|
* addresses (`aa:bb:..,cc:dd:..`), learned from the host's mDNS `mac` TXT while it was online;
|
||||||
|
* [lastIp] is the host's last-known IPv4 (or empty). Returns true if at least one datagram was
|
||||||
|
* sent. No handle — callable without a live session. Do NOT call on the main thread (it does
|
||||||
|
* blocking socket sends); run it on a background dispatcher.
|
||||||
|
*/
|
||||||
|
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@@ -99,12 +114,28 @@ 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 18 doubles (unified stats spec, `design/stats-unification.md`):
|
||||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]`
|
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||||
* (the two flags are 1.0/0.0). Poll ~1 Hz; each call resets the measurement window.
|
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||||
|
* netP50Ms]`
|
||||||
|
* (the two flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
||||||
|
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
||||||
|
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
||||||
|
* `host+network` = capture→received, `decode` = received→decoded; 16/17 split the
|
||||||
|
* `host+network` term via the host's per-AU 0xCF timings — `host` = the host's capture→sent,
|
||||||
|
* `network` = the remainder — both 0.0 when no timing matched this window, i.e. an old host).
|
||||||
|
* Poll ~1 Hz; each call resets the measurement window.
|
||||||
*/
|
*/
|
||||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
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.
|
||||||
@@ -142,6 +173,22 @@ object NativeBridge {
|
|||||||
/** One scroll step. axis: 0=vertical 1=horizontal. delta: signed, 120-scaled, +=up/right. */
|
/** One scroll step. axis: 0=vertical 1=horizontal. delta: signed, 120-scaled, +=up/right. */
|
||||||
external fun nativeSendScroll(handle: Long, axis: Int, delta: Int)
|
external fun nativeSendScroll(handle: Long, axis: Int, delta: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One REAL touchscreen transition (the touch-passthrough input mode). [kind]: 0=down 1=move
|
||||||
|
* 2=up. [id] distinguishes fingers and is reusable after up; coordinates are pixels on the
|
||||||
|
* client's touch surface — the host rescales against [surfaceWidth]×[surfaceHeight] and
|
||||||
|
* injects a real touch contact. On up only [id] matters.
|
||||||
|
*/
|
||||||
|
external fun nativeSendTouch(
|
||||||
|
handle: Long,
|
||||||
|
id: Int,
|
||||||
|
kind: Int,
|
||||||
|
x: Int,
|
||||||
|
y: Int,
|
||||||
|
surfaceWidth: Int,
|
||||||
|
surfaceHeight: Int,
|
||||||
|
)
|
||||||
|
|
||||||
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
||||||
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -17,15 +17,17 @@ data class DiscoveredHost(
|
|||||||
val port: Int,
|
val port: Int,
|
||||||
val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies)
|
val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies)
|
||||||
val pairingRequired: Boolean = false,
|
val pairingRequired: Boolean = false,
|
||||||
|
val mac: List<String> = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN)
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||||
private const val FIELD_SEP = '\u001F'
|
private const val FIELD_SEP = '\u001F'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
|
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac`), or
|
||||||
* if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side
|
* null if it's malformed. `mac` (7th field) is optional — an older host omits it. Pure —
|
||||||
* already applied the protocol gate and address selection, so this is just field marshaling.
|
* unit-tested without Android (see ParseRecordTest). The native side already applied the protocol
|
||||||
|
* gate and address selection, so this is just field marshaling.
|
||||||
*/
|
*/
|
||||||
fun parseHostRecord(record: String): DiscoveredHost? {
|
fun parseHostRecord(record: String): DiscoveredHost? {
|
||||||
val f = record.split(FIELD_SEP)
|
val f = record.split(FIELD_SEP)
|
||||||
@@ -40,6 +42,8 @@ fun parseHostRecord(record: String): DiscoveredHost? {
|
|||||||
port = port,
|
port = port,
|
||||||
fingerprint = f[4].ifBlank { null },
|
fingerprint = f[4].ifBlank { null },
|
||||||
pairingRequired = f[5] == "required",
|
pairingRequired = f[5] == "required",
|
||||||
|
mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
else emptyList(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package io.unom.punktfunk.kit.library
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.net.ssl.HostnameVerifier
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import javax.net.ssl.KeyManagerFactory
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.TrustManager
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
// Android game-library client — the mirror of the Apple client's LibraryClient.swift. Fetches a
|
||||||
|
// host's unified game library from its management REST API (`GET /api/v1/library`) over **mTLS**: the
|
||||||
|
// paired client presents its persistent cert/key (the same identity the host paired over QUIC), and
|
||||||
|
// the host's self-signed cert is pinned by SHA-256(DER). Read-only. Mirrors the GameEntry/Artwork
|
||||||
|
// schema in crates/punktfunk-host/src/library.rs.
|
||||||
|
|
||||||
|
/** The management API's default port — matches `mgmt::DEFAULT_PORT` on the host and the Apple client. */
|
||||||
|
const val DEFAULT_MGMT_PORT = 47990
|
||||||
|
|
||||||
|
/** Cover-art URLs. Steam art arrives as host-relative proxy paths, resolved to absolute by [LibraryClient]. */
|
||||||
|
data class Artwork(val portrait: String?, val header: String?, val hero: String?) {
|
||||||
|
/** Poster preference for a 2:3 tile: portrait capsule → header → hero (near-universal fallbacks). */
|
||||||
|
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
|
||||||
|
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
|
||||||
|
val isCustom: Boolean get() = store == "custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
|
||||||
|
sealed class LibraryResult {
|
||||||
|
data class Ok(val games: List<GameEntry>) : LibraryResult()
|
||||||
|
data class Unauthorized(val message: String) : LibraryResult()
|
||||||
|
data class Error(val message: String) : LibraryResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
object LibraryClient {
|
||||||
|
/**
|
||||||
|
* `GET https://<address>:<mgmtPort>/api/v1/library`, authenticated by mTLS. [fpHex] is the pinned
|
||||||
|
* host-cert SHA-256 (64 hex, from the paired [io.unom.punktfunk.kit.security.KnownHost]); a blank
|
||||||
|
* value means the host was never connected/paired, so there's nothing authorized to browse.
|
||||||
|
* BLOCKING — call from a background dispatcher.
|
||||||
|
*/
|
||||||
|
fun fetch(
|
||||||
|
address: String,
|
||||||
|
mgmtPort: Int = DEFAULT_MGMT_PORT,
|
||||||
|
certPem: String,
|
||||||
|
keyPem: String,
|
||||||
|
fpHex: String,
|
||||||
|
): LibraryResult {
|
||||||
|
if (fpHex.isBlank()) {
|
||||||
|
return LibraryResult.Unauthorized(
|
||||||
|
"Connect to this host once first — the library uses the identity created on pairing to authenticate.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val client = try {
|
||||||
|
mtlsHttpClient(certPem, keyPem, address, fpHex)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return LibraryResult.Error("Couldn't set up the secure connection: ${e.message}")
|
||||||
|
}
|
||||||
|
val base = "https://$address:$mgmtPort"
|
||||||
|
val req = Request.Builder().url("$base/api/v1/library").build()
|
||||||
|
return try {
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
200 -> LibraryResult.Ok(parse(resp.body?.string().orEmpty(), base))
|
||||||
|
401 -> LibraryResult.Unauthorized(
|
||||||
|
"The host didn't recognize this device. Pair with the host first — it authorizes paired clients by their certificate.",
|
||||||
|
)
|
||||||
|
else -> LibraryResult.Error("The management API returned HTTP ${resp.code}.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LibraryResult.Error(
|
||||||
|
"Couldn't reach the host's management API: ${e.message}. It binds the LAN by default, so check the host is updated and reachable.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(json: String, base: String): List<GameEntry> {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
val out = ArrayList<GameEntry>(arr.length())
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val art = o.optJSONObject("art") ?: JSONObject()
|
||||||
|
out.add(
|
||||||
|
GameEntry(
|
||||||
|
id = o.optString("id"),
|
||||||
|
store = o.optString("store"),
|
||||||
|
title = o.optString("title"),
|
||||||
|
art = Artwork(
|
||||||
|
portrait = resolveArt(str(art, "portrait"), base),
|
||||||
|
header = resolveArt(str(art, "header"), base),
|
||||||
|
hero = resolveArt(str(art, "hero"), base),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A present, non-null, non-blank JSON string field, else null. */
|
||||||
|
private fun str(o: JSONObject, key: String): String? =
|
||||||
|
if (o.has(key) && !o.isNull(key)) o.optString(key).ifBlank { null } else null
|
||||||
|
|
||||||
|
/** Host-relative art path (`/api/v1/library/art/...`) → absolute against the host; else unchanged. */
|
||||||
|
private fun resolveArt(s: String?, base: String): String? =
|
||||||
|
if (s != null && s.startsWith("/")) base + s else s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
|
||||||
|
* SHA-256(DER) — reused for BOTH the library fetch and the cover-art loads (so a paired client
|
||||||
|
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
|
||||||
|
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
|
||||||
|
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
|
||||||
|
*/
|
||||||
|
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
|
||||||
|
val clientCert = CertificateFactory.getInstance("X.509")
|
||||||
|
.generateCertificate(ByteArrayInputStream(certPem.toByteArray())) as X509Certificate
|
||||||
|
val privateKey = parsePrivateKey(keyPem)
|
||||||
|
|
||||||
|
val keyStore = KeyStore.getInstance("PKCS12").apply {
|
||||||
|
load(null, null)
|
||||||
|
setKeyEntry("client", privateKey, CharArray(0), arrayOf(clientCert))
|
||||||
|
}
|
||||||
|
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||||
|
kmf.init(keyStore, CharArray(0))
|
||||||
|
|
||||||
|
// System default trust manager, for non-host (external CDN) origins.
|
||||||
|
val sysTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
sysTmf.init(null as KeyStore?)
|
||||||
|
val sysTm = sysTmf.trustManagers.filterIsInstance<X509TrustManager>().first()
|
||||||
|
|
||||||
|
val pinned = fpHex.lowercase()
|
||||||
|
val trustManager = object : X509TrustManager {
|
||||||
|
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||||
|
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||||
|
if (sha256Hex(chain[0].encoded) == pinned) return // the pinned host
|
||||||
|
sysTm.checkServerTrusted(chain, authType) // external CDN — normal public trust
|
||||||
|
}
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = sysTm.acceptedIssuers
|
||||||
|
}
|
||||||
|
|
||||||
|
val ssl = SSLContext.getInstance("TLS")
|
||||||
|
ssl.init(kmf.keyManagers, arrayOf<TrustManager>(trustManager), null)
|
||||||
|
|
||||||
|
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||||
|
val verifier = HostnameVerifier { hostname, session ->
|
||||||
|
hostname == host || defaultVerifier.verify(hostname, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.sslSocketFactory(ssl.socketFactory, trustManager)
|
||||||
|
.hostnameVerifier(verifier)
|
||||||
|
.connectTimeout(8, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a PKCS#8 PEM private key (rcgen emits `-----BEGIN PRIVATE KEY-----`), trying EC then RSA/Ed25519. */
|
||||||
|
private fun parsePrivateKey(pem: String): PrivateKey {
|
||||||
|
val body = pem
|
||||||
|
.replace(Regex("-----BEGIN [A-Z ]*PRIVATE KEY-----"), "")
|
||||||
|
.replace(Regex("-----END [A-Z ]*PRIVATE KEY-----"), "")
|
||||||
|
.replace(Regex("\\s"), "")
|
||||||
|
val der = Base64.getDecoder().decode(body)
|
||||||
|
val spec = PKCS8EncodedKeySpec(der)
|
||||||
|
for (alg in listOf("EC", "RSA", "Ed25519")) {
|
||||||
|
try {
|
||||||
|
return KeyFactory.getInstance(alg).generatePrivate(spec)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// try the next algorithm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("unsupported private-key format (not EC/RSA/Ed25519 PKCS#8)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256Hex(der: ByteArray): String =
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(der).joinToString("") { "%02x".format(it) }
|
||||||
@@ -13,6 +13,11 @@ data class KnownHost(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val fpHex: String,
|
val fpHex: String,
|
||||||
val paired: Boolean,
|
val paired: Boolean,
|
||||||
|
/**
|
||||||
|
* Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it was
|
||||||
|
* online, so the client can wake it once it sleeps. Empty until first learned.
|
||||||
|
*/
|
||||||
|
val mac: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,9 +47,22 @@ class KnownHostStore(context: Context) {
|
|||||||
.put("name", host.name)
|
.put("name", host.name)
|
||||||
.put("fp", host.fpHex.lowercase())
|
.put("fp", host.fpHex.lowercase())
|
||||||
.put("paired", host.paired)
|
.put("paired", host.paired)
|
||||||
|
.put("mac", host.mac.joinToString(","))
|
||||||
prefs.edit().putString(key(host.address, host.port), json.toString()).apply()
|
prefs.edit().putString(key(host.address, host.port), json.toString()).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while online).
|
||||||
|
* No-op when the host isn't saved, the list is empty, or it's unchanged — so it doesn't churn
|
||||||
|
* prefs on every discovery tick.
|
||||||
|
*/
|
||||||
|
fun learnMac(address: String, port: Int, mac: List<String>) {
|
||||||
|
if (mac.isEmpty()) return
|
||||||
|
val h = get(address, port) ?: return
|
||||||
|
if (h.mac == mac) return
|
||||||
|
save(h.copy(mac = mac))
|
||||||
|
}
|
||||||
|
|
||||||
/** Forget [address]:[port] (the next connect re-pairs / re-TOFUs). */
|
/** Forget [address]:[port] (the next connect re-pairs / re-TOFUs). */
|
||||||
fun remove(address: String, port: Int) {
|
fun remove(address: String, port: Int) {
|
||||||
prefs.edit().remove(key(address, port)).apply()
|
prefs.edit().remove(key(address, port)).apply()
|
||||||
@@ -56,6 +74,16 @@ class KnownHostStore(context: Context) {
|
|||||||
save(h.copy(name = newName))
|
save(h.copy(name = newName))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a saved host, RE-KEYING if the address or port changed (the pref key IS `address:port`, so
|
||||||
|
* a plain [save] would otherwise leave a stale record under the old key). The caller passes an
|
||||||
|
* [updated] copy that preserves `fpHex`/`paired` (and sets `mac` from the edit form).
|
||||||
|
*/
|
||||||
|
fun update(oldAddress: String, oldPort: Int, updated: KnownHost) {
|
||||||
|
if (oldAddress != updated.address || oldPort != updated.port) remove(oldAddress, oldPort)
|
||||||
|
save(updated)
|
||||||
|
}
|
||||||
|
|
||||||
/** 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() }
|
||||||
@@ -68,6 +96,25 @@ class KnownHostStore(context: Context) {
|
|||||||
name = j.getString("name"),
|
name = j.getString("name"),
|
||||||
fpHex = j.getString("fp"),
|
fpHex = j.getString("fp"),
|
||||||
paired = j.optBoolean("paired", false),
|
paired = j.optBoolean("paired", false),
|
||||||
|
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||||
)
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Parse a free-typed Wake-on-LAN field into normalized `aa:bb:cc:dd:ee:ff` entries (comma /
|
||||||
|
* space / newline separated). Anything that isn't six colon-separated hex octets is dropped;
|
||||||
|
* an empty result clears the host's MAC. Mirrors the Apple client's `AddHostSheet.parseMacs`.
|
||||||
|
*/
|
||||||
|
fun parseMacs(s: String): List<String> = s
|
||||||
|
.split(',', ';', ' ', '\n', '\t')
|
||||||
|
.map { it.trim().lowercase() }
|
||||||
|
.filter { m ->
|
||||||
|
// Exactly six octets, each two literal hex digits. (Not toIntOrNull(16) — that accepts
|
||||||
|
// a leading +/- sign, so "aa:bb:cc:dd:ee:-1" would wrongly pass.)
|
||||||
|
m.split(":").let { o ->
|
||||||
|
o.size == 6 && o.all { it.length == 2 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package io.unom.punktfunk.kit.security
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Unit tests for the pure MAC-parsing helper backing the host edit form. */
|
||||||
|
class KnownHostStoreTest {
|
||||||
|
@Test
|
||||||
|
fun parsesAndNormalizesSingleMac() {
|
||||||
|
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("AA:BB:CC:DD:EE:FF"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesMultipleSeparators() {
|
||||||
|
val expected = listOf("aa:bb:cc:dd:ee:ff", "11:22:33:44:55:66")
|
||||||
|
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff, 11:22:33:44:55:66"))
|
||||||
|
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff 11:22:33:44:55:66"))
|
||||||
|
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff\n11:22:33:44:55:66"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dropsMalformedEntries() {
|
||||||
|
// Not six octets / bad hex / wrong width are all dropped; an empty field clears the MAC.
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs(""))
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("not-a-mac"))
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee")) // 5 octets
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("gg:bb:cc:dd:ee:ff")) // non-hex
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aaa:bb:cc:dd:ee:ff")) // wrong width
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee:-1")) // signed octet
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("+a:-b:+c:-d:+e:-f")) // signed octets
|
||||||
|
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("junk, aa:bb:cc:dd:ee:ff"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,14 +27,18 @@ log = "0.4"
|
|||||||
mdns-sd = "0.20"
|
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).
|
||||||
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
||||||
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
||||||
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-31"] }
|
# api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and
|
||||||
|
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; the one API-30 call we make
|
||||||
|
# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not
|
||||||
|
# linked, so the .so still loads on API 28/29.
|
||||||
|
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
|
||||||
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
|
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
|
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! Android Adaptive Performance Framework (ADPF) — CPU performance hints for the decode thread.
|
||||||
|
//!
|
||||||
|
//! ADPF lets a latency-critical app tell the platform "these threads run a repeating workload with
|
||||||
|
//! this per-cycle deadline, and here's how long they *actually* took." The kernel's CPU governor
|
||||||
|
//! (on Qualcomm Snapdragon in particular — its ADPF backend is among the most responsive) then keeps
|
||||||
|
//! those threads on the fast cores at high clocks instead of migrating them to a little core or
|
||||||
|
//! down-clocking between frames. For a stream client the win is on the in-process hot path we
|
||||||
|
//! control — the `pf-decode` feed/drain/present loop — *not* the hardware codec itself (that decodes
|
||||||
|
//! in the mediacodec service, a separate process we can't hint); keeping our loop from being
|
||||||
|
//! scheduled late directly trims the jitter between "AU received" and "buffer released to the
|
||||||
|
//! Surface." It complements the codec-side `operating-rate`/`priority` hints, which push the codec's
|
||||||
|
//! own clocks.
|
||||||
|
//!
|
||||||
|
//! The `APerformanceHint_*` API arrived in NDK **API level 33**. minSdk is 31, so we CANNOT link the
|
||||||
|
//! symbols directly: a `libpunktfunk_android.so` carrying an unresolved
|
||||||
|
//! `APerformanceHint_createSession` import fails to load on API 31/32 devices
|
||||||
|
//! (`System.loadLibrary` throws) even if the code path is never taken. Instead we resolve the
|
||||||
|
//! entry points from `libandroid.so` with `dlsym` at runtime — absent on < 33 ⇒
|
||||||
|
//! [`HintSession::create`] returns `None` and the decode loop simply runs without hints.
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::os::raw::c_int;
|
||||||
|
|
||||||
|
// `APerformanceHint_*` function-pointer types. The manager/session handles are opaque, so we treat
|
||||||
|
// them as `*mut c_void`.
|
||||||
|
type GetManagerFn = unsafe extern "C" fn() -> *mut c_void;
|
||||||
|
type CreateSessionFn = unsafe extern "C" fn(*mut c_void, *const i32, usize, i64) -> *mut c_void;
|
||||||
|
type ReportFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
|
||||||
|
type UpdateTargetFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
|
||||||
|
type CloseFn = unsafe extern "C" fn(*mut c_void);
|
||||||
|
|
||||||
|
/// The entry points we use, resolved once from `libandroid.so`, plus the process-wide manager.
|
||||||
|
struct Api {
|
||||||
|
create_session: CreateSessionFn,
|
||||||
|
report: ReportFn,
|
||||||
|
update_target: UpdateTargetFn,
|
||||||
|
close: CloseFn,
|
||||||
|
manager: *mut c_void,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the ADPF entry points + the process manager, or `None` on API < 33 (symbols absent) or if
|
||||||
|
/// the manager is unavailable.
|
||||||
|
fn resolve_api() -> Option<Api> {
|
||||||
|
// SAFETY: `dlopen` of an always-present system library with a NUL-terminated name; it returns
|
||||||
|
// null on failure (checked below). `libandroid.so` is already mapped into every app process, so
|
||||||
|
// this only bumps its refcount — we intentionally never `dlclose` (process-lifetime handle).
|
||||||
|
let lib = unsafe { libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW) };
|
||||||
|
if lib.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// SAFETY: `dlsym` on the valid handle above with NUL-terminated symbol names; each returns null
|
||||||
|
// when the symbol is absent (device API < 33), which we check before transmuting the non-null
|
||||||
|
// pointer to its fn-pointer type (layout-compatible; a resolved symbol is a valid code address).
|
||||||
|
unsafe {
|
||||||
|
let get_manager = libc::dlsym(lib, c"APerformanceHint_getManager".as_ptr());
|
||||||
|
let create_session = libc::dlsym(lib, c"APerformanceHint_createSession".as_ptr());
|
||||||
|
let report = libc::dlsym(lib, c"APerformanceHint_reportActualWorkDuration".as_ptr());
|
||||||
|
let update_target = libc::dlsym(lib, c"APerformanceHint_updateTargetWorkDuration".as_ptr());
|
||||||
|
let close = libc::dlsym(lib, c"APerformanceHint_closeSession".as_ptr());
|
||||||
|
if get_manager.is_null()
|
||||||
|
|| create_session.is_null()
|
||||||
|
|| report.is_null()
|
||||||
|
|| update_target.is_null()
|
||||||
|
|| close.is_null()
|
||||||
|
{
|
||||||
|
return None; // device API < 33 — no ADPF
|
||||||
|
}
|
||||||
|
let get_manager = std::mem::transmute::<*mut c_void, GetManagerFn>(get_manager);
|
||||||
|
let manager = get_manager();
|
||||||
|
if manager.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Api {
|
||||||
|
create_session: std::mem::transmute::<*mut c_void, CreateSessionFn>(create_session),
|
||||||
|
report: std::mem::transmute::<*mut c_void, ReportFn>(report),
|
||||||
|
update_target: std::mem::transmute::<*mut c_void, UpdateTargetFn>(update_target),
|
||||||
|
close: std::mem::transmute::<*mut c_void, CloseFn>(close),
|
||||||
|
manager,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live ADPF hint session bound to a set of thread ids. Dropping it closes the session. Holds raw
|
||||||
|
/// handles, so it is `!Send`/`!Sync` — created and used only on the `pf-decode` thread.
|
||||||
|
pub struct HintSession {
|
||||||
|
api: Api,
|
||||||
|
session: *mut c_void,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HintSession {
|
||||||
|
/// Open a session hinting `tids` with an initial per-frame target of `target_ns` nanoseconds.
|
||||||
|
/// `None` when ADPF is unavailable (device API < 33) or the platform declines — the caller then
|
||||||
|
/// runs unhinted (a no-op, not an error).
|
||||||
|
pub fn create(target_ns: i64, tids: &[i32]) -> Option<Self> {
|
||||||
|
if target_ns <= 0 || tids.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let api = resolve_api()?;
|
||||||
|
// SAFETY: `api.manager` is the live process manager returned above; `tids` is a valid slice
|
||||||
|
// of `len` i32s that `createSession` copies; it returns null on failure (checked).
|
||||||
|
let session =
|
||||||
|
unsafe { (api.create_session)(api.manager, tids.as_ptr(), tids.len(), target_ns) };
|
||||||
|
if session.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self { api, session })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report the wall-clock time the hinted thread spent producing the last displayed frame. When
|
||||||
|
/// it exceeds the session target the governor boosts the cores running the thread; when it
|
||||||
|
/// stays under, clocks may relax. No-op on a non-positive duration (the API rejects it).
|
||||||
|
pub fn report_actual(&self, actual_ns: i64) {
|
||||||
|
if actual_ns <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: `self.session` is a live session for `self`'s lifetime.
|
||||||
|
unsafe { (self.api.report)(self.session, actual_ns) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the per-frame target (e.g. after a mid-session refresh-rate change). Unused today —
|
||||||
|
/// the decode thread restarts on renegotiation — but kept for that path.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn update_target(&self, target_ns: i64) {
|
||||||
|
if target_ns <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: `self.session` is a live session for `self`'s lifetime.
|
||||||
|
unsafe { (self.api.update_target)(self.session, target_ns) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for HintSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.session` was created by `createSession` and is closed exactly once, here.
|
||||||
|
unsafe { (self.api.close)(self.session) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,86 +114,185 @@ 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
|
||||||
let cb_counters = counters.clone();
|
// AND the callback, so nothing survives a failed try to reuse.
|
||||||
let mut ring: VecDeque<f32> = VecDeque::with_capacity(PCM_SCRATCH);
|
let try_open = |sharing: AudioSharingMode| -> ndk::audio::Result<(
|
||||||
let mut primed = false;
|
AudioStream,
|
||||||
let callback = move |_s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
SyncSender<Vec<f32>>,
|
||||||
let want = num_frames as usize * CHANNELS;
|
Receiver<Vec<f32>>,
|
||||||
// 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 (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||||
while let Ok(chunk) = rx.try_recv() {
|
// Recycle free-list: drained PCM buffers go BACK to the decode thread to be refilled, so
|
||||||
ring.extend(chunk);
|
// 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
|
||||||
// Prime to ~3 quanta (15 ms; floor 15 ms / ceiling 200 ms); drop OLDEST above the cap.
|
// rarely allocates. Same depth as the data channel.
|
||||||
let target = (3 * want).clamp(720 * CHANNELS, 9600 * CHANNELS);
|
let (free_tx, free_rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||||
while ring.len() > target.max(want) + want {
|
|
||||||
ring.pop_front();
|
// 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`.
|
||||||
if !primed && ring.len() >= target {
|
let cb_counters = counters.clone();
|
||||||
primed = true;
|
// 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)
|
||||||
if primed {
|
// frames — the punktfunk protocol always sends 5 ms Opus frames (host `audio_thread`); a
|
||||||
for slot in out.iter_mut() {
|
// larger frame would force a one-time realloc, asserted (not silently corrupted) in
|
||||||
*slot = ring.pop_front().unwrap_or(0.0);
|
// `decode_loop`.
|
||||||
|
let mut ring: VecDeque<f32> =
|
||||||
|
VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms);
|
||||||
|
let mut primed = false;
|
||||||
|
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
|
||||||
|
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`.
|
||||||
|
let out = unsafe { std::slice::from_raw_parts_mut(data as *mut f32, want) };
|
||||||
|
// Drain decoded chunks into the ring WITHOUT freeing on the RT thread: `drain(..)`
|
||||||
|
// empties 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);
|
||||||
|
}
|
||||||
|
// Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained
|
||||||
|
// drain; drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst
|
||||||
|
// `want` (tiny on the LowLatency MMAP path) so the depth doesn't collapse to a single
|
||||||
|
// quantum.
|
||||||
|
let target = (3 * want).clamp(prime_floor, prime_ceil);
|
||||||
|
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
|
||||||
|
while ring.len() > hard_cap {
|
||||||
|
ring.pop_front();
|
||||||
|
}
|
||||||
|
if !primed && ring.len() >= target {
|
||||||
|
primed = true;
|
||||||
|
}
|
||||||
|
if primed {
|
||||||
|
for slot in out.iter_mut() {
|
||||||
|
*slot = ring.pop_front().unwrap_or(0.0);
|
||||||
|
}
|
||||||
|
cb_counters
|
||||||
|
.pcm_written
|
||||||
|
.fetch_add(num_frames as u64, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
|
out.fill(0.0);
|
||||||
|
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
// Re-prime only after a RUN of empty callbacks, not a single transient one —
|
||||||
|
// otherwise every momentary drain costs a fresh 40 ms silence (the old behaviour,
|
||||||
|
// self-inflicted crackle on any jitter spike).
|
||||||
|
if ring.is_empty() {
|
||||||
|
empties += 1;
|
||||||
|
if empties >= DEPRIME_AFTER_CALLBACKS {
|
||||||
|
primed = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
empties = 0;
|
||||||
}
|
}
|
||||||
cb_counters
|
cb_counters
|
||||||
.pcm_written
|
.ring_depth
|
||||||
.fetch_add(num_frames as u64, Ordering::Relaxed);
|
.store(ring.len() as u64, Ordering::Relaxed);
|
||||||
} else {
|
// Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the
|
||||||
out.fill(0.0);
|
// HW buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are
|
||||||
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
|
// both callback-safe / non-blocking, and set clamps to capacity so it self-limits.
|
||||||
}
|
// Throttled.
|
||||||
if ring.is_empty() {
|
cb_count = cb_count.wrapping_add(1);
|
||||||
primed = false; // re-prime after a genuine drain (avoids sustained crackle on loss)
|
if cb_count % XRUN_CHECK_EVERY == 0 {
|
||||||
}
|
let xr = s.x_run_count();
|
||||||
cb_counters
|
if xr > last_xrun {
|
||||||
.ring_depth
|
last_xrun = xr;
|
||||||
.store(ring.len() as u64, Ordering::Relaxed);
|
let burst = s.frames_per_burst().max(1);
|
||||||
AudioCallbackResult::Continue
|
let grown =
|
||||||
|
(s.buffer_size_in_frames() + burst).min(s.buffer_capacity_in_frames());
|
||||||
|
let _ = s.set_buffer_size_in_frames(grown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioCallbackResult::Continue
|
||||||
|
};
|
||||||
|
|
||||||
|
let stream = AudioStreamBuilder::new()?
|
||||||
|
.direction(AudioDirection::Output)
|
||||||
|
.sample_rate(SAMPLE_RATE)
|
||||||
|
// 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)
|
||||||
|
.performance_mode(AudioPerformanceMode::LowLatency)
|
||||||
|
.sharing_mode(sharing)
|
||||||
|
.data_callback(Box::new(callback))
|
||||||
|
.error_callback(Box::new(|_s, e| {
|
||||||
|
log::warn!("audio: AAudio error (device reroute/disconnect?): {e:?}");
|
||||||
|
}))
|
||||||
|
.open_stream()?;
|
||||||
|
Ok((stream, tx, free_rx))
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream = AudioStreamBuilder::new()
|
// Exclusive first — MMAP-exclusive is AAudio's lowest-latency path (once proven on-device it
|
||||||
.map_err(|e| log::error!("audio: AudioStreamBuilder::new: {e}"))
|
// may also allow lowering the jitter-ring depths above; those stay put pending crackle
|
||||||
.ok()?
|
// testing) — and fall back to Shared when the device refuses (no MMAP, output claimed, …).
|
||||||
.direction(AudioDirection::Output)
|
// The started-log below prints the mode the device actually GRANTED (`share=`): AAudio may
|
||||||
.sample_rate(SAMPLE_RATE)
|
// still resolve an Exclusive request to Shared.
|
||||||
.channel_count(CHANNELS as i32)
|
let (stream, tx, free_rx) = match try_open(AudioSharingMode::Exclusive) {
|
||||||
.format(AudioFormat::PCM_Float)
|
Ok(opened) => opened,
|
||||||
.performance_mode(AudioPerformanceMode::LowLatency)
|
Err(e) => {
|
||||||
.sharing_mode(AudioSharingMode::Shared)
|
log::info!("audio: Exclusive open failed ({e}) — retrying Shared");
|
||||||
.data_callback(Box::new(callback))
|
match try_open(AudioSharingMode::Shared) {
|
||||||
.error_callback(Box::new(|_s, e| {
|
Ok(opened) => opened,
|
||||||
log::warn!("audio: AAudio error (device reroute/disconnect?): {e:?}");
|
Err(e) => {
|
||||||
}))
|
log::error!("audio: open_stream: {e}");
|
||||||
.open_stream()
|
return None;
|
||||||
.map_err(|e| log::error!("audio: open_stream: {e}"))
|
}
|
||||||
.ok()?;
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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,57 @@ 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) {
|
// Fold this Opus→AAudio thread into the client's hot-thread set so the ADPF session the decode
|
||||||
|
// thread opens also keeps audio decode on a fast core (registered before the video pump's first
|
||||||
|
// frame arrives, so it's captured when that session is created). No-op below API 33.
|
||||||
|
client.register_hot_thread();
|
||||||
|
// Interleaved f32 samples per millisecond at this layout — the ring's 5 ms reserve check below.
|
||||||
|
let ms = (SAMPLE_RATE as usize / 1000) * channels;
|
||||||
|
// Opus decode scratch: worst-case 120 ms frame (5760 samples/ch) × channels.
|
||||||
|
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,28 @@
|
|||||||
use ndk::data_space::DataSpace;
|
use ndk::data_space::DataSpace;
|
||||||
use ndk::media::media_codec::{
|
use ndk::media::media_codec::{
|
||||||
DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection,
|
DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection,
|
||||||
|
OutputBuffer,
|
||||||
};
|
};
|
||||||
use ndk::media::media_format::MediaFormat;
|
use ndk::media::media_format::MediaFormat;
|
||||||
use ndk::native_window::{FrameRateCompatibility, NativeWindow};
|
use ndk::native_window::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::collections::VecDeque;
|
||||||
|
use std::ffi::c_void;
|
||||||
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};
|
||||||
|
|
||||||
|
/// Cap on the pts→received-timestamp map below: MediaCodec holds only a handful of frames in
|
||||||
|
/// flight, so anything beyond this is stale (codec flushed / HUD toggled) and gets evicted.
|
||||||
|
const IN_FLIGHT_CAP: usize = 64;
|
||||||
|
|
||||||
|
/// Cap on received AUs awaiting their 0xCF host timing (Phase 2 host/network split): the timing
|
||||||
|
/// datagram trails its AU by at most the wire, so a match lands within a frame or two — anything
|
||||||
|
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
|
||||||
|
const PENDING_SPLIT_CAP: usize = 256;
|
||||||
|
|
||||||
/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes.
|
/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes.
|
||||||
pub fn run(
|
pub fn run(
|
||||||
client: Arc<NativeClient>,
|
client: Arc<NativeClient>,
|
||||||
@@ -27,16 +40,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,11 +66,21 @@ 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.
|
||||||
format.set_i32("priority", 0); // 0 = realtime
|
format.set_i32("priority", 0); // 0 = realtime
|
||||||
format.set_i32("operating-rate", mode.refresh_hz as i32);
|
// Operating rate = the codec's clock hint. Setting it to the display rate merely asks the
|
||||||
|
// decoder to *sustain* that cadence — a Qualcomm decoder can meet 60/120 fps at a power-saving
|
||||||
|
// clock that adds a millisecond-plus of decode latency per frame. Setting it to the AOSP
|
||||||
|
// "unbounded" sentinel (Short.MAX) instead asks the decoder to run each frame at max clocks and
|
||||||
|
// finish ASAP, minimising per-frame decode latency — the right trade for a real-time stream
|
||||||
|
// (costs power/heat; the dial to lower if a device thermally throttles over a long session).
|
||||||
|
// Ignored where unsupported.
|
||||||
|
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
||||||
|
|
||||||
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
|
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
|
||||||
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
|
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
|
||||||
@@ -84,50 +114,185 @@ pub fn run(
|
|||||||
mode.height
|
mode.height
|
||||||
);
|
);
|
||||||
// Tell the display the stream's refresh so Android can pick a matching display mode and align
|
// Tell the display the stream's refresh so Android can pick a matching display mode and align
|
||||||
// vsync (no 60-in-120 judder on high-refresh panels). minSdk 31 ≥ API 30, so the underlying
|
// vsync (no 60-in-120 judder on high-refresh panels). `ANativeWindow_setFrameRate` is NDK API 30,
|
||||||
// ANativeWindow_setFrameRate is always present; non-fatal if the platform declines.
|
// above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link
|
||||||
if let Err(e) = window.set_frame_rate(mode.refresh_hz as f32, FrameRateCompatibility::Default) {
|
// it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent
|
||||||
log::warn!(
|
// there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it).
|
||||||
"decode: set_frame_rate({} Hz) failed (non-fatal): {e}",
|
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32) {
|
||||||
|
log::debug!(
|
||||||
|
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
||||||
mode.refresh_hz
|
mode.refresh_hz
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADPF: hint the platform that the whole video pipeline — this pf-decode feed/drain/present
|
||||||
|
// loop, the core's data-plane pump (UDP receive + FEC reassembly), and the audio thread — runs a
|
||||||
|
// per-frame real-time workload, so the CPU governor keeps those threads on fast cores at high
|
||||||
|
// clocks instead of down-clocking between frames or parking them on a little core. Snapdragon's
|
||||||
|
// ADPF backend responds well to this. We register this thread now but create the session lazily
|
||||||
|
// on the first presented frame: by then the pump + audio threads have registered their ids too,
|
||||||
|
// and ADPF `createSession` rejects a set with any not-yet-live/dead tid. No-op below API 33.
|
||||||
|
let frame_period_ns = if mode.refresh_hz > 0 {
|
||||||
|
1_000_000_000i64 / mode.refresh_hz as i64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
client.register_hot_thread(); // this decode thread → the pipeline's hot-thread set
|
||||||
|
let mut hint: Option<crate::adpf::HintSession> = None;
|
||||||
|
let mut hint_tried = false;
|
||||||
|
// Accumulates the loop's productive (feed+drain) time between displayed frames; reported to ADPF
|
||||||
|
// once per rendered frame against the frame-period target.
|
||||||
|
let mut work_accum_ns: i64 = 0;
|
||||||
|
|
||||||
let mut fed: u64 = 0;
|
let mut 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();
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
let mut last_kf_req: Option<Instant> = None;
|
||||||
// Capture→client-receipt latency uses the negotiated host-minus-client clock offset (0 if the
|
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
||||||
// host didn't answer the skew handshake — then the HUD flags it "same-host").
|
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
||||||
|
// HUD flags it "(same-host clock)").
|
||||||
let clock_offset = client.clock_offset_ns;
|
let clock_offset = client.clock_offset_ns;
|
||||||
|
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
|
||||||
|
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
||||||
|
// to its receipt for the `decode` stage. Only fed while the HUD is visible.
|
||||||
|
let mut in_flight: VecDeque<(u64, i128)> = VecDeque::new();
|
||||||
|
// Phase-2 host/network split (design/stats-unification.md): received AUs awaiting their 0xCF
|
||||||
|
// host timing, as (pts_ns, capture→received µs). The timings are drained non-blockingly right
|
||||||
|
// where receipts are recorded and matched by pts; `network = hostnet − host` (saturating).
|
||||||
|
// Only fed while the HUD is visible; an old host never sends a 0xCF, so entries just age out.
|
||||||
|
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
|
||||||
// The dataspace we've signalled on the Surface so far (None = default/SDR). Set reactively once
|
// The 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) {
|
||||||
match client.next_frame(Duration::from_millis(5)) {
|
if pending.is_none() {
|
||||||
Ok(frame) => {
|
match client.next_frame(Duration::from_millis(5)) {
|
||||||
if fed == 0 {
|
Ok(frame) => {
|
||||||
let p = &frame.data;
|
if fed == 0 {
|
||||||
log::info!(
|
let p = &frame.data;
|
||||||
"decode: first AU {} bytes, head {:02x?}",
|
log::info!(
|
||||||
p.len(),
|
"decode: first AU {} bytes, head {:02x?}",
|
||||||
&p[..p.len().min(6)]
|
p.len(),
|
||||||
);
|
&p[..p.len().min(6)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
||||||
|
// capture_pts. Gated on the HUD being visible — `enabled` first so the hidden
|
||||||
|
// steady state skips the wall-clock read and the lock entirely. The receipt
|
||||||
|
// stamp is also parked in `in_flight` (keyed by the pts the codec will echo on
|
||||||
|
// the output buffer) for the decoded-point pairing in `drain`.
|
||||||
|
if stats.enabled() {
|
||||||
|
let received_ns = now_realtime_ns();
|
||||||
|
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||||
|
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||||
|
.then_some((lat_ns / 1000) as u64);
|
||||||
|
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||||
|
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
||||||
|
if in_flight.len() > IN_FLIGHT_CAP {
|
||||||
|
in_flight.pop_front(); // stale — codec never echoed it back
|
||||||
|
}
|
||||||
|
// Phase-2 split: park this AU's capture→received sample, then match any
|
||||||
|
// 0xCF host timings that have arrived — host = the host's own
|
||||||
|
// capture→sent, network = our capture→received minus it (per-frame
|
||||||
|
// tiling; saturating in case of clock jitter).
|
||||||
|
if let Some(hostnet_us) = lat_us {
|
||||||
|
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||||
|
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||||
|
pending_split.pop_front(); // 0xCF lost / old host — evict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||||
|
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||||
|
{
|
||||||
|
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||||
|
stats.note_host_split(
|
||||||
|
t.host_us as u64,
|
||||||
|
hostnet_us.saturating_sub(t.host_us as u64),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending = Some(frame);
|
||||||
}
|
}
|
||||||
fed += 1;
|
Err(PunktfunkError::NoFrame) => {} // timeout — still drain output below
|
||||||
// HUD stat: capture→client-receipt latency = client_now + (host−client) − capture_pts.
|
Err(_) => break, // session closed
|
||||||
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);
|
|
||||||
feed(&codec, &frame.data, frame.pts_ns / 1000);
|
|
||||||
}
|
}
|
||||||
Err(PunktfunkError::NoFrame) => {} // timeout — still drain output below
|
|
||||||
Err(_) => break, // session closed
|
|
||||||
}
|
}
|
||||||
rendered += drain(&codec, &window, &mut applied_ds);
|
// Time the productive work (feed + drain) only — the `next_frame` poll wait above is idle
|
||||||
|
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
|
||||||
|
let work_t0 = Instant::now();
|
||||||
|
if let Some(frame) = pending.take() {
|
||||||
|
if feed(&codec, &frame.data, frame.pts_ns / 1000) {
|
||||||
|
fed += 1;
|
||||||
|
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,
|
||||||
|
&stats,
|
||||||
|
&mut in_flight,
|
||||||
|
clock_offset,
|
||||||
|
);
|
||||||
|
rendered += r;
|
||||||
|
discarded += d;
|
||||||
|
|
||||||
|
// ADPF: attribute this iteration's feed+drain time to the frame being produced, and report
|
||||||
|
// the accumulated per-frame work once one is actually presented (r > 0). Under back-pressure
|
||||||
|
// the short output-dequeue wait is included in the tally — for a latency-first client,
|
||||||
|
// biasing the governor toward "boost" is the desired behaviour. Cheap when `hint` is None
|
||||||
|
// (one `Instant` diff, no report).
|
||||||
|
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||||
|
if r > 0 {
|
||||||
|
if !hint_tried {
|
||||||
|
// First presented frame: the pump + audio threads have registered their ids by now.
|
||||||
|
// Build one ADPF session over the whole pipeline's thread set (empty below API 33,
|
||||||
|
// or where the platform declines → `None`, and the loop runs unhinted).
|
||||||
|
hint_tried = true;
|
||||||
|
let tids = client.hot_thread_ids();
|
||||||
|
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
|
||||||
|
log::info!(
|
||||||
|
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
||||||
|
if hint.is_some() {
|
||||||
|
"active"
|
||||||
|
} else {
|
||||||
|
"unavailable"
|
||||||
|
},
|
||||||
|
tids.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(h) = &hint {
|
||||||
|
h.report_actual(work_accum_ns);
|
||||||
|
}
|
||||||
|
work_accum_ns = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
// 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 +310,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 +343,38 @@ fn boost_thread_priority() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy one access unit into a codec input buffer and queue it.
|
/// `ANativeWindow_setFrameRate` (NDK **API 30**) resolved from `libandroid.so` at runtime, so the lib
|
||||||
fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) {
|
/// still loads on our API-28 floor — a hard import of a >floor symbol makes `dlopen`/`System.load`
|
||||||
match codec.dequeue_input_buffer(Duration::from_millis(10)) {
|
/// fail on every API-28/29 device, even where this path is never hit. Mirrors the dlsym approach in
|
||||||
|
/// [`crate::adpf`]. Returns `true` when the platform accepted the hint; `false` on API < 30 (symbol
|
||||||
|
/// absent) or when the platform declined. `compatibility` is fixed to the DEFAULT (0) policy.
|
||||||
|
fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32) -> bool {
|
||||||
|
// int32_t ANativeWindow_setFrameRate(ANativeWindow*, float frameRate, int8_t compatibility)
|
||||||
|
type SetFrameRateFn = unsafe extern "C" fn(*mut c_void, f32, i8) -> i32;
|
||||||
|
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never closed —
|
||||||
|
// process-lifetime handle). `dlsym` returns null when the symbol is absent (device API < 30),
|
||||||
|
// checked before transmuting the non-null pointer to its fn-pointer type. `window.ptr()` is the
|
||||||
|
// live `ANativeWindow` this `NativeWindow` owns for the call's duration.
|
||||||
|
unsafe {
|
||||||
|
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
|
||||||
|
if lib.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
|
||||||
|
if sym.is_null() {
|
||||||
|
return false; // device API < 30 — no per-surface frame-rate hint
|
||||||
|
}
|
||||||
|
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
|
||||||
|
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
|
||||||
|
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
|
||||||
|
/// 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 +386,74 @@ 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.
|
||||||
|
///
|
||||||
|
/// Each dequeued buffer is also the HUD's `decoded` measurement point (rendered or not — the frame
|
||||||
|
/// finished decoding either way): end-to-end = decoded + clock_offset − capture pts, and the
|
||||||
|
/// `decode` stage pairs the buffer's echoed presentationTimeUs back to the receipt stamp in
|
||||||
|
/// `in_flight` (single-clock local difference, no skew involved).
|
||||||
|
fn drain(
|
||||||
|
codec: &MediaCodec,
|
||||||
|
window: &NativeWindow,
|
||||||
|
applied_ds: &mut Option<DataSpace>,
|
||||||
|
first_wait: Duration,
|
||||||
|
stats: &crate::stats::VideoStats,
|
||||||
|
in_flight: &mut VecDeque<(u64, i128)>,
|
||||||
|
clock_offset: i64,
|
||||||
|
) -> (u64, u64) {
|
||||||
|
let mut held = None; // newest ready buffer so far, presented after the loop
|
||||||
|
let mut discarded: u64 = 0;
|
||||||
|
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 stats.enabled() {
|
||||||
break;
|
note_decoded(stats, in_flight, clock_offset, &buf);
|
||||||
|
}
|
||||||
|
if let Some(stale) = held.replace(buf) {
|
||||||
|
// A newer frame is ready — drop the held one without rendering.
|
||||||
|
if let Err(e) = codec.release_output_buffer(stale, false) {
|
||||||
|
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 +468,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 +476,49 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HUD `decoded` point for one dequeued output buffer: build the end-to-end (capture→decoded,
|
||||||
|
/// skew-corrected, clamped to (0, 10 s)) and `decode` (received→decoded, single-clock local, ≥ 0)
|
||||||
|
/// samples and hand them to [`crate::stats::VideoStats::note_decoded`]. The codec echoes the input
|
||||||
|
/// `presentationTimeUs` on the output buffer, which keys the receipt stamp in `in_flight`; entries
|
||||||
|
/// older than the echoed pts are evicted (decode order == input order here — low-latency, no
|
||||||
|
/// B-frames — so anything before it was dropped inside the codec or stamped before a flush).
|
||||||
|
fn note_decoded(
|
||||||
|
stats: &crate::stats::VideoStats,
|
||||||
|
in_flight: &mut VecDeque<(u64, i128)>,
|
||||||
|
clock_offset: i64,
|
||||||
|
buf: &OutputBuffer<'_>,
|
||||||
|
) {
|
||||||
|
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
||||||
|
let decoded_ns = now_realtime_ns();
|
||||||
|
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
||||||
|
let mut received_ns = None;
|
||||||
|
while let Some(&(p, r)) = in_flight.front() {
|
||||||
|
if p > pts_us {
|
||||||
|
break; // future frame — leave it for its own output buffer
|
||||||
|
}
|
||||||
|
in_flight.pop_front();
|
||||||
|
if p == pts_us {
|
||||||
|
received_ns = Some(r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// pts_us is the truncated frame.pts_ns/1000 we queued, so ×1000 re-approximates capture time
|
||||||
|
// to < 1 µs — negligible against the ms-scale figures shown.
|
||||||
|
let e2e_ns = decoded_ns + clock_offset as i128 - pts_us as i128 * 1000;
|
||||||
|
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||||
|
let decode_us = received_ns.map(|r| ((decoded_ns - r).max(0) / 1000) as u64);
|
||||||
|
stats.note_decoded(e2e_us, decode_us);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const PROTO: &str = "punktfunk/1";
|
|||||||
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||||
const FIELD_SEP: char = '\u{1f}';
|
const FIELD_SEP: char = '\u{1f}';
|
||||||
|
|
||||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]).
|
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = [`FIELD_SEP`]).
|
||||||
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
||||||
/// every field so no value can break it.
|
/// every field so no value can break it.
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
@@ -42,6 +42,8 @@ struct Host {
|
|||||||
port: u16,
|
port: u16,
|
||||||
fp: String,
|
fp: String,
|
||||||
pair: String,
|
pair: String,
|
||||||
|
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated), for later wake. Empty if absent.
|
||||||
|
mac: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Host {
|
impl Host {
|
||||||
@@ -54,13 +56,14 @@ impl Host {
|
|||||||
s.replace(['\n', '\r', FIELD_SEP], "")
|
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||||
}
|
}
|
||||||
format!(
|
format!(
|
||||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||||
clean(&self.key),
|
clean(&self.key),
|
||||||
clean(&self.name),
|
clean(&self.name),
|
||||||
clean(&self.addr),
|
clean(&self.addr),
|
||||||
self.port,
|
self.port,
|
||||||
clean(&self.fp),
|
clean(&self.fp),
|
||||||
clean(&self.pair),
|
clean(&self.pair),
|
||||||
|
clean(&self.mac),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,6 +185,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
|||||||
port: info.get_port(),
|
port: info.get_port(),
|
||||||
fp: val("fp"),
|
fp: val("fp"),
|
||||||
pair: val("pair"),
|
pair: val("pair"),
|
||||||
|
mac: val("mac"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +206,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
/// `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 /
|
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts /
|
||||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||||
@@ -263,16 +267,18 @@ mod tests {
|
|||||||
port: 9777,
|
port: 9777,
|
||||||
fp: "ab".repeat(32),
|
fp: "ab".repeat(32),
|
||||||
pair: "required".into(),
|
pair: "required".into(),
|
||||||
|
mac: "aa:bb:cc:dd:ee:ff".into(),
|
||||||
};
|
};
|
||||||
let encoded = h.encode();
|
let encoded = h.encode();
|
||||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||||
assert_eq!(fields.len(), 6);
|
assert_eq!(fields.len(), 7);
|
||||||
assert_eq!(fields[0], "host-123");
|
assert_eq!(fields[0], "host-123");
|
||||||
assert_eq!(fields[1], "home-worker-2");
|
assert_eq!(fields[1], "home-worker-2");
|
||||||
assert_eq!(fields[2], "192.168.1.70");
|
assert_eq!(fields[2], "192.168.1.70");
|
||||||
assert_eq!(fields[3], "9777");
|
assert_eq!(fields[3], "9777");
|
||||||
assert_eq!(fields[4], "ab".repeat(32));
|
assert_eq!(fields[4], "ab".repeat(32));
|
||||||
assert_eq!(fields[5], "required");
|
assert_eq!(fields[5], "required");
|
||||||
|
assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff");
|
||||||
assert!(
|
assert!(
|
||||||
!encoded.contains('\n'),
|
!encoded.contains('\n'),
|
||||||
"a record must never contain the record separator"
|
"a record must never contain the record separator"
|
||||||
@@ -282,7 +288,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
||||||
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
|
// 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.
|
// them so the snapshot stays exactly one record of exactly seven fields.
|
||||||
let h = Host {
|
let h = Host {
|
||||||
key: "k\u{1f}injected".into(),
|
key: "k\u{1f}injected".into(),
|
||||||
name: "evil\nhost\r".into(),
|
name: "evil\nhost\r".into(),
|
||||||
@@ -290,9 +296,14 @@ mod tests {
|
|||||||
port: 9777,
|
port: 9777,
|
||||||
fp: "ab\u{1f}cd".into(),
|
fp: "ab\u{1f}cd".into(),
|
||||||
pair: "required\n".into(),
|
pair: "required\n".into(),
|
||||||
|
mac: "aa:bb\u{1f}cc".into(),
|
||||||
};
|
};
|
||||||
let encoded = h.encode();
|
let encoded = h.encode();
|
||||||
assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields");
|
assert_eq!(
|
||||||
|
encoded.matches(FIELD_SEP).count(),
|
||||||
|
6,
|
||||||
|
"exactly seven fields"
|
||||||
|
);
|
||||||
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||||
assert_eq!(fields[0], "kinjected");
|
assert_eq!(fields[0], "kinjected");
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|||||||
out[2..n].copy_from_slice(&effect);
|
out[2..n].copy_from_slice(&effect);
|
||||||
n
|
n
|
||||||
}
|
}
|
||||||
|
HidOutput::TrackpadHaptic { .. } => {
|
||||||
|
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
|
||||||
|
// rumble already rides the universal 0xCA plane).
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
n as jint
|
n as jint
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,15 +16,17 @@
|
|||||||
//! Wi-Fi `MulticastLock` + permission UX, Keystore identity).
|
//! 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;
|
||||||
use jni::JNIEnv;
|
use jni::JNIEnv;
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod adpf;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod audio;
|
mod audio;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
@@ -37,6 +39,9 @@ mod feedback;
|
|||||||
mod mic;
|
mod mic;
|
||||||
mod session;
|
mod session;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||||
|
// into the host workspace build too. Kotlin only ever calls it on device.
|
||||||
|
mod wol;
|
||||||
|
|
||||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
||||||
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
|
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
|
||||||
|
|||||||
@@ -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));
|
||||||
let cb_captured = captured.clone();
|
// Chunks discarded on the capture thread (free-list empty / encoder lagging); logged
|
||||||
|
// throttled from the encode worker.
|
||||||
|
let dropped = Arc::new(AtomicU64::new(0));
|
||||||
|
|
||||||
let callback = move |_s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
// One open attempt at a given sharing mode (same pattern as [`crate::audio`]: `open_stream`
|
||||||
let n = num_frames as usize * CHANNELS;
|
// consumes the builder AND the callback, so each try rebuilds the channels it captures).
|
||||||
// SAFETY: for an input stream AAudio provides `num_frames * channel_count` captured F32
|
let try_open = |sharing: AudioSharingMode| -> ndk::audio::Result<(
|
||||||
// samples at `data` (read-only for us).
|
AudioStream,
|
||||||
let inp = unsafe { std::slice::from_raw_parts(data as *const f32, n) };
|
Receiver<Vec<f32>>,
|
||||||
match tx.try_send(inp.to_vec()) {
|
SyncSender<Vec<f32>>,
|
||||||
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest if the encoder lags
|
)> {
|
||||||
Err(TrySendError::Disconnected(_)) => return AudioCallbackResult::Stop,
|
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));
|
||||||
}
|
}
|
||||||
cb_captured.fetch_add(num_frames as u64, Ordering::Relaxed);
|
let cb_captured = captured.clone();
|
||||||
AudioCallbackResult::Continue
|
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 n = num_frames as usize * CHANNELS;
|
||||||
|
// SAFETY: for an input stream AAudio provides `num_frames * channel_count` captured
|
||||||
|
// F32 samples at `data` (read-only for us).
|
||||||
|
let inp = unsafe { std::slice::from_raw_parts(data as *const f32, n) };
|
||||||
|
cb_captured.fetch_add(num_frames as u64, Ordering::Relaxed);
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pool empty (every buffer in flight): drop, never allocate on this thread.
|
||||||
|
Err(_) => {
|
||||||
|
cb_dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioCallbackResult::Continue
|
||||||
|
};
|
||||||
|
|
||||||
|
let stream = AudioStreamBuilder::new()?
|
||||||
|
.direction(AudioDirection::Input)
|
||||||
|
.sample_rate(SAMPLE_RATE)
|
||||||
|
.channel_count(CHANNELS as i32)
|
||||||
|
.format(AudioFormat::PCM_Float)
|
||||||
|
.performance_mode(AudioPerformanceMode::LowLatency)
|
||||||
|
.sharing_mode(sharing)
|
||||||
|
.data_callback(Box::new(callback))
|
||||||
|
.error_callback(Box::new(|_s, e| {
|
||||||
|
log::warn!("mic: AAudio error (device reroute/disconnect?): {e:?}");
|
||||||
|
}))
|
||||||
|
.open_stream()?;
|
||||||
|
Ok((stream, rx, free_tx))
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream = AudioStreamBuilder::new()
|
// Exclusive first — MMAP-exclusive is AAudio's lowest-latency path — falling back to Shared
|
||||||
.map_err(|e| log::error!("mic: AudioStreamBuilder::new: {e}"))
|
// when the device refuses (no MMAP, mic claimed, …). The started-log below prints the mode
|
||||||
.ok()?
|
// the device actually GRANTED (`share=`).
|
||||||
.direction(AudioDirection::Input)
|
let (stream, rx, free_tx) = match try_open(AudioSharingMode::Exclusive) {
|
||||||
.sample_rate(SAMPLE_RATE)
|
Ok(opened) => opened,
|
||||||
.channel_count(CHANNELS as i32)
|
Err(e) => {
|
||||||
.format(AudioFormat::PCM_Float)
|
log::info!("mic: Exclusive open failed ({e}) — retrying Shared");
|
||||||
.performance_mode(AudioPerformanceMode::LowLatency)
|
match try_open(AudioSharingMode::Shared) {
|
||||||
.sharing_mode(AudioSharingMode::Shared)
|
Ok(opened) => opened,
|
||||||
.data_callback(Box::new(callback))
|
Err(e) => {
|
||||||
.error_callback(Box::new(|_s, e| {
|
log::error!("mic: open_stream (RECORD_AUDIO granted?): {e}");
|
||||||
log::warn!("mic: AAudio error (device reroute/disconnect?): {e:?}");
|
return None;
|
||||||
}))
|
}
|
||||||
.open_stream()
|
}
|
||||||
.map_err(|e| log::error!("mic: open_stream (RECORD_AUDIO granted?): {e}"))
|
}
|
||||||
.ok()?;
|
};
|
||||||
|
|
||||||
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,732 +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::panic::AssertUnwindSafe;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::thread::JoinHandle;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// Run a JNI body, catching any panic at the FFI boundary and returning `default` instead.
|
|
||||||
///
|
|
||||||
/// A panic unwinding out of an `extern "system"` function aborts the whole process on Rust ≥ 1.81 —
|
|
||||||
/// a hard crash of the embedding Android app with no logcat trace. This mirrors the discipline the C
|
|
||||||
/// ABI already enforces (`punktfunk_core::abi` wraps every entry point in `catch_unwind`); the
|
|
||||||
/// `panic = "unwind"` profile in the workspace `Cargo.toml` exists precisely so these guards work.
|
|
||||||
/// We apply it to the teardown + background-thread shims (the "leaving a stream" path), where an
|
|
||||||
/// unexpected panic (e.g. a poisoned `Mutex` during concurrent teardown) must degrade to a logged
|
|
||||||
/// no-op rather than kill the app.
|
|
||||||
pub(crate) fn jni_guard<T>(default: T, f: impl FnOnce() -> T) -> T {
|
|
||||||
std::panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| {
|
|
||||||
log::error!("punktfunk JNI: caught a panic at the FFI boundary (returning default)");
|
|
||||||
default
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
|
|
||||||
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,
|
|
||||||
) {
|
|
||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `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,
|
|
||||||
) {
|
|
||||||
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 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 {
|
|
||||||
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) };
|
|
||||||
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,
|
|
||||||
) {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 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.nativeSendPointerAbs(handle, x, y, surfaceWidth, surfaceHeight)` — absolute cursor
|
|
||||||
/// position: the host moves the pointer to `x`/`y` in a `surfaceWidth`×`surfaceHeight` pixel space,
|
|
||||||
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
|
|
||||||
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
|
|
||||||
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
x: jint,
|
|
||||||
y: jint,
|
|
||||||
surface_width: jint,
|
|
||||||
surface_height: jint,
|
|
||||||
) {
|
|
||||||
if handle == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
let w = (surface_width.max(0) as u32) & 0xffff;
|
|
||||||
let ht = (surface_height.max(0) as u32) & 0xffff;
|
|
||||||
let _ = h.client.send_input(&InputEvent {
|
|
||||||
kind: InputKind::MouseMoveAbs,
|
|
||||||
_pad: [0; 3],
|
|
||||||
code: 0,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
flags: (w << 16) | ht,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
|
|
||||||
/// `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,187 @@
|
|||||||
|
//! 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.nativeSendTouch(handle, id, kind, x, y, surfaceWidth, surfaceHeight)` — one REAL
|
||||||
|
/// touchscreen transition (`kind`: 0=down 1=move 2=up), for the touch-passthrough input mode. `id`
|
||||||
|
/// distinguishes fingers (reusable after up); coordinates are pixels on the client's touch
|
||||||
|
/// surface, whose size rides in `flags` so the host can rescale into the output (identical
|
||||||
|
/// packing to MouseMoveAbs). On up only the id matters. The host injects a real touch contact
|
||||||
|
/// (libei touchscreen / wlroots / SendInput).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
id: jint,
|
||||||
|
kind: jint,
|
||||||
|
x: jint,
|
||||||
|
y: jint,
|
||||||
|
surface_width: jint,
|
||||||
|
surface_height: jint,
|
||||||
|
) {
|
||||||
|
let kind = match kind {
|
||||||
|
0 => InputKind::TouchDown,
|
||||||
|
1 => InputKind::TouchMove,
|
||||||
|
_ => InputKind::TouchUp,
|
||||||
|
};
|
||||||
|
let w = (surface_width.max(0) as u32) & 0xffff;
|
||||||
|
let h = (surface_height.max(0) as u32) & 0xffff;
|
||||||
|
send_event(handle, kind, id as u32, x, y, (w << 16) | h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
|
||||||
|
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
|
||||||
|
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
|
||||||
|
#[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,248 @@
|
|||||||
|
//! 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
|
||||||
|
/// (unified stats spec, `design/stats-unification.md`). Returns 18 doubles
|
||||||
|
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||||
|
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||||
|
/// netP50Ms]`
|
||||||
|
/// (the two flags are 1.0/0.0; indexes 0–15 match the previous 16-double layout — 0–13 the original
|
||||||
|
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||||
|
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||||
|
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
|
||||||
|
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
|
||||||
|
/// window, i.e. an old host), or `null` when no decode thread is running. Poll ~1 Hz from the UI; each call
|
||||||
|
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||||
|
/// the host build too (Kotlin only ever calls it on device).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||||
|
env: JNIEnv,
|
||||||
|
_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; 18] = [
|
||||||
|
snap.fps,
|
||||||
|
snap.mbps,
|
||||||
|
snap.e2e_p50_ms,
|
||||||
|
snap.e2e_p95_ms,
|
||||||
|
if snap.lat_valid { 1.0 } else { 0.0 },
|
||||||
|
if snap.skew_corrected { 1.0 } else { 0.0 },
|
||||||
|
mode.width as f64,
|
||||||
|
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,
|
||||||
|
// Stage p50s tiling the end-to-end headline (appended to keep 0–13 index-compatible).
|
||||||
|
snap.hostnet_p50_ms,
|
||||||
|
snap.decode_p50_ms,
|
||||||
|
// Phase-2 host/network split of the `host+network` stage (0xCF host timings): 0.0
|
||||||
|
// when no timing matched this window (old host) — the HUD keeps the combined term.
|
||||||
|
snap.host_p50_ms,
|
||||||
|
snap.net_p50_ms,
|
||||||
|
];
|
||||||
|
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||||
|
Ok(a) => a,
|
||||||
|
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,27 @@
|
|||||||
//! Live decode stats for the on-stream HUD (mirrors the Apple client's stats overlay): FPS,
|
//! Live decode stats for the on-stream HUD, following the unified stats spec
|
||||||
//! receive throughput, and capture→client-receipt latency (p50/p95). The decode thread is the sole
|
//! (`design/stats-unification.md`): FPS, receive throughput, and the Android v1 stage split —
|
||||||
//! writer (`note` per access unit); the JNI accessor `nativeVideoStats` drains a snapshot ~1 Hz and
|
//! headline `end-to-end` = capture→decoded (p50/p95) tiled by `host+network` = capture→received
|
||||||
//! resets the window. Pure `std` so it compiles on the host build too (the decode thread is
|
//! and `decode` = received→decoded (stage p50s). When the host emits per-AU 0xCF host timings, the
|
||||||
//! android-only, but `VideoThread` holds the shared handle unconditionally).
|
//! `host+network` term further splits into `host` + `network` (Phase 2, `note_host_split`); an old
|
||||||
|
//! host emits none and the combined term stands. The decode thread is the sole writer
|
||||||
|
//! (`note_received` per access unit at receipt, `note_decoded` per decoder output buffer); the JNI
|
||||||
|
//! accessor `nativeVideoStats` drains a snapshot ~1 Hz and resets the window. Sampling is gated on
|
||||||
|
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
|
||||||
|
//! hidden steady state costs one relaxed atomic load per frame.
|
||||||
|
//! Pure `std` so it compiles on the host build too (the decode thread is android-only, but
|
||||||
|
//! `SessionHandle` holds the shared handle unconditionally).
|
||||||
|
|
||||||
|
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: the samplers run on the per-frame decode path, so while the overlay is hidden
|
||||||
|
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
|
||||||
|
/// Off until Kotlin shows the HUD.
|
||||||
|
enabled: AtomicBool,
|
||||||
inner: Mutex<Inner>,
|
inner: Mutex<Inner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,77 +29,198 @@ struct Inner {
|
|||||||
window_start: Instant,
|
window_start: Instant,
|
||||||
frames: u64,
|
frames: u64,
|
||||||
bytes: u64,
|
bytes: u64,
|
||||||
/// capture→client-receipt latency samples for this window, in microseconds.
|
/// `end-to-end` = capture→decoded latency samples for this window, in microseconds
|
||||||
lat_us: Vec<u64>,
|
/// (skew-corrected clock base).
|
||||||
|
e2e_us: Vec<u64>,
|
||||||
|
/// `host+network` stage = capture→received samples, in microseconds (skew-corrected).
|
||||||
|
hostnet_us: Vec<u64>,
|
||||||
|
/// Phase-2 split of `host+network` (design/stats-unification.md Phase 2), fed only when the
|
||||||
|
/// host emits per-AU 0xCF timings: `host` = the host's own capture→sent duration, µs.
|
||||||
|
host_us: Vec<u64>,
|
||||||
|
/// The matching `network` term, µs: capture→received minus the host's capture→sent
|
||||||
|
/// (wire + reassembly). Always pushed in lockstep with `host_us`.
|
||||||
|
net_us: Vec<u64>,
|
||||||
|
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
|
||||||
|
decode_us: Vec<u64>,
|
||||||
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
|
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
|
||||||
skew_corrected: bool,
|
skew_corrected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A drained, computed view of one window. `lat_valid` is false when no in-range latency sample
|
/// A drained, computed view of one window. `lat_valid` is false when no in-range end-to-end sample
|
||||||
/// landed (then p50/p95 are 0 and the HUD hides the latency line, exactly like the Apple client).
|
/// landed (then the latency figures are 0 and the HUD hides the latency lines, exactly like the
|
||||||
|
/// Apple client).
|
||||||
pub struct Snapshot {
|
pub struct Snapshot {
|
||||||
pub fps: f64,
|
pub fps: f64,
|
||||||
pub mbps: f64,
|
pub mbps: f64,
|
||||||
pub lat_p50_ms: f64,
|
/// Headline `end-to-end` (capture→decoded) percentiles, ms.
|
||||||
pub lat_p95_ms: f64,
|
pub e2e_p50_ms: f64,
|
||||||
|
pub e2e_p95_ms: f64,
|
||||||
|
/// Stage p50s (ms): `host+network` (capture→received) and `decode` (received→decoded).
|
||||||
|
pub hostnet_p50_ms: f64,
|
||||||
|
pub decode_p50_ms: f64,
|
||||||
|
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
|
||||||
|
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
|
||||||
|
pub host_p50_ms: f64,
|
||||||
|
pub net_p50_ms: f64,
|
||||||
pub lat_valid: bool,
|
pub lat_valid: bool,
|
||||||
pub skew_corrected: bool,
|
pub skew_corrected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Percentile over a sorted-in-place µs sample vec, in ms. 0.0 when empty.
|
||||||
|
fn pctl_ms(sorted_us: &[u64], p: f64) -> f64 {
|
||||||
|
if sorted_us.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let n = sorted_us.len();
|
||||||
|
sorted_us[((n as f64 * p) as usize).min(n - 1)] as f64 / 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
impl VideoStats {
|
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,
|
||||||
bytes: 0,
|
bytes: 0,
|
||||||
lat_us: Vec::with_capacity(256),
|
e2e_us: Vec::with_capacity(256),
|
||||||
|
hostnet_us: Vec::with_capacity(256),
|
||||||
|
host_us: Vec::with_capacity(256),
|
||||||
|
net_us: Vec::with_capacity(256),
|
||||||
|
decode_us: Vec::with_capacity(256),
|
||||||
skew_corrected: false,
|
skew_corrected: false,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record one decoded access unit: its wire size and (if in range) its capture→client latency.
|
/// Whether the HUD wants samples. The decode thread checks this BEFORE building a latency
|
||||||
|
/// sample, so the per-frame wall-clock reads are skipped too while hidden.
|
||||||
|
// Read only by the android-only decode thread; unreferenced on the host build — expected.
|
||||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
pub fn note(&self, bytes: usize, lat_us: Option<u64>, skew_corrected: bool) {
|
pub fn enabled(&self) -> bool {
|
||||||
let mut g = self.inner.lock().unwrap();
|
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.e2e_us.clear();
|
||||||
|
g.hostnet_us.clear();
|
||||||
|
g.host_us.clear();
|
||||||
|
g.net_us.clear();
|
||||||
|
g.decode_us.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record one received access unit: its wire size and (if in range) its capture→received
|
||||||
|
/// `host+network` stage sample. Receipt is the fps/goodput counting point per the spec.
|
||||||
|
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
pub fn note_received(&self, bytes: usize, hostnet_us: Option<u64>, skew_corrected: bool) {
|
||||||
|
if !self.enabled.load(Ordering::Relaxed) {
|
||||||
|
return; // HUD hidden — skip the lock (the caller already skipped the clock read)
|
||||||
|
}
|
||||||
|
// Poison-proof: this runs per-frame on the decode thread, which has no catch_unwind —
|
||||||
|
// a panic elsewhere must not turn every later lock into a second panic (the counters
|
||||||
|
// stay consistent regardless).
|
||||||
|
let mut g = self
|
||||||
|
.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;
|
||||||
if let Some(l) = lat_us {
|
if let Some(l) = hostnet_us {
|
||||||
g.lat_us.push(l);
|
g.hostnet_us.push(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record one matched host/network split sample (Phase 2): the host's reported capture→sent
|
||||||
|
/// duration and our capture→received minus it, both µs — one pair per AU whose 0xCF host
|
||||||
|
/// timing arrived and matched by pts. An old host emits none, leaving the vecs empty and the
|
||||||
|
/// snapshot p50s at 0 (HUD keeps the combined `host+network` term).
|
||||||
|
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
pub fn note_host_split(&self, host_us: u64, net_us: u64) {
|
||||||
|
if !self.enabled.load(Ordering::Relaxed) {
|
||||||
|
return; // HUD hidden — skip the lock
|
||||||
|
}
|
||||||
|
// Poison-proof for the same reason as `note_received`.
|
||||||
|
let mut g = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
g.host_us.push(host_us);
|
||||||
|
g.net_us.push(net_us);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record one decoded output frame: its capture→decoded `end-to-end` sample and its
|
||||||
|
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
|
||||||
|
/// this pts predates the HUD being shown).
|
||||||
|
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
pub fn note_decoded(&self, e2e_us: Option<u64>, decode_us: Option<u64>) {
|
||||||
|
if !self.enabled.load(Ordering::Relaxed) {
|
||||||
|
return; // HUD hidden — skip the lock (the caller already skipped the clock read)
|
||||||
|
}
|
||||||
|
// Poison-proof for the same reason as `note_received`.
|
||||||
|
let mut g = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
if let Some(l) = e2e_us {
|
||||||
|
g.e2e_us.push(l);
|
||||||
|
}
|
||||||
|
if let Some(l) = decode_us {
|
||||||
|
g.decode_us.push(l);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
/// 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_received` — 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;
|
||||||
let (p50, p95, valid) = if g.lat_us.is_empty() {
|
g.e2e_us.sort_unstable();
|
||||||
(0.0, 0.0, false)
|
g.hostnet_us.sort_unstable();
|
||||||
} else {
|
g.host_us.sort_unstable();
|
||||||
g.lat_us.sort_unstable();
|
g.net_us.sort_unstable();
|
||||||
let n = g.lat_us.len();
|
g.decode_us.sort_unstable();
|
||||||
let at = |p: f64| g.lat_us[((n as f64 * p) as usize).min(n - 1)] as f64 / 1000.0;
|
let snap = Snapshot {
|
||||||
(at(0.50), at(0.95), true)
|
fps,
|
||||||
|
mbps,
|
||||||
|
e2e_p50_ms: pctl_ms(&g.e2e_us, 0.50),
|
||||||
|
e2e_p95_ms: pctl_ms(&g.e2e_us, 0.95),
|
||||||
|
hostnet_p50_ms: pctl_ms(&g.hostnet_us, 0.50),
|
||||||
|
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
|
||||||
|
host_p50_ms: pctl_ms(&g.host_us, 0.50),
|
||||||
|
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
||||||
|
lat_valid: !g.e2e_us.is_empty(),
|
||||||
|
skew_corrected: g.skew_corrected,
|
||||||
};
|
};
|
||||||
let skew = g.skew_corrected;
|
|
||||||
g.window_start = Instant::now();
|
g.window_start = Instant::now();
|
||||||
g.frames = 0;
|
g.frames = 0;
|
||||||
g.bytes = 0;
|
g.bytes = 0;
|
||||||
g.lat_us.clear();
|
g.e2e_us.clear();
|
||||||
Snapshot {
|
g.hostnet_us.clear();
|
||||||
fps,
|
g.host_us.clear();
|
||||||
mbps,
|
g.net_us.clear();
|
||||||
lat_p50_ms: p50,
|
g.decode_us.clear();
|
||||||
lat_p95_ms: p95,
|
snap
|
||||||
lat_valid: valid,
|
|
||||||
skew_corrected: skew,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//! JNI seam for Wake-on-LAN: parse the stored MAC strings and hand them to the shared core sender
|
||||||
|
//! (`punktfunk_core::wol`). Like [`crate::discovery`], this takes no session handle — a sleeping
|
||||||
|
//! host has no ARP entry, so the broadcast the core sends is what wakes it, and Kotlin calls this
|
||||||
|
//! just before connecting to an offline saved host.
|
||||||
|
|
||||||
|
use jni::objects::{JObject, JString};
|
||||||
|
use jni::JNIEnv;
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean` — send a Wake-on-LAN
|
||||||
|
/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's
|
||||||
|
/// mDNS `mac` TXT while it was online); `lastIp` is the host's last-known IPv4 (or empty).
|
||||||
|
/// Returns true if at least one datagram went out.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>(
|
||||||
|
mut env: JNIEnv<'local>,
|
||||||
|
_this: JObject<'local>,
|
||||||
|
macs_csv: JString<'local>,
|
||||||
|
last_ip: JString<'local>,
|
||||||
|
) -> jni::sys::jboolean {
|
||||||
|
let macs_csv: String = match env.get_string(&macs_csv) {
|
||||||
|
Ok(s) => s.into(),
|
||||||
|
Err(_) => return 0,
|
||||||
|
};
|
||||||
|
let last_ip: String = env
|
||||||
|
.get_string(&last_ip)
|
||||||
|
.map(Into::<String>::into)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let macs: Vec<[u8; 6]> = macs_csv
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|s| punktfunk_core::wol::parse_mac(s.trim()))
|
||||||
|
.collect();
|
||||||
|
if macs.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let ip = last_ip.trim().parse::<std::net::Ipv4Addr>().ok();
|
||||||
|
match punktfunk_core::wol::send_magic_packet(&macs, ip) {
|
||||||
|
Ok(()) => 1,
|
||||||
|
Err(_) => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -48,21 +48,21 @@
|
|||||||
<key>com.apple.security.device.usb</key>
|
<key>com.apple.security.device.usb</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|
||||||
<!-- Controller rumble via CoreHaptics: GCDeviceHaptics.createEngine → CHHapticEngine
|
<!-- NO mach-lookup temporary exception here — and none is needed. Build 0.4.2 (3384) shipped a
|
||||||
(GamepadFeedback's RumbleRenderer), and AVAudioEngine playback, reach the system
|
`com.apple.security.temporary-exception.mach-lookup.global-name` = com.apple.audioanalyticsd
|
||||||
audio-analytics daemon `com.apple.audioanalyticsd` over Mach. The sandbox denies that
|
exception on the THEORY that CoreHaptics controller rumble (CHHapticEngine — the session
|
||||||
global-name lookup unless it's whitelisted here, and the framework's own precondition
|
RumbleRenderer + MenuHaptics) hard-crashes under the App Sandbox without it, because the
|
||||||
turns the denial into a HARD CRASH ("Process is sandboxed but
|
framework reaches the audio-analytics daemon over Mach and the sandbox denies that lookup.
|
||||||
com.apple.security.exception.mach-lookup.global-name doesn't contain
|
App Review REJECTED the exception under guideline 2.4.5(i) (review 2026-07-04). We then
|
||||||
com.apple.audioanalyticsd") the moment a controller's haptics engine starts. This
|
tested the premise directly on macOS: a CHHapticEngine start + full-intensity rumble on a
|
||||||
temporary exception is the documented, App-Store-acceptable way to permit exactly that
|
real Xbox pad, in a genuinely ENFORCED sandbox (NSHomeDirectory redirected into the app
|
||||||
lookup — and ONLY that service (the key takes exact names, no wildcards). App Store:
|
container) with NO exception on the codesigned binary — and it ran WITHOUT crashing, rumble
|
||||||
declare it in App Store Connect → App Sandbox Entitlement Usage Information ("CoreHaptics
|
and all, even with a live AVAudioEngine stream running concurrently. CoreHaptics simply
|
||||||
gamepad rumble contacts the system audio-analytics daemon"). -->
|
tolerates the denied audioanalyticsd lookup (it's telemetry, not a hard precondition). So
|
||||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
controller rumble works fully sandboxed with none of these exceptions. Do NOT re-add one —
|
||||||
<array>
|
it will be rejected again AND it buys nothing. (DualSense rumble separately goes over raw
|
||||||
<string>com.apple.audioanalyticsd</string>
|
HID via device.usb/device.bluetooth — CoreHaptics genuinely doesn't drive Sony motors on
|
||||||
</array>
|
macOS — but that path needs no exception either; see DualSenseHID.) -->
|
||||||
|
|
||||||
<!-- Keychain Sharing (unchanged from the shared file): a team-scoped access group so the
|
<!-- Keychain Sharing (unchanged from the shared file): a team-scoped access group so the
|
||||||
punktfunk/1 client identity in the data-protection keychain is gated by the app's
|
punktfunk/1 client identity in the data-protection keychain is gated by the app's
|
||||||
|
|||||||
@@ -11,5 +11,22 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
||||||
</array>
|
</array>
|
||||||
|
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
|
||||||
|
entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to
|
||||||
|
broadcast/multicast addresses unless the app carries this managed entitlement — it must
|
||||||
|
be requested from and approved by Apple for the App ID, then enabled in the provisioning
|
||||||
|
profile. macOS is not gated by this (its App Sandbox network.client/server cover it).
|
||||||
|
|
||||||
|
GATED pending Apple's approval of the request (form filed) — an unauthorized managed
|
||||||
|
entitlement breaks iOS/tvOS signing, so it's commented out to keep those apps releasable.
|
||||||
|
ON APPROVAL: (1) uncomment the two lines below, and (2) flip
|
||||||
|
PunktfunkConnection.wakeOnLANAvailable (PunktfunkConnection.swift) to enable the iOS/tvOS
|
||||||
|
wake path + UI. Until then iOS/tvOS Wake-on-LAN is a clean no-op — MACs are still learned
|
||||||
|
from mDNS so it works immediately once ungated. macOS is unaffected (separate entitlements
|
||||||
|
file, no multicast entitlement needed). -->
|
||||||
|
<!--
|
||||||
|
<key>com.apple.developer.networking.multicast</key>
|
||||||
|
<true/>
|
||||||
|
-->
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user