Compare commits
128 Commits
dda-parity
...
e2c9bfd3d9
| Author | SHA1 | Date | |
|---|---|---|---|
| e2c9bfd3d9 | |||
| c5dab484df | |||
| e27abc065e | |||
| d39da4bc06 | |||
| 095540efc2 | |||
| de232ec2f7 | |||
| e4e34fdb48 | |||
| 3ec462c2ea | |||
| 58f4dccc02 | |||
| 32879f45bf | |||
| b54f781524 | |||
| 5e106c51cf | |||
| d2746bd65a | |||
| 9b840151e4 | |||
| a12c6e0ba4 | |||
| b0c82333d2 | |||
| f208f3d92e | |||
| 51de8ccbdb | |||
| 118752c136 | |||
| 9af8e9a7d9 | |||
| e466814ef8 | |||
| 95c6ceb072 | |||
| e919fa6a2e | |||
| 6db3525e29 | |||
| 6a501f484a | |||
| 72eeedc4da | |||
| fde438a1ed | |||
| 01dc0b616c | |||
| 4a73102d48 | |||
| aa159df33f | |||
| 983adc5347 | |||
| 78c16e5136 | |||
| 0205c7b8d6 | |||
| 3e6c9f6060 | |||
| b3811ff72e | |||
| b6b0b6c29e | |||
| 527c2f677e | |||
| f3555d5eb5 | |||
| 75d5a6d7fb | |||
| 1fe4161d4d | |||
| 54b75c9be4 | |||
| 3c55ec37fa | |||
| 551012bb43 | |||
| 3526517eb1 | |||
| 22a9ce4229 | |||
| 450bcf1e7b | |||
| a2a6b858f7 | |||
| f85d51b9f9 | |||
| 516efcc3a3 | |||
| 4afdb18cc4 | |||
| 9f049f965f | |||
| f37a304fba | |||
| 76f4484ded | |||
| cba3ae48e2 | |||
| 2dc54bc651 | |||
| 480dee863d | |||
| 618602d802 | |||
| fdf388436a | |||
| 0f7f1be3c3 | |||
| e88c28c15c | |||
| 72ca0419db | |||
| 40109056e9 | |||
| 3df9dd6d32 | |||
| e8bc178d45 | |||
| 333f66b45b | |||
| 6922e1c467 | |||
| 708c62788d | |||
| 5e27f65f2e | |||
| f96e4ec9f8 | |||
| b390dd883b | |||
| 86979d0abc | |||
| 53aade0279 | |||
| 24ee05a4d0 | |||
| d59de1553f | |||
| e905801567 | |||
| 43e0be4cf4 | |||
| bd3f417d4b | |||
| aef552f04a | |||
| 22aff1c7ac | |||
| 822fde1e89 | |||
| d7aa528d7e | |||
| 3074b30988 | |||
| 7dad881d98 | |||
| 68744d5743 | |||
| bfbe5ab888 | |||
| 1fc6f73784 | |||
| a58b6b8e76 | |||
| 0cc36fa130 | |||
| af9bb54785 | |||
| 112a054c35 | |||
| 16d3b7767e | |||
| f4cff765ed | |||
| b9e50faa40 | |||
| f39230e8f4 | |||
| 55cd58e487 | |||
| 586c4d0ddc | |||
| 1cd5e0e375 | |||
| 2d697fc26c | |||
| 844f4b86bd | |||
| 5262e28b79 | |||
| f1032a7a23 | |||
| 7121b0eb43 | |||
| d9d495a53e | |||
| 9c8fa9340c | |||
| 1faa6c6ad4 | |||
| 72d1b19743 | |||
| 9abb9a2496 | |||
| 02b1be652d | |||
| b8c9f88cfd | |||
| 22409acba5 | |||
| 8f720e0e46 | |||
| a24679ce69 | |||
| 6c02acab59 | |||
| 1f7b8eba66 | |||
| a7daed5797 | |||
| 3b3e8b4ba9 | |||
| 9771aa8815 | |||
| a4df75132a | |||
| 4cc57d5c39 | |||
| 15d3d423fa | |||
| 67608944f0 | |||
| 25c8dd58c7 | |||
| d5757980f8 | |||
| a5b99b2928 | |||
| 41b289780f | |||
| 64b167946f | |||
| 9537efdcd5 | |||
| 5cbd249d09 |
@@ -0,0 +1,20 @@
|
||||
# cargo-audit configuration — consumed by `.gitea/workflows/audit.yml` (`cargo audit`).
|
||||
#
|
||||
# Silence only advisories that are KNOWN-UNFIXABLE and either not applicable to how we use the crate
|
||||
# or an accepted, documented risk. Keep this list TIGHT and justify every entry — an ignore here
|
||||
# means the audit job stops flagging it, so the reasoning must hold up.
|
||||
#
|
||||
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
|
||||
# `unmaintained` warnings (audiopus_sys / paste / rustls-pemfile). Those are left visible on purpose
|
||||
# so we keep getting the maintenance signal — they do not fail CI.
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
# rsa "Marvin Attack" — a timing sidechannel in RSA *decryption* (PKCS#1 v1.5 padding oracle).
|
||||
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream), and rsa
|
||||
# is required for GameStream/Moonlight pairing. Crucially, the host uses rsa ONLY for PKCS#1 v1.5
|
||||
# SIGNING / VERIFYING (gamestream/cert.rs + gamestream/pairing.rs: SigningKey / VerifyingKey /
|
||||
# Signer / Verifier) — it never performs RSA decryption, which is the operation Marvin targets.
|
||||
# So the vulnerable code path is not exercised. Revisit if a fixed rsa ships or we add RSA decrypt.
|
||||
"RUSTSEC-2023-0071",
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
# Android client CI (Gitea Actions). Builds the Rust JNI core (crates/punktfunk-android) 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
|
||||
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
|
||||
#
|
||||
@@ -12,6 +12,10 @@ name: android
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed
|
||||
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
|
||||
# push is canary (Play `internal`).
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -41,12 +45,12 @@ jobs:
|
||||
- name: Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: NDK r28 LTS + platform 36 + build-tools + CMake (libopus cross-build)
|
||||
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
|
||||
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
|
||||
# kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build.
|
||||
# Keep platforms;android-36 (android-37 isn't in the runner's sdkmanager channel yet —
|
||||
# "Failed to find package"); AGP auto-installs the compileSdk-37 platform during the build.
|
||||
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0" "ndk;28.2.13676358" "cmake;3.22.1"
|
||||
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
|
||||
# auto-download it if needed during the build.
|
||||
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
|
||||
|
||||
- name: Caches (cargo + gradle)
|
||||
uses: actions/cache@v4
|
||||
@@ -65,4 +69,85 @@ jobs:
|
||||
|
||||
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
||||
working-directory: clients/android
|
||||
env:
|
||||
VERSION_CODE: ${{ github.run_number }}
|
||||
run: ./gradlew :app:assembleDebug --stacktrace
|
||||
|
||||
# Single source of the version name + the Play track for the release steps below. versionCode
|
||||
# stays github.run_number (monotonic across both tracks; Play rejects a regressed code).
|
||||
- name: Version + channel
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
||||
*) VN="0.3.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
esac
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
echo "android version $VN -> Play track '$TRACK'"
|
||||
|
||||
- name: Build Release (signed AAB + universal APK)
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
working-directory: clients/android
|
||||
env:
|
||||
VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV)
|
||||
RELEASE_KEYSTORE_FILE: "../release.jks"
|
||||
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
|
||||
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
||||
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||
run: |
|
||||
echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks
|
||||
# AAB for Play; a universal APK (both ABIs) for direct sideload/testing — same upload key.
|
||||
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
|
||||
|
||||
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
||||
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
|
||||
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
|
||||
# to the unified Gitea Release.
|
||||
- name: Publish to generic registry + attach to Gitea release
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
PKG: punktfunk-android
|
||||
VERSION: ${{ github.run_number }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
AAB=clients/android/app/build/outputs/bundle/release/app-release.aab
|
||||
APK=clients/android/app/build/outputs/apk/release/app-release.apk
|
||||
base="https://$REGISTRY/api/packages/$OWNER/generic/$PKG"
|
||||
# 1) immutable, run-number-versioned store (sideload + provenance)
|
||||
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$AAB" "$base/$VERSION/punktfunk-android-r$VERSION.aab"
|
||||
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$VERSION/punktfunk-android-r$VERSION.apk"
|
||||
echo "published store version $VERSION (versionCode)"
|
||||
# 2) channel alias for a predictable sideload URL: stable -> latest/, canary -> canary/
|
||||
case "$GITHUB_REF" in refs/tags/v*) ALIAS=latest ;; *) ALIAS=canary ;; esac
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$REGISTRY_TOKEN" -X DELETE "$base/$ALIAS/punktfunk-android.apk" || true
|
||||
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$ALIAS/punktfunk-android.apk"
|
||||
echo "sideload alias: $base/$ALIAS/punktfunk-android.apk"
|
||||
# 3) on a real release, attach the .aab + .apk to the unified Gitea Release (X.Y.Z names)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$AAB" "punktfunk-${VERSION_NAME}.aab"
|
||||
upsert_asset "$RID" "$APK" "punktfunk-${VERSION_NAME}.apk"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
|
||||
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
|
||||
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual
|
||||
# promotion to production in the Play console.
|
||||
- name: Upload to Google Play
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
echo "uploading to Play track '$PLAY_TRACK'"
|
||||
python3 clients/android/ci/play-upload.py \
|
||||
--package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into
|
||||
# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent
|
||||
# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST.
|
||||
#
|
||||
# A second job (`screenshots`) captures the App Store Connect screenshots of the REAL UI
|
||||
# (mac window + iOS/iPad/tvOS Simulators, see clients/apple/tools/screenshots.sh) and attaches
|
||||
# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated
|
||||
# from the build/test job and best-effort, so a capture gap never reds the core signal.
|
||||
name: apple
|
||||
|
||||
on:
|
||||
@@ -37,3 +42,55 @@ jobs:
|
||||
- name: Test (unit + real-codec round trip; remote tests self-skip)
|
||||
working-directory: clients/apple
|
||||
run: swift test
|
||||
|
||||
# App Store screenshots of the real UI, zipped and attached to the run as a build artifact.
|
||||
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
|
||||
# first, and is a separate job so a capture hiccup can never red the core signal.
|
||||
#
|
||||
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13"), captured on the Simulator
|
||||
# (`simctl io screenshot`, no Screen Recording grant needed). macOS and tvOS are deliberately
|
||||
# NOT in CI: the self-hosted runner is headless (no window-server session), so the mac window
|
||||
# capture can't run there; tvOS needs the Tier-3 build-std slice. Generate those two locally on
|
||||
# a GUI Mac with `clients/apple/tools/screenshots.sh macos tvos`.
|
||||
screenshots:
|
||||
needs: swift
|
||||
if: gitea.event_name != 'pull_request'
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rust toolchain + iOS Simulator targets
|
||||
run: |
|
||||
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal
|
||||
fi
|
||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
|
||||
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
- name: Capture screenshots (iPhone 6.9" + iPad 13"; auto-creates the Simulators)
|
||||
working-directory: clients/apple
|
||||
env:
|
||||
SETTLE: "8" # Simulators settle slower than a local run
|
||||
run: |
|
||||
# Independent invocations: one platform failing skips it, not the other.
|
||||
bash tools/screenshots.sh ios || echo "::warning::iOS (iPhone 6.9\") screenshots skipped"
|
||||
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
|
||||
echo "Produced:"; ls -la screenshots || true
|
||||
|
||||
- name: Upload screenshots (zip artifact)
|
||||
if: always()
|
||||
# v3, not v4: Gitea's artifact backend identifies as GHES, which @actions/artifact v2+
|
||||
# (upload-artifact@v4) refuses. v3 uses the older API Gitea supports; download is still a zip.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: punktfunk-appstore-screenshots
|
||||
path: clients/apple/screenshots
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
+32
-14
@@ -13,16 +13,16 @@ name: deb
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# HOST-scoped tags only. The Apple client uses `v*` (release.yml); those must NOT trigger a
|
||||
# host publish — a `v0.1.1` client tag previously shipped a host package versioned 0.1.1 that
|
||||
# outranked every rolling build (the version-shadow). Host releases use `host-v*`.
|
||||
tags: ['host-v*']
|
||||
# Single project version: a `vX.Y.Z` tag is THE release for every platform (see
|
||||
# docs-site channels.md). The old version-shadow (a client tag shipping a host package
|
||||
# that outranked rolling builds) is now structurally impossible — main publishes to the
|
||||
# `canary` apt distribution, tags to `stable`, so the two never share a version line.
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
DISTRIBUTION: stable
|
||||
COMPONENT: main
|
||||
|
||||
jobs:
|
||||
@@ -34,19 +34,22 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Version
|
||||
# host-vX.Y.Z tag -> X.Y.Z (a real host release). A main push -> 0.2.0~ciN.g<sha>: the '~'
|
||||
# sorts it BELOW the eventual 0.2.0 tag, it climbs monotonically by run number, AND it sits
|
||||
# ABOVE the stray 0.1.1, so `apt upgrade` truly moves boxes forward. Computed BEFORE the
|
||||
# build so it's stamped into the binary (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
||||
- name: Version + channel
|
||||
# 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
|
||||
# below the eventual 0.3.0 tag, it climbs monotonically by run number, and the canary base
|
||||
# stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves
|
||||
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
||||
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
||||
run: |
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/host-v*) V="${GITHUB_REF_NAME#host-v}" ;;
|
||||
*) V="0.2.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||
*) V="0.3.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "package version $V"
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
echo "package version $V -> apt distribution '$DIST'"
|
||||
|
||||
# dpkg-shlibdeps (Depends resolution) + dpkg-deb live in dpkg-dev. The client's link
|
||||
# deps are also baked into the rust-ci image, but this job runs against the image
|
||||
@@ -55,7 +58,8 @@ jobs:
|
||||
- name: dpkg-dev + client link deps
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends dpkg-dev \
|
||||
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
|
||||
apt-get install -y --no-install-recommends dpkg-dev python3 \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
||||
|
||||
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
||||
@@ -124,3 +128,17 @@ jobs:
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||
done
|
||||
echo "published to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||
|
||||
# On a real release, also attach the .debs to the unified Gitea Release so they're on the
|
||||
# downloads page next to every other platform's artifact (canary builds live in the apt
|
||||
# `canary` distribution above — no release page for those).
|
||||
- name: Attach .debs to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
+25
-28
@@ -56,19 +56,20 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run build # rollup -> clients/decky/dist/index.js
|
||||
|
||||
- name: Version
|
||||
# Tag v1.2.3 -> 1.2.3; main push -> 0.0.1-ciN.g<sha>. Used only for the registry
|
||||
# version path + the zip name (the plugin.json version is the source of truth Decky
|
||||
# reads after install).
|
||||
- name: Version + channel
|
||||
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.0-ciN.g<sha>
|
||||
# (`canary/` alias). Used for the registry version path + the zip name (the plugin.json
|
||||
# version is the source of truth Decky reads after install — bump it in the release commit).
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
run: |
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
|
||||
*) V="0.0.1-ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;;
|
||||
*) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; ALIAS=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "decky version $V"
|
||||
echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
|
||||
echo "decky version $V -> alias '$ALIAS'"
|
||||
|
||||
- name: Assemble store-layout zip
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
@@ -76,12 +77,16 @@ jobs:
|
||||
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
|
||||
STAGE="$RUNNER_TEMP/decky"
|
||||
DEST="$STAGE/$PLUGIN"
|
||||
rm -rf "$STAGE"; mkdir -p "$DEST/dist"
|
||||
rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin"
|
||||
cp clients/decky/plugin.json "$DEST/"
|
||||
cp clients/decky/package.json "$DEST/"
|
||||
cp clients/decky/main.py "$DEST/"
|
||||
cp clients/decky/dist/index.js "$DEST/dist/"
|
||||
cp clients/decky/README.md "$DEST/"
|
||||
# The stream-launch wrapper (target of the Steam shortcut); keep it executable
|
||||
# (runner_info() also re-chmods at runtime in case the zip/extract drops the bit).
|
||||
cp clients/decky/bin/punktfunkrun.sh "$DEST/bin/"
|
||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
|
||||
cp LICENSE-MIT "$DEST/LICENSE"
|
||||
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||
@@ -98,29 +103,21 @@ jobs:
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
||||
"$BASE/$VERSION/punktfunk.zip"
|
||||
echo "published $BASE/$VERSION/punktfunk.zip"
|
||||
# 2) Stable `latest/punktfunk.zip` — this is the link to paste into Decky's
|
||||
# "install from URL". The generic registry rejects re-uploading an existing
|
||||
# version/file (409), so delete the prior `latest` first (ignore 404 on run #1).
|
||||
# 2) Channel alias (stable release -> latest/, canary main build -> canary/) — the link
|
||||
# to paste into Decky's "install from URL". The generic registry rejects re-uploading
|
||||
# an existing version/file (409), so delete the prior alias first (ignore 404 on run #1).
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"$BASE/latest/punktfunk.zip" || true
|
||||
"$BASE/$ALIAS/punktfunk.zip" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
||||
"$BASE/latest/punktfunk.zip"
|
||||
echo "install-from-URL link: $BASE/latest/punktfunk.zip"
|
||||
"$BASE/$ALIAS/punktfunk.zip"
|
||||
echo "install-from-URL link: $BASE/$ALIAS/punktfunk.zip"
|
||||
|
||||
- name: Attach zip to the Gitea release (tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/')
|
||||
- name: Attach zip to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
ID=$(curl -sf -X POST "$API/releases" \
|
||||
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|
||||
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
||||
curl -sf -X POST "$API/releases/$ID/assets?name=punktfunk-${VERSION}.zip" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$RUNNER_TEMP/punktfunk.zip" >/dev/null
|
||||
echo "attached punktfunk-${VERSION}.zip to release $GITHUB_REF_NAME"
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$RUNNER_TEMP/punktfunk.zip" "punktfunk-${VERSION}.zip"
|
||||
|
||||
@@ -58,16 +58,21 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
# On a release tag, also tag the image vX.Y.Z so a release pins reproducible web/docs images.
|
||||
EXTRA=""
|
||||
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
|
||||
docker build --pull ${{ matrix.buildargs }} \
|
||||
-f "${{ matrix.dockerfile }}" \
|
||||
-t "$REGISTRY/$OWNER/${{ matrix.image }}:latest" \
|
||||
-t "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" \
|
||||
$EXTRA \
|
||||
"${{ matrix.context }}"
|
||||
|
||||
- name: Push
|
||||
run: |
|
||||
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}"
|
||||
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest"
|
||||
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
|
||||
|
||||
# Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to
|
||||
# (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set
|
||||
|
||||
+125
-31
@@ -26,7 +26,7 @@ on:
|
||||
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
||||
# docs/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
||||
paths:
|
||||
- 'crates/punktfunk-client-linux/**'
|
||||
- 'clients/linux/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'packaging/flatpak/**'
|
||||
- 'Cargo.lock'
|
||||
@@ -40,6 +40,8 @@ env:
|
||||
APP_ID: io.unom.Punktfunk
|
||||
MANIFEST: packaging/flatpak/io.unom.Punktfunk.yml
|
||||
PACKAGE: punktfunk-client-flatpak # generic-registry package name
|
||||
REPO_URL: https://flatpak.unom.io # shared unom OSTree repo (reusable across unom apps)
|
||||
DEPLOY_DIR: unom-flatpak # ~/<dir> on unom-1 (compose + ./site tree)
|
||||
|
||||
jobs:
|
||||
build-publish:
|
||||
@@ -61,34 +63,47 @@ jobs:
|
||||
- name: Tooling
|
||||
run: |
|
||||
# flatpak-cargo-generator.py (master) needs aiohttp + tomlkit (NOT the old `toml`).
|
||||
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq
|
||||
# gnupg2/rsync/openssh-clients: sign the OSTree repo + rsync it to unom-1 (see the deploy step).
|
||||
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \
|
||||
gnupg2 rsync openssh-clients
|
||||
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
|
||||
flatpak remote-add --user --if-not-exists flathub \
|
||||
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
- name: Version
|
||||
# Tag v1.2.3 -> 1.2.3; a main push -> 0.0.1-ciN.g<sha> (sorts before a real release,
|
||||
# increases by run number — newest main build always wins). The generic registry
|
||||
# version string allows letters/dots/hyphens.
|
||||
- name: Version + channel
|
||||
# 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
|
||||
# (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
|
||||
# on a stable box never jumps to a canary build. The generic-registry version string allows
|
||||
# letters/dots/hyphens.
|
||||
run: |
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
|
||||
*) V="0.0.1-ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
||||
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 ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
|
||||
echo "flatpak version $V"
|
||||
echo "FLATPAK_BRANCH=$BRANCH" >> "$GITHUB_ENV"
|
||||
echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
|
||||
echo "flatpak version $V -> branch '$BRANCH' alias '$ALIAS'"
|
||||
|
||||
- name: Generate offline cargo sources
|
||||
# flatpak builds with no network; vendor every crate from Cargo.lock into
|
||||
# cargo-sources.json next to the manifest (referenced by the manifest's
|
||||
# punktfunk-client module).
|
||||
#
|
||||
# Prune the microsoft/windows-rs git crates first: they belong to
|
||||
# punktfunk-client-windows, which the flatpak never builds, and leaving them in makes
|
||||
# flatpak-builder full-clone that multi-GB repo at build time → "No space left on
|
||||
# device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is
|
||||
# untouched; cargo --offline only needs sources for the crates it compiles.
|
||||
run: |
|
||||
curl -fsSL -o /tmp/flatpak-cargo-generator.py \
|
||||
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||
python3 /tmp/flatpak-cargo-generator.py Cargo.lock \
|
||||
python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock
|
||||
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
|
||||
-o packaging/flatpak/cargo-sources.json
|
||||
|
||||
- name: Build the flatpak (install deps from Flathub, offline build)
|
||||
@@ -97,14 +112,20 @@ jobs:
|
||||
# runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus
|
||||
# the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the
|
||||
# container-safe path (no FUSE).
|
||||
# --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch>
|
||||
# (canary or stable) so the matching hosted .flatpakref resolves deterministically
|
||||
# (manifest sets no branch).
|
||||
flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--default-branch="$FLATPAK_BRANCH" \
|
||||
--install-deps-from=flathub \
|
||||
--repo="$PWD/repo" \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
|
||||
- name: Export single-file bundle
|
||||
run: |
|
||||
flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID"
|
||||
# Branch must be passed explicitly (matches --default-branch above); build-bundle
|
||||
# otherwise defaults to `master` and errors "Refspec … not found".
|
||||
flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID" "$FLATPAK_BRANCH"
|
||||
ls -lh "$BUNDLE"
|
||||
|
||||
- name: Publish to the Gitea generic registry
|
||||
@@ -116,28 +137,101 @@ jobs:
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
||||
"$BASE/$VERSION/$BUNDLE"
|
||||
echo "published $BASE/$VERSION/$BUNDLE"
|
||||
# 2) Stable `latest/punktfunk-client.flatpak` alias for the Decky fallback + scripts.
|
||||
# The generic registry rejects re-uploading an existing version/file (409), so
|
||||
# delete the prior `latest` file first (ignore 404 on the first ever run).
|
||||
# 2) Channel alias (stable release -> latest/, canary main build -> canary/) for the
|
||||
# Decky fallback + scripts. The generic registry rejects re-uploading an existing
|
||||
# version/file (409), so delete the prior alias file first (ignore 404 on run #1).
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"$BASE/latest/punktfunk-client.flatpak" || true
|
||||
"$BASE/$ALIAS/punktfunk-client.flatpak" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
||||
"$BASE/latest/punktfunk-client.flatpak"
|
||||
echo "published $BASE/latest/punktfunk-client.flatpak"
|
||||
"$BASE/$ALIAS/punktfunk-client.flatpak"
|
||||
echo "published $BASE/$ALIAS/punktfunk-client.flatpak"
|
||||
|
||||
- name: Attach bundle to the Gitea release (tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/')
|
||||
# Sign the OSTree repo flatpak-builder already produced and publish it to flatpak.unom.io on
|
||||
# unom-1, so users get `flatpak update` (the single-file bundle above has no remote). Mirrors
|
||||
# docker.yml's deploy-docs (DEPLOY_* = the unom-ci-deploy key). No-ops cleanly until the GPG
|
||||
# secret + DEPLOY_* exist, so the bundle build stays green during setup.
|
||||
- name: Sign + deploy the OSTree repo to unom-1 (flatpak.unom.io)
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
ID=$(curl -sf -X POST "$API/releases" \
|
||||
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|
||||
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
||||
curl -sf -X POST "$API/releases/$ID/assets?name=$BUNDLE" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$BUNDLE" >/dev/null
|
||||
echo "attached $BUNDLE to release $GITHUB_REF_NAME"
|
||||
set -euo pipefail
|
||||
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ]; then
|
||||
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_* not set — skipping repo deploy (bundle still published)."
|
||||
exit 0
|
||||
fi
|
||||
# 1) Import the signing key into a throwaway keyring; sign the repo.
|
||||
export GNUPGHOME="$(mktemp -d)"; chmod 700 "$GNUPGHOME"
|
||||
echo "$FLATPAK_GPG_PRIVATE_KEY" | base64 -d | gpg --batch --import
|
||||
KEYID="$(gpg --list-keys --with-colons | awk -F: '/^fpr:/{print $10; exit}')"
|
||||
# build-sign signs the COMMIT objects; build-update-repo signs the SUMMARY. Both are
|
||||
# required — clients with gpg-verify=true verify the commit, so summary-only signing
|
||||
# fails the pull with "GPG verification enabled, but no signatures found".
|
||||
flatpak build-sign "$PWD/repo" "$APP_ID" "$FLATPAK_BRANCH" \
|
||||
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME"
|
||||
flatpak build-update-repo --generate-static-deltas \
|
||||
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" "$PWD/repo"
|
||||
# 2) Build the install descriptors (GPGKey = the committed public key, base64).
|
||||
GPGKEY="$(base64 -w0 packaging/flatpak/unom-flatpak.gpg)"
|
||||
rm -rf site && mkdir -p site
|
||||
cat > site/unom.flatpakrepo <<EOF
|
||||
[Flatpak Repo]
|
||||
Title=unom
|
||||
Url=$REPO_URL/repo/
|
||||
Homepage=https://punktfunk.unom.io
|
||||
Comment=unom Flatpak applications
|
||||
GPGKey=$GPGKEY
|
||||
EOF
|
||||
# Two refs, one per channel — both regenerated every run and rsync'd without --delete, so
|
||||
# the server always offers both (the stable ref only resolves once a release has built the
|
||||
# `stable` branch). A box installs ONE; `flatpak update` then tracks that channel's branch.
|
||||
write_ref() { # <filename> <branch> <title>
|
||||
cat > "site/$1" <<EOF
|
||||
[Flatpak Ref]
|
||||
Name=$APP_ID
|
||||
Branch=$2
|
||||
Url=$REPO_URL/repo/
|
||||
Title=$3
|
||||
Homepage=https://punktfunk.unom.io
|
||||
IsRuntime=false
|
||||
GPGKey=$GPGKEY
|
||||
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
EOF
|
||||
}
|
||||
write_ref "${APP_ID}.flatpakref" stable "Punktfunk"
|
||||
write_ref "${APP_ID}.Canary.flatpakref" canary "Punktfunk (Canary)"
|
||||
cat > site/index.html <<EOF
|
||||
<!doctype html><meta charset=utf-8><title>unom flatpak repo</title>
|
||||
<h1>unom Flatpak repository</h1>
|
||||
<p>Install the Punktfunk Linux client (auto-adds Flathub for the GNOME runtime, then tracks updates).</p>
|
||||
<p><b>Stable</b> (recommended — only moves on releases):</p>
|
||||
<pre>flatpak install --user $REPO_URL/${APP_ID}.flatpakref
|
||||
flatpak run $APP_ID</pre>
|
||||
<p><b>Canary</b> (latest main build, unstable):</p>
|
||||
<pre>flatpak install --user $REPO_URL/${APP_ID}.Canary.flatpakref</pre>
|
||||
<p>Or add the whole remote: <code>flatpak remote-add --user --if-not-exists unom $REPO_URL/unom.flatpakrepo</code></p>
|
||||
EOF
|
||||
# 3) Ship to unom-1 and (re)start the static server. rsync WITHOUT --delete keeps old
|
||||
# objects so clients mid-update aren't broken; the fresh signed summary advertises latest.
|
||||
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}"
|
||||
$SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
|
||||
rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
|
||||
rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
|
||||
rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
|
||||
$SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
||||
echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
|
||||
|
||||
- name: Attach bundle 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)
|
||||
upsert_asset "$RID" "$BUNDLE"
|
||||
|
||||
@@ -46,6 +46,19 @@ name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
# Canary: a relevant main push uploads the iOS + macOS + tvOS builds to TestFlight (Apple's
|
||||
# own canary channel) — no notarized DMG (that's stable-only; see the per-step gates).
|
||||
# Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are
|
||||
# continue-on-error until the App Store Connect record exists, so this no-ops until then.
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'clients/apple/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'scripts/build-xcframework.sh'
|
||||
- 'Cargo.lock'
|
||||
- '.gitea/workflows/release.yml'
|
||||
# Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release
|
||||
# + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store.
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -87,8 +100,8 @@ jobs:
|
||||
- name: Version from tag
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
|
||||
*) V="0.0.${GITHUB_RUN_NUMBER}" ;;
|
||||
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
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
||||
@@ -106,6 +119,9 @@ jobs:
|
||||
"$RUSTUP" component add rust-src --toolchain nightly
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||
# same track as iOS/macOS (the nightly toolchain is installed unconditionally above).
|
||||
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
- name: Stage App Store Connect API key
|
||||
@@ -116,6 +132,9 @@ jobs:
|
||||
chmod 600 "$RUNNER_TEMP/asc.p8"
|
||||
|
||||
- name: macOS — archive, codesign Developer ID, notarize, DMG
|
||||
# Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not
|
||||
# relevant to TestFlight testers (the canary channel). Skipped on canary main pushes.
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
run: |
|
||||
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
|
||||
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
|
||||
@@ -154,23 +173,14 @@ jobs:
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
|
||||
echo "DMG=$DMG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Attach DMG to Gitea release
|
||||
if: startsWith(gitea.ref, 'refs/tags/')
|
||||
- name: Attach DMG to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
# Create the release (409 -> already exists, fetch it instead).
|
||||
ID=$(curl -sf -X POST "$API/releases" \
|
||||
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|
||||
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
||||
curl -sf -X POST "$API/releases/$ID/assets?name=Punktfunk-$VERSION.dmg" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$DMG" >/dev/null
|
||||
echo "attached Punktfunk-$VERSION.dmg to release $GITHUB_REF_NAME"
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg"
|
||||
|
||||
- name: macOS App Store — archive + upload to TestFlight
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
@@ -278,6 +288,8 @@ jobs:
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
|
||||
- name: tvOS — archive + upload to TestFlight
|
||||
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
|
||||
# on every apple push (above), so this matches the iOS step's gate exactly.
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
|
||||
# on the runner (xcodebuild -downloadPlatform tvOS).
|
||||
|
||||
+32
-13
@@ -13,9 +13,10 @@ name: rpm
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# HOST-scoped tags only — the Apple client's `v*` tags (release.yml) must NOT publish a host
|
||||
# RPM (a `v0.1.1` client tag previously shipped a host 0.1.1 that shadowed every rolling build).
|
||||
tags: ['host-v*']
|
||||
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm
|
||||
# groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old
|
||||
# version-shadow (a release outranking rolling builds in one group) is structurally gone.
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -66,20 +67,22 @@ jobs:
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
|
||||
- name: Version
|
||||
# host-vX.Y.Z tag -> X.Y.Z-1 (a real host release); main push -> 0.2.0-0.ciN.g<sha>, whose
|
||||
# "0." release sorts BELOW the eventual 0.2.0-1 yet climbs by run number AND outranks the
|
||||
# stray 0.1.1, so `rpm-ostree upgrade` truly moves to the newest build. The spec %build
|
||||
# stamps PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
||||
- 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>
|
||||
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.3.0-1 yet
|
||||
# climbs by run number. The canary base stays one minor ahead of the latest stable so a
|
||||
# stable->canary box re-point still moves forward. The spec %build stamps
|
||||
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
||||
run: |
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/host-v*) V="${GITHUB_REF_NAME#host-v}"; R="1" ;;
|
||||
*) V="0.2.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
||||
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" ;;
|
||||
esac
|
||||
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
|
||||
echo "rpm $V-$R"
|
||||
echo "GROUP=$GROUP" >> "$GITHUB_ENV"
|
||||
echo "rpm $V-$R -> group '$GROUP'"
|
||||
|
||||
- name: Build RPM
|
||||
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below
|
||||
@@ -101,6 +104,22 @@ jobs:
|
||||
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
|
||||
echo "uploading $rpm"
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/${{ matrix.group }}/upload"
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
||||
done
|
||||
echo "published to $OWNER/rpm/$GROUP"
|
||||
|
||||
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
|
||||
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
|
||||
# both on the release; canary builds live in the `*-canary` rpm groups (no release page).
|
||||
- name: Attach .rpms to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for rpm in dist/*.rpm; do
|
||||
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
|
||||
base="$(basename "$rpm" .rpm)"
|
||||
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
|
||||
done
|
||||
echo "published to $OWNER/rpm/${{ matrix.group }}"
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# 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
|
||||
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
|
||||
# bun) from one signed setup.exe. Runs on the self-hosted Windows runner
|
||||
# (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml.
|
||||
#
|
||||
# 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
|
||||
# kernel/IDD driver — neither is expressible in MSIX's sandbox. The real install logic already lives
|
||||
# in `punktfunk-host service install` (crates/punktfunk-host/src/service.rs); the installer just lays
|
||||
# the exe down and calls it elevated. Packaging internals: packaging/windows/README.md.
|
||||
#
|
||||
# Registry (public reads, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
#
|
||||
# Versioning (free-form; not MSIX's 4-part rule) — single project version:
|
||||
# vX.Y.Z tag -> X.Y.Z (THE release; published + stable `latest/` alias + attached to the
|
||||
# unified Gitea Release).
|
||||
# main push / dispatch -> 0.3.<run_number> (canary; `canary/` alias; climbs by run number).
|
||||
#
|
||||
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
|
||||
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
||||
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
||||
#
|
||||
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
||||
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export
|
||||
# .def with llvm-dlltool (no GPU/SDK at build time).
|
||||
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN gpl-shared
|
||||
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
||||
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
|
||||
name: windows-host
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/punktfunk-host/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'packaging/windows/**'
|
||||
- 'scripts/windows/**'
|
||||
- 'web/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-host.yml'
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
PKG: punktfunk-host-windows
|
||||
|
||||
jobs:
|
||||
package:
|
||||
runs-on: windows-amd64
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Configure + version
|
||||
shell: pwsh
|
||||
run: |
|
||||
# CARGO_TARGET_DIR=C:\t dodges the MAX_PATH wall in the CMake-from-source crates (aws-lc,
|
||||
# opus) the host pulls; CARGO_WORKSPACE_DIR mirrors the client workflows. Both via GITHUB_ENV
|
||||
# (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_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
|
||||
# by scripts/ci/setup-windows-runner.ps1). The host's AMD/Intel AMF/QSV encode backend
|
||||
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
||||
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
||||
if (-not $env:FFMPEG_DIR) {
|
||||
"FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
$env:GITHUB_REF_NAME -replace '^v', ''
|
||||
} else {
|
||||
"0.3.$($env:GITHUB_RUN_NUMBER)"
|
||||
}
|
||||
"HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "host version $v"
|
||||
|
||||
- name: Generate NVENC import lib
|
||||
shell: pwsh
|
||||
run: |
|
||||
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
|
||||
"PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build (release, nvenc + amf-qsv)
|
||||
shell: pwsh
|
||||
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
|
||||
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
|
||||
|
||||
- name: Clippy (host, Windows)
|
||||
shell: pwsh
|
||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
||||
run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings
|
||||
|
||||
- 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)
|
||||
shell: pwsh
|
||||
run: |
|
||||
# ONE pinned bun, used both to BUILD the console and shipped in the installer to RUN it. The
|
||||
# .output is self-contained (Nitro noExternals — deps bundled + tree-shaken, no node_modules),
|
||||
# so the installer ships just bun + a ~75-file .output instead of node + a node_modules forest.
|
||||
$ver = 'bun-v1.3.14'
|
||||
$url = "https://github.com/oven-sh/bun/releases/download/$ver/bun-windows-x64.zip"
|
||||
New-Item -ItemType Directory -Force -Path C:\t | Out-Null
|
||||
$zip = 'C:\t\bun.zip'; $dst = 'C:\t\bundist'
|
||||
Invoke-WebRequest -Uri $url -OutFile $zip
|
||||
if (Test-Path $dst) { Remove-Item $dst -Recurse -Force }
|
||||
Expand-Archive -Path $zip -DestinationPath $dst -Force
|
||||
$bun = (Get-ChildItem -Path $dst -Recurse -Filter bun.exe | Select-Object -First 1).FullName
|
||||
if (-not $bun) { throw "bun.exe not found in $url" }
|
||||
"BUN_EXE=$bun" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
& $bun --version
|
||||
|
||||
- name: Build + smoke-boot web console (bun)
|
||||
shell: pwsh
|
||||
env:
|
||||
# PAT with read access to the unom org packages — the @unom npm registry needs auth to BUILD.
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
# The bun fetched above builds the Nitro server AND runs it. noExternals (vite.config) makes the
|
||||
# output self-contained, so there's no .output/server install — the installer ships bun + the
|
||||
# ~75-file .output. The runner is SYSTEM with no ~/.npmrc, so supply the private @unom token in
|
||||
# the SYSTEM home .npmrc to BUILD (kept OUT of the shipped bundle — web\.npmrc has only the
|
||||
# registry mapping, and nothing copies it into .output).
|
||||
run: |
|
||||
$bun = $env:BUN_EXE
|
||||
if ($env:REGISTRY_TOKEN) {
|
||||
$rc = Join-Path $env:USERPROFILE '.npmrc'
|
||||
Add-Content -Path $rc -Value '@unom:registry=https://git.unom.io/api/packages/unom/npm/'
|
||||
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
|
||||
}
|
||||
Push-Location web
|
||||
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
||||
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
|
||||
if (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet) {
|
||||
throw "web build is a bun bundle (Bun.serve) - need the node-server preset"
|
||||
}
|
||||
Pop-Location
|
||||
# Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login.
|
||||
$env:PORT = '3009'; $env:HOST = '127.0.0.1'; $env:PUNKTFUNK_UI_PASSWORD = 'ci'
|
||||
$server = (Resolve-Path 'web\.output\server\index.mjs').Path
|
||||
$p = Start-Process -FilePath $bun -ArgumentList $server -PassThru -WindowStyle Hidden
|
||||
Start-Sleep -Seconds 4
|
||||
try { $code = (Invoke-WebRequest -Uri 'http://127.0.0.1:3009/login' -UseBasicParsing -TimeoutSec 10).StatusCode } catch { $code = 0 }
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
Write-Output "web console smoke (bun): /login -> $code"
|
||||
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
||||
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Pack + sign installer
|
||||
shell: pwsh
|
||||
env:
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
& packaging/windows/pack-host-installer.ps1 `
|
||||
-Version $env:HOST_VERSION -TargetDir C:\t\release -OutDir C:\t\out
|
||||
|
||||
- name: Publish to Gitea generic registry
|
||||
shell: pwsh
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
# Check curl's exit code ourselves — a best-effort DELETE (404 on first run) must not abort.
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
function Publish-File($f, $url) {
|
||||
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
|
||||
if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" }
|
||||
Write-Output "published $url"
|
||||
}
|
||||
$files = @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
|
||||
foreach ($f in $files) { Publish-File $f "$base/$($env:HOST_VERSION)/$(Split-Path $f -Leaf)" }
|
||||
# Refresh the channel alias (delete-then-reupload, like flatpak.yml/decky.yml) for a
|
||||
# predictable download URL: stable release -> `latest/`, canary main build -> `canary/`.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
|
||||
foreach ($f in $files) {
|
||||
$an = $aliasNames[$f]; if (-not $an) { continue }
|
||||
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
|
||||
Publish-File $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the signed installer (+ its .cer) to the unified Gitea Release.
|
||||
- name: Attach host installer to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
# Build the punktfunk Windows client as a signed MSIX and publish it to 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 (host mode;
|
||||
# scripts/ci/setup-windows-runner.ps1) — the MSVC/WinUI/FFmpeg toolchain + the Windows SDK's
|
||||
# makeappx/signtool are baked into the runner's daemon env, same as windows.yml.
|
||||
# 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
|
||||
# tile, clean install/uninstall) instead of a loose exe. Runs on the self-hosted Windows runner
|
||||
# (host mode; scripts/ci/setup-windows-runner.ps1) — the MSVC/WinUI/FFmpeg toolchain + the Windows
|
||||
# SDK's makeappx/signtool are baked into the runner's daemon env, same as windows.yml.
|
||||
#
|
||||
# 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
|
||||
# windows.yml for the cross-build rationale + the BOM/MAX_PATH runner gotchas.
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: crates/punktfunk-client-windows/packaging/README.md. BOM/MAX_PATH runner
|
||||
# gotchas baked into the daemon env + windows.yml: see that workflow.
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
#
|
||||
# Versioning — MSIX requires a strictly 4-part numeric version (no ~/- suffixes), so:
|
||||
# win-vX.Y.Z tag -> X.Y.Z.0 (a real Windows-client release; `win-v*` is its own tag namespace,
|
||||
# kept off the host's `host-v*` and the Apple `v*` to avoid the
|
||||
# version-shadow class of bug — see deb.yml).
|
||||
# main push / dispatch -> 0.2.<run_number>.0 (rolling; climbs monotonically by run number).
|
||||
# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so:
|
||||
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
|
||||
# Published to the generic registry + the stable `latest/` alias + attached to the
|
||||
# unified Gitea Release alongside every other platform's artifact.
|
||||
# main push / dispatch -> 0.3.<run_number>.0 (canary; climbs monotonically by run number).
|
||||
# Published to the generic registry + the `canary/` alias.
|
||||
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||
#
|
||||
# Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets
|
||||
# are set (a real or shared code-signing .pfx whose subject DN == Publisher), the package is signed
|
||||
@@ -25,12 +30,12 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/punktfunk-client-windows/**'
|
||||
- 'clients/windows/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-msix.yml'
|
||||
tags: ['win-v*']
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -41,30 +46,47 @@ env:
|
||||
jobs:
|
||||
package:
|
||||
runs-on: windows-amd64
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
ffmpeg: C:\Users\Public\ffmpeg
|
||||
td: C:\t
|
||||
- arch: arm64
|
||||
target: aarch64-pc-windows-msvc
|
||||
ffmpeg: C:\Users\Public\ffmpeg-arm64
|
||||
td: C:\t-a64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Configure + version
|
||||
shell: pwsh
|
||||
run: |
|
||||
# windows-reactor's build.rs unwraps CARGO_WORKSPACE_DIR; CARGO_TARGET_DIR=C:\t dodges the
|
||||
# MAX_PATH wall in the CMake-from-source crates (see windows.yml). Both via GITHUB_ENV.
|
||||
# windows-reactor's build.rs unwraps CARGO_WORKSPACE_DIR; CARGO_TARGET_DIR (per-arch, short)
|
||||
# dodges the MAX_PATH wall in the CMake-from-source crates (see windows.yml). FFMPEG_DIR
|
||||
# selects the arch's import libs + is read by pack-msix.ps1 for the runtime DLLs. All via
|
||||
# GITHUB_ENV.
|
||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/win-v*') {
|
||||
($env:GITHUB_REF_NAME -replace '^win-v', '').Split('.')
|
||||
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
rustup target add ${{ matrix.target }}
|
||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
|
||||
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
|
||||
} else {
|
||||
@('0', '2', $env:GITHUB_RUN_NUMBER)
|
||||
@('0', '3', $env:GITHUB_RUN_NUMBER)
|
||||
}
|
||||
while ($parts.Count -lt 4) { $parts += '0' }
|
||||
$v = ($parts[0..3] -join '.')
|
||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "MSIX version $v"
|
||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
||||
|
||||
- name: Build (release)
|
||||
shell: pwsh
|
||||
run: cargo build --release -p punktfunk-client-windows
|
||||
run: cargo build --release -p punktfunk-client-windows --target ${{ matrix.target }}
|
||||
|
||||
- name: Pack + sign MSIX
|
||||
shell: pwsh
|
||||
@@ -72,19 +94,52 @@ jobs:
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
& crates/punktfunk-client-windows/packaging/pack-msix.ps1 `
|
||||
-Version $env:MSIX_VERSION -TargetDir C:\t\release -OutDir C:\t\msix
|
||||
& clients/windows/packaging/pack-msix.ps1 `
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
|
||||
|
||||
- name: Publish to Gitea generic registry
|
||||
shell: pwsh
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
|
||||
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
|
||||
$aliasNames = @{
|
||||
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
|
||||
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
|
||||
}
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
|
||||
if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" }
|
||||
Write-Output "published $url"
|
||||
}
|
||||
foreach ($f in $files) {
|
||||
$name = Split-Path $f -Leaf
|
||||
$url = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)/$($env:MSIX_VERSION)/$name"
|
||||
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
|
||||
Write-Output "published $name -> $url"
|
||||
# 1) immutable, versioned path
|
||||
Put $f "$base/$($env:MSIX_VERSION)/$name"
|
||||
# 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file)
|
||||
$an = $aliasNames["$f"]
|
||||
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
|
||||
Put $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
|
||||
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
|
||||
# the race, and x64/arm64 filenames differ so the assets don't collide.
|
||||
- name: Attach MSIX to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
# Windows client CI — runs on the self-hosted Windows runner (home-windows-1, host mode; see
|
||||
# scripts/ci/setup-windows-runner.ps1). Build + clippy + fmt + test the WinUI 3 client
|
||||
# (windows-reactor + D3D11/SwapChainPanel + WASAPI + SDL3) on x86_64-pc-windows-msvc.
|
||||
# (windows-reactor + D3D11/SwapChainPanel + WASAPI + SDL3).
|
||||
#
|
||||
# The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, FFmpeg,
|
||||
# CARGO_HOME, CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Two
|
||||
# per-checkout vars are set in a step:
|
||||
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
|
||||
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
|
||||
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust
|
||||
# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64
|
||||
# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one
|
||||
# arch-specific external dep is FFmpeg's import libs: the runner keeps an x64 tree at
|
||||
# C:\Users\Public\ffmpeg and an ARM64 tree at C:\Users\Public\ffmpeg-arm64 (both FFmpeg 7.x /
|
||||
# avcodec-61); the matrix points FFMPEG_DIR at the right one. aarch64 can't *run* on the x64 host,
|
||||
# so fmt + test run only for x64.
|
||||
#
|
||||
# The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, the x64 FFmpeg,
|
||||
# CARGO_HOME, CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
|
||||
# / per-arch vars are set in a step:
|
||||
# - CARGO_WORKSPACE_DIR windows-reactor's build.rs unwraps it + stages the Win App SDK
|
||||
# NuGets/winmd under it (from GITHUB_WORKSPACE).
|
||||
# - CARGO_TARGET_DIR=C:\t the runner's host workdir is buried deep under
|
||||
# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under
|
||||
# C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\,
|
||||
# so the default target\ path blows past Windows' MAX_PATH (260) inside the
|
||||
# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then
|
||||
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
|
||||
# root keeps every nested path well under the limit.
|
||||
# root keeps every nested path well under the limit (per-arch so the two
|
||||
# matrix legs don't share a target dir).
|
||||
# - FFMPEG_DIR per-arch FFmpeg import libs (x64 vs arm64 tree).
|
||||
#
|
||||
# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's
|
||||
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (the
|
||||
@@ -24,14 +36,14 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/punktfunk-client-windows/**'
|
||||
- 'clients/windows/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/punktfunk-client-windows/**'
|
||||
- 'clients/windows/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
@@ -41,7 +53,11 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-amd64
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [x86_64-pc-windows-msvc, aarch64-pc-windows-msvc]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -49,24 +65,31 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# 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' }
|
||||
"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).
|
||||
$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
|
||||
rustup target add ${{ matrix.target }}
|
||||
rustc --version
|
||||
cargo --version
|
||||
node --version
|
||||
Write-Output "workspace: $env:GITHUB_WORKSPACE"
|
||||
Write-Output "target ${{ matrix.target }} target-dir $td ffmpeg $ff"
|
||||
|
||||
- name: Build
|
||||
shell: pwsh
|
||||
run: cargo build -p punktfunk-client-windows
|
||||
run: cargo build -p punktfunk-client-windows --target ${{ matrix.target }}
|
||||
|
||||
- name: Clippy (-D warnings)
|
||||
shell: pwsh
|
||||
run: cargo clippy -p punktfunk-client-windows --all-targets -- -D warnings
|
||||
run: cargo clippy -p punktfunk-client-windows --all-targets --target ${{ matrix.target }} -- -D warnings
|
||||
|
||||
- name: Rustfmt check
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo fmt -p punktfunk-client-windows -- --check
|
||||
|
||||
- name: Test
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo test -p punktfunk-client-windows
|
||||
run: cargo test -p punktfunk-client-windows --target ${{ matrix.target }}
|
||||
|
||||
+10
@@ -11,6 +11,8 @@ dist/
|
||||
clients/apple/.build/
|
||||
clients/apple/PunktfunkCore.xcframework/
|
||||
clients/apple/.swiftpm/
|
||||
# Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact)
|
||||
clients/apple/screenshots/
|
||||
# Xcode per-user state
|
||||
xcuserdata/
|
||||
|
||||
@@ -20,3 +22,11 @@ xcuserdata/
|
||||
# Windows App SDK staging by windows-reactor build.rs
|
||||
/temp/
|
||||
/winmd/
|
||||
|
||||
# Client crate build artifacts (clients moved out of crates/ -> clients/ 2026-06-18)
|
||||
/clients/*/target
|
||||
/clients/*/*/target
|
||||
|
||||
# Python bytecode (e.g. clients/android/ci tooling)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"type": "swift",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder:punktfunk}/clients/apple",
|
||||
"name": "Debug PunktfunkClient (clients/apple)",
|
||||
"target": "PunktfunkClient",
|
||||
"configuration": "debug",
|
||||
"preLaunchTask": "swift: Build Debug PunktfunkClient (clients/apple)"
|
||||
},
|
||||
{
|
||||
"type": "swift",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder:punktfunk}/clients/apple",
|
||||
"name": "Release PunktfunkClient (clients/apple)",
|
||||
"target": "PunktfunkClient",
|
||||
"configuration": "release",
|
||||
"preLaunchTask": "swift: Build Release PunktfunkClient (clients/apple)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,10 +6,10 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
|
||||
## Where the work stands
|
||||
|
||||
- **M1 (`punktfunk-core` + C ABI): complete and hardened.** FEC recovery, loopback-under-loss,
|
||||
- **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`).
|
||||
- **M2 (GameStream host): working end-to-end with a stock Moonlight client.** Validated live
|
||||
- **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
|
||||
@@ -28,11 +28,11 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
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`).
|
||||
- **M3 (`punktfunk/1`, the native protocol): full session planes, validated live.** QUIC
|
||||
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
|
||||
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
|
||||
plane = the hardened M1 `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
||||
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
||||
(inexpressible in GameStream), host creates the native virtual output at the client's
|
||||
requested mode. `m3-host` is a **persistent listener** (sessions back to back;
|
||||
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:**
|
||||
@@ -41,15 +41,15 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
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
|
||||
(`m3-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs;
|
||||
(`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 --native` and `m3-host` advertise the native service over
|
||||
**LAN auto-discovery**: both `serve` and `punktfunk1-host` advertise the native service over
|
||||
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
|
||||
pin)/`pair`(required|optional)/`id`; `punktfunk-client-rs --discover` lists hosts, Apple clients
|
||||
pin)/`pair`(required|optional)/`id`; `punktfunk-probe --discover` lists hosts, Apple clients
|
||||
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
|
||||
**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
|
||||
@@ -58,18 +58,59 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
(`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-client-rs` is the
|
||||
`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; DualSense (UHID) only on Linux hosts.
|
||||
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). **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. **M4 — client decode + present: macOS stage 1 done, first light achieved
|
||||
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
|
||||
@@ -85,20 +126,22 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
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 m3-hosts on loopback — runs on macOS;
|
||||
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
|
||||
includes the pairing ceremony + `--require-pairing` gate),
|
||||
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
||||
[`clients/apple/README.md`](clients/apple/README.md). Next: stage 2 presenter
|
||||
(`VTDecompressionSession` + `CAMetalLayer` frame pacing), glass-to-glass numbers via
|
||||
`tools/latency-probe` (scaffold), iOS variant.
|
||||
**Linux stage 1 done, first light 2026-06-12** (`crates/punktfunk-client-linux`, binary
|
||||
[`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 --native` on this box: 1080p60, steady 60 fps, capture→decoded p50
|
||||
against `serve` on this box: 1080p60, steady 60 fps, capture→decoded p50
|
||||
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
|
||||
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
|
||||
@@ -108,48 +151,82 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
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; needs an
|
||||
Intel/AMD client box to live-verify the hw path. Next: the stage-2 raw-Wayland
|
||||
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** (`crates/punktfunk-client-windows`, binary
|
||||
**Windows stage 1 done 2026-06-15** (`clients/windows`, binary
|
||||
`punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like
|
||||
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 software HEVC decode** (D3D11VA hw decode
|
||||
is the follow-up), **WASAPI** render + mic capture, **SDL3** gamepads (rumble/lightbar/DualSense),
|
||||
`mdns-sd` discovery, and the full trust surface — all **in-app**: host list (live mDNS + saved +
|
||||
manual), settings (resolution/refresh/mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch
|
||||
re-pair. **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor
|
||||
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` (on the dev VM). **windows-reactor is unpublished** (git
|
||||
+ 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** (the dev VM is headless/Session-0 → the WinUI window needs a display: RDP or the RTX
|
||||
box), D3D11VA hw decode + 10-bit/HDR present, RAWINPUT relative-mouse pointer-lock, and a per-host
|
||||
speed test in the UI.
|
||||
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**: concurrent sessions (today: one at a time, extras wait
|
||||
in the accept queue). **Done:** unified host (`serve --native` runs GameStream + the
|
||||
punktfunk/1 QUIC host in one process) with native pairing driven over the mgmt API /
|
||||
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. Next (see roadmap): **delegated pairing approval** (an already-paired device
|
||||
approves a new one).
|
||||
4. **M2 polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
|
||||
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).
|
||||
5. **Native clients** (`clients/{apple,android}` scaffolds) consuming `punktfunk_core.h`.
|
||||
|
||||
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
|
||||
@@ -176,7 +253,10 @@ workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/
|
||||
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`).
|
||||
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
|
||||
|
||||
@@ -187,11 +267,17 @@ crates/punktfunk-host/
|
||||
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)
|
||||
capture.rs · encode.rs · audio.rs · m0.rs · m3.rs · mgmt.rs · native_pairing.rs
|
||||
crates/punktfunk-client-rs/ punktfunk/1 reference client (M3 headless test/measurement tool)
|
||||
crates/punktfunk-client-linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
||||
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
|
||||
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)
|
||||
packaging/ Fedora/Bazzite RPM · bootc · COPR (packaging/bazzite/README.md)
|
||||
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
|
||||
@@ -209,7 +295,7 @@ include/punktfunk_core.h generated C header
|
||||
- **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.
|
||||
- **M1 security hardening stays intact**: reassembler bounds attacker-controlled fields
|
||||
- **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
|
||||
@@ -228,14 +314,14 @@ scanout → KWin `--drm` impossible; everything renders offscreen via `renderD12
|
||||
# launcher menu is EMPTY (no apps, no System Settings).
|
||||
bash scripts/headless/run-headless-kde.sh 1920x1080
|
||||
|
||||
# host (shell 2):
|
||||
# host (shell 2): bare `serve` is native-only (secure default); add --gamestream for Moonlight compat.
|
||||
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_VIDEO_SOURCE=virtual \
|
||||
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve
|
||||
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve --gamestream
|
||||
|
||||
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
|
||||
# across sessions — bound it with --max-sessions):
|
||||
cargo run -rp punktfunk-host -- m3-host --source virtual --seconds 10 --max-sessions 1
|
||||
cargo run -rp punktfunk-client-rs -- --mode 1280x720x120 --out /tmp/a.h265 --input-test # + --pin HEX
|
||||
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
|
||||
@@ -244,7 +330,8 @@ or 8.x/libavcodec 62** — validated live on Ubuntu 26.04 (8) and Bazzite F43 (7
|
||||
FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKTFUNK_VIDEO_SOURCE=virtual|portal`,
|
||||
`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`.
|
||||
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
|
||||
|
||||
|
||||
Generated
+18
-47
@@ -2540,12 +2540,14 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-android"
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
"mdns-sd",
|
||||
"ndk",
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
@@ -2571,20 +2573,6 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-rs"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"quinn",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.0.1"
|
||||
@@ -2655,6 +2643,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"mdns-sd",
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
@@ -2680,7 +2669,6 @@ dependencies = [
|
||||
"utoipa",
|
||||
"utoipa-axum",
|
||||
"utoipa-scalar",
|
||||
"vigem-client",
|
||||
"wasapi",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
@@ -2693,6 +2681,20 @@ dependencies = [
|
||||
"xkbcommon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"quinn",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
@@ -4070,15 +4072,6 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vigem-client"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b857e6f99efe1e1eb1e4dfb035de8ae7ec8ec56bd1928edcbd7c6e4427634d52"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
@@ -4332,22 +4325,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -4357,12 +4334,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
|
||||
+4
-4
@@ -3,10 +3,10 @@ resolver = "2"
|
||||
members = [
|
||||
"crates/punktfunk-core",
|
||||
"crates/punktfunk-host",
|
||||
"crates/punktfunk-client-rs",
|
||||
"crates/punktfunk-client-linux",
|
||||
"crates/punktfunk-client-windows",
|
||||
"crates/punktfunk-android",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
"clients/windows",
|
||||
"clients/android/native",
|
||||
"tools/latency-probe",
|
||||
"tools/loss-harness",
|
||||
]
|
||||
|
||||
@@ -1,95 +1,145 @@
|
||||
# punktfunk
|
||||
|
||||
*A ground-up low-latency desktop streaming stack, built Linux-first, with a shared Rust
|
||||
protocol core and native clients per platform.*
|
||||
**Low-latency desktop and game streaming, Linux-first.** Run the host on a Linux machine — or a
|
||||
Windows PC — with an NVIDIA GPU, connect from a Mac, PC, phone, tablet, or TV, and stream your desktop
|
||||
or games — each device at its **own native resolution and refresh rate**, over your local network.
|
||||
|
||||
`punktfunk` is a placeholder codename. The bet: ship a **Linux virtual-display streaming
|
||||
host** that speaks the existing Moonlight protocol (every Moonlight/Artemis client works
|
||||
day one), then break the ~1 Gbps FEC wall with a **GF(2¹⁶) Leopard-RS** transport as a
|
||||
negotiated extension. See [`docs/implementation-plan.md`](docs/implementation-plan.md).
|
||||
📖 **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
|
||||
[Quick Start](https://docs.punktfunk.unom.io/docs/quickstart).
|
||||
|
||||
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
||||
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
||||
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
||||
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
|
||||
protocol, FEC, and crypto, linked into the host and every client over a stable C ABI.
|
||||
|
||||
## What makes it different
|
||||
|
||||
- **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
|
||||
letterboxing, no scaling, no rearranging your real monitors.
|
||||
- **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
|
||||
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on a LAN.
|
||||
- **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.
|
||||
- **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices
|
||||
reconnect on a pinned identity. No accounts, no cloud. Hosts auto-advertise over mDNS, so clients
|
||||
find them on the network without typing an IP.
|
||||
|
||||
## Status
|
||||
|
||||
| Milestone | State |
|
||||
| Component | State |
|
||||
|-----------|-------|
|
||||
| **M1 — `punktfunk-core` + C ABI** | ✅ done & hardened (FEC, packetization, AES-GCM, session, adversarial-review fixes, `punktfunk_core.h`) |
|
||||
| **M2 — GameStream host → stock Moonlight** | ✅ live end-to-end: pairing, RTSP, audio, per-client virtual output at native res, GPU zero-copy NVENC, gamepads |
|
||||
| **M3 — `punktfunk/1` native protocol** | ✅ validated live: QUIC control + GF(2¹⁶) FEC/AES data plane, SPAKE2 PIN pairing, mid-stream mode renegotiation |
|
||||
| **M4 — client decode + present (Apple)** | 🟡 macOS first light: AnnexB→VideoToolbox HEVC on glass + input/pairing over `punktfunk/1` (`clients/apple`); iOS + presenter next |
|
||||
| **Web console + management API** | ✅ TanStack web console (`web/`) over the OpenAPI mgmt API: host status, paired devices, on-demand native pairing (arm → show PIN) |
|
||||
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
||||
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
||||
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
||||
| **Windows host** (NVIDIA, x64) | 🟡 Implemented & shipping as a signed installer (DXGI capture · SudoVDA virtual display · NVENC · WASAPI · ViGEm); NVIDIA-only, newer than the Linux host |
|
||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
|
||||
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
|
||||
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, Oboe audio, controllers, discovery, pairing |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | 🟡 Stage 1 complete, ships as signed MSIX (x64 + ARM64); D3D11VA decode + HDR present pending on-glass validation |
|
||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing |
|
||||
|
||||
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA
|
||||
(RTX 5070 Ti & RTX 4090, driver 595): trust-on-first-use pairing that persists, an app
|
||||
catalog, RTSP/ENet/audio, and **video at the client's exact resolution and refresh** via a
|
||||
per-session virtual output (KWin, gamescope, Mutter, Sway backends), encoded with GPU
|
||||
**zero-copy** (dmabuf → CUDA/Vulkan → NVENC) at 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). Its trust model is **SPAKE2 PIN pairing by
|
||||
default** — a new host requires the PIN ceremony; trust-on-first-use is an explicit host opt-in
|
||||
(`m3-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs. Both
|
||||
run from **one process** (`serve --native`), managed through a REST API + web console. Builds
|
||||
against FFmpeg 7 or 8; deployed live on Bazzite. Full status: [`CLAUDE.md`](CLAUDE.md);
|
||||
roadmap, setup guides & progress: the docs site ([`docs-site/`](docs-site) — Fumadocs;
|
||||
`bun run dev`), with the canonical [roadmap](docs-site/content/docs/roadmap.md) and
|
||||
[status](docs-site/content/docs/status.md) there. Design notes stay in [`docs/`](docs).
|
||||
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
|
||||
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
|
||||
and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin,
|
||||
gamescope, Mutter, and Sway/wlroots backends), encoded with GPU **zero-copy** (dmabuf → CUDA/Vulkan →
|
||||
NVENC) up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a
|
||||
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→reassembled at 720p120), with
|
||||
mid-stream mode renegotiation and a wall-clock skew handshake so latency stays valid across machines.
|
||||
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
|
||||
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
|
||||
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
|
||||
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
|
||||
|
||||
## Install (host)
|
||||
Full milestone status: **[docs.punktfunk.unom.io/docs/status](https://docs.punktfunk.unom.io/docs/status)** ·
|
||||
roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
||||
|
||||
The package registries are the real distribution channel — pick your distro and run one command.
|
||||
Per-distro setup (add the repo, first-run, web console) lives in the linked READMEs.
|
||||
## Install the host
|
||||
|
||||
| Distro | One-command happy path | Details |
|
||||
|--------|------------------------|---------|
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [`packaging/debian/README.md`](packaging/debian/README.md) |
|
||||
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(after adding the repo; or the bootc image)* | [`packaging/rpm/README.md`](packaging/rpm/README.md) |
|
||||
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS/Deck)* | [`packaging/arch/README.md`](packaging/arch/README.md) |
|
||||
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
|
||||
Windows host (NVIDIA-only) also ships as a signed installer.
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status);
|
||||
`punktfunk-client` is the GTK4 desktop client (also shipped via apt/RPM/Arch/Flatpak). After install,
|
||||
run `punktfunk-host serve --native` inside your desktop session, then pair from the web console.
|
||||
| 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) |
|
||||
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Windows** (NVIDIA, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
|
||||
Building from source (below) is a fallback.
|
||||
`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;
|
||||
add `--gamestream` on a trusted LAN if you also want stock Moonlight clients), then pair from the web
|
||||
console. Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
|
||||
|
||||
## Layout
|
||||
## Connect a client
|
||||
|
||||
```
|
||||
crates/
|
||||
punktfunk-core/ protocol · FEC · pacing · crypto · quic — the C ABI (lib + cdylib + staticlib)
|
||||
punktfunk-host/ Linux host: vdisplay · capture · encode · inject · gamestream · m3 · mgmt · native_pairing
|
||||
punktfunk-client-rs/ punktfunk/1 reference client (M3 headless; M4 adds decode+present)
|
||||
clients/{apple,android}/ native client scaffolds (import punktfunk_core.h); apple = macOS first light
|
||||
web/ TanStack web console (host status · paired devices · pairing) over the mgmt API
|
||||
packaging/ Fedora/Bazzite RPM · bootc image · COPR (see packaging/bazzite/README.md)
|
||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||
tools/{latency-probe,loss-harness}/ measurement (plan §10)
|
||||
docs/{implementation-plan,roadmap,windows-host,dualsense-haptics}.md
|
||||
```
|
||||
| Streaming to… | Use |
|
||||
|---|---|
|
||||
| Mac, iPhone, iPad, Apple TV | The **Apple app** (`clients/apple`) — also on TestFlight |
|
||||
| Linux desktop / laptop, Steam Deck | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
|
||||
| Android phone or TV | The **Android app** (`clients/android`) |
|
||||
| Windows | Native **`punktfunk-client`** (signed MSIX) or **Moonlight** |
|
||||
| Anything else (browser, old phone, smart TV) | **Moonlight** over GameStream |
|
||||
|
||||
Each client discovers hosts on the network automatically and does a one-time
|
||||
[PIN pairing](https://docs.punktfunk.unom.io/docs/pairing). Per-device install steps:
|
||||
**[/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
|
||||
|
||||
## Build & test (from source)
|
||||
|
||||
For development, or as an install fallback where no package is available:
|
||||
|
||||
```sh
|
||||
cargo build --workspace # green on Linux and macOS
|
||||
cargo build --workspace # the Rust core, host, Linux client, and probe (Linux & macOS)
|
||||
cargo test --workspace # unit + loopback + proptest + C ABI harness
|
||||
cargo clippy --workspace --all-targets
|
||||
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
|
||||
bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip proof
|
||||
```
|
||||
|
||||
The C header regenerates from `crates/punktfunk-core/src/abi.rs` on every build (cbindgen via
|
||||
`build.rs`) into `include/punktfunk_core.h`.
|
||||
`build.rs`) into `include/punktfunk_core.h`. The Apple, Android, and Windows clients have their own
|
||||
toolchains (Xcode/`swift build`, Gradle, and `cargo` on the MSVC target) — see each client's README
|
||||
and the [docs site](https://docs.punktfunk.unom.io).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
crates/
|
||||
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
|
||||
clients/
|
||||
apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController)
|
||||
linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3)
|
||||
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
|
||||
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · Oboe)
|
||||
probe/ headless reference / measurement client for punktfunk/1
|
||||
decky/ Steam Deck Decky plugin
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing
|
||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||
docs/ design notes & deep-dive plans
|
||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||
tools/ latency-probe · loss-harness (measurement)
|
||||
```
|
||||
|
||||
## Design invariants
|
||||
|
||||
- **One core, linked everywhere.** Protocol/FEC/crypto/pacing live in `punktfunk-core` exactly
|
||||
once, exposed over a stable, versioned C ABI (`punktfunk_abi_version()`, `PunktfunkConfig`
|
||||
carries its own `struct_size`).
|
||||
- **No async on the hot path.** The per-frame pipeline uses native threads only;
|
||||
`tokio`/`quinn` are gated behind the off-by-default `quic` feature (control plane only).
|
||||
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block) for Moonlight compat;
|
||||
GF(2¹⁶) (≤65535 shards/block, SIMD, O(n log n)) to push past ~1 Gbps.
|
||||
- **One core, linked everywhere.** Protocol, FEC, and crypto live in `punktfunk-core` exactly once,
|
||||
exposed over a stable, versioned C ABI (`punktfunk_abi_version()`, `PunktfunkConfig` carries its own
|
||||
`struct_size`). Every native client links the same core.
|
||||
- **No async on the hot path.** The per-frame pipeline uses native threads only; `tokio`/`quinn` are
|
||||
gated behind the off-by-default `quic` feature (control plane only).
|
||||
- **Native client resolution, no scaling.** Each session gets a virtual output at exactly the
|
||||
client's WxH@Hz; each compositor keeps its own backend behind a shared `VirtualDisplay` trait.
|
||||
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block) for Moonlight compatibility; GF(2¹⁶)
|
||||
(≤65535 shards/block, SIMD, O(n log n)) for `punktfunk/1` to push past ~1 Gbps.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Punktfunk Android Release Secrets
|
||||
# Copy this file to .env and fill in the values.
|
||||
# DO NOT COMMIT THE .env FILE!
|
||||
|
||||
RELEASE_KEYSTORE_FILE=../punktfunk-release.jks
|
||||
RELEASE_KEYSTORE_PASSWORD=
|
||||
RELEASE_KEY_ALIAS=punktfunk-key
|
||||
RELEASE_KEY_PASSWORD=
|
||||
VERSION_CODE=1
|
||||
@@ -9,3 +9,7 @@ captures/
|
||||
|
||||
# Native libraries produced by cargo-ndk — regenerated by the :kit cargoNdk* tasks.
|
||||
**/src/main/jniLibs/
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
*.jks
|
||||
|
||||
+36
-22
@@ -11,29 +11,34 @@ machine, trust logic) instead of re-porting it into Kotlin.
|
||||
|
||||
| Side | Owns |
|
||||
|------|------|
|
||||
| **Rust** (`crates/punktfunk-android` → `libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing |
|
||||
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, `NsdManager` discovery, Keystore identity, permissions |
|
||||
| **Rust** (`clients/android/native` → `libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing, **mDNS discovery** (`mdns-sd`, the same browse the Linux/Windows clients use) |
|
||||
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, the Wi-Fi `MulticastLock` + permission UX, Keystore identity, permissions |
|
||||
|
||||
The single seam is `io.unom.punktfunk.kit.NativeBridge` ⇄ `Java_io_unom_punktfunk_kit_NativeBridge_*`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
crates/punktfunk-android/ Rust cdylib (workspace member)
|
||||
src/lib.rs JNI_OnLoad + abiVersion/coreVersion (native-link proof)
|
||||
src/session.rs session handle lifecycle (connect/close); plane pumps = TODO
|
||||
clients/android/native/ Rust cdylib (workspace member) — links punktfunk-core directly
|
||||
src/lib.rs JNI seam (connect/pair, input, plane getters, abi/core version)
|
||||
src/session.rs session lifecycle + plane pumps
|
||||
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/feedback.rs rumble + HID output (lightbar / adaptive triggers)
|
||||
src/stats.rs live video stats
|
||||
|
||||
clients/android/ Gradle project (this dir)
|
||||
settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew
|
||||
app/ :app — Compose application (MainActivity)
|
||||
kit/ :kit — Android library: NativeBridge + the cargo-ndk build
|
||||
build.gradle.kts cargoNdk{Debug,Release} → src/main/jniLibs/<abi>/*.so
|
||||
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 (already set up on the dev Mac)
|
||||
## Prerequisites
|
||||
|
||||
- Android SDK + **NDK r28 LTS** (`28.2.13676358`), `platforms;android-37.0`, `build-tools;37.0.0`
|
||||
- **JDK 21** for Gradle/AGP (the machine default JDK 25 is too new for AGP 9.2)
|
||||
- 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 ·
|
||||
@@ -44,10 +49,11 @@ compileSdk 37 · targetSdk 36 · minSdk 31 · ABIs arm64-v8a + x86_64.
|
||||
**Android Studio:** open `clients/android` — it uses its bundled JBR 21 automatically. The
|
||||
`cargoNdk*` task builds the `.so` as part of the normal build.
|
||||
|
||||
**CLI** (the machine default is JDK 25, so point Gradle at JDK 21):
|
||||
**CLI** (point Gradle at a JDK 21 if your machine default is newer, e.g. JDK 25):
|
||||
|
||||
```sh
|
||||
export JAVA_HOME="$(brew --prefix openjdk@21)/libexec/openjdk.jdk/Contents/Home"
|
||||
# Adoptium/Temurin 21 (installed by the Android Studio setup, or `brew install temurin@21`):
|
||||
export JAVA_HOME="$(/usr/libexec/java_home -v 21)"
|
||||
cd clients/android
|
||||
./gradlew :app:assembleDebug # cargo-ndk cross-compiles libpunktfunk_android.so first
|
||||
./gradlew :app:installDebug # onto a running emulator/device
|
||||
@@ -55,15 +61,23 @@ cd clients/android
|
||||
# Emulators (created during env setup): emulator -avd pf_phone | emulator -avd pf_tv
|
||||
```
|
||||
|
||||
The debug APK lands in `app/build/outputs/apk/debug/`. The scaffold screen calls
|
||||
`NativeBridge.abiVersion()` across JNI — a live ABI version proves the whole native stack is wired.
|
||||
The debug APK lands in `app/build/outputs/apk/debug/`. Launch it, pick a host from the list, pair,
|
||||
and stream.
|
||||
|
||||
## Status
|
||||
|
||||
- **Scaffold (done):** Gradle modules, cargo-ndk wiring, JNI native-link proof, phone+TV-installable
|
||||
manifest. `crates/punktfunk-core` `rcgen` switched to the `ring` backend so the client `.so` is
|
||||
aws-lc-free.
|
||||
- **Next (M4 Android stage 1):** video decode (`AMediaCodec` async → `SurfaceView`), audio
|
||||
(Opus + Oboe + jitter ring), input capture → `send_input`, pairing/identity (Keystore-wrapped),
|
||||
mDNS discovery, the phone/TV Compose UI. The Rust-side homes are stubbed in
|
||||
`crates/punktfunk-android/src/session.rs` with port pointers to `crates/punktfunk-client-linux`.
|
||||
A working native client (phone + Android TV), at parity with the Linux and Apple apps for the core
|
||||
streaming experience:
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// AGP 9 built-in Kotlin: NO org.jetbrains.kotlin.android. The Compose compiler plugin is
|
||||
@@ -12,17 +14,49 @@ android {
|
||||
compileSdk = 37 // Android 17 — required by androidx.core 1.19.0; targetSdk stays 36 for now.
|
||||
|
||||
defaultConfig {
|
||||
// Load from .env if it exists (local dev), otherwise from System.getenv (CI)
|
||||
val envFile = project.rootProject.file(".env")
|
||||
val props = Properties()
|
||||
if (envFile.exists()) {
|
||||
envFile.inputStream().use { props.load(it) }
|
||||
}
|
||||
|
||||
applicationId = "io.unom.punktfunk"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.0.1"
|
||||
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
||||
versionCode = vCode?.toInt() ?: 1
|
||||
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
|
||||
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
|
||||
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2"
|
||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
// Load from .env if it exists (local dev), otherwise from System.getenv (CI)
|
||||
val envFile = project.rootProject.file(".env")
|
||||
val props = Properties()
|
||||
if (envFile.exists()) {
|
||||
envFile.inputStream().use { props.load(it) }
|
||||
}
|
||||
|
||||
val ksFile = props.getProperty("RELEASE_KEYSTORE_FILE") ?: System.getenv("RELEASE_KEYSTORE_FILE")
|
||||
if (ksFile != null) {
|
||||
storeFile = file(ksFile)
|
||||
storePassword = props.getProperty("RELEASE_KEYSTORE_PASSWORD") ?: System.getenv("RELEASE_KEYSTORE_PASSWORD")
|
||||
keyAlias = props.getProperty("RELEASE_KEY_ALIAS") ?: System.getenv("RELEASE_KEY_ALIAS")
|
||||
keyPassword = props.getProperty("RELEASE_KEY_PASSWORD") ?: System.getenv("RELEASE_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false // scaffold; enable R8 + shrinkResources later
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Punktfunk ProGuard Rules
|
||||
|
||||
# Keep the Native Bridge and its methods for JNI
|
||||
-keep class io.unom.punktfunk.kit.NativeBridge { *; }
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Keep the models that might be serialized or accessed via JNI
|
||||
-keep class io.unom.punktfunk.models.** { *; }
|
||||
-keep class io.unom.punktfunk.kit.discovery.** { *; }
|
||||
-keep class io.unom.punktfunk.kit.security.** { *; }
|
||||
|
||||
# Compose rules are usually handled by the plugin, but we can add more if needed
|
||||
-keepclassmembers class **.R$* {
|
||||
public static <fields>;
|
||||
}
|
||||
@@ -4,11 +4,13 @@
|
||||
<!-- punktfunk/1 QUIC/UDP data plane. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- mDNS discovery of _punktfunk._udp on the LAN (NsdManager). -->
|
||||
<!-- mDNS discovery of _punktfunk._udp on the LAN (native mdns-sd browse). Requested
|
||||
opportunistically — raw multicast reception needs only the MulticastLock, not this. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<!-- Hold a MulticastLock while NsdManager discovery runs (OEM Wi-Fi power-save hedge). -->
|
||||
<!-- HostDiscovery holds a MulticastLock while the native mDNS browse runs — raw multicast
|
||||
reception needs it (also an OEM Wi-Fi power-save hedge). -->
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.unom.punktfunk.models.Tab
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
val context = LocalContext.current
|
||||
val settingsStore = remember { SettingsStore(context) }
|
||||
var settings by remember { mutableStateOf(settingsStore.load()) }
|
||||
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
|
||||
var tab by remember { mutableStateOf(Tab.Connect) }
|
||||
|
||||
AnimatedContent(
|
||||
targetState = streamHandle != 0L,
|
||||
transitionSpec = {
|
||||
fadeIn() togetherWith fadeOut()
|
||||
},
|
||||
label = "StreamTransition"
|
||||
) { isStreaming ->
|
||||
if (isStreaming) {
|
||||
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
|
||||
} 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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
AnimatedContent(
|
||||
targetState = tab,
|
||||
transitionSpec = {
|
||||
if (targetState.ordinal > initialState.ordinal) {
|
||||
slideInHorizontally { it } + fadeIn() togetherWith
|
||||
slideOutHorizontally { -it } + fadeOut()
|
||||
} else {
|
||||
slideInHorizontally { -it } + fadeIn() togetherWith
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
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.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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.components.EmptyHostsState
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.discovery.HostDiscovery
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.IdentityStore
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var host by remember { mutableStateOf("") }
|
||||
var hostName by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("9777") }
|
||||
var connecting by remember { mutableStateOf(false) }
|
||||
var status by remember { mutableStateOf<String?>(null) }
|
||||
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
||||
val (w, h, hz) = settings.effectiveMode(context)
|
||||
|
||||
// mDNS discovery scoped to this screen, via the native mdns-sd browse (HostDiscovery) — its
|
||||
// onChange fires on the main thread, so it can set Compose state directly. (Emulator SLIRP drops
|
||||
// multicast → empty; that's the network, not the API.) Raw multicast reception only needs the
|
||||
// Wi-Fi MulticastLock (HostDiscovery holds it), NOT NEARBY_WIFI_DEVICES — that gated the old
|
||||
// NsdManager path. We still request NEARBY_WIFI_DEVICES opportunistically (some OEMs filter
|
||||
// multicast without it; harmless where it isn't), but never block discovery on the grant — a
|
||||
// denial used to leave discovery dead forever.
|
||||
val discovery = remember { HostDiscovery(context) }
|
||||
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
|
||||
val nearbyLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { _ -> /* best-effort hint; discovery runs regardless of the result */ }
|
||||
LaunchedEffect(Unit) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) {
|
||||
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
discovery.onChange = { discovered = it }
|
||||
discovery.start()
|
||||
onDispose {
|
||||
discovery.onChange = null
|
||||
discovery.stop()
|
||||
}
|
||||
}
|
||||
|
||||
val identityStore = remember { IdentityStore(context) }
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||
// 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).
|
||||
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { withContext(Dispatchers.IO) { obtainIdentity(identityStore) } }
|
||||
.onSuccess { identity = it }
|
||||
.onFailure { status = "Identity unavailable: ${it.message} — re-pair may be required" }
|
||||
}
|
||||
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
|
||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||
// A saved host whose label is being edited (the Rename dialog).
|
||||
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||
|
||||
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
|
||||
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
|
||||
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
|
||||
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
|
||||
|
||||
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
|
||||
// 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 doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
val id = identity
|
||||
if (id == null) {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
connecting = true
|
||||
status = "Connecting to $targetHost:$targetPort…"
|
||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||
scope.launch {
|
||||
// Advertise HDR only when this device's display can present it (else the host sends a
|
||||
// proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val hdrEnabled = 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
|
||||
if (handle != 0L) {
|
||||
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
||||
val fp = NativeBridge.nativeHostFingerprint(handle)
|
||||
if (fp.isNotEmpty()) {
|
||||
knownHostStore.save(KnownHost(targetHost, targetPort, name, fp, paired = false))
|
||||
}
|
||||
}
|
||||
onConnected(handle)
|
||||
} else {
|
||||
status = "Connection failed — check host/port, PIN, and logcat"
|
||||
discovery.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust state is
|
||||
// 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
|
||||
// pair=required host, or a manual/unknown-policy host, must pair by PIN.
|
||||
fun connect(
|
||||
targetHost: String,
|
||||
targetPort: Int,
|
||||
dh: DiscoveredHost? = null,
|
||||
manualName: String? = null,
|
||||
) {
|
||||
val known = knownHostStore.get(targetHost, targetPort)
|
||||
val adv = dh?.fingerprint?.lowercase()
|
||||
// Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered
|
||||
// mDNS name; else the name typed in the Add-host sheet; else the bare address.
|
||||
val name = known?.name ?: dh?.name ?: manualName?.trim()?.takeIf { it.isNotEmpty() } ?: targetHost
|
||||
when {
|
||||
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
|
||||
known != null && (adv == null || adv == known.fpHex) ->
|
||||
doConnect(targetHost, targetPort, known.name, known.fpHex)
|
||||
// Known host whose fp changed → force re-pairing (no silent re-trust shortcut).
|
||||
known != null -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED)
|
||||
// Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it,
|
||||
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
|
||||
dh?.pairingRequired == false -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
|
||||
// pair=required, or a manual/unknown-policy host → PIN pairing is mandatory.
|
||||
else -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.PAIR)
|
||||
}
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
var showManualSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
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))
|
||||
|
||||
status?.let {
|
||||
// While connecting it's progress (spinner, neutral); otherwise it's a
|
||||
// result/error (red). Previously every status showed in error-red, so a
|
||||
// normal "Connecting…" looked like a failure.
|
||||
if (connecting) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Result/error: a filled error container reads as a real failure banner,
|
||||
// not just red text lost in the layout.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
EmptyHostsState()
|
||||
}
|
||||
}
|
||||
|
||||
if (savedHosts.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SectionLabel("Saved hosts")
|
||||
}
|
||||
items(savedHosts, key = { "saved-${it.address}-${it.port}" }) { kh ->
|
||||
HostCard(
|
||||
name = kh.name,
|
||||
address = "${kh.address}:${kh.port}",
|
||||
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
|
||||
enabled = !connecting,
|
||||
onConnect = { connect(kh.address, kh.port) },
|
||||
onForget = {
|
||||
knownHostStore.remove(kh.address, kh.port)
|
||||
savedHosts = knownHostStore.all()
|
||||
},
|
||||
onRename = { renameTarget = kh },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (discoveredUnsaved.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SectionLabel("Discovered on the network")
|
||||
}
|
||||
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
|
||||
HostCard(
|
||||
name = dh.name,
|
||||
address = "${dh.host}:${dh.port}",
|
||||
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
|
||||
enabled = !connecting,
|
||||
onConnect = { connect(dh.host, dh.port, dh) },
|
||||
onForget = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
|
||||
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
|
||||
// rather than looking idle/empty.
|
||||
if (!connecting && discovered.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Searching the local network…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(96.dp))
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = true, // Static for now, could be based on scroll if needed
|
||||
enter = scaleIn() + fadeIn(),
|
||||
exit = scaleOut() + fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showManualSheet = true },
|
||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||
text = { Text("Add host") },
|
||||
expanded = !connecting,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showManualSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showManualSheet = false },
|
||||
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 = { hostName = it },
|
||||
label = { Text("Name (optional)") },
|
||||
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 ->
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW -> AlertDialog(
|
||||
onDismissRequest = { pendingTrust = null },
|
||||
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({ 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
|
||||
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
||||
renameTarget?.let { kh ->
|
||||
var newName by remember(kh) { mutableStateOf(kh.name) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { renameTarget = null },
|
||||
title = { Text("Rename host") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text(kh.address) },
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = newName.isNotBlank(),
|
||||
onClick = {
|
||||
knownHostStore.rename(kh.address, kh.port, newName.trim())
|
||||
savedHosts = knownHostStore.all()
|
||||
renameTarget = null
|
||||
},
|
||||
) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { renameTarget = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
|
||||
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
|
||||
* the native core + MulticastLock) does not depend on it.
|
||||
*/
|
||||
fun hasNearbyPermission(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* True when a saved host and a discovered advert are the same machine — matched by certificate
|
||||
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
|
||||
* Mirrors the Apple client's `StoredHost.matches`; de-dupes "Discovered" against "Saved hosts".
|
||||
*/
|
||||
private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
|
||||
val advFp = dh.fingerprint?.lowercase()
|
||||
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
|
||||
return address == dh.host && port == dh.port
|
||||
}
|
||||
@@ -1,101 +1,19 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.Column
|
||||
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.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.discovery.HostDiscovery
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.IdentityStore
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
/**
|
||||
@@ -118,7 +36,7 @@ class MainActivity : ComponentActivity() {
|
||||
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||
)
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
PunktfunkTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) { App() }
|
||||
}
|
||||
}
|
||||
@@ -161,667 +79,56 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
||||
// buttons to the navigation keys the focus system understands; D-pad *keys* already move
|
||||
// focus on their own, so they fall through to super untouched.
|
||||
val mapped = when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element
|
||||
KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss
|
||||
else -> 0
|
||||
}
|
||||
if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped))
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
/** Last D-pad direction synthesised from a stick/HAT — edge detection (one focus move per push). */
|
||||
private var lastNavDir = 0
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (streamHandle != 0L && axisMapper?.onMotion(event) == true) return true
|
||||
if (streamHandle != 0L) {
|
||||
if (axisMapper?.onMotion(event) == true) return true
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
// 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
|
||||
// for stick-based navigation. Edge-detected so a held direction moves focus exactly once.
|
||||
if (event.isFromSource(InputDevice.SOURCE_JOYSTICK) ||
|
||||
event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
) {
|
||||
val x = event.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
.let { if (it != 0f) it else event.getAxisValue(MotionEvent.AXIS_X) }
|
||||
val y = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
.let { if (it != 0f) it else event.getAxisValue(MotionEvent.AXIS_Y) }
|
||||
val dir = when {
|
||||
x <= -0.5f -> KeyEvent.KEYCODE_DPAD_LEFT
|
||||
x >= 0.5f -> KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
y <= -0.5f -> KeyEvent.KEYCODE_DPAD_UP
|
||||
y >= 0.5f -> KeyEvent.KEYCODE_DPAD_DOWN
|
||||
else -> 0
|
||||
}
|
||||
if (dir != lastNavDir) {
|
||||
lastNavDir = dir
|
||||
if (dir != 0) {
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
||||
return true
|
||||
}
|
||||
} else if (dir != 0) {
|
||||
return true // already moved for this push; swallow until the stick returns to centre
|
||||
}
|
||||
}
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
/** Bottom-bar destinations (the immersive stream view is shown full-screen, outside the bar). */
|
||||
private enum class Tab(val label: String, val icon: ImageVector) {
|
||||
Connect("Connect", Icons.Filled.Home),
|
||||
Settings("Settings", Icons.Filled.Settings),
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* pair=optional; a pair=required host or a manually-typed/unknown-policy host goes straight to PIN
|
||||
* pairing ([Kind.PAIR]), and a changed fingerprint forces re-pairing — never a silent re-trust.
|
||||
*/
|
||||
private data class PendingTrust(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val name: String,
|
||||
val advertisedFp: String?,
|
||||
val kind: Kind,
|
||||
) {
|
||||
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun App() {
|
||||
val context = LocalContext.current
|
||||
val settingsStore = remember { SettingsStore(context) }
|
||||
var settings by remember { mutableStateOf(settingsStore.load()) }
|
||||
var streamHandle by remember { mutableStateOf(0L) } // 0 = not streaming
|
||||
var tab by remember { mutableStateOf(Tab.Connect) }
|
||||
|
||||
if (streamHandle != 0L) {
|
||||
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
|
||||
} 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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
when (tab) {
|
||||
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
|
||||
Tab.Settings -> SettingsScreen(
|
||||
initial = settings,
|
||||
onChange = { settings = it; settingsStore.save(it) },
|
||||
onBack = { tab = Tab.Connect },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var host by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("9777") }
|
||||
var connecting by remember { mutableStateOf(false) }
|
||||
var status by remember { mutableStateOf<String?>(null) }
|
||||
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
||||
val (w, h, hz) = settings.effectiveMode(context)
|
||||
|
||||
// mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the
|
||||
// onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.)
|
||||
// NsdManager discovery needs NEARBY_WIFI_DEVICES on Android 13+ (a runtime permission) — without
|
||||
// it discoverServices silently finds nothing. Request it once, then (re)start discovery on grant.
|
||||
val discovery = remember { HostDiscovery(context) }
|
||||
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
|
||||
var nearbyGranted by remember { mutableStateOf(hasNearbyPermission(context)) }
|
||||
val nearbyLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> nearbyGranted = granted }
|
||||
LaunchedEffect(Unit) {
|
||||
if (!nearbyGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||
}
|
||||
}
|
||||
DisposableEffect(nearbyGranted) {
|
||||
discovery.onChange = { discovered = it }
|
||||
if (nearbyGranted) discovery.start()
|
||||
onDispose {
|
||||
discovery.onChange = null
|
||||
discovery.stop()
|
||||
}
|
||||
}
|
||||
|
||||
val identityStore = remember { IdentityStore(context) }
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||
// 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).
|
||||
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { withContext(Dispatchers.IO) { obtainIdentity(identityStore) } }
|
||||
.onSuccess { identity = it }
|
||||
.onFailure { status = "Identity unavailable: ${it.message} — re-pair may be required" }
|
||||
}
|
||||
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
|
||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||
|
||||
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
|
||||
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
|
||||
// straight through and it appears in the saved-hosts list.
|
||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
val id = identity
|
||||
if (id == null) {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
connecting = true
|
||||
status = "Connecting to $targetHost:$targetPort…"
|
||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||
scope.launch {
|
||||
val handle = withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativeConnect(
|
||||
targetHost, targetPort, w, h, hz,
|
||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
||||
settings.bitrateKbps, settings.compositor, settings.gamepad,
|
||||
)
|
||||
}
|
||||
connecting = false
|
||||
if (handle != 0L) {
|
||||
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
||||
val fp = NativeBridge.nativeHostFingerprint(handle)
|
||||
if (fp.isNotEmpty()) {
|
||||
knownHostStore.save(KnownHost(targetHost, targetPort, name, fp, paired = false))
|
||||
}
|
||||
}
|
||||
onConnected(handle)
|
||||
} else {
|
||||
status = "Connection failed — check host/port, PIN, and logcat"
|
||||
discovery.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust state is
|
||||
// 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
|
||||
// pair=required host, or a manual/unknown-policy host, must pair by PIN.
|
||||
fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) {
|
||||
val known = knownHostStore.get(targetHost, targetPort)
|
||||
val adv = dh?.fingerprint?.lowercase()
|
||||
val name = dh?.name ?: targetHost
|
||||
when {
|
||||
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
|
||||
known != null && (adv == null || adv == known.fpHex) ->
|
||||
doConnect(targetHost, targetPort, known.name, known.fpHex)
|
||||
// Known host whose fp changed → force re-pairing (no silent re-trust shortcut).
|
||||
known != null -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED)
|
||||
// Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it,
|
||||
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
|
||||
dh?.pairingRequired == false -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
|
||||
// pair=required, or a manual/unknown-policy host → PIN pairing is mandatory.
|
||||
else -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.PAIR)
|
||||
}
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
var showManualSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
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))
|
||||
|
||||
status?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (savedHosts.isEmpty() && discovered.isEmpty()) {
|
||||
EmptyHostsState()
|
||||
}
|
||||
|
||||
if (savedHosts.isNotEmpty()) {
|
||||
SectionLabel("Saved hosts")
|
||||
savedHosts.forEach { kh ->
|
||||
HostCard(
|
||||
name = kh.name,
|
||||
address = "${kh.address}:${kh.port}",
|
||||
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
|
||||
enabled = !connecting,
|
||||
onConnect = { connect(kh.address, kh.port) },
|
||||
onForget = {
|
||||
knownHostStore.remove(kh.address, kh.port)
|
||||
savedHosts = knownHostStore.all()
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (discovered.isNotEmpty()) {
|
||||
SectionLabel("Discovered on the network")
|
||||
discovered.forEach { dh ->
|
||||
HostCard(
|
||||
name = dh.name,
|
||||
address = "${dh.host}:${dh.port}",
|
||||
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
|
||||
enabled = !connecting,
|
||||
onConnect = { connect(dh.host, dh.port, dh) },
|
||||
onForget = null,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(96.dp)) // clearance so the last card scrolls clear of the FAB
|
||||
}
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showManualSheet = true },
|
||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||
text = { Text("Add host") },
|
||||
expanded = !connecting,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (showManualSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showManualSheet = false },
|
||||
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 = host,
|
||||
onValueChange = { host = it },
|
||||
label = { Text("Host") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
||||
label = { Text("Port") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(
|
||||
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
|
||||
onClick = {
|
||||
val h = host.trim()
|
||||
val p = port.toIntOrNull() ?: 9777
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
||||
showManualSheet = false
|
||||
connect(h, p)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Connect ($w×$h@$hz)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingTrust?.let { pt ->
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW -> AlertDialog(
|
||||
onDismissRequest = { pendingTrust = null },
|
||||
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({ 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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** NsdManager discovery needs NEARBY_WIFI_DEVICES on API 33+; below that it doesn't apply. */
|
||||
private fun hasNearbyPermission(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Left-aligned section header above each block of the connect screen. */
|
||||
@Composable
|
||||
private fun SectionLabel(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Trust state of a host, shown as a colored pill on its card. */
|
||||
private enum class HostStatus(val label: String) {
|
||||
PAIRED("Paired"),
|
||||
PAIRING("PIN pairing"),
|
||||
TOFU("Trust on first use"),
|
||||
}
|
||||
|
||||
/**
|
||||
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
|
||||
* saved hosts) an overflow menu with Forget. Tapping the card connects.
|
||||
*/
|
||||
@Composable
|
||||
private fun HostCard(
|
||||
name: String,
|
||||
address: String,
|
||||
status: HostStatus,
|
||||
enabled: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
onForget: (() -> Unit)?,
|
||||
) {
|
||||
ElevatedCard(
|
||||
onClick = onConnect,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 14.dp, top = 12.dp, bottom = 12.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
HostAvatar(name)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
address,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
StatusPill(status)
|
||||
}
|
||||
if (onForget != null) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = "More")
|
||||
}
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Forget") },
|
||||
onClick = {
|
||||
menu = false
|
||||
onForget()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A circular avatar with the host's first letter (Apple-contact style). */
|
||||
@Composable
|
||||
private fun HostAvatar(name: String) {
|
||||
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
letter,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A small colored dot + label for the host's trust state. */
|
||||
@Composable
|
||||
private fun StatusPill(status: HostStatus) {
|
||||
val color = when (status) {
|
||||
HostStatus.PAIRED -> MaterialTheme.colorScheme.primary
|
||||
HostStatus.PAIRING -> MaterialTheme.colorScheme.tertiary
|
||||
HostStatus.TOFU -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(status.label, style = MaterialTheme.typography.labelMedium, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
/** Shown when there are no saved or discovered hosts. */
|
||||
@Composable
|
||||
private fun EmptyHostsState() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 56.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("No hosts yet", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Hosts on your network show up here automatically.\nTap “Add host” to enter one by address.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
val window = activity?.window
|
||||
// Start mic only if the user enabled it AND granted RECORD_AUDIO (else the AAudio input fails).
|
||||
val micWanted = micEnabled && ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
DisposableEffect(handle) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
activity?.streamHandle = handle // route hardware keys to this session
|
||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||
onDispose {
|
||||
feedback.stop() // stop + join the poll threads BEFORE nativeClose frees the handle
|
||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
||||
activity?.axisMapper = null
|
||||
activity?.streamHandle = 0L
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
// Leaving the stream: stop the mic + audio + decode threads and tear down the session.
|
||||
NativeBridge.nativeStopMic(handle)
|
||||
NativeBridge.nativeStopAudio(handle)
|
||||
NativeBridge.nativeStopVideo(handle)
|
||||
NativeBridge.nativeClose(handle)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler { onDisconnect() }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
SurfaceView(ctx).apply {
|
||||
holder.addCallback(object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
NativeBridge.nativeStartVideo(handle, holder.surface)
|
||||
NativeBridge.nativeStartAudio(handle)
|
||||
if (micWanted) NativeBridge.nativeStartMic(handle)
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
NativeBridge.nativeStopMic(handle)
|
||||
NativeBridge.nativeStopAudio(handle)
|
||||
NativeBridge.nativeStopVideo(handle)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
// Touch virtual-trackpad overlay: 1-finger drag → relative mouse move; tap → left click;
|
||||
// 2-finger drag → scroll. (Physical-mouse pointer capture comes in a later increment.)
|
||||
Box(
|
||||
Modifier.fillMaxSize().pointerInput(handle) {
|
||||
awaitEachGesture {
|
||||
val first = awaitFirstDown(requireUnconsumed = false)
|
||||
var moved = false
|
||||
var maxFingers = 1
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val fingers = ev.changes.count { it.pressed }
|
||||
if (fingers == 0) break
|
||||
if (fingers > maxFingers) maxFingers = fingers
|
||||
val primary = ev.changes.firstOrNull { it.id == first.id } ?: ev.changes.first()
|
||||
val d = primary.positionChange()
|
||||
if (abs(d.x) > 0.5f || abs(d.y) > 0.5f) {
|
||||
moved = true
|
||||
if (fingers >= 2) {
|
||||
// screen +y down → wire +up, so negate y. Coarse divisor; tune live.
|
||||
val sy = (-d.y / 4f).toInt()
|
||||
val sx = (d.x / 4f).toInt()
|
||||
if (sy != 0) NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
||||
if (sx != 0) NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
||||
} else {
|
||||
NativeBridge.nativeSendPointerMove(handle, d.x.toInt(), d.y.toInt())
|
||||
}
|
||||
}
|
||||
ev.changes.forEach { it.consume() }
|
||||
}
|
||||
if (!moved && maxFingers == 1) {
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, false)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Display
|
||||
|
||||
/**
|
||||
* User-tunable stream settings, persisted in `SharedPreferences`. A `0` resolution/refresh means
|
||||
@@ -16,6 +17,8 @@ data class Settings(
|
||||
val compositor: Int = 0,
|
||||
val gamepad: Int = 0,
|
||||
val micEnabled: Boolean = false,
|
||||
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
|
||||
val statsHudEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
||||
@@ -31,6 +34,7 @@ class SettingsStore(context: Context) {
|
||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
statsHudEnabled = prefs.getBoolean(K_HUD, true),
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -42,6 +46,7 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_COMPOSITOR, s.compositor)
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -53,6 +58,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_COMPOSITOR = "compositor"
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
const val K_MIC = "mic_enabled"
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +77,21 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
return Triple(maxOf(w, h), minOf(w, h), hz)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this device's display can actually present HDR10, so we should advertise HDR to the
|
||||
* host. On an SDR panel we advertise `0` instead — the host then sends a proper 8-bit BT.709 stream
|
||||
* rather than BT.2020 PQ the panel would mis-tone-map (the washed-out/dark failure). Mirrors the
|
||||
* capability gate the Apple/Windows clients apply.
|
||||
*/
|
||||
fun displaySupportsHdr(context: Context): Boolean {
|
||||
val display = runCatching { context.display }.getOrNull() ?: return false
|
||||
@Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31
|
||||
val caps = display.hdrCapabilities ?: return false
|
||||
return caps.supportedHdrTypes.any {
|
||||
it == Display.HdrCapabilities.HDR_TYPE_HDR10 || it == Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
||||
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
||||
val native = nativeDisplayMode(context)
|
||||
@@ -121,9 +142,11 @@ val COMPOSITOR_OPTIONS = listOf(
|
||||
"gamescope",
|
||||
)
|
||||
|
||||
/** index = GamepadPref wire byte. */
|
||||
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
|
||||
val GAMEPAD_OPTIONS = listOf(
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
"DualSense",
|
||||
"Xbox One",
|
||||
"DualShock 4",
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
@@ -34,8 +36,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Stream settings. Edits are persisted immediately via [onChange]; [onBack] returns to the connect
|
||||
* screen. Resolution/refresh "Native" resolve from the device display at connect time.
|
||||
* Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted
|
||||
* immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native"
|
||||
* resolve from the device display at connect time.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
|
||||
@@ -48,58 +51,62 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
|
||||
val micLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> update(s.copy(micEnabled = granted)) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
val (nw, nh, nhz) = nativeDisplayMode(context)
|
||||
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)) }
|
||||
SettingsGroup("Display") {
|
||||
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 = "Bitrate",
|
||||
options = BITRATE_OPTIONS,
|
||||
selected = s.bitrateKbps,
|
||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
||||
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 = "Compositor (virtual-display host backend)",
|
||||
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.compositor,
|
||||
) { c -> update(s.copy(compositor = c)) }
|
||||
SettingDropdown(
|
||||
label = "Bitrate",
|
||||
options = BITRATE_OPTIONS,
|
||||
selected = s.bitrateKbps,
|
||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
||||
}
|
||||
|
||||
SettingDropdown(
|
||||
label = "Controller type",
|
||||
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.gamepad,
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
SettingsGroup("Host") {
|
||||
SettingDropdown(
|
||||
label = "Compositor",
|
||||
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.compositor,
|
||||
) { c -> update(s.copy(compositor = c)) }
|
||||
|
||||
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
|
||||
val micLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> update(s.copy(micEnabled = granted)) }
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Microphone", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Send your mic to the host's virtual microphone",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
SettingDropdown(
|
||||
label = "Controller type",
|
||||
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.gamepad,
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
}
|
||||
|
||||
SettingsGroup("Audio") {
|
||||
ToggleRow(
|
||||
title = "Microphone",
|
||||
subtitle = "Send your mic to the host's virtual microphone",
|
||||
checked = s.micEnabled,
|
||||
onCheckedChange = { on ->
|
||||
when {
|
||||
@@ -111,6 +118,56 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Overlay") {
|
||||
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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A titled group of settings rendered inside an outlined card. */
|
||||
@Composable
|
||||
private fun SettingsGroup(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),
|
||||
)
|
||||
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. */
|
||||
@Composable
|
||||
private fun ToggleRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.WindowManager
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
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
|
||||
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
val window = activity?.window
|
||||
val controller = remember(window) {
|
||||
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
|
||||
}
|
||||
|
||||
// Start mic only if the user enabled it AND granted RECORD_AUDIO (else the AAudio input fails).
|
||||
val micWanted = micEnabled && ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
// Live decode stats for the HUD. Poll once a second for the whole stream (cheap, and each call
|
||||
// drains+resets the native window so it never grows unbounded even while the overlay is hidden);
|
||||
// `showStats` only gates rendering. A 3-finger tap toggles it live; the default comes from Settings.
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var showStats by remember { mutableStateOf(SettingsStore(context).load().statsHudEnabled) }
|
||||
LaunchedEffect(handle) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
stats = NativeBridge.nativeVideoStats(handle)
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot teardown guard. Both the SurfaceView callback and DisposableEffect tear down on the
|
||||
// way out, but `nativeClose` frees the handle — so once it's closed, NO path may touch the handle
|
||||
// again (use-after-free → SIGSEGV: the consistent back-while-streaming crash). Both run on the
|
||||
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
|
||||
val closed = remember { AtomicBoolean(false) }
|
||||
|
||||
DisposableEffect(handle) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
controller?.let {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
activity?.streamHandle = handle // route hardware keys to this session
|
||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||
onDispose {
|
||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||
feedback.stop() // stop + join the poll threads BEFORE nativeClose frees the handle
|
||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
||||
activity?.axisMapper = null
|
||||
activity?.streamHandle = 0L
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
// Leaving the stream: stop the mic + audio + decode threads and tear down the session.
|
||||
NativeBridge.nativeStopMic(handle)
|
||||
NativeBridge.nativeStopAudio(handle)
|
||||
NativeBridge.nativeStopVideo(handle)
|
||||
NativeBridge.nativeClose(handle)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler { onDisconnect() }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
SurfaceView(ctx).apply {
|
||||
holder.addCallback(object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
NativeBridge.nativeStartVideo(handle, holder.surface)
|
||||
NativeBridge.nativeStartAudio(handle)
|
||||
if (micWanted) NativeBridge.nativeStartMic(handle)
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
// Surface gone (backgrounding, or on the way out). Stop the threads that
|
||||
// render to it — but only while the session is still open. Once
|
||||
// DisposableEffect has closed it, the handle is freed; dereferencing it
|
||||
// here is the use-after-free that crashed on back-navigation.
|
||||
if (!closed.get()) {
|
||||
NativeBridge.nativeStopMic(handle)
|
||||
NativeBridge.nativeStopAudio(handle)
|
||||
NativeBridge.nativeStopVideo(handle)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
|
||||
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||
if (showStats) {
|
||||
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
||||
}
|
||||
// Touch → mouse, absolute "direct pointing" like the Apple client: the host cursor follows
|
||||
// your finger (MouseMoveAbs, host-normalized against the overlay size — which fills the video,
|
||||
// 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(
|
||||
Modifier.fillMaxSize().pointerInput(handle) {
|
||||
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
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
// 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.
|
||||
private val BrandDark = darkColorScheme(
|
||||
primary = Color(0xFFA79FF8),
|
||||
onPrimary = Color(0xFF1B1442),
|
||||
primaryContainer = Color(0xFF4C3FB3),
|
||||
onPrimaryContainer = Color(0xFFE5E0FF),
|
||||
secondary = Color(0xFFC8C2EC),
|
||||
onSecondary = Color(0xFF2E2A4D),
|
||||
tertiary = Color(0xFF8FD0E8),
|
||||
onTertiary = Color(0xFF053543),
|
||||
background = Color(0xFF131129),
|
||||
onBackground = Color(0xFFE5E1F2),
|
||||
surface = Color(0xFF1A1733),
|
||||
onSurface = Color(0xFFE5E1F2),
|
||||
surfaceVariant = Color(0xFF2A2647),
|
||||
onSurfaceVariant = Color(0xFFC7C2DE),
|
||||
)
|
||||
|
||||
/**
|
||||
* App theme — always dark (a streaming client reads best on a dark canvas, and the immersive
|
||||
* stream view assumes it), but uses **Material You** dynamic colour on Android 12+ so the UI
|
||||
* harmonises with the user's wallpaper, falling back to the punktfunk brand violets below that.
|
||||
*/
|
||||
@Composable
|
||||
fun PunktfunkTheme(content: @Composable () -> Unit) {
|
||||
val scheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
dynamicDarkColorScheme(LocalContext.current)
|
||||
} else {
|
||||
BrandDark
|
||||
}
|
||||
MaterialTheme(colorScheme = scheme, content = content)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package io.unom.punktfunk.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
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.getValue
|
||||
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.focus.onFocusChanged
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
|
||||
/** Left-aligned section header above each block of the connect screen. */
|
||||
@Composable
|
||||
fun SectionLabel(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
|
||||
* saved hosts) an overflow menu with Rename / Forget. Tapping the card connects.
|
||||
*/
|
||||
@Composable
|
||||
fun HostCard(
|
||||
name: String,
|
||||
address: String,
|
||||
status: HostStatus,
|
||||
enabled: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
onForget: (() -> Unit)?,
|
||||
onRename: (() -> Unit)? = null,
|
||||
) {
|
||||
// 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.
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
ElevatedCard(
|
||||
onClick = onConnect,
|
||||
enabled = enabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.then(
|
||||
if (focused) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CardDefaults.elevatedShape)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
HostAvatar(name)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
address,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
StatusPill(status)
|
||||
}
|
||||
|
||||
if (onForget != null || onRename != null) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||
Icon(
|
||||
Icons.Filled.MoreVert,
|
||||
contentDescription = "More",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
if (onRename != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
onClick = {
|
||||
menu = false
|
||||
onRename()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (onForget != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Forget") },
|
||||
onClick = {
|
||||
menu = false
|
||||
onForget()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A circular avatar with the host's first letter (Apple-contact style). */
|
||||
@Composable
|
||||
fun HostAvatar(name: String) {
|
||||
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
letter,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A small colored dot + label for the host's trust state. */
|
||||
@Composable
|
||||
fun StatusPill(status: HostStatus) {
|
||||
val color = when (status) {
|
||||
HostStatus.PAIRED -> MaterialTheme.colorScheme.primary
|
||||
HostStatus.PAIRING -> MaterialTheme.colorScheme.tertiary
|
||||
HostStatus.TOFU -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(status.label, style = MaterialTheme.typography.labelMedium, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
/** Shown when there are no saved or discovered hosts. */
|
||||
@Composable
|
||||
fun EmptyHostsState() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 56.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("No hosts yet", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Hosts on your network show up here automatically.\nTap “Add host” to enter one by address.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package io.unom.punktfunk.models
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/** Bottom-bar destinations (the immersive stream view is shown full-screen, outside the bar). */
|
||||
enum class Tab(val label: String, val icon: ImageVector) {
|
||||
Connect("Connect", Icons.Filled.Home),
|
||||
Settings("Settings", Icons.Filled.Settings),
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* pair=optional; a pair=required host or a manually-typed/unknown-policy host goes straight to PIN
|
||||
* pairing ([Kind.PAIR]), and a changed fingerprint forces re-pairing — never a silent re-trust.
|
||||
*/
|
||||
data class PendingTrust(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val name: String,
|
||||
val advertisedFp: String?,
|
||||
val kind: Kind,
|
||||
) {
|
||||
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR }
|
||||
}
|
||||
|
||||
/** Trust state of a host, shown as a colored pill on its card. */
|
||||
enum class HostStatus(val label: String) {
|
||||
PAIRED("Paired"),
|
||||
PAIRING("PIN pairing"),
|
||||
TOFU("Trust on first use"),
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
// Toolchain: AGP 9.2.0 · Gradle 9.4.1 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM
|
||||
// 2026.05.01 · compileSdk 37 · targetSdk 36 · minSdk 31.
|
||||
plugins {
|
||||
id("com.android.application") version "9.2.0" apply false
|
||||
id("com.android.library") version "9.2.0" apply false
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("com.android.library") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Upload a signed AAB to Google Play via the Publishing API — a direct replacement for
|
||||
r0adkll/upload-google-play, which swallows real API errors into "Unknown error occurred."
|
||||
|
||||
Why hand-rolled: stdlib + `openssl` only (no pip on the runner), and it prints Google's actual
|
||||
error at the stage it fails instead of a catch-all. Reuses the SERVICE_ACCOUNT_JSON secret and
|
||||
tolerates it being raw JSON *or* base64-encoded JSON.
|
||||
|
||||
Usage:
|
||||
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--aab path/to/app-release.aab --track internal --status completed [--no-commit]
|
||||
|
||||
--no-commit: do insert -> upload -> track-update -> validate, then delete the edit (publishes
|
||||
nothing). Use it to dry-run the credentials/AAB without touching the live track.
|
||||
"""
|
||||
import argparse, base64, json, os, subprocess, sys, tempfile, time
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
|
||||
API = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
||||
UPLOAD = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
def __init__(self, code, method, url, body):
|
||||
super().__init__(f"HTTP {code} from {method} {url}\n{body}")
|
||||
self.code, self.body = code, body
|
||||
|
||||
|
||||
def _b64url(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def call(method, url, token=None, data=None, content_type=None, want_json=True):
|
||||
headers = {}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300) as r:
|
||||
body = r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
|
||||
return json.loads(body) if (want_json and body) else body
|
||||
|
||||
|
||||
def load_sa():
|
||||
raw = os.environ.get("SERVICE_ACCOUNT_JSON", "")
|
||||
if not raw.strip():
|
||||
sys.exit("ERROR: SERVICE_ACCOUNT_JSON env is empty")
|
||||
try: # raw JSON (what r0adkll expects)
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
try: # or base64-encoded JSON (common mistake)
|
||||
sa = json.loads(base64.b64decode(raw))
|
||||
print("note: SERVICE_ACCOUNT_JSON was base64-encoded; decoded it.")
|
||||
return sa
|
||||
except Exception:
|
||||
sys.exit("ERROR: SERVICE_ACCOUNT_JSON is neither valid JSON nor base64-encoded JSON")
|
||||
|
||||
|
||||
def access_token(sa) -> str:
|
||||
now = int(time.time())
|
||||
header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode())
|
||||
claims = _b64url(json.dumps({
|
||||
"iss": sa["client_email"],
|
||||
"scope": "https://www.googleapis.com/auth/androidpublisher",
|
||||
"aud": sa["token_uri"], "iat": now, "exp": now + 3600,
|
||||
}).encode())
|
||||
signing_input = f"{header}.{claims}".encode()
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False) as f:
|
||||
f.write(sa["private_key"])
|
||||
keyfile = f.name
|
||||
try:
|
||||
sig = subprocess.run(["openssl", "dgst", "-sha256", "-sign", keyfile],
|
||||
input=signing_input, capture_output=True, check=True).stdout
|
||||
finally:
|
||||
os.unlink(keyfile)
|
||||
jwt = f"{header}.{claims}.{_b64url(sig)}"
|
||||
tok = call("POST", sa["token_uri"],
|
||||
data=urllib.parse.urlencode({
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
"assertion": jwt}).encode(),
|
||||
content_type="application/x-www-form-urlencoded")
|
||||
return tok["access_token"]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--package", required=True)
|
||||
ap.add_argument("--aab", required=True)
|
||||
ap.add_argument("--track", default="internal")
|
||||
ap.add_argument("--status", default="completed")
|
||||
ap.add_argument("--no-commit", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(a.aab):
|
||||
sys.exit(f"ERROR: AAB not found: {a.aab}")
|
||||
|
||||
sa = load_sa()
|
||||
tok = access_token(sa)
|
||||
print(f"authenticated as {sa['client_email']} (project {sa.get('project_id')})")
|
||||
app = f"{API}/{a.package}"
|
||||
|
||||
try:
|
||||
edit = call("POST", f"{app}/edits", token=tok)["id"]
|
||||
with open(a.aab, "rb") as f:
|
||||
blob = f.read()
|
||||
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
|
||||
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
|
||||
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
|
||||
print(f"uploaded versionCode={vc}")
|
||||
call("PUT", f"{app}/edits/{edit}/tracks/{a.track}", token=tok,
|
||||
data=json.dumps({"track": a.track,
|
||||
"releases": [{"status": a.status, "versionCodes": [str(vc)]}]}).encode(),
|
||||
content_type="application/json")
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}")
|
||||
|
||||
if a.no_commit:
|
||||
call("POST", f"{app}/edits/{edit}:validate", token=tok)
|
||||
print("validated (dry-run) OK — deleting edit, nothing published")
|
||||
call("DELETE", f"{app}/edits/{edit}", token=tok, want_json=False)
|
||||
return
|
||||
|
||||
try:
|
||||
call("POST", f"{app}/edits/{edit}:commit", token=tok)
|
||||
except ApiError as e:
|
||||
if "changesNotSentForReview" in e.body:
|
||||
print("commit needs changesNotSentForReview=true — retrying")
|
||||
call("POST", f"{app}/edits/{edit}:commit?changesNotSentForReview=true", token=tok)
|
||||
else:
|
||||
raise
|
||||
print(f"COMMITTED: versionCode={vc} live on track '{a.track}' ({a.status})")
|
||||
except ApiError as e:
|
||||
print(f"\nPLAY API ERROR:\n{e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,12 @@
|
||||
#This file is generated by updateDaemonJvm
|
||||
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
|
||||
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
|
||||
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
|
||||
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
|
||||
toolchainVersion=21
|
||||
@@ -7,7 +7,7 @@ plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
val ndkVer = "28.2.13676358" // r28 LTS — matches the SDK NDK installed for cargo-ndk
|
||||
val ndkVer = "30.0.14904198" // r30-beta1 — matches the SDK NDK installed for cargo-ndk
|
||||
|
||||
android {
|
||||
namespace = "io.unom.punktfunk.kit"
|
||||
@@ -32,7 +32,7 @@ dependencies {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// cargo-ndk: cross-compile crates/punktfunk-android into this module's jniLibs/<abi>/ so the
|
||||
// cargo-ndk: cross-compile clients/android/native (punktfunk-client-android) into this module's jniLibs/<abi>/ so the
|
||||
// resulting libpunktfunk_android.so is packaged into the app (and any AAR this module produces).
|
||||
// NDK r28+ aligns to 16 KB pages by default — no extra linker flags. Prereqs (see clients/android
|
||||
// /README.md): `cargo install cargo-ndk` + `rustup target add aarch64-linux-android x86_64-linux-android`.
|
||||
@@ -57,7 +57,7 @@ fun androidSdkDir(): String {
|
||||
fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
tasks.register<Exec>(taskName) {
|
||||
group = "rust"
|
||||
description = "cargo-ndk build of punktfunk-android (${if (release) "release" else "debug"})"
|
||||
description = "cargo-ndk build of punktfunk-client-android (${if (release) "release" else "debug"})"
|
||||
workingDir = repoRoot
|
||||
val sdk = androidSdkDir()
|
||||
// A GUI Android Studio launch does not source the login shell, so make cargo, the NDK, and
|
||||
@@ -78,13 +78,18 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
// (pure C) so the android .so links it instead of looking for the host's libopus.so.
|
||||
environment("LIBOPUS_STATIC", "1")
|
||||
environment("LIBOPUS_NO_PKG", "1")
|
||||
// Resolve cargo by ABSOLUTE path: Gradle's Exec resolves command[0] via the JVM's
|
||||
// inherited PATH, NOT the environment("PATH", …) set above (that only reaches the spawned
|
||||
// child). A GUI Android Studio launch (and any daemon it started) has no ~/.cargo/bin on
|
||||
// its PATH, so a bare "cargo" fails to start. The env PATH above still lets cargo/cargo-ndk
|
||||
// find their subtools.
|
||||
val cmd = mutableListOf(
|
||||
"cargo", "ndk",
|
||||
"$cargoBin/cargo", "ndk",
|
||||
"-t", "arm64-v8a", "-t", "x86_64",
|
||||
// Link against the minSdk-31 sysroot so libaaudio (API 26+) is found.
|
||||
"--platform", "31",
|
||||
"-o", file("src/main/jniLibs").absolutePath,
|
||||
"build", "-p", "punktfunk-android",
|
||||
"build", "-p", "punktfunk-client-android",
|
||||
)
|
||||
if (release) cmd += "--release"
|
||||
commandLine(cmd)
|
||||
|
||||
@@ -44,6 +44,71 @@ object Gamepad {
|
||||
const val AXIS_LT = 4
|
||||
const val AXIS_RT = 5
|
||||
|
||||
// GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`.
|
||||
const val PREF_AUTO = 0
|
||||
const val PREF_XBOX360 = 1
|
||||
const val PREF_DUALSENSE = 2
|
||||
const val PREF_XBOXONE = 3
|
||||
const val PREF_DUALSHOCK4 = 4
|
||||
|
||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||
private const val VID_SONY = 0x054C
|
||||
private const val VID_MICROSOFT = 0x045E
|
||||
|
||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
||||
|
||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
private val PID_XBOXONE = setOf(
|
||||
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve a connected controller's [GamepadPref] wire byte from its USB VID/PID, mirroring the
|
||||
* Linux client's `pref_for_type` (SDL3 `GamepadType`) and the Apple client's GameController type
|
||||
* auto-resolution. Android exposes no controller-type enum, so we match `getVendorId()` /
|
||||
* `getProductId()`. Used only when the user picked "Automatic" — an explicit choice is honored as
|
||||
* is. An unrecognized pad (or none) falls back to [PREF_XBOX360], the safe XInput default the
|
||||
* host always supports. Never returns [PREF_AUTO] (the host would then decide) — once we have a
|
||||
* physical pad we resolve it concretely, matching the other native clients.
|
||||
*/
|
||||
fun prefFor(dev: InputDevice?): Int {
|
||||
if (dev == null) return PREF_XBOX360
|
||||
val vid = dev.vendorId
|
||||
val pid = dev.productId
|
||||
return when {
|
||||
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
||||
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||
else -> PREF_XBOX360
|
||||
}
|
||||
}
|
||||
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? {
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
val s = d.sources
|
||||
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A
|
||||
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
|
||||
* type from the first connected controller via [prefFor] (so the host gets the right pad even
|
||||
* though Android can't tell it the controller type any other way).
|
||||
*/
|
||||
fun resolvePref(setting: Int): Int =
|
||||
if (setting == PREF_AUTO) prefFor(firstPad()) else setting
|
||||
|
||||
/**
|
||||
* Gamepad `KEYCODE_*` → BTN_* bit, or 0 if not a gamepad button we forward. A/B/X/Y are
|
||||
* positional (Xbox layout; Nintendo relabeling needs device-type detection, deferred).
|
||||
|
||||
@@ -78,9 +78,19 @@ class GamepadFeedback(private val handle: Long) {
|
||||
/** Idempotent. Stops + joins the poll threads (must complete before the session handle is freed). */
|
||||
fun stop() {
|
||||
running = false
|
||||
rumbleThread?.interrupt()
|
||||
hidoutThread?.interrupt()
|
||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||
runCatching { rumbleThread?.join(500) }
|
||||
runCatching { hidoutThread?.join(500) }
|
||||
// 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
|
||||
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
||||
// would let a thread survive into the freed handle → use-after-free SIGSEGV (the
|
||||
// back-while-streaming crash, on the one path the main-thread `closed` guard can't cover).
|
||||
// Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms)
|
||||
// and rendering is a quick best-effort binder call, so each thread observes running=false and
|
||||
// exits within ~one timeout — the join returns promptly (well under any ANR threshold).
|
||||
runCatching { rumbleThread?.join() }
|
||||
runCatching { hidoutThread?.join() }
|
||||
rumbleThread = null
|
||||
hidoutThread = null
|
||||
runCatching { lightsSession?.close() }
|
||||
@@ -92,18 +102,7 @@ class GamepadFeedback(private val handle: Long) {
|
||||
}
|
||||
|
||||
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
|
||||
private fun resolvePad(): InputDevice? {
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
val s = d.sources
|
||||
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
private fun resolvePad(): InputDevice? = Gamepad.firstPad()
|
||||
|
||||
// ---- Rumble ----
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package io.unom.punktfunk.kit
|
||||
/**
|
||||
* The single JNI seam to `libpunktfunk_android.so` (the Rust-heavy client core).
|
||||
*
|
||||
* Symbols are implemented in `crates/punktfunk-android`. This object is intentionally thin —
|
||||
* Symbols are implemented in `clients/android/native`. This object is intentionally thin —
|
||||
* all protocol logic lives in Rust (`punktfunk-core` + the connector); Kotlin only marshals.
|
||||
*/
|
||||
object NativeBridge {
|
||||
@@ -44,6 +44,7 @@ object NativeBridge {
|
||||
bitrateKbps: Int,
|
||||
compositorPref: Int,
|
||||
gamepadPref: Int,
|
||||
hdrEnabled: Boolean,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
@@ -66,6 +67,27 @@ object NativeBridge {
|
||||
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
|
||||
external fun nativeClose(handle: Long)
|
||||
|
||||
// ---- LAN discovery: mDNS browse of `_punktfunk._udp` in Rust (mdns-sd), polled by Kotlin ----
|
||||
// Replaces NsdManager. The caller holds the Wi-Fi MulticastLock for the browse lifetime; raw
|
||||
// multicast *reception* needs it. See io.unom.punktfunk.kit.discovery.HostDiscovery.
|
||||
|
||||
/**
|
||||
* Start browsing `_punktfunk._udp` on the LAN. Returns an opaque discovery handle, or `0` on
|
||||
* failure. Pair with exactly one [nativeDiscoveryStop]. Cheap + non-blocking (spawns the mDNS
|
||||
* daemon + a fold thread).
|
||||
*/
|
||||
external fun nativeDiscoveryStart(): Long
|
||||
|
||||
/**
|
||||
* The current resolved-host snapshot for [handle]: newline-joined records, each
|
||||
* `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
|
||||
* cheap (a lock + string build), safe to call on the main thread.
|
||||
*/
|
||||
external fun nativeDiscoveryPoll(handle: Long): String
|
||||
|
||||
/** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */
|
||||
external fun nativeDiscoveryStop(handle: Long)
|
||||
|
||||
/**
|
||||
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
||||
@@ -75,6 +97,14 @@ object NativeBridge {
|
||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||
external fun nativeStopVideo(handle: Long)
|
||||
|
||||
/**
|
||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||
* Returns 10 doubles:
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]`
|
||||
* (the two flags are 1.0/0.0). Poll ~1 Hz; each call resets the measurement window.
|
||||
*/
|
||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -99,6 +129,13 @@ object NativeBridge {
|
||||
/** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */
|
||||
external fun nativeSendPointerMove(handle: Long, dx: Int, dy: Int)
|
||||
|
||||
/**
|
||||
* Absolute mouse position — the host moves the cursor to (x, y) in a [surfaceWidth]×[surfaceHeight]
|
||||
* pixel space (it normalizes against that size and maps into the output region). Touch
|
||||
* "direct pointing": the cursor jumps to the finger. Parity with the Apple client's absolute touch.
|
||||
*/
|
||||
external fun nativeSendPointerAbs(handle: Long, x: Int, y: Int, surfaceWidth: Int, surfaceHeight: Int)
|
||||
|
||||
/** One mouse-button transition. button: 1=left 2=middle 3=right 4=X1 5=X2. */
|
||||
external fun nativeSendPointerButton(handle: Long, button: Int, down: Boolean)
|
||||
|
||||
|
||||
+88
-138
@@ -1,17 +1,13 @@
|
||||
package io.unom.punktfunk.kit.discovery
|
||||
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
|
||||
private const val TAG = "PunktfunkNsd"
|
||||
|
||||
/** DNS-SD service type punktfunk hosts advertise (host: `_punktfunk._udp.local.`). */
|
||||
const val PUNKTFUNK_SERVICE_TYPE = "_punktfunk._udp"
|
||||
const val PUNKTFUNK_PROTO = "punktfunk/1"
|
||||
private const val TAG = "PunktfunkMdns"
|
||||
|
||||
/** One resolved host fit for the picker. [key] is the stable dedup id. */
|
||||
data class DiscoveredHost(
|
||||
@@ -23,165 +19,115 @@ data class DiscoveredHost(
|
||||
val pairingRequired: Boolean = false,
|
||||
)
|
||||
|
||||
/** Parsed TXT fields. Pure — unit-testable without Android (see ParseTxtTest). */
|
||||
data class TxtFields(
|
||||
val proto: String?,
|
||||
val fp: String?,
|
||||
val pair: String?,
|
||||
val id: String?,
|
||||
) {
|
||||
val pairingRequired: Boolean get() = pair == "required"
|
||||
val isPunktfunk: Boolean get() = proto == PUNKTFUNK_PROTO
|
||||
}
|
||||
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||
private const val FIELD_SEP = '\u001F'
|
||||
|
||||
/**
|
||||
* Pure TXT parser. NSD hands TXT as a `Map<String, ByteArray?>` (a null/empty value = present-but-
|
||||
* empty key). Decode UTF-8; missing keys are null, never an error.
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
|
||||
* if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side
|
||||
* already applied the protocol gate and address selection, so this is just field marshaling.
|
||||
*/
|
||||
fun parseTxt(attrs: Map<String, ByteArray?>): TxtFields {
|
||||
fun s(k: String): String? = attrs[k]?.takeIf { it.isNotEmpty() }?.toString(Charsets.UTF_8)
|
||||
return TxtFields(proto = s("proto"), fp = s("fp"), pair = s("pair"), id = s("id"))
|
||||
fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
val f = record.split(FIELD_SEP)
|
||||
if (f.size < 6) return null
|
||||
val addr = f[2]
|
||||
val port = f[3].toIntOrNull() ?: return null
|
||||
if (addr.isBlank() || port !in 1..65535) return null
|
||||
return DiscoveredHost(
|
||||
key = f[0].ifBlank { "$addr:$port" },
|
||||
name = f[1].ifBlank { addr },
|
||||
host = addr,
|
||||
port = port,
|
||||
fingerprint = f[4].ifBlank { null },
|
||||
pairingRequired = f[5] == "required",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Browses `_punktfunk._udp` via NsdManager, resolves each service (the reliable
|
||||
* `registerServiceInfoCallback` path on API 34+, legacy `resolveService` on 31–33 where its TXT is
|
||||
* often empty), and pushes the live host set to [onChange] (invoked on the main thread).
|
||||
* Browses `_punktfunk._udp` for punktfunk/1 hosts via the native `mdns-sd` core (the same browse the
|
||||
* Linux/Windows clients use), exposed over JNI — *not* `NsdManager`, whose per-OEM system daemon
|
||||
* made discovery "mostly broken". [start] spins up the native browse and polls it ~1 Hz on the main
|
||||
* thread, pushing the live host set to [onChange] (also on the main thread, only when it changes);
|
||||
* [stop] tears it down.
|
||||
*
|
||||
* Lifecycle: [start] when the picker appears, [stop] when it leaves / on connect — holds a
|
||||
* MulticastLock while running (an OEM Wi-Fi power-save hedge). Note: the Android emulator's SLIRP
|
||||
* NAT drops multicast, so on the emulator discovery starts but never finds a LAN host.
|
||||
* We hold a Wi-Fi [WifiManager.MulticastLock] for the browse lifetime — raw multicast *reception*
|
||||
* needs it. (The Android emulator's SLIRP NAT drops multicast, so on the emulator discovery starts
|
||||
* but never finds a LAN host — same as before; that's the network, not the API.)
|
||||
*/
|
||||
class HostDiscovery(context: Context) {
|
||||
private val appCtx = context.applicationContext
|
||||
private val nsd = appCtx.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
|
||||
/** Invoked on the main thread whenever the resolved host set changes. */
|
||||
var onChange: ((List<DiscoveredHost>) -> Unit)? = null
|
||||
|
||||
private val resolved = LinkedHashMap<String, DiscoveredHost>() // key -> host
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var multicastLock: WifiManager.MulticastLock? = null
|
||||
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
||||
private val infoCallbacks = mutableListOf<NsdManager.ServiceInfoCallback>() // API 34+ registrations
|
||||
private var nativeHandle = 0L
|
||||
private var running = false
|
||||
private var last: List<DiscoveredHost> = emptyList()
|
||||
|
||||
@Synchronized
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
acquireMulticastLock()
|
||||
val listener = makeDiscoveryListener()
|
||||
discoveryListener = listener
|
||||
runCatching {
|
||||
nsd.discoverServices(PUNKTFUNK_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
|
||||
}.onFailure {
|
||||
Log.e(TAG, "discoverServices failed", it)
|
||||
stop()
|
||||
private val poll = object : Runnable {
|
||||
override fun run() {
|
||||
if (!running) return
|
||||
val hosts = snapshot()
|
||||
if (hosts != last) {
|
||||
last = hosts
|
||||
onChange?.invoke(hosts)
|
||||
}
|
||||
handler.postDelayed(this, POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
if (!running) return
|
||||
running = false
|
||||
discoveryListener?.let { runCatching { nsd.stopServiceDiscovery(it) } }
|
||||
discoveryListener = null
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
for (cb in infoCallbacks) runCatching { nsd.unregisterServiceInfoCallback(cb) }
|
||||
fun start() {
|
||||
if (running) return
|
||||
acquireMulticastLock()
|
||||
val h = runCatching { NativeBridge.nativeDiscoveryStart() }
|
||||
.onFailure { Log.e(TAG, "nativeDiscoveryStart threw", it) }
|
||||
.getOrDefault(0L)
|
||||
if (h == 0L) {
|
||||
Log.e(TAG, "native mDNS discovery failed to start")
|
||||
releaseMulticastLock()
|
||||
return
|
||||
}
|
||||
infoCallbacks.clear()
|
||||
nativeHandle = h
|
||||
running = true
|
||||
last = emptyList()
|
||||
handler.post(poll)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!running && nativeHandle == 0L) return
|
||||
running = false
|
||||
handler.removeCallbacks(poll)
|
||||
val h = nativeHandle
|
||||
nativeHandle = 0L
|
||||
if (h != 0L) runCatching { NativeBridge.nativeDiscoveryStop(h) }
|
||||
.onFailure { Log.e(TAG, "nativeDiscoveryStop threw", it) }
|
||||
releaseMulticastLock()
|
||||
resolved.clear()
|
||||
last = emptyList()
|
||||
onChange?.invoke(emptyList())
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
onChange?.invoke(resolved.values.sortedBy { it.name.lowercase() })
|
||||
}
|
||||
|
||||
private fun makeDiscoveryListener() = object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(type: String) {
|
||||
Log.d(TAG, "discovery started: $type")
|
||||
}
|
||||
override fun onDiscoveryStopped(type: String) {
|
||||
Log.d(TAG, "discovery stopped: $type")
|
||||
}
|
||||
override fun onStartDiscoveryFailed(type: String, code: Int) {
|
||||
Log.e(TAG, "start discovery failed: $code")
|
||||
runCatching { nsd.stopServiceDiscovery(this) }
|
||||
}
|
||||
override fun onStopDiscoveryFailed(type: String, code: Int) {
|
||||
Log.e(TAG, "stop discovery failed: $code")
|
||||
}
|
||||
|
||||
override fun onServiceFound(info: NsdServiceInfo) {
|
||||
Log.d(TAG, "found: ${info.serviceName}")
|
||||
resolve(info)
|
||||
}
|
||||
override fun onServiceLost(info: NsdServiceInfo) {
|
||||
Log.d(TAG, "lost: ${info.serviceName}")
|
||||
// onServiceLost carries no TXT, so drop by the instance-name fallback key only.
|
||||
if (resolved.remove(info.serviceName) != null) publish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolve(found: NsdServiceInfo) {
|
||||
if (Build.VERSION.SDK_INT >= 34) resolveViaCallback(found) else resolveViaLegacy(found)
|
||||
}
|
||||
|
||||
private fun resolveViaCallback(found: NsdServiceInfo) {
|
||||
val cb = object : NsdManager.ServiceInfoCallback {
|
||||
override fun onServiceUpdated(info: NsdServiceInfo) = ingest(info)
|
||||
override fun onServiceLost() {}
|
||||
override fun onServiceInfoCallbackRegistrationFailed(code: Int) {
|
||||
Log.e(TAG, "ServiceInfoCallback reg failed: $code")
|
||||
}
|
||||
override fun onServiceInfoCallbackUnregistered() {}
|
||||
}
|
||||
runCatching {
|
||||
nsd.registerServiceInfoCallback(found, appCtx.mainExecutor, cb)
|
||||
infoCallbacks.add(cb)
|
||||
}.onFailure { Log.e(TAG, "registerServiceInfoCallback failed", it) }
|
||||
}
|
||||
|
||||
private fun resolveViaLegacy(found: NsdServiceInfo) {
|
||||
// A ResolveListener can't be reused — allocate one per resolve. TXT may be empty pre-34.
|
||||
val listener = object : NsdManager.ResolveListener {
|
||||
override fun onServiceResolved(info: NsdServiceInfo) = ingest(info)
|
||||
override fun onResolveFailed(info: NsdServiceInfo, code: Int) {
|
||||
Log.e(TAG, "resolve failed: $code")
|
||||
}
|
||||
}
|
||||
runCatching { nsd.resolveService(found, listener) }
|
||||
.onFailure { Log.e(TAG, "resolveService failed", it) }
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION") // info.host is deprecated at API 34 (replaced by hostAddresses)
|
||||
private fun ingest(info: NsdServiceInfo) {
|
||||
val txt = parseTxt(info.attributes)
|
||||
// Reject an incompatible protocol IF the host advertised one; tolerate empty TXT (pre-34).
|
||||
if (txt.proto != null && !txt.isPunktfunk) {
|
||||
Log.d(TAG, "skip non-punktfunk proto=${txt.proto}")
|
||||
return
|
||||
}
|
||||
val ip = (if (Build.VERSION.SDK_INT >= 34) info.hostAddresses.firstOrNull() else info.host)
|
||||
?.hostAddress ?: return
|
||||
val key = txt.id?.takeIf { it.isNotBlank() } ?: info.serviceName
|
||||
resolved[key] = DiscoveredHost(
|
||||
key = key,
|
||||
name = info.serviceName.removeSuffix("."),
|
||||
host = ip,
|
||||
port = info.port,
|
||||
fingerprint = txt.fp,
|
||||
pairingRequired = txt.pairingRequired,
|
||||
)
|
||||
Log.d(TAG, "resolved: ${resolved[key]}")
|
||||
publish()
|
||||
private fun snapshot(): List<DiscoveredHost> {
|
||||
val h = nativeHandle
|
||||
if (h == 0L) return emptyList()
|
||||
// getOrNull (not getOrDefault): the JNI returns a platform String!, so a (near-impossible)
|
||||
// native null is a *success* value here — coalesce it so the main-thread poll can't NPE.
|
||||
val blob = runCatching { NativeBridge.nativeDiscoveryPoll(h) }
|
||||
.onFailure { Log.e(TAG, "nativeDiscoveryPoll threw", it) }
|
||||
.getOrNull() ?: ""
|
||||
if (blob.isEmpty()) return emptyList()
|
||||
return blob.split('\n')
|
||||
.filter { it.isNotBlank() }
|
||||
.mapNotNull { parseHostRecord(it) }
|
||||
.associateBy { it.key } // dedup by stable key (id, or addr:port)
|
||||
.values
|
||||
.sortedBy { it.name.lowercase() }
|
||||
}
|
||||
|
||||
private fun acquireMulticastLock() {
|
||||
val wifi = appCtx.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
multicastLock = wifi.createMulticastLock("punktfunk-nsd").apply {
|
||||
multicastLock = wifi.createMulticastLock("punktfunk-mdns").apply {
|
||||
setReferenceCounted(true)
|
||||
runCatching { acquire() }
|
||||
}
|
||||
@@ -191,4 +137,8 @@ class HostDiscovery(context: Context) {
|
||||
multicastLock?.takeIf { it.isHeld }?.let { runCatching { it.release() } }
|
||||
multicastLock = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val POLL_MS = 1000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ class KnownHostStore(context: Context) {
|
||||
prefs.edit().remove(key(address, port)).apply()
|
||||
}
|
||||
|
||||
/** Set a saved host's display name, keeping its pin + paired flag. No-op if not saved. */
|
||||
fun rename(address: String, port: Int, newName: String) {
|
||||
val h = get(address, port) ?: return
|
||||
save(h.copy(name = newName))
|
||||
}
|
||||
|
||||
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
||||
fun all(): List<KnownHost> =
|
||||
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package io.unom.punktfunk.kit.discovery
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of the native-record parser (`key␟name␟addr␟port␟fp␟pair`), the Kotlin half of the
|
||||
* discovery JNI seam. No Android types. Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class ParseRecordTest {
|
||||
private val s = '\u001F' // field separator (must match the Rust side, discovery.rs FIELD_SEP)
|
||||
|
||||
private fun rec(vararg f: String) = f.joinToString(s.toString())
|
||||
|
||||
@Test
|
||||
fun parsesFullRecord() {
|
||||
val fp = "a".repeat(64)
|
||||
val h = parseHostRecord(rec("host-123", "home-worker-2", "192.168.1.70", "9777", fp, "required"))!!
|
||||
assertEquals("host-123", h.key)
|
||||
assertEquals("home-worker-2", h.name)
|
||||
assertEquals("192.168.1.70", h.host)
|
||||
assertEquals(9777, h.port)
|
||||
assertEquals(fp, h.fingerprint)
|
||||
assertTrue(h.pairingRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun optionalPairingAndEmptyFingerprint() {
|
||||
val h = parseHostRecord(rec("id", "name", "10.0.0.5", "9777", "", "optional"))!!
|
||||
assertNull(h.fingerprint)
|
||||
assertEquals(false, h.pairingRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyKeyFallsBackToAddrPort() {
|
||||
// Host advertised no `id` TXT → the native side leaves the key blank; we synthesize addr:port.
|
||||
val h = parseHostRecord(rec("", "name", "10.0.0.5", "9777", "", "required"))!!
|
||||
assertEquals("10.0.0.5:9777", h.key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyNameFallsBackToAddr() {
|
||||
val h = parseHostRecord(rec("k", "", "10.0.0.5", "9777", "", "optional"))!!
|
||||
assertEquals("10.0.0.5", h.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsTooFewFields() {
|
||||
assertNull(parseHostRecord("only${'\u001F'}three${'\u001F'}fields"))
|
||||
assertNull(parseHostRecord(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsBadPortOrAddress() {
|
||||
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "notaport", "", "required")))
|
||||
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "0", "", "required")))
|
||||
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "70000", "", "required")))
|
||||
assertNull(parseHostRecord(rec("k", "n", "", "9777", "", "required")))
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package io.unom.punktfunk.kit.discovery
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Pure JVM test of the mDNS TXT parser (no Android types). Run: `./gradlew :kit:testDebugUnitTest`. */
|
||||
class ParseTxtTest {
|
||||
private fun b(s: String): ByteArray = s.toByteArray(Charsets.UTF_8)
|
||||
|
||||
@Test
|
||||
fun parsesFullRecord() {
|
||||
val fp = "a".repeat(64)
|
||||
val t = parseTxt(
|
||||
mapOf(
|
||||
"proto" to b("punktfunk/1"),
|
||||
"fp" to b(fp),
|
||||
"pair" to b("required"),
|
||||
"id" to b("host-123"),
|
||||
),
|
||||
)
|
||||
assertEquals("punktfunk/1", t.proto)
|
||||
assertEquals(fp, t.fp)
|
||||
assertEquals("host-123", t.id)
|
||||
assertTrue(t.isPunktfunk)
|
||||
assertTrue(t.pairingRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun optionalPairingAndMissingKeys() {
|
||||
val t = parseTxt(mapOf("proto" to b("punktfunk/1"), "pair" to b("optional")))
|
||||
assertFalse(t.pairingRequired)
|
||||
assertNull(t.fp)
|
||||
assertNull(t.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyMapYieldsAllNull() {
|
||||
val t = parseTxt(emptyMap())
|
||||
assertNull(t.proto)
|
||||
assertNull(t.fp)
|
||||
assertNull(t.pair)
|
||||
assertNull(t.id)
|
||||
assertFalse(t.isPunktfunk)
|
||||
assertFalse(t.pairingRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullAndEmptyValuesTreatedAsAbsent() {
|
||||
// NSD delivers present-but-empty TXT keys as null / empty ByteArray.
|
||||
val t = parseTxt(mapOf("fp" to null, "id" to ByteArray(0), "proto" to b("punktfunk/1")))
|
||||
assertNull(t.fp)
|
||||
assertNull(t.id)
|
||||
assertTrue(t.isPunktfunk)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonPunktfunkProtoIsNotAccepted() {
|
||||
assertFalse(parseTxt(mapOf("proto" to b("moonlight/7"))).isPunktfunk)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "punktfunk-android"
|
||||
name = "punktfunk-client-android"
|
||||
description = "punktfunk Android client — JNI bridge ('nativecore') over punktfunk-core (Rust-heavy client model)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
@@ -16,9 +16,15 @@ crate-type = ["cdylib"]
|
||||
[dependencies]
|
||||
# The whole protocol/transport/FEC/crypto + the embeddable NativeClient connector. `quic` pulls
|
||||
# the punktfunk/1 control plane (now ring-only — no aws-lc, see punktfunk-core/Cargo.toml).
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
|
||||
jni = "0.21"
|
||||
log = "0.4"
|
||||
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
|
||||
# Linux/Windows clients use (`clients/linux/src/discovery.rs`), replacing Android's per-OEM
|
||||
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
|
||||
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
|
||||
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
|
||||
mdns-sd = "0.20"
|
||||
|
||||
# Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still
|
||||
# compiles this crate (as a host cdylib) — the Android-framework glue (logging now; AMediaCodec via
|
||||
@@ -28,7 +34,9 @@ android_logger = "0.14"
|
||||
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
|
||||
# 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).
|
||||
ndk = { version = "0.9", features = ["media", "audio"] }
|
||||
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-31"] }
|
||||
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
|
||||
libc = "0.2"
|
||||
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
|
||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Android video decode (android-only): pull HEVC access units from the connector and render them
|
||||
//! to the SurfaceView via NDK `AMediaCodec` — hardware decode, zero per-frame JNI.
|
||||
//!
|
||||
//! One-in/one-out: the host opens every stream with an IDR carrying VPS/SPS/PPS **in-band**, so the
|
||||
//! decoder needs no out-of-band codec-specific data — we configure with mime + the negotiated
|
||||
//! WxH (from [`NativeClient::mode`]) and feed each access unit as it arrives. The decode thread owns
|
||||
//! the codec + window for its whole life; [`crate::session`] signals it to stop via the shared flag.
|
||||
|
||||
use ndk::data_space::DataSpace;
|
||||
use ndk::media::media_codec::{
|
||||
DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection,
|
||||
};
|
||||
use ndk::media::media_format::MediaFormat;
|
||||
use ndk::native_window::{FrameRateCompatibility, NativeWindow};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::error::PunktfunkError;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes.
|
||||
pub fn run(
|
||||
client: Arc<NativeClient>,
|
||||
window: NativeWindow,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
) {
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
let codec = match MediaCodec::from_decoder_type("video/hevc") {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
log::error!("decode: no HEVC decoder on this device");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut format = MediaFormat::new();
|
||||
format.set_str("mime", "video/hevc");
|
||||
format.set_i32("width", mode.width as i32);
|
||||
format.set_i32("height", mode.height as i32);
|
||||
// Generous input buffer so a large keyframe AU is never truncated.
|
||||
format.set_i32(
|
||||
"max-input-size",
|
||||
(mode.width * mode.height).max(2_000_000) as i32,
|
||||
);
|
||||
// Ask for the low-latency decode path where the decoder supports it (no reordering buffer).
|
||||
format.set_i32("low-latency", 1);
|
||||
// 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
|
||||
// clocks instead of a power-saving cadence that adds dequeue latency.
|
||||
format.set_i32("priority", 0); // 0 = realtime
|
||||
format.set_i32("operating-rate", mode.refresh_hz as i32);
|
||||
|
||||
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
|
||||
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
|
||||
// MediaCodec wants it BEFORE configure(), and the host sends a 0xCE right after the handshake,
|
||||
// so it's typically already queued; wait briefly otherwise. The Surface DataSpace (applied on
|
||||
// OutputFormatChanged below) carries transfer/primaries regardless — this adds the luminance the
|
||||
// tone-mapper needs. A non-HDR display still gets sensible SurfaceFlinger tone-mapping.
|
||||
if client.color.is_hdr() {
|
||||
match client.next_hdr_meta(Duration::from_millis(250)) {
|
||||
Ok(meta) => {
|
||||
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
|
||||
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
|
||||
}
|
||||
Err(_) => {
|
||||
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
|
||||
log::error!("decode: configure failed: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = codec.start() {
|
||||
log::error!("decode: start failed: {e}");
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
"decode: HEVC decoder started at {}x{}",
|
||||
mode.width,
|
||||
mode.height
|
||||
);
|
||||
// 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
|
||||
// ANativeWindow_setFrameRate is always present; non-fatal if the platform declines.
|
||||
if let Err(e) = window.set_frame_rate(mode.refresh_hz as f32, FrameRateCompatibility::Default) {
|
||||
log::warn!(
|
||||
"decode: set_frame_rate({} Hz) failed (non-fatal): {e}",
|
||||
mode.refresh_hz
|
||||
);
|
||||
}
|
||||
|
||||
let mut fed: u64 = 0;
|
||||
let mut rendered: u64 = 0;
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it
|
||||
// climbs.
|
||||
let mut last_dropped = client.frames_dropped();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
// Capture→client-receipt latency uses the negotiated host-minus-client clock offset (0 if the
|
||||
// host didn't answer the skew handshake — then the HUD flags it "same-host").
|
||||
let clock_offset = client.clock_offset_ns;
|
||||
// The dataspace we've signalled on the Surface so far (None = default/SDR). Set reactively once
|
||||
// the decoder reports an HDR stream (see `drain`); avoids re-applying every format event.
|
||||
let mut applied_ds: Option<DataSpace> = None;
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_frame(Duration::from_millis(5)) {
|
||||
Ok(frame) => {
|
||||
if fed == 0 {
|
||||
let p = &frame.data;
|
||||
log::info!(
|
||||
"decode: first AU {} bytes, head {:02x?}",
|
||||
p.len(),
|
||||
&p[..p.len().min(6)]
|
||||
);
|
||||
}
|
||||
fed += 1;
|
||||
// HUD stat: capture→client-receipt latency = client_now + (host−client) − capture_pts.
|
||||
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);
|
||||
|
||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
||||
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
|
||||
// reference-missing delta frames that follow and renders them without error, so keying off
|
||||
// a decode error rarely fires. Request an IDR when the drop count climbs, throttled — the
|
||||
// decode stays wedged for several frames until the IDR lands, so requesting every frame
|
||||
// would flood the control stream.
|
||||
let dropped = client.frames_dropped();
|
||||
if dropped > last_dropped {
|
||||
last_dropped = dropped;
|
||||
let now = Instant::now();
|
||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||
last_kf_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
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();
|
||||
log::info!("decode: stopped (fed={fed} rendered={rendered})");
|
||||
}
|
||||
|
||||
/// Wall-clock now in nanoseconds (CLOCK_REALTIME basis), to compare against the host-stamped
|
||||
/// capture `pts_ns` after the skew offset is applied.
|
||||
fn now_realtime_ns() -> i128 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as i128)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Best-effort: raise the decode thread toward Android's URGENT_DISPLAY band so background work
|
||||
/// can't preempt it under load (which shows up as late/dropped frames). Non-fatal if the platform
|
||||
/// refuses (foreground apps may set their own threads; the exact floor is policy-dependent).
|
||||
fn boost_thread_priority() {
|
||||
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls. PRIO_PROCESS
|
||||
// with a TID targets that one task on Linux — the same idiom `Process.setThreadPriority` uses.
|
||||
unsafe {
|
||||
let tid = libc::gettid();
|
||||
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -10) != 0 {
|
||||
log::warn!(
|
||||
"decode: setpriority(-10) failed (non-fatal): {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy one access unit into a codec input buffer and queue it.
|
||||
fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) {
|
||||
match codec.dequeue_input_buffer(Duration::from_millis(10)) {
|
||||
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
|
||||
let n = {
|
||||
let dst = buf.buffer_mut();
|
||||
let n = au.len().min(dst.len());
|
||||
if n < au.len() {
|
||||
log::warn!(
|
||||
"decode: AU {} > input buffer {}, truncated",
|
||||
au.len(),
|
||||
dst.len()
|
||||
);
|
||||
}
|
||||
for (slot, &b) in dst.iter_mut().zip(&au[..n]) {
|
||||
slot.write(b);
|
||||
}
|
||||
n
|
||||
};
|
||||
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
|
||||
log::warn!("decode: queue_input_buffer: {e}");
|
||||
}
|
||||
}
|
||||
Ok(DequeuedInputBufferResult::TryAgainLater) => {
|
||||
// No input buffer free right now; the AU is dropped (FEC/keyframes recover).
|
||||
}
|
||||
Err(e) => log::warn!("decode: dequeue_input_buffer: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Release any ready output buffers to the surface (render = true), latency-first. Returns the
|
||||
/// number of frames presented. Also reacts to `OutputFormatChanged` to signal HDR on the Surface.
|
||||
fn drain(codec: &MediaCodec, window: &NativeWindow, applied_ds: &mut Option<DataSpace>) -> u64 {
|
||||
let mut n = 0;
|
||||
loop {
|
||||
match codec.dequeue_output_buffer(Duration::from_millis(0)) {
|
||||
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
||||
if let Err(e) = codec.release_output_buffer(buf, true) {
|
||||
log::warn!("decode: release_output_buffer: {e}");
|
||||
break;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
||||
// 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).
|
||||
// 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
|
||||
// Main10 path from the SPS — no profile override needed. Keep looping (buffers follow).
|
||||
if let Some(ds) = hdr_dataspace(codec) {
|
||||
if *applied_ds != Some(ds) {
|
||||
match window.set_buffers_data_space(ds) {
|
||||
Ok(()) => {
|
||||
*applied_ds = Some(ds);
|
||||
log::info!("decode: HDR stream → Surface dataspace {ds}");
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"decode: set_buffers_data_space({ds}) failed (non-fatal): {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// TryAgainLater / OutputBuffersChanged — nothing to render now.
|
||||
Ok(_) => break,
|
||||
Err(e) => {
|
||||
log::warn!("decode: dequeue_output_buffer: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
||||
/// integer values are the Android MediaFormat colour constants the NDK shares: COLOR_TRANSFER
|
||||
/// ST2084 = 6 (PQ/HDR10), HLG = 7; COLOR_RANGE FULL = 1, LIMITED = 2 (the host encodes limited).
|
||||
fn hdr_dataspace(codec: &MediaCodec) -> Option<DataSpace> {
|
||||
let fmt = codec.output_format();
|
||||
let full_range = fmt.i32("color-range") == Some(1);
|
||||
match fmt.i32("color-transfer") {
|
||||
Some(6) => Some(if full_range {
|
||||
DataSpace::Bt2020Pq
|
||||
} else {
|
||||
DataSpace::Bt2020ItuPq
|
||||
}),
|
||||
Some(7) => Some(if full_range {
|
||||
DataSpace::Bt2020Hlg
|
||||
} else {
|
||||
DataSpace::Bt2020ItuHlg
|
||||
}),
|
||||
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize [`HdrMeta`](punktfunk_core::quic::HdrMeta) into Android's `KEY_HDR_STATIC_INFO`
|
||||
/// (`hdr-static-info`) layout: a 25-byte CTA-861.3 / `HDRStaticInfo.Type1` blob — descriptor id 0,
|
||||
/// then primaries in **R, G, B** order, white point, max/min display luminance, MaxCLL, MaxFALL, all
|
||||
/// **little-endian** `u16`. Two conversions vs our wire form: HdrMeta stores primaries in ST.2086
|
||||
/// **G, B, R** order (reorder to R, G, B), and `max_display_mastering_luminance` is in 0.0001-cd/m²
|
||||
/// units while Android wants **whole nits** (min stays 0.0001-nit). Chromaticities (1/50000) and
|
||||
/// MaxCLL/MaxFALL (nits) match 1:1.
|
||||
fn android_hdr_static_info(m: &punktfunk_core::quic::HdrMeta) -> [u8; 25] {
|
||||
let [g, b_, r] = m.display_primaries; // ST.2086 G, B, R
|
||||
let max_nits = (m.max_display_mastering_luminance / 10_000).min(u16::MAX as u32) as u16;
|
||||
let min_units = m.min_display_mastering_luminance.min(u16::MAX as u32) as u16;
|
||||
let fields: [u16; 12] = [
|
||||
r[0],
|
||||
r[1],
|
||||
g[0],
|
||||
g[1],
|
||||
b_[0],
|
||||
b_[1], // R, G, B primaries
|
||||
m.white_point[0],
|
||||
m.white_point[1], // white point
|
||||
max_nits,
|
||||
min_units, // max (nits) / min (0.0001-nit) display luminance
|
||||
m.max_cll,
|
||||
m.max_fall, // MaxCLL / MaxFALL (nits)
|
||||
];
|
||||
let mut out = [0u8; 25]; // out[0] = 0 (Type 1 descriptor id), already zero
|
||||
for (i, v) in fields.iter().enumerate() {
|
||||
out[1 + i * 2..3 + i * 2].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! LAN host discovery over mDNS, in Rust via `mdns-sd` — the same crate + service type the
|
||||
//! Linux/Windows clients use (`clients/linux/src/discovery.rs`), exposed to Kotlin over JNI.
|
||||
//!
|
||||
//! Why not `NsdManager`: that API delegates to a per-OEM system mDNS daemon whose reliability
|
||||
//! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust
|
||||
//! core — the crate is already linked for the whole protocol — gives one tested code path across
|
||||
//! every desktop + mobile client and removes the system-daemon dependency. Kotlin still holds the
|
||||
//! Wi-Fi `MulticastLock` for the browse lifetime (raw multicast *reception* needs it) and owns the
|
||||
//! permission UX; this module owns the socket + resolve.
|
||||
//!
|
||||
//! Shape: [`Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart`] spins up a
|
||||
//! [`ServiceDaemon`] browsing `_punktfunk._udp.local.` on a background thread that folds
|
||||
//! resolve/remove events into a shared map; Kotlin polls `nativeDiscoveryPoll` ~1 Hz for a
|
||||
//! newline-joined snapshot and calls `nativeDiscoveryStop` to tear it down. Polling (not a JVM
|
||||
//! callback) mirrors `nativeVideoStats`: no `AttachCurrentThread`/global-ref lifecycle to get
|
||||
//! wrong, and 1 Hz is plenty for a host picker.
|
||||
|
||||
use crate::session::jni_guard;
|
||||
use jni::objects::JObject;
|
||||
use jni::sys::jlong;
|
||||
use jni::JNIEnv;
|
||||
use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
|
||||
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
|
||||
/// Wire protocol id in the `proto` TXT record; a host advertising anything else is skipped.
|
||||
const PROTO: &str = "punktfunk/1";
|
||||
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||
const FIELD_SEP: char = '\u{1f}';
|
||||
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]).
|
||||
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
||||
/// every field so no value can break it.
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct Host {
|
||||
key: String,
|
||||
name: String,
|
||||
addr: String,
|
||||
port: u16,
|
||||
fp: String,
|
||||
pair: String,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
fn encode(&self) -> String {
|
||||
// mDNS instance labels + TXT values are arbitrary UTF-8 from an UNauthenticated source, so
|
||||
// strip the field/record separators: a rogue advert that smuggled '\n'/U+001F could otherwise
|
||||
// inject or suppress picker rows. (Trust is still gated on connect — this only protects the
|
||||
// list's integrity.)
|
||||
fn clean(s: &str) -> String {
|
||||
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||
}
|
||||
format!(
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
clean(&self.key),
|
||||
clean(&self.name),
|
||||
clean(&self.addr),
|
||||
self.port,
|
||||
clean(&self.fp),
|
||||
clean(&self.pair),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A running browse behind the `jlong` handle: the daemon, the shared resolved-host map keyed by
|
||||
/// mDNS fullname (stable across re-announce and present on both resolve *and* remove — which fixes
|
||||
/// the old `NsdManager` key mismatch that leaked stale hosts), and the event-fold thread.
|
||||
struct Discovery {
|
||||
daemon: ServiceDaemon,
|
||||
hosts: Arc<Mutex<HashMap<String, Host>>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
fn start() -> Option<Discovery> {
|
||||
let daemon = match ServiceDaemon::new() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::error!("mDNS daemon failed — discovery disabled: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let rx = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("mDNS browse failed — discovery disabled: {e}");
|
||||
let _ = daemon.shutdown();
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let hosts: Arc<Mutex<HashMap<String, Host>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
let map = hosts.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pf-mdns".into())
|
||||
.spawn(move || {
|
||||
// Exits when the daemon is shut down (the browse channel closes → recv errors).
|
||||
while let Ok(event) = rx.recv() {
|
||||
match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
if let Some(host) = resolve(&info) {
|
||||
map.lock()
|
||||
.unwrap()
|
||||
.insert(info.get_fullname().to_string(), host);
|
||||
}
|
||||
}
|
||||
ServiceEvent::ServiceRemoved(_ty, fullname) => {
|
||||
map.lock().unwrap().remove(&fullname);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
let thread = match spawned {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// The daemon thread + bound :5353 socket outlive a dropped handle (no Drop impl), so
|
||||
// shut it down explicitly — same cleanup as the browse-failure path above.
|
||||
log::error!("mDNS fold thread spawn failed: {e}");
|
||||
let _ = daemon.shutdown();
|
||||
return None;
|
||||
}
|
||||
};
|
||||
log::info!("native mDNS discovery started ({SERVICE_TYPE})");
|
||||
Some(Discovery {
|
||||
daemon,
|
||||
hosts,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
/// Current resolved-host set, newline-joined (empty string = none). Sorted for a stable order
|
||||
/// across polls; Kotlin re-sorts by display name.
|
||||
fn snapshot(&self) -> String {
|
||||
let mut records: Vec<String> = self
|
||||
.hosts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.map(Host::encode)
|
||||
.collect();
|
||||
records.sort();
|
||||
records.join("\n")
|
||||
}
|
||||
|
||||
fn stop(mut self) {
|
||||
let _ = self.daemon.shutdown(); // closes the browse channel → the fold thread exits
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`Host`] from a resolved mDNS record, or `None` if it isn't a usable punktfunk host
|
||||
/// (incompatible advertised proto, or no IPv4 address). IPv4 only on purpose: the core dials with
|
||||
/// `format!("{host}:{port}").parse::<SocketAddr>()`, which can't parse a bare/scoped IPv6 literal
|
||||
/// (it needs the `[addr%scope]:port` form), so surfacing a v6-only host would present a card that
|
||||
/// fails on every tap. Dropping it shows the honest "not found" instead.
|
||||
fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
let val = |k: &str| info.get_property_val_str(k).unwrap_or("").to_string();
|
||||
let proto = val("proto");
|
||||
if !proto.is_empty() && proto != PROTO {
|
||||
return None; // some other DNS-SD service sharing the type — ignore
|
||||
}
|
||||
let addr = info
|
||||
.get_addresses_v4()
|
||||
.iter()
|
||||
.next()
|
||||
.map(|a| a.to_string())?;
|
||||
let id = val("id");
|
||||
let fullname = info.get_fullname();
|
||||
Some(Host {
|
||||
key: if id.is_empty() {
|
||||
fullname.to_string()
|
||||
} else {
|
||||
id
|
||||
},
|
||||
name: fullname.split('.').next().unwrap_or("?").to_string(),
|
||||
addr,
|
||||
port: info.get_port(),
|
||||
fp: val("fp"),
|
||||
pair: val("pair"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryStart(): Long` — start browsing `_punktfunk._udp`; returns an opaque
|
||||
/// handle, or `0` on failure (logged). Pair with exactly one [`nativeDiscoveryStop`]. Kotlin must
|
||||
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
|
||||
///
|
||||
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
) -> jlong {
|
||||
jni_guard(0, || match Discovery::start() {
|
||||
Some(d) => Box::into_raw(Box::new(d)) as jlong,
|
||||
None => 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jni::sys::jstring {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
let out = if handle == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
// SAFETY: live handle per the start/stop contract — Kotlin owns the lifecycle and never
|
||||
// polls after stop (it nulls the handle first).
|
||||
let d = unsafe { &*(handle as *const Discovery) };
|
||||
d.snapshot()
|
||||
};
|
||||
match env.new_string(out) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryStop(handle)` — stop the browse, shut the daemon down and join its
|
||||
/// thread. No-op on `0`.
|
||||
///
|
||||
/// # Safety contract
|
||||
/// `handle` must be `0` or a live handle from [`nativeDiscoveryStart`], stopped exactly once and not
|
||||
/// concurrently with [`nativeDiscoveryPoll`] (Kotlin owns this; all calls are on the main thread).
|
||||
///
|
||||
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
|
||||
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle from nativeDiscoveryStart, stopped exactly once per the contract.
|
||||
let d = unsafe { Box::from_raw(handle as *mut Discovery) };
|
||||
d.stop();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encode_round_trips_all_fields_with_unit_separator() {
|
||||
let h = Host {
|
||||
key: "host-123".into(),
|
||||
name: "home-worker-2".into(),
|
||||
addr: "192.168.1.70".into(),
|
||||
port: 9777,
|
||||
fp: "ab".repeat(32),
|
||||
pair: "required".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields.len(), 6);
|
||||
assert_eq!(fields[0], "host-123");
|
||||
assert_eq!(fields[1], "home-worker-2");
|
||||
assert_eq!(fields[2], "192.168.1.70");
|
||||
assert_eq!(fields[3], "9777");
|
||||
assert_eq!(fields[4], "ab".repeat(32));
|
||||
assert_eq!(fields[5], "required");
|
||||
assert!(
|
||||
!encoded.contains('\n'),
|
||||
"a record must never contain the record separator"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
||||
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
|
||||
// them so the snapshot stays exactly one record of exactly six fields.
|
||||
let h = Host {
|
||||
key: "k\u{1f}injected".into(),
|
||||
name: "evil\nhost\r".into(),
|
||||
addr: "10.0.0.5".into(),
|
||||
port: 9777,
|
||||
fp: "ab\u{1f}cd".into(),
|
||||
pair: "required\n".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields");
|
||||
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields[0], "kinjected");
|
||||
assert_eq!(fields[1], "evilhost");
|
||||
assert_eq!(fields[4], "abcd");
|
||||
assert_eq!(fields[5], "required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Host→client gamepad feedback pulls (Option B): blocking JNI shims that forward to the connector's
|
||||
//! rumble (0xCA) / HID-output (0xCD) planes and return one decoded event. Kotlin owns the poll
|
||||
//! threads + the Android Vibrator/Lights rendering (see `GamepadFeedback.kt`) — no JNI upcalls, no
|
||||
//! `JavaVM` attach, no cached method ids. Mirrors the audio plane's one-thread-per-plane contract,
|
||||
//! except the thread lives in Kotlin and we just expose the blocking pull.
|
||||
//!
|
||||
//! Not android-gated: `next_rumble`/`next_hidout` are pure-Rust on the `quic` feature, so these
|
||||
//! compile on the host build too (parity with the input shims in [`crate::session`]).
|
||||
|
||||
use crate::session::{jni_guard, SessionHandle};
|
||||
use jni::objects::{JByteBuffer, JObject};
|
||||
use jni::sys::{jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Short blocking timeout: long enough not to busy-spin, short enough that the Kotlin poll thread
|
||||
/// observes its `running=false` flag promptly on teardown.
|
||||
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
|
||||
const TAG_LED: u8 = 0x01;
|
||||
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||
const TAG_TRIGGER: u8 = 0x03;
|
||||
|
||||
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
||||
/// Returns `(low << 16) | high` (each 0..=0xFFFF; `0` = stop), or `-1` on timeout / session closed.
|
||||
/// Pad index is dropped (single-pad model). Run from a dedicated Kotlin poll thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jlong {
|
||||
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
||||
jni_guard(-1, || {
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
|
||||
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
||||
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble(PULL_TIMEOUT) {
|
||||
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
||||
/// HID-output event, written into the caller's direct ByteBuffer as `[kind][fields…]`:
|
||||
/// Led → `[0x01][r][g][b]` (len 4)
|
||||
/// PlayerLeds → `[0x02][bits]` (len 2)
|
||||
/// Trigger → `[0x03][which][effect…]` (len 2 + effect.len())
|
||||
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
buf: JByteBuffer,
|
||||
) -> jint {
|
||||
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
||||
jni_guard(-1, || {
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
|
||||
Ok(ev) => ev,
|
||||
Err(_) => return -1, // timeout or closed — Kotlin loops
|
||||
};
|
||||
|
||||
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
|
||||
let cap = match env.get_direct_buffer_capacity(&buf) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return -1,
|
||||
};
|
||||
let ptr = match env.get_direct_buffer_address(&buf) {
|
||||
Ok(p) if !p.is_null() => p,
|
||||
_ => return -1,
|
||||
};
|
||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
||||
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
||||
|
||||
let n = match ev {
|
||||
HidOutput::Led { r, g, b, .. } => {
|
||||
if cap < 4 {
|
||||
return -1;
|
||||
}
|
||||
out[0] = TAG_LED;
|
||||
out[1] = r;
|
||||
out[2] = g;
|
||||
out[3] = b;
|
||||
4
|
||||
}
|
||||
HidOutput::PlayerLeds { bits, .. } => {
|
||||
if cap < 2 {
|
||||
return -1;
|
||||
}
|
||||
out[0] = TAG_PLAYER_LEDS;
|
||||
out[1] = bits;
|
||||
2
|
||||
}
|
||||
HidOutput::Trigger { which, effect, .. } => {
|
||||
let n = 2 + effect.len();
|
||||
if cap < n {
|
||||
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
||||
}
|
||||
out[0] = TAG_TRIGGER;
|
||||
out[1] = which;
|
||||
out[2..n].copy_from_slice(&effect);
|
||||
n
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
}
|
||||
@@ -3,13 +3,17 @@
|
||||
//! Architecture: the **Rust-heavy** client model (like `punktfunk-client-linux`, *not* the
|
||||
//! thin-native-over-C-ABI Apple model). This `cdylib` links `punktfunk-core` directly and drives
|
||||
//! the whole `punktfunk/1` protocol through [`punktfunk_core::client::NativeClient`]; Kotlin owns
|
||||
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture,
|
||||
//! `NsdManager` discovery, Keystore). The JNI seam below is the one place the two languages meet.
|
||||
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture, the
|
||||
//! Wi-Fi `MulticastLock` + permission UX, Keystore). The JNI seam below is the one place the two
|
||||
//! languages meet.
|
||||
//!
|
||||
//! Why Rust-heavy: Kotlin cannot `import` the cbindgen C header the way Swift can, so a native
|
||||
//! bridge is unavoidable. Writing it in Rust lets the Android client reuse the Linux client's
|
||||
//! orchestration verbatim — audio jitter ring, the VK keymap inverse, latency/skew math, the
|
||||
//! input capture state machine, trust/pairing logic — instead of re-porting it into Kotlin.
|
||||
//! input capture state machine, trust/pairing logic, **mDNS discovery** ([`discovery`], the same
|
||||
//! `mdns-sd` browse the Linux/Windows clients use) — instead of re-porting it into Kotlin. Kotlin
|
||||
//! keeps only the Android-framework surface it must (Compose UI, `SurfaceView`, input capture, the
|
||||
//! Wi-Fi `MulticastLock` + permission UX, Keystore identity).
|
||||
//!
|
||||
//! JNI symbols map to `io.unom.punktfunk.kit.NativeBridge` in the `:kit` Gradle module
|
||||
//! (`clients/android`). The current surface is the scaffold's native-link proof
|
||||
@@ -25,10 +29,14 @@ use jni::JNIEnv;
|
||||
mod audio;
|
||||
#[cfg(target_os = "android")]
|
||||
mod decode;
|
||||
// Ungated: pure `mdns-sd` + `jni`, so the browse + its JNI seam link into the host workspace build
|
||||
// (and its unit test runs there) exactly like `session`/`stats`. Kotlin only ever calls it on device.
|
||||
mod discovery;
|
||||
mod feedback;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mic;
|
||||
mod session;
|
||||
mod stats;
|
||||
|
||||
/// 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.
|
||||
@@ -11,19 +11,36 @@
|
||||
//! 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 `crates/punktfunk-client-linux`.
|
||||
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
||||
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::sys::{jboolean, jint, jlong};
|
||||
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
|
||||
@@ -40,6 +57,8 @@ pub(crate) struct SessionHandle {
|
||||
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 {
|
||||
@@ -142,6 +161,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
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(),
|
||||
@@ -182,9 +202,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
CompositorPref::from_u8(compositor_pref.clamp(0, u8::MAX as jint) as u8),
|
||||
GamepadPref::from_u8(gamepad_pref.clamp(0, u8::MAX as jint) as u8),
|
||||
bitrate_kbps.max(0) as u32, // 0 = host default
|
||||
None, // launch: default app
|
||||
pin, // Some → Crypto on host-fp mismatch
|
||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||
// 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) => {
|
||||
@@ -217,10 +248,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle != 0 {
|
||||
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer.
|
||||
unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) };
|
||||
}
|
||||
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
|
||||
@@ -330,13 +363,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
}
|
||||
};
|
||||
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))
|
||||
.spawn(move || crate::decode::run(client, window, sd, st))
|
||||
.ok();
|
||||
*guard = Some(VideoThread { shutdown, join });
|
||||
*guard = Some(VideoThread {
|
||||
shutdown,
|
||||
join,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
|
||||
@@ -347,11 +386,59 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
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) };
|
||||
h.stop_video();
|
||||
}
|
||||
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
|
||||
@@ -387,11 +474,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_audio();
|
||||
}
|
||||
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`).
|
||||
@@ -428,11 +517,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_mic();
|
||||
}
|
||||
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 ----------------------------------
|
||||
@@ -466,6 +557,38 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPointerAbs(handle, x, y, surfaceWidth, surfaceHeight)` — absolute cursor
|
||||
/// position: the host moves the pointer to `x`/`y` in a `surfaceWidth`×`surfaceHeight` pixel space,
|
||||
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
|
||||
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
|
||||
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
x: jint,
|
||||
y: jint,
|
||||
surface_width: jint,
|
||||
surface_height: jint,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let w = (surface_width.max(0) as u32) & 0xffff;
|
||||
let ht = (surface_height.max(0) as u32) & 0xffff;
|
||||
let _ = h.client.send_input(&InputEvent {
|
||||
kind: InputKind::MouseMoveAbs,
|
||||
_pad: [0; 3],
|
||||
code: 0,
|
||||
x,
|
||||
y,
|
||||
flags: (w << 16) | ht,
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
|
||||
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
|
||||
#[no_mangle]
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Live decode stats for the on-stream HUD (mirrors the Apple client's stats overlay): FPS,
|
||||
//! receive throughput, and capture→client-receipt latency (p50/p95). The decode thread is the sole
|
||||
//! writer (`note` per access unit); the JNI accessor `nativeVideoStats` drains a snapshot ~1 Hz and
|
||||
//! resets the window. Pure `std` so it compiles on the host build too (the decode thread is
|
||||
//! android-only, but `VideoThread` holds the shared handle unconditionally).
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Rolling per-window accumulator. Rates are computed over the actual elapsed wall-time at drain
|
||||
/// (robust to poll jitter), so a poll that lands at 0.9 s or 1.1 s still reports the right FPS.
|
||||
pub struct VideoStats {
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
window_start: Instant,
|
||||
frames: u64,
|
||||
bytes: u64,
|
||||
/// capture→client-receipt latency samples for this window, in microseconds.
|
||||
lat_us: Vec<u64>,
|
||||
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
|
||||
skew_corrected: bool,
|
||||
}
|
||||
|
||||
/// A drained, computed view of one window. `lat_valid` is false when no in-range latency sample
|
||||
/// landed (then p50/p95 are 0 and the HUD hides the latency line, exactly like the Apple client).
|
||||
pub struct Snapshot {
|
||||
pub fps: f64,
|
||||
pub mbps: f64,
|
||||
pub lat_p50_ms: f64,
|
||||
pub lat_p95_ms: f64,
|
||||
pub lat_valid: bool,
|
||||
pub skew_corrected: bool,
|
||||
}
|
||||
|
||||
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 {
|
||||
VideoStats {
|
||||
inner: Mutex::new(Inner {
|
||||
window_start: Instant::now(),
|
||||
frames: 0,
|
||||
bytes: 0,
|
||||
lat_us: Vec::with_capacity(256),
|
||||
skew_corrected: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one decoded access unit: its wire size and (if in range) its capture→client latency.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note(&self, bytes: usize, lat_us: Option<u64>, skew_corrected: bool) {
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
g.frames += 1;
|
||||
g.bytes += bytes as u64;
|
||||
g.skew_corrected = skew_corrected;
|
||||
if let Some(l) = lat_us {
|
||||
g.lat_us.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
||||
pub fn drain(&self) -> Snapshot {
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
let elapsed = g.window_start.elapsed().as_secs_f64().max(1e-3);
|
||||
let fps = g.frames as f64 / elapsed;
|
||||
let mbps = g.bytes as f64 * 8.0 / 1_000_000.0 / elapsed;
|
||||
let (p50, p95, valid) = if g.lat_us.is_empty() {
|
||||
(0.0, 0.0, false)
|
||||
} else {
|
||||
g.lat_us.sort_unstable();
|
||||
let n = g.lat_us.len();
|
||||
let at = |p: f64| g.lat_us[((n as f64 * p) as usize).min(n - 1)] as f64 / 1000.0;
|
||||
(at(0.50), at(0.95), true)
|
||||
};
|
||||
let skew = g.skew_corrected;
|
||||
g.window_start = Instant::now();
|
||||
g.frames = 0;
|
||||
g.bytes = 0;
|
||||
g.lat_us.clear();
|
||||
Snapshot {
|
||||
fps,
|
||||
mbps,
|
||||
lat_p50_ms: p50,
|
||||
lat_p95_ms: p95,
|
||||
lat_valid: valid,
|
||||
skew_corrected: skew,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,6 +425,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
@@ -463,6 +464,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
@@ -500,6 +502,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
@@ -529,6 +532,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
|
||||
+63
-6
@@ -6,9 +6,14 @@ input datagrams, Opus audio, cert pinning — lives in the shared Rust core (sta
|
||||
linked as `PunktfunkCore.xcframework`); this package is the Swift shell: decode
|
||||
(VideoToolbox), present (SwiftUI), input capture.
|
||||
|
||||
## Status — first light achieved (2026-06-10)
|
||||
## Status — working client (macOS, with iOS / tvOS in the shared build)
|
||||
|
||||
Validated live, Mac ↔ Linux box over the LAN: gamescope virtual output → NVENC HEVC →
|
||||
A full streaming client: VideoToolbox HEVC decode, controllers incl. DualSense feedback, host
|
||||
discovery, PIN pairing, and a network speed test. The lower-latency **stage-2 presenter**
|
||||
(`VTDecompressionSession` → `CAMetalLayer`) is built and opt-in (Settings → Presenter); see below.
|
||||
|
||||
First light was achieved 2026-06-10 — validated live, Mac ↔ a Linux host over the LAN: gamescope
|
||||
virtual output → NVENC HEVC →
|
||||
`punktfunk/1` (GF(2¹⁶) FEC + AES-GCM over UDP, QUIC control) → VideoToolbox →
|
||||
`AVSampleBufferDisplayLayer` on glass at 1280×720@60, with mouse/keyboard flowing back as
|
||||
QUIC datagrams into the host's gamescope EIS injector (thousands of events injected during
|
||||
@@ -20,8 +25,8 @@ full session: video AUs, **Opus audio** (`nextAudio()`), **rumble** (`nextRumble
|
||||
**DualSense feedback** (`nextHidOutput()` — lightbar, player LEDs, adaptive-trigger
|
||||
effects), input incl. gamepads + DualSense touchpad/motion (`sendTouchpad`/`sendMotion`),
|
||||
and **cert pinning + TOFU** (`pinSHA256:`/`hostFingerprint`) — see
|
||||
`m3.rs::tests::c_abi_connection_roundtrip` (three sequential sessions: TOFU, pinned
|
||||
reconnect, wrong-pin rejection). The host (`punktfunk-host m3-host`) is a persistent listener:
|
||||
`punktfunk1.rs::tests::c_abi_connection_roundtrip` (three sequential sessions: TOFU, pinned
|
||||
reconnect, wrong-pin rejection). The host (`punktfunk-host punktfunk1-host`) is a persistent listener:
|
||||
reconnect at will during development.
|
||||
|
||||
What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3):
|
||||
@@ -127,10 +132,10 @@ bash test-loopback.sh # full loopback proof: builds punktfunk
|
||||
# (synthetic source — runs on macOS), streams
|
||||
# byte-verified frames into the Swift client
|
||||
|
||||
# against the real host (Linux box, see CLAUDE.md "Running on this box") — m3-host is a
|
||||
# against the real host (Linux box, see CLAUDE.md "Running on this box") — punktfunk1-host is a
|
||||
# persistent listener, reconnect at will:
|
||||
# PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
|
||||
# cargo run -rp punktfunk-host -- m3-host --source virtual --seconds 60
|
||||
# cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 60
|
||||
PUNKTFUNK_REMOTE_HOST=<box-ip> swift test --filter RemoteFirstLightTests # headless
|
||||
# (+ PUNKTFUNK_REMOTE_PORT / PUNKTFUNK_REMOTE_COMPOSITOR=gamescope|kwin|… /
|
||||
# PUNKTFUNK_REMOTE_PIN=<arming-pin> for the remote pairing test)
|
||||
@@ -169,6 +174,58 @@ signing, bundle id `io.unom.punktfunk`. Notes:
|
||||
in a simulator via `xcrun simctl install/launch` — `SIMCTL_CHILD_PUNKTFUNK_AUTOCONNECT=…`
|
||||
passes the dev autoconnect env through).
|
||||
|
||||
## App Store screenshots
|
||||
|
||||
Automated, faithful screenshots of the real UI for App Store Connect — one set per platform at
|
||||
exactly the accepted pixel sizes. Driver: **`tools/screenshots.sh`**.
|
||||
|
||||
```sh
|
||||
tools/screenshots.sh all # macOS + (if full Xcode) iOS, iPadOS, tvOS → ./screenshots
|
||||
tools/screenshots.sh macos # just macOS
|
||||
OUT=~/Desktop/shots tools/screenshots.sh ios ipad tvos
|
||||
PUNKTFUNK_SHOT_HERO=~/frame.png tools/screenshots.sh ios # real captured frame behind the hero
|
||||
```
|
||||
|
||||
How it works: the app has a DEBUG-only **shot mode** (`Sources/PunktfunkClient/Screenshots/`).
|
||||
Launched with `PUNKTFUNK_SHOT_SCENE=<name>` it renders **one** mock-populated screen full-bleed
|
||||
(`ScreenshotHostView`) instead of `ContentView`, then the OS screenshots the *real, fully-rendered*
|
||||
window — `screencapture` on macOS, `xcrun simctl io booted screenshot` on the Simulators. The five
|
||||
scenes (`ShotScenes.all`): `01-stream` (the stream hero — a synthetic frame + the glass HUD, since
|
||||
`StreamView` needs a live connection), `02-hosts`, `03-pair`, `04-trust`, `05-settings`. Mock data
|
||||
is in `ShotMock`; nothing touches a host.
|
||||
|
||||
Output pixels are App Store Connect's required/largest sizes (Apple auto-derives the smaller ones):
|
||||
`mac` 2880×1800 · `iphone-6.9` 1320×2868 (hero 2868×1320) · `ipad-13` 2064×2752 (hero 2752×2064) ·
|
||||
`appletv` 1920×1080.
|
||||
|
||||
Why not `ImageRenderer` (the obvious offscreen route)? It can't rasterize this app's chrome —
|
||||
`NavigationStack`, `Form`/`TabView`, and Liquid-Glass/`NSVisualEffect` materials all render black or
|
||||
SwiftUI's "can't render" placeholder. Capturing the live window/Simulator avoids that entirely.
|
||||
|
||||
Requirements / gotchas:
|
||||
- **macOS**: only the Swift toolchain is needed, **plus a one-time Screen Recording grant** for
|
||||
your terminal (System Settings → Privacy & Security → Screen Recording) — without it
|
||||
`screencapture -l` fails with "could not create image from window". (A no-permission fallback,
|
||||
`PUNKTFUNK_SHOT_SELFCAPTURE=<dir>`, uses `cacheDisplay` — but it omits material blur and can't
|
||||
read `ScrollView` content, so it's for quick checks, not submission.)
|
||||
- **iOS/iPadOS/tvOS**: needs **full Xcode** (xcodebuild + Simulators), not just Command Line Tools,
|
||||
and the matching device Simulators installed (iPhone 16 Pro Max, iPad Pro 13", Apple TV). Run it
|
||||
on a full-Xcode Mac (e.g. the `macos-arm64` CI mini).
|
||||
- The hero defaults to a synthetic synthwave frame — set `PUNKTFUNK_SHOT_HERO` to a real captured
|
||||
frame for a production-quality lead screenshot.
|
||||
|
||||
**CI**: the `apple` workflow's **`screenshots`** job runs on the `macos-arm64` runner on every main
|
||||
push + manual dispatch (skipped on PRs), and attaches the result as a single zip artifact,
|
||||
**`punktfunk-appstore-screenshots`** (download it from the run's Artifacts; `upload-artifact@v3` —
|
||||
Gitea's backend rejects v4). It captures the two **required iOS sizes — iPhone 6.9" + iPad 13"** —
|
||||
on the Simulator (auto-creating the device if the runner lacks it), and is isolated from the
|
||||
build/test job so a capture hiccup never reds the build.
|
||||
|
||||
**macOS and tvOS are NOT in CI**, by design: the self-hosted runner is **headless** (no
|
||||
window-server session), so the macOS window capture can't run there, and tvOS needs the Tier-3
|
||||
build-std slice. Generate those on a GUI Mac: `tools/screenshots.sh macos tvos`. (If the runner is
|
||||
ever switched to a logged-in GUI session, re-adding macOS to the job's capture step is one line.)
|
||||
|
||||
## Notes for whoever picks this up next
|
||||
|
||||
1. **cbindgen import quirk** (the predicted "small compile fixes", now fixed): the
|
||||
|
||||
@@ -81,24 +81,50 @@ struct AddHostSheet: View {
|
||||
#if !os(tvOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// macOS: UNCHANGED — Cancel + Spacer + Add in an HStack, both wired to the
|
||||
// window's default/cancel keyboard actions. The 380-wide .fixedSize panel below
|
||||
// keeps this compact and centered.
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
Spacer()
|
||||
Button("Add Host") { add() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.glassProminentButtonStyle()
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
.padding(16)
|
||||
#else
|
||||
// iOS / iPadOS: NO Cancel — the sheet is dismissed by the drag indicator,
|
||||
// swipe-down, or tap-outside. (AddHostSheet never sets interactiveDismissDisabled,
|
||||
// so all three are live; if anyone adds it later, restore a Cancel here or there is
|
||||
// no way back out.) A single FULL-WIDTH primary action reads as the one thing to do.
|
||||
// The fill must be on the LABEL, not the Button: .frame(maxWidth:.infinity) on the
|
||||
// Button only widens its hit area and leaves the styled capsule hugging the text —
|
||||
// stretching the label is what makes the glass/bordered pill itself go edge-to-edge.
|
||||
// .controlSize(.large) gives the tall, thumb-friendly height; .defaultAction lets a
|
||||
// hardware keyboard / iPad Return submit.
|
||||
Button { add() } label: {
|
||||
Text("Add Host").frame(maxWidth: .infinity)
|
||||
}
|
||||
.glassProminentButtonStyle()
|
||||
.controlSize(.large)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.padding(16)
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
// A short bottom sheet, not a full-screen modal. .height(320) hugs the 3-field grouped
|
||||
// Form + the full-width action row, instead of the half-screen .medium it used to rest
|
||||
// at. A single fixed detent is enough: the system keeps the content above the keyboard
|
||||
// when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a
|
||||
// centered formSheet card). If Dynamic Type grows the rows past this height the Form just
|
||||
// scrolls inside the detent — nothing is clipped. (.height(_:) is iOS 16+, safe at iOS 17.)
|
||||
.presentationDetents([.height(320)])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.frame(width: 380)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -205,7 +205,13 @@ struct ContentView: View {
|
||||
Image(systemName: "xmark")
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(width: 36, height: 36)
|
||||
.background(.regularMaterial, in: Circle())
|
||||
// Sole touch exit when the HUD is off — a floating glass disc
|
||||
// over the frame (26+, material fallback). interactive: the disc
|
||||
// IS the tap target, so the glass reacts to press.
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
// Match the hit region to the visible disc so every tap also
|
||||
// triggers the interactive-glass press highlight.
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(12)
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
// DEBUG-only controller test panel, reached from Settings → Controllers → "Test Controller…".
|
||||
// It shows the live input of the active controller and lets you fire the host→client feedback
|
||||
// channels — rumble, DualSense adaptive triggers, lightbar, player LEDs — straight at the
|
||||
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
|
||||
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
|
||||
//
|
||||
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
|
||||
// layout); macOS + iOS/iPadOS cover the validation need.
|
||||
|
||||
#if DEBUG && !os(tvOS)
|
||||
import GameController
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ControllerTestView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
@StateObject private var tester = ControllerTester()
|
||||
|
||||
@State private var heavyOn = false
|
||||
@State private var lightOn = false
|
||||
@State private var intensity = 0.75
|
||||
@State private var triggerTarget = TriggerTarget.both
|
||||
@State private var playerLED = -1
|
||||
|
||||
private enum TriggerTarget: String, CaseIterable, Identifiable {
|
||||
case left = "L2", right = "R2", both = "Both"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private struct TriggerDemo: Identifiable {
|
||||
let label: String
|
||||
let effect: DualSenseTriggerEffect
|
||||
var id: String { label }
|
||||
}
|
||||
|
||||
private static let triggerDemos: [TriggerDemo] = [
|
||||
.init(label: "Off", effect: .off),
|
||||
.init(label: "Resistance", effect: .feedback(start: 0.3, strength: 0.7)),
|
||||
.init(label: "Weapon", effect: .weapon(start: 0.4, end: 0.7, strength: 0.9)),
|
||||
.init(label: "Vibration", effect: .vibration(start: 0.1, amplitude: 0.8, frequency: 0.5)),
|
||||
.init(label: "Bow", effect: .slope(start: 0.2, end: 0.9, startStrength: 0.2, endStrength: 0.9)),
|
||||
]
|
||||
|
||||
// (display name, hardware colour, swatch colour)
|
||||
private static let lightSwatches: [(String, GCColor, Color)] = [
|
||||
("Red", GCColor(red: 1, green: 0, blue: 0), .red),
|
||||
("Green", GCColor(red: 0, green: 1, blue: 0), .green),
|
||||
("Blue", GCColor(red: 0, green: 0.2, blue: 1), .blue),
|
||||
("White", GCColor(red: 1, green: 1, blue: 1), .white),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Test Controller").font(.headline)
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
.padding()
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let active = gamepads.active {
|
||||
header(active)
|
||||
inputCard
|
||||
rumbleCard()
|
||||
triggerCard(active)
|
||||
extrasCard(active)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No controller",
|
||||
systemImage: "gamecontroller",
|
||||
description: Text("Connect a controller and pick it under "
|
||||
+ "Settings → Controllers → Use controller."))
|
||||
.frame(maxWidth: .infinity, minHeight: 220)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 420, minHeight: 540)
|
||||
.onAppear { tester.target(gamepads.active?.controller) }
|
||||
.onDisappear { tester.stop() }
|
||||
.onChange(of: gamepads.active?.id) { _, _ in
|
||||
heavyOn = false
|
||||
lightOn = false
|
||||
playerLED = -1
|
||||
tester.target(gamepads.active?.controller)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Header
|
||||
|
||||
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(c.name).font(.headline)
|
||||
Text(c.productCategory).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Input
|
||||
|
||||
private var inputCard: some View {
|
||||
card("Input") {
|
||||
// Poll the live controller at 30 Hz — no handlers installed, so nothing else's
|
||||
// capture is disturbed.
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
|
||||
if let gp = gamepads.active?.controller.extendedGamepad {
|
||||
inputReadout(gp, controller: gamepads.active?.controller)
|
||||
} else {
|
||||
Text("Not an extended gamepad").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
||||
pressed: g.leftThumbstickButton?.isPressed ?? false)
|
||||
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
||||
pressed: g.rightThumbstickButton?.isPressed ?? false)
|
||||
VStack(spacing: 8) {
|
||||
triggerBar("L2", value: g.leftTrigger.value)
|
||||
triggerBar("R2", value: g.rightTrigger.value)
|
||||
}
|
||||
}
|
||||
buttonGrid(g)
|
||||
if let tp = Self.touchpad(g) {
|
||||
touchpadView(tp)
|
||||
}
|
||||
if let m = controller?.motion {
|
||||
motionReadout(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.secondary.opacity(0.3))
|
||||
Circle()
|
||||
.fill(pressed ? Color.accentColor : Color.secondary)
|
||||
.frame(width: 12, height: 12)
|
||||
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerBar(_ label: String, value: Float) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Text(label).font(.caption2.monospaced()).frame(width: 22, alignment: .leading)
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.secondary.opacity(0.15))
|
||||
Capsule().fill(Color.accentColor).frame(width: geo.size.width * CGFloat(value))
|
||||
}
|
||||
}
|
||||
.frame(height: 10)
|
||||
Text(mag(value)).font(.caption2.monospaced()).frame(width: 34, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: 150)
|
||||
}
|
||||
|
||||
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
|
||||
var items: [(String, Bool)] = [
|
||||
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
||||
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
||||
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
||||
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
||||
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
||||
("Menu", g.buttonMenu.isPressed),
|
||||
("Opts", g.buttonOptions?.isPressed ?? false),
|
||||
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
||||
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
||||
]
|
||||
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
|
||||
return LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
|
||||
) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
Text(items[i].0)
|
||||
.font(.caption.monospaced())
|
||||
.frame(maxWidth: .infinity, minHeight: 24)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(items[i].1 ? Color.accentColor : Color.secondary.opacity(0.15)))
|
||||
.foregroundStyle(items[i].1 ? Color.white : Color.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func touchpadView(
|
||||
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||
button: GCControllerButtonInput)
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||
fingerDot(tp.primary, color: .accentColor)
|
||||
fingerDot(tp.secondary, color: .orange)
|
||||
}
|
||||
.frame(width: 150, height: 74)
|
||||
}
|
||||
}
|
||||
|
||||
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
|
||||
let x = pad.xAxis.value, y = pad.yAxis.value
|
||||
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
|
||||
return Circle().fill(color).frame(width: 10, height: 10)
|
||||
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
|
||||
.opacity(active ? 1 : 0)
|
||||
}
|
||||
|
||||
private func motionReadout(_ m: GCMotion) -> some View {
|
||||
let a = Self.totalAccel(m)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Motion").font(.caption2).foregroundStyle(.secondary)
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||
.font(.caption2.monospaced())
|
||||
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
|
||||
.font(.caption2.monospaced())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Rumble
|
||||
|
||||
private func rumbleCard() -> some View {
|
||||
card("Rumble") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Strength", selection: $intensity) {
|
||||
Text("25%").tag(0.25)
|
||||
Text("50%").tag(0.5)
|
||||
Text("75%").tag(0.75)
|
||||
Text("100%").tag(1.0)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||
Toggle("Light motor (right)", isOn: $lightOn)
|
||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
|
||||
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
||||
+ "can't reach its motors on macOS).")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
||||
.onChange(of: lightOn) { _, _ in applyRumble() }
|
||||
.onChange(of: intensity) { _, _ in applyRumble() }
|
||||
}
|
||||
}
|
||||
|
||||
private func applyRumble() {
|
||||
tester.rumble(low: heavyOn ? Float(intensity) : 0, high: lightOn ? Float(intensity) : 0)
|
||||
}
|
||||
|
||||
// MARK: Adaptive triggers
|
||||
|
||||
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
card("Adaptive triggers") {
|
||||
if c.hasAdaptiveTriggers {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Apply to", selection: $triggerTarget) {
|
||||
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8
|
||||
) {
|
||||
ForEach(Self.triggerDemos) { demo in
|
||||
Button(demo.label) { applyTrigger(demo.effect) }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Adaptive triggers need a DualSense.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyTrigger(_ e: DualSenseTriggerEffect) {
|
||||
switch triggerTarget {
|
||||
case .left: tester.applyTrigger(e, right: false)
|
||||
case .right: tester.applyTrigger(e, right: true)
|
||||
case .both:
|
||||
tester.applyTrigger(e, right: false)
|
||||
tester.applyTrigger(e, right: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Lightbar + player LED
|
||||
|
||||
@ViewBuilder
|
||||
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
if c.hasLight {
|
||||
card("Lightbar & player LED") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(Self.lightSwatches.indices, id: \.self) { i in
|
||||
Button { tester.setLight(Self.lightSwatches[i].1) } label: {
|
||||
Circle().fill(Self.lightSwatches[i].2)
|
||||
.frame(width: 26, height: 26)
|
||||
.overlay(Circle().stroke(Color.secondary.opacity(0.4)))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Button("Off") { tester.setLight(nil) }.buttonStyle(.bordered)
|
||||
}
|
||||
Picker("Player LED", selection: $playerLED) {
|
||||
Text("Off").tag(-1)
|
||||
Text("1").tag(0)
|
||||
Text("2").tag(1)
|
||||
Text("3").tag(2)
|
||||
Text("4").tag(3)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: playerLED) { _, v in
|
||||
tester.setPlayerIndex(GCControllerPlayerIndex(rawValue: v) ?? .indexUnset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
private func card<Content: View>(
|
||||
_ title: String, @ViewBuilder _ content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title).font(.subheadline.weight(.semibold))
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.08)))
|
||||
}
|
||||
|
||||
private func sgn(_ v: Float) -> String { String(format: "%+.2f", v) }
|
||||
private func mag(_ v: Float) -> String { String(format: "%.2f", v) }
|
||||
|
||||
/// The touchpad surface of a PlayStation pad — `GCDualSenseGamepad` and `GCDualShockGamepad`
|
||||
/// don't share a touchpad type, so downcast either. `nil` for any other controller.
|
||||
private static func touchpad(
|
||||
_ g: GCExtendedGamepad
|
||||
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||
button: GCControllerButtonInput)? {
|
||||
if let ds = g as? GCDualSenseGamepad {
|
||||
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
|
||||
}
|
||||
if let ds4 = g as? GCDualShockGamepad {
|
||||
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Total acceleration in g: gravity + user when the pad splits them, else the raw vector.
|
||||
private static func totalAccel(_ m: GCMotion) -> (Double, Double, Double) {
|
||||
if m.hasGravityAndUserAcceleration {
|
||||
return (m.gravity.x + m.userAcceleration.x,
|
||||
m.gravity.y + m.userAcceleration.y,
|
||||
m.gravity.z + m.userAcceleration.z)
|
||||
}
|
||||
return (m.acceleration.x, m.acceleration.y, m.acceleration.z)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,69 @@
|
||||
// GlassStyle.swift — the app's single, availability-gated entry point to Apple's "Liquid
|
||||
// Glass" (iOS / macOS / tvOS 26). Every Liquid Glass symbol (glassEffect, Glass, the
|
||||
// .glassProminent button style …) is HARD-gated to OS 26: referencing one with our
|
||||
// deployment targets (macOS 14 / iOS 17 / tvOS 17) is a COMPILE error, not a silent no-op,
|
||||
// unless it sits behind `if #available`. So all glass in the app routes through the two
|
||||
// helpers below, each of which falls back to the EXACT look the app shipped before
|
||||
// (.regularMaterial / .borderedProminent) — nothing regresses on older OSes, and the gating
|
||||
// lives in exactly one file.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Glass background
|
||||
|
||||
/// Liquid Glass behind a floating / overlay surface, with the pre-26 `.regularMaterial`
|
||||
/// look as the fallback. Use ONLY on the floating control / overlay layer (the streaming
|
||||
/// HUD, the trust card, the touch exit chip) — never on content tiles or dense forms (HIG).
|
||||
///
|
||||
/// `glassEffect()`'s own default shape is a Capsule, so panels MUST pass an explicit shape
|
||||
/// (a RoundedRectangle / Circle) or they render as a pill. `interactive` makes the glass
|
||||
/// react to press — only meaningful when the glass itself is the tap target.
|
||||
private struct GlassBackground<S: Shape>: ViewModifier {
|
||||
let shape: S
|
||||
var interactive = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26, macOS 26, tvOS 26, *) {
|
||||
content.glassEffect(interactive ? .regular.interactive() : .regular, in: shape)
|
||||
} else {
|
||||
content.background(.regularMaterial, in: shape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Liquid Glass (26+) or the existing `.regularMaterial` (pre-26) behind a floating
|
||||
/// surface. Pass the surface's shape explicitly — glass defaults to a Capsule otherwise.
|
||||
func glassBackground<S: Shape>(_ shape: S, interactive: Bool = false) -> some View {
|
||||
modifier(GlassBackground(shape: shape, interactive: interactive))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Glass primary button
|
||||
|
||||
/// The single prominent action on a floating / overlay or sheet surface: the Liquid-Glass
|
||||
/// prominent button style on 26+, falling back to `.borderedProminent` (the app's current
|
||||
/// primary style) below. Apply directly to a `Button`; role / keyboardShortcut / disabled
|
||||
/// chain after it as usual. tvOS stays `.borderedProminent` always — glass chrome fights the
|
||||
/// focus engine, and keeping it preserves today's tvOS look exactly.
|
||||
private struct GlassProminentButton: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
#if os(tvOS)
|
||||
content.buttonStyle(.borderedProminent)
|
||||
#else
|
||||
if #available(iOS 26, macOS 26, *) {
|
||||
content.buttonStyle(.glassProminent)
|
||||
} else {
|
||||
content.buttonStyle(.borderedProminent)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Liquid-Glass prominent style (26+, non-tvOS) or `.borderedProminent`. Drop-in for the
|
||||
/// `.buttonStyle(.borderedProminent)` on a surface's primary action.
|
||||
func glassProminentButtonStyle() -> some View {
|
||||
modifier(GlassProminentButton())
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Punktfunkempfänger")
|
||||
.navigationTitle("Punktfunk")
|
||||
// Browse the LAN for advertised hosts only while the grid is up — not during a
|
||||
// session. The home appears/disappears as the stream swaps in and out.
|
||||
.onAppear { discovery.start() }
|
||||
@@ -217,7 +217,7 @@ struct HomeView: View {
|
||||
Text("Add your punktfunk host with the + button.")
|
||||
} actions: {
|
||||
Button("Add Host") { showAddHost = true }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.glassProminentButtonStyle()
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
|
||||
@@ -88,6 +88,8 @@ struct HostCardView: View {
|
||||
#if !os(tvOS)
|
||||
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
||||
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
||||
// Deliberately .regularMaterial, not Liquid Glass: HIG keeps glass off content
|
||||
// tiles (it flattens hierarchy over an opaque grid) — see GlassStyle.swift.
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.overlay {
|
||||
if isMostRecent {
|
||||
|
||||
@@ -81,7 +81,7 @@ struct LibraryView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 420)
|
||||
Button("Retry") { Task { await load() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.glassProminentButtonStyle()
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
@@ -150,7 +150,7 @@ struct PairSheet: View {
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
Button("Pair & Connect") { runCeremony() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.glassProminentButtonStyle()
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
@@ -165,6 +165,15 @@ struct PairSheet: View {
|
||||
.frame(width: 400)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// Bottom sheet instead of a full-screen modal (Liquid Glass background on iOS 26).
|
||||
// .medium rests; .large is included so the sheet grows to keep the Pair/Cancel row
|
||||
// above the keyboard when the PIN field is focused. Hide the grabber while the ceremony
|
||||
// is in flight — dismissal is disabled then (interactiveDismissDisabled), so a drag
|
||||
// would only rubber-band; the always-enabled Cancel button is the exit.
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(busy ? .hidden : .visible)
|
||||
#endif
|
||||
.interactiveDismissDisabled(busy)
|
||||
.onDisappear { token.cancelled = true } // any other dismissal path
|
||||
#endif
|
||||
|
||||
@@ -13,8 +13,19 @@ struct PunktfunkClientApp: App {
|
||||
#endif
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("Punktfunkempfänger") {
|
||||
WindowGroup("Punktfunk") {
|
||||
#if DEBUG
|
||||
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
||||
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
|
||||
// the whole path is absent from Release builds.
|
||||
if let scene = ScreenshotMode.requestedScene {
|
||||
ScreenshotHostView(scene: scene)
|
||||
} else {
|
||||
ContentView()
|
||||
}
|
||||
#else
|
||||
ContentView()
|
||||
#endif
|
||||
}
|
||||
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
||||
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// App Store screenshot harness — device catalog.
|
||||
//
|
||||
// The harness captures the REAL running UI (not an offscreen ImageRenderer snapshot, which can't
|
||||
// rasterize NavigationStack / Form / Liquid-Glass — they come out black). The app is launched in
|
||||
// "shot mode" (PUNKTFUNK_SHOT_SCENE=<name>, see ScreenshotHost) showing one mock-populated scene
|
||||
// full-bleed, and the OS screenshots it: `xcrun simctl io booted screenshot` on the iOS/tvOS
|
||||
// simulators (native pixels = the exact App Store size), `screencapture` for the mac window.
|
||||
// tools/screenshots.sh drives it. DEBUG-only — none of this ships in Release.
|
||||
//
|
||||
// This catalog records the target App Store sizes; on Apple platforms only the mac size is read
|
||||
// at runtime (to size the capture window) — the simulator IS the device, so iOS/tvOS pixels are
|
||||
// whatever the booted device is.
|
||||
|
||||
#if DEBUG
|
||||
import CoreGraphics
|
||||
|
||||
enum ShotOrientation { case natural, portrait, landscape }
|
||||
|
||||
/// A target App Store canvas: a natural-orientation pixel size + backing scale.
|
||||
struct ShotDevice {
|
||||
let id: String
|
||||
let naturalWidth: Int
|
||||
let naturalHeight: Int
|
||||
let scale: CGFloat
|
||||
|
||||
func pixels(_ o: ShotOrientation) -> (w: Int, h: Int) {
|
||||
let long = max(naturalWidth, naturalHeight)
|
||||
let short = min(naturalWidth, naturalHeight)
|
||||
switch o {
|
||||
case .natural: return (naturalWidth, naturalHeight)
|
||||
case .portrait: return (short, long)
|
||||
case .landscape: return (long, short)
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical point size (pixels / scale) — used to size the mac capture window so that a
|
||||
/// `screencapture` on a 2× display yields exactly `pixels(_:)`.
|
||||
func points(_ o: ShotOrientation) -> CGSize {
|
||||
let (w, h) = pixels(o)
|
||||
return CGSize(width: CGFloat(w) / scale, height: CGFloat(h) / scale)
|
||||
}
|
||||
|
||||
/// Mac: 2880×1800 (16:10 Retina) — an accepted size; on a 1× display the window capture is
|
||||
/// 1440×900, also accepted.
|
||||
static let mac = ShotDevice(id: "mac", naturalWidth: 2880, naturalHeight: 1800, scale: 2)
|
||||
|
||||
/// iPhone 6.9" (required) — for reference / the driver script's simulator choice.
|
||||
static let iphone69 = ShotDevice(id: "iphone-6.9", naturalWidth: 1320, naturalHeight: 2868,
|
||||
scale: 3)
|
||||
/// iPad 13" (required).
|
||||
static let ipad13 = ShotDevice(id: "ipad-13", naturalWidth: 2064, naturalHeight: 2752,
|
||||
scale: 2)
|
||||
/// Apple TV (always landscape).
|
||||
static let appleTV = ShotDevice(id: "appletv", naturalWidth: 1920, naturalHeight: 1080,
|
||||
scale: 1)
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,147 @@
|
||||
// App Store screenshot harness — the in-app "shot mode" root.
|
||||
//
|
||||
// Launched with PUNKTFUNK_SHOT_SCENE=<name> (one of ShotScenes.all), the app shows that single
|
||||
// mock-populated scene full-bleed instead of ContentView, so the OS can screenshot the REAL,
|
||||
// fully-rendered UI (materials, NavigationStack, glass — all the things ImageRenderer can't
|
||||
// rasterize offscreen). tools/screenshots.sh drives one launch per scene per device.
|
||||
//
|
||||
// Capture per platform:
|
||||
// • iOS / tvOS simulator → `xcrun simctl io booted screenshot` (native pixels = exact size).
|
||||
// • macOS → `screencapture -l<windowID>` of the borderless capture window (the configurator
|
||||
// prints `PF_SHOT_WINDOW=<id>`), or the no-permission self-capture fallback
|
||||
// (PUNKTFUNK_SHOT_SELFCAPTURE=<dir> → cacheDisplay; renders the real hierarchy but, like all
|
||||
// non-window-server capture, omits material blur).
|
||||
//
|
||||
// Every screen prints `PF_SHOT_READY scene=<name>` to stdout once it has settled, so the driver
|
||||
// can wait for layout instead of guessing with a fixed sleep.
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import ImageIO
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
enum ScreenshotMode {
|
||||
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
|
||||
static var requestedScene: ShotScene? {
|
||||
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
|
||||
guard !name.isEmpty else { return nil }
|
||||
return ShotScenes.all.first { $0.name == name }
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-bleed host for a single scene, with per-platform window sizing / orientation and a
|
||||
/// readiness ping for the capture script.
|
||||
struct ScreenshotHostView: View {
|
||||
let scene: ShotScene
|
||||
|
||||
var body: some View {
|
||||
scene.make()
|
||||
.environment(\.colorScheme, scene.colorScheme)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
.ignoresSafeArea()
|
||||
#if os(macOS)
|
||||
.background(MacShotWindowConfigurator(scene: scene))
|
||||
#elseif os(iOS)
|
||||
.background(IOSOrientationConfigurator(orientation: scene.orientation))
|
||||
#endif
|
||||
.task {
|
||||
// Let layout + materials settle, then signal the driver.
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
announceReady()
|
||||
}
|
||||
}
|
||||
|
||||
private func announceReady() {
|
||||
print("PF_SHOT_READY scene=\(scene.name)")
|
||||
fflush(stdout)
|
||||
#if os(macOS)
|
||||
MacSelfCapture.captureIfRequested(scene: scene)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Sizes the hosting window to the mac canvas, strips the title bar to a clean full-bleed
|
||||
/// surface, and prints the CGWindowID for `screencapture -l`.
|
||||
private struct MacShotWindowConfigurator: NSViewRepresentable {
|
||||
let scene: ShotScene
|
||||
|
||||
func makeNSView(context: Context) -> NSView { NSView() }
|
||||
|
||||
func updateNSView(_ view: NSView, context: Context) {
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window, !context.coordinator.configured else { return }
|
||||
context.coordinator.configured = true
|
||||
// NavigationStack / Form / material chrome follow the WINDOW's appearance, not the
|
||||
// SwiftUI colorScheme — without this the dark scenes render on a light window (white
|
||||
// background, washed-out materials).
|
||||
window.appearance = NSAppearance(named: scene.colorScheme == .dark ? .darkAqua : .aqua)
|
||||
let size = ShotDevice.mac.points(scene.orientation)
|
||||
window.styleMask = [.titled, .fullSizeContentView]
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.titleVisibility = .hidden
|
||||
window.isMovable = false
|
||||
for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
|
||||
window.standardWindowButton(button)?.isHidden = true
|
||||
}
|
||||
window.setContentSize(size)
|
||||
window.center()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
print("PF_SHOT_WINDOW=\(window.windowNumber) scene=\(scene.name) "
|
||||
+ "size=\(Int(size.width))x\(Int(size.height))pt")
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
final class Coordinator { var configured = false }
|
||||
}
|
||||
|
||||
/// No-permission fallback: capture the window's view tree via cacheDisplay. Renders the real
|
||||
/// hierarchy (NavigationStack/Form/cards — unlike ImageRenderer) but omits material blur, which
|
||||
/// only the window server (screencapture) composites. Used when PUNKTFUNK_SHOT_SELFCAPTURE is set.
|
||||
enum MacSelfCapture {
|
||||
static func captureIfRequested(scene: ShotScene) {
|
||||
guard let dir = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SELFCAPTURE"],
|
||||
!dir.isEmpty,
|
||||
let window = NSApp.windows.first(where: { $0.isVisible }),
|
||||
let content = window.contentView else { return }
|
||||
let outDir = URL(fileURLWithPath: (dir as NSString).expandingTildeInPath, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)
|
||||
guard let rep = content.bitmapImageRepForCachingDisplay(in: content.bounds) else { return }
|
||||
content.cacheDisplay(in: content.bounds, to: rep)
|
||||
let url = outDir.appendingPathComponent("\(ShotDevice.mac.id)-\(scene.name).png")
|
||||
if let dest = CGImageDestinationCreateWithURL(
|
||||
url as CFURL, "public.png" as CFString, 1, nil), let cg = rep.cgImage {
|
||||
CGImageDestinationAddImage(dest, cg, nil)
|
||||
CGImageDestinationFinalize(dest)
|
||||
print("PF_SHOT_SAVED \(url.path) \(rep.pixelsWide)x\(rep.pixelsHigh)px")
|
||||
}
|
||||
fflush(stdout)
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
|
||||
/// for chrome). Requires the app to allow those orientations in Info.plist.
|
||||
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
|
||||
let orientation: ShotOrientation
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
|
||||
|
||||
func updateUIViewController(_ vc: UIViewController, context: Context) {
|
||||
guard let scene = vc.view.window?.windowScene else { return }
|
||||
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
||||
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,284 @@
|
||||
// App Store screenshot scenes — the actual screens we render, each wired with mock data so it
|
||||
// looks populated without a live host. Every scene is built from the REAL app views (HomeView,
|
||||
// SettingsView, PairSheet, TrustCardView) so the screenshots track the shipping UI; only the
|
||||
// live stream is faked (StreamView needs a real punktfunk/1 connection — see ShotStreamHero).
|
||||
|
||||
#if DEBUG
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// One screen to capture: a name (→ file suffix), the canvas orientation, a color scheme, and a
|
||||
/// factory that builds the populated view on the main actor.
|
||||
struct ShotScene {
|
||||
let name: String
|
||||
let orientation: ShotOrientation
|
||||
let colorScheme: ColorScheme
|
||||
let make: @MainActor () -> AnyView
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum ShotScenes {
|
||||
static let all: [ShotScene] = [
|
||||
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotStreamHero())
|
||||
},
|
||||
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotHome())
|
||||
},
|
||||
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotPair())
|
||||
},
|
||||
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotTrust())
|
||||
},
|
||||
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotSettings())
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Mock data
|
||||
|
||||
@MainActor
|
||||
enum ShotMock {
|
||||
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
|
||||
static func hostStore() -> HostStore {
|
||||
let store = HostStore()
|
||||
store.hosts = [
|
||||
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
|
||||
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
||||
pinnedSHA256: fingerprint),
|
||||
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
|
||||
]
|
||||
return store
|
||||
}
|
||||
|
||||
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint)
|
||||
|
||||
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
|
||||
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
|
||||
private struct ShotHome: View {
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||
connect: { _ in }, connectDiscovered: { _ in },
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||
#else
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||
showSettings: .constant(false),
|
||||
connect: { _ in }, connectDiscovered: { _ in },
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
private struct ShotSettings: View {
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
// The mac Settings window is a fixed-size tabbed panel — float it over a dimmed host
|
||||
// grid so the shot reads as the preferences window over the running app.
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
|
||||
SettingsView()
|
||||
.fixedSize()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(radius: 40, y: 16)
|
||||
}
|
||||
#elseif os(iOS)
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
#else
|
||||
NavigationStack { SettingsView() }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pair (PIN ceremony)
|
||||
|
||||
private struct ShotPair: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
|
||||
PairSheet(host: ShotMock.host, onPaired: { _ in })
|
||||
.frame(maxWidth: 460)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.shadow(radius: 40, y: 16)
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trust (TOFU card over the blurred live stream)
|
||||
|
||||
private struct ShotTrust: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ShotDesktopFrame()
|
||||
.blur(radius: 32)
|
||||
.overlay(Color.black.opacity(0.45))
|
||||
TrustCardView(
|
||||
fingerprint: ShotMock.fingerprint, hostName: "Battlestation",
|
||||
onCancel: {}, onTrust: {}, onPairInstead: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stream hero
|
||||
|
||||
/// The marketing hero: a stand-in streamed frame with the real glass HUD chip on top.
|
||||
/// StreamView can't render here (it needs a live punktfunk/1 connection), so the frame is
|
||||
/// synthetic — set `PUNKTFUNK_SHOT_HERO=/path/to/frame.png` to drop in a real captured frame.
|
||||
private struct ShotStreamHero: View {
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ShotDesktopFrame()
|
||||
ShotHUD()
|
||||
}
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
|
||||
/// A faithful copy of StreamHUDView's overlay (which needs a live PunktfunkConnection for the
|
||||
/// mode line) with representative numbers, reusing the app's real `.glassBackground`.
|
||||
private struct ShotHUD: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(Color.accentColor).frame(width: 7, height: 7)
|
||||
Text("5120×1440@240 240 fps 812.4 Mb/s")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
Text("capture→client 1.3/2.1 ms p50/p95")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
#if os(macOS)
|
||||
Text("⌘⎋ releases the mouse")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
#elseif os(tvOS)
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
.glassBackground(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
|
||||
/// A synthetic "streamed frame" — a synthwave scene that reads as game content without shipping
|
||||
/// any real art. Replaced wholesale when `PUNKTFUNK_SHOT_HERO` points at a real PNG.
|
||||
private struct ShotDesktopFrame: View {
|
||||
var body: some View {
|
||||
if let image = Self.overrideImage {
|
||||
image.resizable().scaledToFill()
|
||||
} else {
|
||||
synthetic
|
||||
}
|
||||
}
|
||||
|
||||
private var synthetic: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.05, green: 0.02, blue: 0.16),
|
||||
Color(red: 0.35, green: 0.05, blue: 0.42),
|
||||
Color(red: 0.95, green: 0.30, blue: 0.35),
|
||||
Color(red: 0.99, green: 0.62, blue: 0.32),
|
||||
],
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
Canvas { ctx, size in
|
||||
let horizon = size.height * 0.52
|
||||
// Sun.
|
||||
let sunR = size.height * 0.20
|
||||
let sun = CGRect(x: size.width / 2 - sunR, y: horizon - sunR * 1.6,
|
||||
width: sunR * 2, height: sunR * 2)
|
||||
ctx.fill(Path(ellipseIn: sun),
|
||||
with: .linearGradient(
|
||||
Gradient(colors: [Color(red: 1, green: 0.95, blue: 0.5),
|
||||
Color(red: 1, green: 0.35, blue: 0.45)]),
|
||||
startPoint: CGPoint(x: sun.midX, y: sun.minY),
|
||||
endPoint: CGPoint(x: sun.midX, y: sun.maxY)))
|
||||
// Sun scanlines — clip a copy so the base context stays unclipped (GraphicsContext
|
||||
// is a value type; there is no resetClip).
|
||||
var sunCtx = ctx
|
||||
sunCtx.clip(to: Path(ellipseIn: sun))
|
||||
for i in 0..<7 {
|
||||
let y = sun.minY + sun.height * (0.55 + Double(i) * 0.07)
|
||||
let bar = CGRect(x: sun.minX, y: y, width: sun.width,
|
||||
height: sun.height * (0.012 + Double(i) * 0.006))
|
||||
sunCtx.fill(Path(bar), with: .color(.black.opacity(0.85)))
|
||||
}
|
||||
// Perspective grid below the horizon.
|
||||
ctx.opacity = 0.55
|
||||
let cx = size.width / 2
|
||||
for col in -10...10 {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: cx, y: horizon))
|
||||
p.addLine(to: CGPoint(x: cx + Double(col) * size.width * 0.11,
|
||||
y: size.height))
|
||||
ctx.stroke(p, with: .color(Color(red: 0.6, green: 0.95, blue: 1)),
|
||||
lineWidth: 1.5)
|
||||
}
|
||||
var row = horizon
|
||||
var step = size.height * 0.012
|
||||
while row < size.height {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: 0, y: row))
|
||||
p.addLine(to: CGPoint(x: size.width, y: row))
|
||||
ctx.stroke(p, with: .color(Color(red: 0.6, green: 0.95, blue: 1)),
|
||||
lineWidth: 1.5)
|
||||
step *= 1.32
|
||||
row += step
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
// A small "now playing" chip so the frame reads as live content, not a wallpaper.
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "gamecontroller.fill")
|
||||
Text("Streaming from Battlestation")
|
||||
.font(.system(.callout, weight: .semibold))
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 9)
|
||||
.glassBackground(Capsule())
|
||||
.padding(18)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_SHOT_HERO=/abs/path.png` → use a real captured frame as the hero background.
|
||||
static var overrideImage: Image? {
|
||||
guard let path = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_HERO"],
|
||||
!path.isEmpty, FileManager.default.fileExists(atPath: path) else { return nil }
|
||||
#if os(macOS)
|
||||
guard let ns = NSImage(contentsOfFile: path) else { return nil }
|
||||
return Image(nsImage: ns)
|
||||
#else
|
||||
guard let ui = UIImage(contentsOfFile: path) else { return nil }
|
||||
return Image(uiImage: ui)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -5,6 +5,12 @@ import Foundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
|
||||
/// values. NSLock instead of an actor — the writer is the (non-async) pump thread.
|
||||
final class FrameMeter: @unchecked Sendable {
|
||||
@@ -93,6 +99,7 @@ final class SessionModel: ObservableObject {
|
||||
compositor: PunktfunkConnection.Compositor = .auto,
|
||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||
bitrateKbps: UInt32 = 0,
|
||||
hdrEnabled: Bool = true,
|
||||
launchID: String? = nil,
|
||||
allowTofu: Bool = false,
|
||||
autoTrust: Bool = false) {
|
||||
@@ -101,17 +108,36 @@ final class SessionModel: ObservableObject {
|
||||
activeHost = host
|
||||
errorMessage = nil
|
||||
let pin = host.pinnedSHA256
|
||||
// Capability gate (main-actor — screen APIs): only advertise HDR when this display can
|
||||
// actually present it, so the host sends a proper SDR stream to an SDR display rather than
|
||||
// BT.2020 PQ the panel would mis-tone-map. The display self-tone-maps HDR from the mastering
|
||||
// metadata we apply (Step 2) when it IS HDR.
|
||||
let displayHDR: Bool = {
|
||||
#if os(macOS)
|
||||
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
||||
#else
|
||||
return UIScreen.main.potentialEDRHeadroom > 1.0
|
||||
#endif
|
||||
}()
|
||||
let hdrCapable = hdrEnabled && displayHDR
|
||||
Task.detached(priority: .userInitiated) {
|
||||
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
||||
// actor. The persistent identity is presented on every connect so a paired
|
||||
// host recognizes this Mac (nil = anonymous, fine for hosts without
|
||||
// --require-pairing; Keychain/generation failure must not block connecting).
|
||||
let identity = (try? ClientIdentityStore.shared.load())?.identity
|
||||
// Advertise 10-bit + HDR10 when enabled: the host upgrades to a BT.2020 PQ Main10 stream
|
||||
// only for actual HDR content (its own gate); the VideoToolbox/Metal present path is
|
||||
// HDR-capable (P010 + itur_2100_PQ + EDR). 0 keeps the 8-bit BT.709 SDR stream.
|
||||
let videoCaps: UInt8 = hdrCapable
|
||||
? (PunktfunkConnection.videoCap10Bit | PunktfunkConnection.videoCapHDR)
|
||||
: 0
|
||||
let result = Result { try PunktfunkConnection(
|
||||
host: host.address, port: host.port,
|
||||
width: width, height: height, refreshHz: hz,
|
||||
pinSHA256: pin, identity: identity, compositor: compositor,
|
||||
gamepad: gamepad, bitrateKbps: bitrateKbps, launchID: launchID) }
|
||||
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||
launchID: launchID) }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
// The user may have abandoned this attempt (window closed, another host
|
||||
|
||||
@@ -28,6 +28,9 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State private var showControllerTest = false
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
||||
@@ -411,6 +414,11 @@ struct SettingsView: View {
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
#if DEBUG && !os(tvOS)
|
||||
Button("Test Controller…") { showControllerTest = true }
|
||||
.disabled(gamepads.active == nil)
|
||||
.sheet(isPresented: $showControllerTest) { ControllerTestView() }
|
||||
#endif
|
||||
} header: {
|
||||
Text("Controllers")
|
||||
} footer: {
|
||||
@@ -511,15 +519,18 @@ struct SettingsView: View {
|
||||
private static let padTypes: [(label: String, tag: Int)] = [
|
||||
("Automatic", 0),
|
||||
("Xbox 360", 1),
|
||||
("Xbox One", 3),
|
||||
("DualSense", 2),
|
||||
("DualShock 4", 4),
|
||||
]
|
||||
|
||||
private static let controllersFooter =
|
||||
"One controller is forwarded to the host, as player 1 — Automatic picks the most "
|
||||
+ "recently connected one. The type is the virtual pad the host creates: Automatic "
|
||||
+ "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad "
|
||||
+ "and motion), and changes apply from the next session. Two identical controllers "
|
||||
+ "may swap a manual selection after reconnecting."
|
||||
+ "and motion; a DualShock 4 the same minus adaptive triggers), and changes apply "
|
||||
+ "from the next session. Two identical controllers may swap a manual selection "
|
||||
+ "after reconnecting."
|
||||
|
||||
/// "Use controller" choices: Automatic, every forwardable controller, and — so a stale
|
||||
/// pin stays visible instead of leaving the Picker selection tag-less — any pinned id
|
||||
@@ -537,7 +548,7 @@ struct SettingsView: View {
|
||||
|
||||
private func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: controller.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||
Image(systemName: controller.hasTouchpadAndMotion ? "playstation.logo" : "gamecontroller.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(controller.name)
|
||||
|
||||
@@ -91,14 +91,14 @@ struct SpeedTestSheet: View {
|
||||
bitrateKbps = rec
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.glassProminentButtonStyle()
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
}
|
||||
if case .failed = phase {
|
||||
Button("Retry") { run() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.glassProminentButtonStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,12 @@ struct SpeedTestSheet: View {
|
||||
.frame(width: 420)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// Bottom sheet rather than a full-screen modal; .medium stays put as the result view
|
||||
// swaps in (a measured height would resize the sheet mid-probe).
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
.onAppear { run() }
|
||||
.onDisappear { token.cancelled = true }
|
||||
}
|
||||
|
||||
@@ -99,7 +99,9 @@ struct StreamHUDView: View {
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||
// Floating HUD over live video — the canonical Liquid-Glass overlay surface (26+);
|
||||
// falls back to .regularMaterial below 26 (see GlassStyle).
|
||||
.glassBackground(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ struct TrustCardView: View {
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
Button("Trust & Connect", action: onTrust)
|
||||
// Opaque prominent, NOT glass: this card is itself a glass panel
|
||||
// (.glassBackground below), and glass-on-glass loses contrast — a tinted
|
||||
// bordered button reads cleanly over glass (HIG). The sheet primaries stay
|
||||
// glass because the system manages the sheet's own glass layering.
|
||||
.buttonStyle(.borderedProminent)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
@@ -58,7 +62,9 @@ struct TrustCardView: View {
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 440)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
// Floating trust card over the blurred stream — Liquid Glass on 26+, .regularMaterial
|
||||
// fallback below. The inner fingerprint box stays .quaternary (content, not glass).
|
||||
.glassBackground(RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
|
||||
/// 64 hex chars → four groups per line, two lines — easy to eyeball against the log.
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Raw-HID DualSense rumble for macOS.
|
||||
//
|
||||
// Apple's GameController/CHHapticEngine path does NOT drive the DualSense's rumble motors on
|
||||
// macOS — a documented platform gap: adaptive triggers, lightbar and player LEDs all work
|
||||
// (different APIs), but `CHHapticEngine` output never reaches the motors. So we write the motor
|
||||
// amplitudes straight into the DualSense HID output report, exactly the way SDL and the Linux
|
||||
// `hid-playstation` driver do (the same report that already rumbles this pad on a Linux host).
|
||||
//
|
||||
// USB (report 0x02, 48 bytes, no CRC) and Bluetooth (report 0x31, 78 bytes, trailing CRC32) are
|
||||
// both handled. The App Sandbox permits the raw-HID access via the app's `device.usb` +
|
||||
// `device.bluetooth` entitlements, and this coexists with GameController holding the same device
|
||||
// (non-seized open). Output-only, so no run-loop scheduling is needed.
|
||||
//
|
||||
// macOS-only: IOKit HID device access isn't available to apps on iOS/tvOS.
|
||||
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
import IOKit
|
||||
import IOKit.hid
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
|
||||
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
|
||||
final class DualSenseHID {
|
||||
private let manager: IOHIDManager
|
||||
private var device: IOHIDDevice?
|
||||
private var bluetooth = false
|
||||
private var closed = false
|
||||
|
||||
private static let vendorSony = 0x054C
|
||||
// DualSense (0x0CE6) and DualSense Edge (0x0DF2). The DualShock 4 uses a different report
|
||||
// layout and is intentionally not handled here.
|
||||
private static let productIDs = [0x0CE6, 0x0DF2]
|
||||
|
||||
/// "USB" or "Bluetooth" — for logs / the debug panel. Valid after a successful `open()`.
|
||||
var transport: String { bluetooth ? "Bluetooth" : "USB" }
|
||||
|
||||
init() {
|
||||
manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
}
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Find and open the first connected DualSense. Returns false if none is present or it can't
|
||||
/// be opened (caller then falls back to CoreHaptics).
|
||||
func open() -> Bool {
|
||||
let matches = Self.productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
IOHIDManagerSetDeviceMatchingMultiple(manager, matches as CFArray)
|
||||
guard IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
|
||||
log.info("rumble: DualSense HID manager open failed — falling back to CoreHaptics")
|
||||
return false
|
||||
}
|
||||
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
|
||||
let dev = devices.first
|
||||
else {
|
||||
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
|
||||
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
return false
|
||||
}
|
||||
device = dev
|
||||
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
|
||||
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
|
||||
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
|
||||
return true
|
||||
}
|
||||
|
||||
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
|
||||
/// each 0...255. (0, 0) stops.
|
||||
func rumble(low: UInt8, high: UInt8) {
|
||||
guard let dev = device else { return }
|
||||
let report = bluetooth
|
||||
? Self.bluetoothReport(low: low, high: high)
|
||||
: Self.usbReport(low: low, high: high)
|
||||
let rc = report.withUnsafeBufferPointer { buf in
|
||||
IOHIDDeviceSetReport(
|
||||
dev, kIOHIDReportTypeOutput, CFIndex(report[0]), buf.baseAddress!, buf.count)
|
||||
}
|
||||
if rc != kIOReturnSuccess {
|
||||
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
guard !closed else { return }
|
||||
closed = true
|
||||
if device != nil { rumble(low: 0, high: 0) } // silence the motors before releasing
|
||||
device = nil
|
||||
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
}
|
||||
|
||||
// MARK: - Report builders
|
||||
|
||||
// DualSense effects payload (DS5EffectsState_t / hid-playstation `common`) — offsets relative
|
||||
// to the payload start:
|
||||
// 0 flag0 (enable bits) 2 motor_right (high-freq) 3 motor_left (low-freq)
|
||||
// 1 flag1 38 flag2 (enhanced enable)
|
||||
// We mirror the Linux driver: flag0 = COMPATIBLE_VIBRATION | HAPTICS_SELECT, flag2 =
|
||||
// COMPATIBLE_VIBRATION2 (the enhanced-firmware path), motors sent directly. valid_flag1 stays
|
||||
// 0 so this rumble-only report leaves the lightbar / triggers / player LEDs (driven by
|
||||
// GameController) untouched.
|
||||
private static func fillEffects(_ data: inout [UInt8], at base: Int, low: UInt8, high: UInt8) {
|
||||
data[base + 0] = 0x03 // COMPATIBLE_VIBRATION (0x01) | HAPTICS_SELECT (0x02)
|
||||
data[base + 2] = high // motor_right
|
||||
data[base + 3] = low // motor_left
|
||||
data[base + 38] = 0x04 // COMPATIBLE_VIBRATION2 (enhanced rumble, firmware ≥ 0x0224)
|
||||
}
|
||||
|
||||
// `usbReport` / `bluetoothReport` / `crc32` are internal (not private) so the unit tests can
|
||||
// pin the exact wire layout against the SDL / hid-playstation spec without a physical pad.
|
||||
static func usbReport(low: UInt8, high: UInt8) -> [UInt8] {
|
||||
var d = [UInt8](repeating: 0, count: 48)
|
||||
d[0] = 0x02 // report id
|
||||
fillEffects(&d, at: 1, low: low, high: high)
|
||||
return d
|
||||
}
|
||||
|
||||
static func bluetoothReport(low: UInt8, high: UInt8) -> [UInt8] {
|
||||
var d = [UInt8](repeating: 0, count: 78)
|
||||
d[0] = 0x31 // report id
|
||||
d[1] = 0x00 // seq/tag (static, as SDL)
|
||||
d[2] = 0x10 // magic
|
||||
fillEffects(&d, at: 3, low: low, high: high)
|
||||
// Trailing CRC32 over a 0xA2 seed byte + the report minus its 4 CRC bytes, little-endian.
|
||||
let crc = Self.crc32(seed: 0xA2, d[0..<(d.count - 4)])
|
||||
d[74] = UInt8(crc & 0xFF)
|
||||
d[75] = UInt8((crc >> 8) & 0xFF)
|
||||
d[76] = UInt8((crc >> 16) & 0xFF)
|
||||
d[77] = UInt8((crc >> 24) & 0xFF)
|
||||
return d
|
||||
}
|
||||
|
||||
/// Standard reflected CRC32 (zlib poly 0xEDB88320, init 0xFFFFFFFF, final XOR) over `seed`
|
||||
/// followed by `bytes` — the DualSense Bluetooth output-report checksum (seed 0xA2). Matches
|
||||
/// SDL's `SDL_crc32`/the kernel's `crc32_le` framing.
|
||||
static func crc32<S: Sequence>(seed: UInt8, _ bytes: S) -> UInt32
|
||||
where S.Element == UInt8 {
|
||||
var crc: UInt32 = 0xFFFF_FFFF
|
||||
func step(_ b: UInt8) {
|
||||
crc ^= UInt32(b)
|
||||
for _ in 0..<8 {
|
||||
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB8_8320 : crc >> 1
|
||||
}
|
||||
}
|
||||
step(seed)
|
||||
for b in bytes { step(b) }
|
||||
return ~crc
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -6,12 +6,14 @@
|
||||
// full GCExtendedGamepad state on every valueChanged and diff against the previous
|
||||
// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255.
|
||||
//
|
||||
// DualSense extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
||||
// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
||||
// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion
|
||||
// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g —
|
||||
// derived from the host's fixed calibration blob; the conversion lives in ONE place,
|
||||
// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both
|
||||
// unless the session's virtual pad is a DualSense.
|
||||
// unless the session's virtual pad is a DualSense or DualShock 4 — both carry a touchpad
|
||||
// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same
|
||||
// `touchpad*` surface as `GCDualSenseGamepad`).
|
||||
//
|
||||
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture
|
||||
// toggle — a controller can't click local UI, so it always drives the host while the app
|
||||
@@ -154,8 +156,9 @@ public final class GamepadCapture {
|
||||
releaseAll()
|
||||
if let ext = bound?.extendedGamepad {
|
||||
ext.valueChangedHandler = nil
|
||||
(ext as? GCDualSenseGamepad)?.touchpadPrimary.valueChangedHandler = nil
|
||||
(ext as? GCDualSenseGamepad)?.touchpadSecondary.valueChangedHandler = nil
|
||||
let tp = Self.touchpad(ext)
|
||||
tp?.primary.valueChangedHandler = nil
|
||||
tp?.secondary.valueChangedHandler = nil
|
||||
}
|
||||
if let motion = bound?.motion {
|
||||
motion.valueChangedHandler = nil
|
||||
@@ -186,11 +189,11 @@ public final class GamepadCapture {
|
||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))
|
||||
sync(ext)
|
||||
|
||||
if let ds = ext as? GCDualSenseGamepad {
|
||||
ds.touchpadPrimary.valueChangedHandler = { [weak self] _, x, y in
|
||||
if let tp = Self.touchpad(ext) {
|
||||
tp.primary.valueChangedHandler = { [weak self] _, x, y in
|
||||
MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) }
|
||||
}
|
||||
ds.touchpadSecondary.valueChangedHandler = { [weak self] _, x, y in
|
||||
tp.secondary.valueChangedHandler = { [weak self] _, x, y in
|
||||
MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) }
|
||||
}
|
||||
}
|
||||
@@ -257,12 +260,29 @@ public final class GamepadCapture {
|
||||
if g.buttonB.isPressed { b |= GamepadWire.b }
|
||||
if g.buttonX.isPressed { b |= GamepadWire.x }
|
||||
if g.buttonY.isPressed { b |= GamepadWire.y }
|
||||
if (g as? GCDualSenseGamepad)?.touchpadButton.isPressed == true {
|
||||
if Self.touchpad(g)?.button.isPressed == true {
|
||||
b |= GamepadWire.touchpadClick
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
/// The touchpad surface of a PlayStation pad — present on both `GCDualSenseGamepad` and
|
||||
/// `GCDualShockGamepad` (DualShock 4), which don't share a common touchpad type, so we
|
||||
/// downcast either and project the identical `touchpad*` properties. `nil` for any other
|
||||
/// controller (Xbox, MFi).
|
||||
private static func touchpad(
|
||||
_ g: GCExtendedGamepad
|
||||
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||
button: GCControllerButtonInput)? {
|
||||
if let ds = g as? GCDualSenseGamepad {
|
||||
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
|
||||
}
|
||||
if let ds4 = g as? GCDualShockGamepad {
|
||||
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on
|
||||
/// lift — treated as the lift signal (a real finger landing on the precise center
|
||||
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger.
|
||||
//
|
||||
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
|
||||
// only on DualSense sessions — the drain always polls both planes with short timeouts and
|
||||
// never spins, so an Xbox session just renders rumble. GameController profile mutation
|
||||
// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) — the
|
||||
// drain always polls both planes with short timeouts and never spins, so an Xbox session
|
||||
// just renders rumble. GameController profile mutation
|
||||
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
|
||||
// touches neither. When GamepadManager switches the active controller mid-session, the
|
||||
// old pad is reset (triggers off, player index unset) and the last known feedback state
|
||||
@@ -49,50 +50,105 @@ private final class FeedbackStopFlag: @unchecked Sendable {
|
||||
private final class RumbleRenderer: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||
|
||||
/// One actuator's started engine plus the player currently driving it (nil = idle). The
|
||||
/// player is rebuilt per level change — `drive` bakes the target intensity into a fresh
|
||||
/// continuous event rather than scaling a long-lived one with a dynamic parameter.
|
||||
private struct Motor {
|
||||
let engine: CHHapticEngine
|
||||
let player: CHHapticAdvancedPatternPlayer
|
||||
var playing = false
|
||||
var player: CHHapticAdvancedPatternPlayer?
|
||||
}
|
||||
|
||||
private var controller: GCController?
|
||||
private var low: Motor?
|
||||
private var high: Motor?
|
||||
// `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad
|
||||
// on an OS that doesn't expose rumble through GameController, a Siri Remote) — nothing to retry
|
||||
// until the controller changes. A transient engine failure does NOT latch it; it tears down for
|
||||
// a lazy rebuild instead, so a single hiccup can't kill rumble for the whole session.
|
||||
private var broken = false
|
||||
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
||||
private var wasActive = false
|
||||
|
||||
func retarget(_ c: GCController?) {
|
||||
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
||||
/// defined frequency to move at all — an intensity-only event (no sharpness) left them
|
||||
/// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid
|
||||
/// value the known-working macOS DualSense rumble implementations use. (Used only on the
|
||||
/// CoreHaptics path — a DualSense on macOS is driven over raw HID instead, see below.)
|
||||
private static let sharpness: Float = 0.5
|
||||
|
||||
#if os(macOS)
|
||||
/// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
|
||||
/// does not reach them on macOS — adaptive triggers/lightbar work, rumble is silent). nil for
|
||||
/// every other controller, which keeps the CoreHaptics path.
|
||||
private var dualSenseHID: DualSenseHID?
|
||||
#endif
|
||||
|
||||
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
||||
/// rumble backend now in use — for the debug controller-test panel.
|
||||
func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) {
|
||||
queue.async {
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
self.controller = c
|
||||
self.broken = false
|
||||
_ = self.openHIDIfDualSense(c)
|
||||
onBackend?(self.backendNote(for: c))
|
||||
}
|
||||
}
|
||||
|
||||
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
||||
queue.async {
|
||||
let active = lowAmp != 0 || highAmp != 0
|
||||
if active != self.wasActive {
|
||||
self.wasActive = active
|
||||
log.debug(
|
||||
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
||||
}
|
||||
// A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every
|
||||
// other pad (and for a DualSense whose HID device could not be opened).
|
||||
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
||||
guard !self.broken else { return }
|
||||
if (lowAmp != 0 || highAmp != 0), self.low == nil, self.high == nil {
|
||||
if active, self.low == nil, self.high == nil {
|
||||
self.setup()
|
||||
}
|
||||
let ok: Bool
|
||||
if self.high != nil {
|
||||
self.drive(&self.low, Float(lowAmp) / 65535)
|
||||
self.drive(&self.high, Float(highAmp) / 65535)
|
||||
// Per-handle: low = left/heavy motor, high = right/light — the XInput convention
|
||||
// the wire carries.
|
||||
let okLow = self.drive(&self.low, Float(lowAmp) / 65535)
|
||||
let okHigh = self.drive(&self.high, Float(highAmp) / 65535)
|
||||
ok = okLow && okHigh
|
||||
} else {
|
||||
// Combined engine: whichever motor is stronger wins.
|
||||
self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
|
||||
ok = self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
|
||||
}
|
||||
// Rebuild on the next nonzero amplitude if an engine errored — and tear down OUTSIDE
|
||||
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
|
||||
// still holds an exclusive reference to.
|
||||
if !ok { self.teardown() }
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.sync { self.teardown() }
|
||||
queue.sync {
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
}
|
||||
}
|
||||
|
||||
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
|
||||
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
||||
/// engine otherwise, driven by whichever amplitude is stronger.
|
||||
private func setup() {
|
||||
guard let haptics = controller?.haptics else { return }
|
||||
guard let haptics = controller?.haptics else {
|
||||
// No haptics engine at all — an Xbox controller on an OS/firmware that doesn't expose
|
||||
// rumble through GameController (works on Android via the standard Vibrator path, but
|
||||
// Apple's support is controller/OS-dependent), or a Siri Remote. Nothing to retry until
|
||||
// the controller changes; latch off (retarget clears it) and say so once.
|
||||
log.info("rumble: active controller exposes no haptics engine — rumble unavailable")
|
||||
broken = true
|
||||
return
|
||||
}
|
||||
let localities = haptics.supportedLocalities
|
||||
if localities.contains(.leftHandle), localities.contains(.rightHandle) {
|
||||
low = makeMotor(haptics, .leftHandle)
|
||||
@@ -100,61 +156,133 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
} else {
|
||||
low = makeMotor(haptics, .default)
|
||||
}
|
||||
if low == nil && high == nil {
|
||||
broken = true // no usable engine (e.g. Siri Remote) — stay silent
|
||||
if low == nil, high == nil {
|
||||
// Haptics present but no engine could be built right now (server busy / a transient
|
||||
// error). Do NOT latch broken — the next nonzero amplitude retries setup().
|
||||
log.warning("rumble: haptics present but engine setup failed — will retry on next rumble")
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
|
||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
||||
// The haptic server can stop or reset the engine out from under us — app backgrounding, an
|
||||
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left
|
||||
// unhandled the players go dead and every later rumble throws, latching rumble off for the
|
||||
// rest of the session (the "rumble worked, then went spotty" failure). Tear down on the
|
||||
// serial queue so the next nonzero amplitude lazily rebuilds the engine, instead.
|
||||
engine.stoppedHandler = { [weak self] reason in
|
||||
log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild")
|
||||
self?.queue.async { self?.teardown() }
|
||||
}
|
||||
engine.resetHandler = { [weak self] in
|
||||
log.info("rumble: haptic engine reset — will rebuild")
|
||||
self?.queue.async { self?.teardown() }
|
||||
}
|
||||
do {
|
||||
// Start the engine now; the player that actually moves the motor is built per level
|
||||
// change in `drive` (a fresh event baked at the target intensity).
|
||||
try engine.start()
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [CHHapticEventParameter(parameterID: .hapticIntensity, value: 1)],
|
||||
relativeTime: 0,
|
||||
duration: TimeInterval(GCHapticDurationInfinite))
|
||||
let player = try engine.makeAdvancedPlayer(with: CHHapticPattern(events: [event], parameters: []))
|
||||
return Motor(engine: engine, player: player)
|
||||
return Motor(engine: engine, player: nil)
|
||||
} catch {
|
||||
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func drive(_ motor: inout Motor?, _ amplitude: Float) {
|
||||
guard var m = motor else { return }
|
||||
/// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
|
||||
/// is BAKED into the event. On a DualSense this is what actually moves the actuators: a
|
||||
/// fixed-intensity event scaled by a dynamic `.hapticIntensityControl` parameter (the old
|
||||
/// path) drives the iPhone Taptic Engine but is silent on a controller's haptic engine. The
|
||||
/// event carries an explicit sharpness (frequency) so the voice coils respond, and an infinite
|
||||
/// duration so a single host update — the host sends rumble only when the level changes —
|
||||
/// sustains until the next one. Returns false if the engine errored; the caller tears down for
|
||||
/// a rebuild (done outside this `inout` access to avoid an exclusivity violation).
|
||||
private func drive(_ motor: inout Motor?, _ amplitude: Float) -> Bool {
|
||||
guard var m = motor else { return true }
|
||||
// Replace any running player: stop the old, and for a zero level leave the motor idle.
|
||||
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||
m.player = nil
|
||||
guard amplitude > 0 else { motor = m; return true }
|
||||
do {
|
||||
if amplitude > 0 {
|
||||
if !m.playing {
|
||||
try m.player.start(atTime: CHHapticTimeImmediate)
|
||||
m.playing = true
|
||||
}
|
||||
try m.player.sendParameters(
|
||||
[CHHapticDynamicParameter(
|
||||
parameterID: .hapticIntensityControl,
|
||||
value: amplitude, relativeTime: 0)],
|
||||
atTime: CHHapticTimeImmediate)
|
||||
} else if m.playing {
|
||||
try m.player.stop(atTime: CHHapticTimeImmediate)
|
||||
m.playing = false
|
||||
}
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
|
||||
],
|
||||
relativeTime: 0,
|
||||
duration: TimeInterval(GCHapticDurationInfinite))
|
||||
let player = try m.engine.makeAdvancedPlayer(
|
||||
with: CHHapticPattern(events: [event], parameters: []))
|
||||
try player.start(atTime: CHHapticTimeImmediate)
|
||||
m.player = player
|
||||
motor = m
|
||||
return true
|
||||
} catch {
|
||||
log.warning("haptic update failed — rumble disabled: \(error, privacy: .public)")
|
||||
teardown()
|
||||
broken = true
|
||||
// A transient failure (the engine stopped/reset between its handler firing and now).
|
||||
// Signal a rebuild — do NOT latch rumble off for the session (the old "spotty" bug).
|
||||
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
|
||||
motor = m
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func teardown() {
|
||||
for m in [low, high].compactMap({ $0 }) {
|
||||
try? m.player.stop(atTime: CHHapticTimeImmediate)
|
||||
// Disarm the handlers before stopping so stop() can't re-enter teardown via them.
|
||||
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
||||
m.engine.stoppedHandler = { _ in }
|
||||
m.engine.resetHandler = {}
|
||||
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||
m.engine.stop()
|
||||
}
|
||||
low = nil
|
||||
high = nil
|
||||
}
|
||||
|
||||
// MARK: - DualSense raw-HID rumble (macOS)
|
||||
//
|
||||
// On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
|
||||
// we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path.
|
||||
// All three run on the serial `queue`, like the rest of the renderer state.
|
||||
|
||||
private func openHIDIfDualSense(_ c: GCController?) -> Bool {
|
||||
#if os(macOS)
|
||||
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
|
||||
let hid = DualSenseHID()
|
||||
guard hid.open() else { return false }
|
||||
dualSenseHID = hid
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Drive the DualSense's motors over HID if that's the active backend; false → not a HID pad,
|
||||
/// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255.
|
||||
private func hidRumble(low: UInt16, high: UInt16) -> Bool {
|
||||
#if os(macOS)
|
||||
guard let hid = dualSenseHID else { return false }
|
||||
hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8))
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
private func closeHID() {
|
||||
#if os(macOS)
|
||||
dualSenseHID?.close()
|
||||
dualSenseHID = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private func backendNote(for c: GCController?) -> String {
|
||||
#if os(macOS)
|
||||
if let hid = dualSenseHID { return "DualSense HID · \(hid.transport)" }
|
||||
#endif
|
||||
return c == nil ? "—" : "CoreHaptics"
|
||||
}
|
||||
}
|
||||
|
||||
public final class GamepadFeedback {
|
||||
@@ -207,9 +335,12 @@ public final class GamepadFeedback {
|
||||
public func start() {
|
||||
guard !drainStarted else { return }
|
||||
drainStarted = true
|
||||
// No hidout traffic can exist on a non-DualSense session — poll that plane
|
||||
// nonblocking there and let rumble own the wait.
|
||||
let hidTimeout: UInt32 = connection.resolvedGamepad == .dualSense ? 10 : 0
|
||||
// Hidout traffic (lightbar / player LEDs / triggers) only exists on a PlayStation-pad
|
||||
// session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
|
||||
// let rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
|
||||
let hasHidout = connection.resolvedGamepad == .dualSense
|
||||
|| connection.resolvedGamepad == .dualShock4
|
||||
let hidTimeout: UInt32 = hasHidout ? 10 : 0
|
||||
let thread = Thread { [connection, flag, drainDone, weak self] in
|
||||
while !flag.isStopped {
|
||||
do {
|
||||
@@ -324,3 +455,74 @@ public final class GamepadFeedback {
|
||||
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Local feedback driver for the Settings → Controllers "Test Controller" panel (DEBUG builds
|
||||
/// only). It drives the SAME CoreHaptics rumble renderer and `DualSenseTriggerEffect` path a
|
||||
/// live session uses — just aimed at the physically-connected controller instead of the
|
||||
/// host→client feedback planes — so rumble, the adaptive triggers, the lightbar and the player
|
||||
/// LEDs can be confirmed on-device without a host. Reusing the real renderers is the point:
|
||||
/// a passing test exercises the exact code a session runs.
|
||||
@MainActor
|
||||
public final class ControllerTester: ObservableObject {
|
||||
private let renderer = RumbleRenderer()
|
||||
private weak var controller: GCController?
|
||||
|
||||
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
||||
/// for the test panel to display so it's obvious which path a given pad takes.
|
||||
@Published public private(set) var rumbleBackend = "—"
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Aim the feedback at a controller (nil releases it). Idempotent — safe to call on every
|
||||
/// active-controller change.
|
||||
public func target(_ c: GCController?) {
|
||||
guard c !== controller else { return }
|
||||
controller = c
|
||||
renderer.retarget(c) { [weak self] note in
|
||||
Task { @MainActor in self?.rumbleBackend = note }
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive both motors at 0...1 amplitudes — low = left/heavy, high = right/light — mapped to
|
||||
/// the 0...0xFFFF wire range the session carries, through the real `RumbleRenderer`.
|
||||
public func rumble(low: Float, high: Float) {
|
||||
func u16(_ v: Float) -> UInt16 { UInt16((min(max(v, 0), 1) * 65535).rounded()) }
|
||||
renderer.apply(low: u16(low), high: u16(high))
|
||||
}
|
||||
|
||||
public func stopRumble() { renderer.apply(low: 0, high: 0) }
|
||||
|
||||
/// Replay an adaptive-trigger effect on a DualSense via the real `DualSenseTriggerEffect`
|
||||
/// renderer. `right == false` → L2, `true` → R2. No-op on a non-DualSense pad.
|
||||
public func applyTrigger(_ effect: DualSenseTriggerEffect, right: Bool) {
|
||||
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
||||
effect.apply(to: right ? ds.rightTrigger : ds.leftTrigger)
|
||||
}
|
||||
|
||||
public func resetTriggers() {
|
||||
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
||||
ds.leftTrigger.setModeOff()
|
||||
ds.rightTrigger.setModeOff()
|
||||
}
|
||||
|
||||
/// Lightbar colour (DualSense / DualShock 4); nil turns it off. No-op without a light.
|
||||
public func setLight(_ color: GCColor?) {
|
||||
controller?.light?.color = color ?? GCColor(red: 0, green: 0, blue: 0)
|
||||
}
|
||||
|
||||
/// Player-indicator LEDs (`.index1`...`.index4`, or `.indexUnset` to clear).
|
||||
public func setPlayerIndex(_ index: GCControllerPlayerIndex) {
|
||||
controller?.playerIndex = index
|
||||
}
|
||||
|
||||
/// Silence every channel and release the controller — call on the panel's disappear.
|
||||
public func stop() {
|
||||
resetTriggers()
|
||||
setPlayerIndex(.indexUnset)
|
||||
setLight(nil)
|
||||
renderer.retarget(nil) // async teardown: stops the motors + drops the controller ref
|
||||
controller = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -30,11 +30,22 @@ public final class GamepadManager: ObservableObject {
|
||||
public let productCategory: String
|
||||
/// The full extended profile exists — only these are forwardable.
|
||||
public let isExtended: Bool
|
||||
public let isDualSense: Bool
|
||||
/// The virtual-pad type a physical match resolves to under `.auto`: DualSense →
|
||||
/// `.dualSense`, DualShock 4 → `.dualShock4`, an Xbox pad → `.xboxOne`, anything
|
||||
/// else → `.xbox360`. (`.auto` is never stored here.)
|
||||
public let kind: PunktfunkConnection.GamepadType
|
||||
public let hasLight: Bool
|
||||
public let hasHaptics: Bool
|
||||
public let hasMotion: Bool
|
||||
public let hasAdaptiveTriggers: Bool
|
||||
/// Specifically a DualSense — gates the DualSense-only feedback (adaptive triggers,
|
||||
/// player LEDs) and the PlayStation glyph in Settings.
|
||||
public var isDualSense: Bool { kind == .dualSense }
|
||||
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) — gates
|
||||
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
|
||||
public var hasTouchpadAndMotion: Bool {
|
||||
kind == .dualSense || kind == .dualShock4
|
||||
}
|
||||
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
|
||||
public let batteryLevel: Float?
|
||||
public let isCharging: Bool
|
||||
@@ -102,7 +113,8 @@ public final class GamepadManager: ObservableObject {
|
||||
|
||||
/// Connect-time resolution of the user's controller-type setting: an explicit choice
|
||||
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense →
|
||||
/// DualSense, anything else → Xbox 360); no controller at all defers to the host.
|
||||
/// DualSense, DualShock 4 → DualShock 4, an Xbox pad → Xbox One, anything else → Xbox
|
||||
/// 360); no controller at all defers to the host.
|
||||
public func resolveType(
|
||||
setting: PunktfunkConnection.GamepadType
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
@@ -113,7 +125,7 @@ public final class GamepadManager: ObservableObject {
|
||||
// pad. `rebuild()` re-reads `GCController.controllers()` synchronously, closing that race.
|
||||
rebuild()
|
||||
guard let active else { return .auto }
|
||||
return active.isDualSense ? .dualSense : .xbox360
|
||||
return active.kind
|
||||
}
|
||||
|
||||
private func noteConnected(_ c: GCController) {
|
||||
@@ -152,20 +164,38 @@ public final class GamepadManager: ObservableObject {
|
||||
|
||||
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
||||
let extended = c.extendedGamepad
|
||||
let ds = extended as? GCDualSenseGamepad
|
||||
let kind = padKind(extended)
|
||||
return DiscoveredController(
|
||||
id: id,
|
||||
name: c.vendorName ?? c.productCategory,
|
||||
productCategory: c.productCategory,
|
||||
isExtended: extended != nil,
|
||||
isDualSense: ds != nil,
|
||||
kind: kind,
|
||||
hasLight: c.light != nil,
|
||||
hasHaptics: c.haptics != nil,
|
||||
hasMotion: c.motion != nil,
|
||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration.
|
||||
hasAdaptiveTriggers: ds != nil,
|
||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the
|
||||
// DualShock 4 has none.
|
||||
hasAdaptiveTriggers: kind == .dualSense,
|
||||
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
||||
isCharging: c.battery?.batteryState == .charging,
|
||||
controller: c)
|
||||
}
|
||||
|
||||
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
||||
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then
|
||||
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent
|
||||
/// profile also falls back to `.xbox360` (it's never forwarded anyway).
|
||||
private static func padKind(
|
||||
_ extended: GCExtendedGamepad?
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
guard let extended else { return .xbox360 }
|
||||
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
||||
// here, so no `@available` guard is needed — matching the unguarded
|
||||
// `GCDualSenseGamepad` use elsewhere in the package.
|
||||
if extended is GCDualSenseGamepad { return .dualSense }
|
||||
if extended is GCDualShockGamepad { return .dualShock4 }
|
||||
if extended is GCXboxGamepad { return .xboxOne }
|
||||
return .xbox360
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,13 +170,18 @@ public final class PunktfunkConnection {
|
||||
|
||||
/// Which virtual gamepad the host creates for this session's pads (the
|
||||
/// `PUNKTFUNK_GAMEPAD_*` ABI values). `.auto` lets the host decide (its env var, else
|
||||
/// X-Box 360); `.dualSense` is honored only on hosts with UHID (Linux) — games then see
|
||||
/// a real DualSense and their lightbar / adaptive-trigger writes come back on the
|
||||
/// HID-output plane (`nextHidOutput`). The host's actual choice is `resolvedGamepad`.
|
||||
/// X-Box 360); `.dualSense` / `.dualShock4` are honored only on hosts with UHID (Linux) —
|
||||
/// games then see a real PlayStation pad and its lightbar (and, on a DualSense,
|
||||
/// adaptive-trigger / player-LED) writes come back on the HID-output plane
|
||||
/// (`nextHidOutput`). `.xboxOne` is an X-Box-Series-glyph variant of `.xbox360` (same
|
||||
/// buttons/sticks/triggers + rumble, no touchpad/motion/lightbar). The host's actual
|
||||
/// choice is `resolvedGamepad`.
|
||||
public enum GamepadType: UInt32, CaseIterable, Sendable {
|
||||
case auto = 0
|
||||
case xbox360 = 1
|
||||
case dualSense = 2
|
||||
case xboxOne = 3
|
||||
case dualShock4 = 4
|
||||
|
||||
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||
/// `GamepadPref::from_name`.
|
||||
@@ -184,7 +189,9 @@ public final class PunktfunkConnection {
|
||||
switch name.lowercased() {
|
||||
case "auto", "default": self = .auto
|
||||
case "xbox", "xbox360", "x360", "uinput": self = .xbox360
|
||||
case "dualsense", "ds", "ps5": self = .dualSense
|
||||
case "dualsense", "ds", "ds5", "ps5": self = .dualSense
|
||||
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
|
||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
@@ -214,6 +221,20 @@ public final class PunktfunkConnection {
|
||||
/// (20 000) when 0 was requested. `0` = an older host that didn't report it.
|
||||
public private(set) var resolvedBitrateKbps: UInt32 = 0
|
||||
|
||||
/// The colour signalling the host actually encodes with (CICP code points): `colorPrimaries`
|
||||
/// (1=BT.709, 9=BT.2020), `colorTransfer` (1=BT.709, 16=PQ, 18=HLG), `colorMatrix`
|
||||
/// (1=BT.709, 9=BT.2020-NCL), `colorFullRange`. BT.709 limited SDR for an older host. Configure
|
||||
/// the decoder/presenter from these; mastering metadata arrives via `nextHdrMeta`.
|
||||
public private(set) var colorPrimaries: UInt8 = 1
|
||||
public private(set) var colorTransfer: UInt8 = 1
|
||||
public private(set) var colorMatrix: UInt8 = 1
|
||||
public private(set) var colorFullRange: Bool = false
|
||||
/// Encoded bit depth (8 or 10).
|
||||
public private(set) var bitDepth: UInt8 = 8
|
||||
/// True when the negotiated stream is HDR (PQ or HLG transfer) — drive an HDR present path and
|
||||
/// drain `nextHdrMeta`.
|
||||
public var isHDR: Bool { colorTransfer == 16 || colorTransfer == 18 }
|
||||
|
||||
/// Connect and start a session at the requested mode (the host creates a native virtual
|
||||
/// output at exactly this size/refresh). Blocks up to `timeoutMs`.
|
||||
///
|
||||
@@ -242,11 +263,14 @@ public final class PunktfunkConnection {
|
||||
compositor: Compositor = .auto,
|
||||
gamepad: GamepadType = .auto,
|
||||
bitrateKbps: UInt32 = 0,
|
||||
videoCaps: UInt8 = 0,
|
||||
launchID: String? = nil,
|
||||
timeoutMs: UInt32 = 10_000
|
||||
) throws {
|
||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||
var observed = [UInt8](repeating: 0, count: 32)
|
||||
// `videoCaps` advertises decode/present capability (PUNKTFUNK_VIDEO_CAP_10BIT | _HDR): the
|
||||
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
|
||||
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
|
||||
// the session; the host resolves it against its own library — nil = the host's default.
|
||||
handle = host.withCString { cs in
|
||||
@@ -255,16 +279,16 @@ public final class PunktfunkConnection {
|
||||
withOptionalCString(launchID) { launch in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex4(
|
||||
punktfunk_connect_ex5(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, launch,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, launch,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||
cert, key, timeoutMs)
|
||||
}
|
||||
}
|
||||
return punktfunk_connect_ex4(
|
||||
return punktfunk_connect_ex5(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, launch,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, launch,
|
||||
nil, &observed, cert, key, timeoutMs)
|
||||
}
|
||||
}
|
||||
@@ -289,6 +313,13 @@ public final class PunktfunkConnection {
|
||||
var br: UInt32 = 0
|
||||
_ = punktfunk_connection_bitrate(handle, &br)
|
||||
resolvedBitrateKbps = br
|
||||
var prim: UInt8 = 1, trc: UInt8 = 1, mtx: UInt8 = 1, fullRange: UInt8 = 0, depth: UInt8 = 8
|
||||
_ = punktfunk_connection_color_info(handle, &prim, &trc, &mtx, &fullRange, &depth)
|
||||
colorPrimaries = prim
|
||||
colorTransfer = trc
|
||||
colorMatrix = mtx
|
||||
colorFullRange = fullRange != 0
|
||||
bitDepth = depth
|
||||
}
|
||||
|
||||
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||
@@ -362,6 +393,21 @@ public final class PunktfunkConnection {
|
||||
_ = punktfunk_connection_request_keyframe(h)
|
||||
}
|
||||
|
||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
/// rebuild them). The video pump polls this and calls `requestKeyframe()` when it climbs — the
|
||||
/// correct loss trigger under the host's infinite GOP, where unrecoverable loss yields
|
||||
/// reference-missing delta frames the decoder *silently conceals* (a frozen / garbage picture,
|
||||
/// no decode error and no `.failed` layer), so a decode-error trigger rarely fires. Monotonic
|
||||
/// for the session; 0 after close. Cheap (an atomic load) — safe to poll every pump iteration.
|
||||
public func framesDropped() -> UInt64 {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return 0 }
|
||||
var out: UInt64 = 0
|
||||
_ = punktfunk_connection_frames_dropped(h, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
/// The currently active session mode (updated by accepted `requestMode` switches).
|
||||
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||
abiLock.lock()
|
||||
@@ -458,10 +504,11 @@ public final class PunktfunkConnection {
|
||||
case triggerEffect(pad: UInt8, which: UInt8, effect: [UInt8])
|
||||
}
|
||||
|
||||
/// Pull the next DualSense feedback event (lightbar / player LEDs / adaptive triggers);
|
||||
/// nil on timeout, throws `.closed` once the session ended. Drain from the (single)
|
||||
/// feedback thread, alongside `nextRumble`. Nothing ever arrives unless
|
||||
/// `resolvedGamepad == .dualSense` — poll with a short timeout, never spin.
|
||||
/// Pull the next PlayStation-pad feedback event (lightbar / player LEDs / adaptive
|
||||
/// triggers); nil on timeout, throws `.closed` once the session ended. Drain from the
|
||||
/// (single) feedback thread, alongside `nextRumble`. Nothing arrives unless the session's
|
||||
/// virtual pad is a DualSense (all three) or a DualShock 4 (lightbar only) — poll with a
|
||||
/// short timeout, never spin.
|
||||
public func nextHidOutput(timeoutMs: UInt32 = 0) throws -> HidOutputEvent? {
|
||||
feedbackLock.lock()
|
||||
defer { feedbackLock.unlock() }
|
||||
@@ -493,6 +540,83 @@ public final class PunktfunkConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Video-capability bit: the client can decode a 10-bit (Main10) HEVC stream.
|
||||
public static let videoCap10Bit: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_10BIT)
|
||||
/// Video-capability bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||
public static let videoCapHDR: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_HDR)
|
||||
|
||||
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
|
||||
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
||||
/// 1/50000 units; mastering luminance in 0.0001 cd/m²; MaxCLL/MaxFALL in nits.
|
||||
public struct HdrMeta: Sendable, Equatable {
|
||||
public let primariesX: [UInt16] // [green, blue, red]
|
||||
public let primariesY: [UInt16]
|
||||
public let whitePointX: UInt16
|
||||
public let whitePointY: UInt16
|
||||
public let maxMasteringLuminance: UInt32 // 0.0001 cd/m²
|
||||
public let minMasteringLuminance: UInt32 // 0.0001 cd/m²
|
||||
public let maxCLL: UInt16
|
||||
public let maxFALL: UInt16
|
||||
|
||||
/// The 24-byte `mastering_display_colour_volume` payload (big-endian, ST.2086 G,B,R) — pass
|
||||
/// directly to `kCVImageBufferMasteringDisplayColorVolumeKey` or `CAEDRMetadata`'s displayInfo.
|
||||
public func masteringDisplayColorVolume() -> Data {
|
||||
var d = Data()
|
||||
func be16(_ v: UInt16) { d.append(UInt8(v >> 8)); d.append(UInt8(v & 0xFF)) }
|
||||
func be32(_ v: UInt32) {
|
||||
d.append(UInt8((v >> 24) & 0xFF)); d.append(UInt8((v >> 16) & 0xFF))
|
||||
d.append(UInt8((v >> 8) & 0xFF)); d.append(UInt8(v & 0xFF))
|
||||
}
|
||||
for i in 0..<3 { be16(primariesX[i]); be16(primariesY[i]) } // G, B, R
|
||||
be16(whitePointX); be16(whitePointY)
|
||||
be32(maxMasteringLuminance); be32(minMasteringLuminance)
|
||||
return d
|
||||
}
|
||||
|
||||
/// The 4-byte `content_light_level_info` payload (big-endian: MaxCLL, MaxFALL) — for
|
||||
/// `kCVImageBufferContentLightLevelInfoKey` or `CAEDRMetadata`'s contentInfo.
|
||||
public func contentLightLevelInfo() -> Data {
|
||||
var d = Data()
|
||||
func be16(_ v: UInt16) { d.append(UInt8(v >> 8)); d.append(UInt8(v & 0xFF)) }
|
||||
be16(maxCLL); be16(maxFALL)
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next static HDR metadata update; nil on timeout, throws `.closed` once the session
|
||||
/// ended. Drain from the feedback thread alongside `nextRumble`/`nextHidOutput`. Nothing arrives
|
||||
/// unless `isHDR` — poll with a short timeout, never spin.
|
||||
public func nextHdrMeta(timeoutMs: UInt32 = 0) throws -> HdrMeta? {
|
||||
feedbackLock.lock()
|
||||
defer { feedbackLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var out = PunktfunkHdrMeta()
|
||||
let rc = punktfunk_connection_next_hdr_meta(h, &out, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
// The fixed C `uint16_t[3]` arrays import as tuples — copy them out.
|
||||
let px = withUnsafeBytes(of: out.display_primaries_x) {
|
||||
Array($0.bindMemory(to: UInt16.self))
|
||||
}
|
||||
let py = withUnsafeBytes(of: out.display_primaries_y) {
|
||||
Array($0.bindMemory(to: UInt16.self))
|
||||
}
|
||||
return HdrMeta(
|
||||
primariesX: px, primariesY: py,
|
||||
whitePointX: out.white_point_x, whitePointY: out.white_point_y,
|
||||
maxMasteringLuminance: out.max_display_mastering_luminance,
|
||||
minMasteringLuminance: out.min_display_mastering_luminance,
|
||||
maxCLL: out.max_cll, maxFALL: out.max_fall)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
|
||||
/// silently dropped after close.
|
||||
public func send(_ event: PunktfunkInputEvent) {
|
||||
|
||||
@@ -113,8 +113,26 @@ public final class Stage2Pipeline {
|
||||
let recovery = recovery
|
||||
let thread = Thread {
|
||||
var format: CMVideoFormatDescription?
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
while token.isLive {
|
||||
do {
|
||||
// Loss recovery (the primary recovery path). The reassembler drops unrecoverable
|
||||
// AUs (framesDropped) and the decoder then conceals the reference-missing delta
|
||||
// frames that follow — often rendering them WITHOUT an error callback — so the
|
||||
// onDecodeError trigger rarely fires after a real network blip. Ask the host for
|
||||
// a fresh IDR whenever the drop count climbs (throttled in KeyframeRecovery).
|
||||
// Polled every iteration so a total-loss drought recovers the moment packets
|
||||
// resume and the reassembler counts the gap.
|
||||
let dropped = connection.framesDropped()
|
||||
if dropped > lastFramesDropped {
|
||||
lastFramesDropped = dropped
|
||||
recovery.request()
|
||||
}
|
||||
// Drain any HDR mastering-metadata update (0xCE) and hand it to the decoder, which
|
||||
// attaches it to subsequent HDR frames. Non-blocking; only HDR sessions emit these.
|
||||
if connection.isHDR, let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
||||
decoder.setHdrMeta(meta)
|
||||
}
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
onFrame?(au)
|
||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||
|
||||
@@ -46,27 +46,44 @@ final class StreamPump {
|
||||
let thread = Thread {
|
||||
var format: CMVideoFormatDescription?
|
||||
var lastKeyframeRequest = Date.distantPast
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
// Coalesced host keyframe request: the decode stays wedged for several frames until
|
||||
// the IDR lands, so requesting on every frame would flood the control stream.
|
||||
func requestKeyframeThrottled() {
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
|
||||
connection.requestKeyframe()
|
||||
lastKeyframeRequest = now
|
||||
}
|
||||
}
|
||||
while token.isLive {
|
||||
do {
|
||||
// Loss recovery (the primary recovery path). Under the host's infinite GOP the
|
||||
// only recovery keyframe is one we request. The reassembler drops unrecoverable
|
||||
// AUs (framesDropped); the decoder then *conceals* the reference-missing delta
|
||||
// frames that follow — a frozen / garbage picture, WITHOUT flipping the layer to
|
||||
// .failed — so the .failed check below rarely fires after a real network blip.
|
||||
// Ask the host for a fresh IDR whenever the drop count climbs. Polled every
|
||||
// iteration (not just per AU) so a total-loss drought still recovers the moment
|
||||
// packets resume and the reassembler counts the gap.
|
||||
let dropped = connection.framesDropped()
|
||||
if dropped > lastFramesDropped {
|
||||
lastFramesDropped = dropped
|
||||
requestKeyframeThrottled()
|
||||
}
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
onFrame?(au)
|
||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
}
|
||||
if layer.status == .failed {
|
||||
// Decode wedged: flush and re-gate on the next in-band parameter sets
|
||||
// (resuming with a delta frame can't recover), AND ask the host for a
|
||||
// fresh IDR. With the host's infinite GOP the next keyframe could be
|
||||
// far off, so without the request the picture stays frozen — the
|
||||
// intermittent first-connect freeze. Throttled: the layer stays .failed
|
||||
// across several polls until the IDR lands, and one request suffices.
|
||||
// Decode wedged hard (the cold-first-connect case — a lost/corrupt opening
|
||||
// IDR): flush and re-gate on the next in-band parameter sets (resuming with
|
||||
// a delta frame can't recover), AND ask the host for a fresh IDR. Throttled:
|
||||
// the layer stays .failed across several polls until the IDR lands.
|
||||
layer.flush()
|
||||
format = AnnexB.formatDescription(fromIDR: au.data)
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
|
||||
connection.requestKeyframe()
|
||||
lastKeyframeRequest = now
|
||||
}
|
||||
requestKeyframeThrottled()
|
||||
}
|
||||
guard let f = format,
|
||||
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
||||
|
||||
@@ -49,6 +49,12 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
/// pump can re-gate on the next IDR.
|
||||
private let onDecodeError: @Sendable (OSStatus) -> Void
|
||||
|
||||
/// Latest source HDR mastering metadata (from `PunktfunkConnection.nextHdrMeta`), attached to
|
||||
/// each decoded HDR pixel buffer so the compositor tone-maps from the real grade. Guarded by its
|
||||
/// own lock — written by the pump thread, read on the VT decode callback.
|
||||
private let metaLock = NSLock()
|
||||
private var hdrMeta: PunktfunkConnection.HdrMeta?
|
||||
|
||||
public init(
|
||||
onDecoded: @escaping @Sendable (ReadyFrame) -> Void,
|
||||
onDecodeError: @escaping @Sendable (OSStatus) -> Void = { _ in }
|
||||
@@ -59,6 +65,14 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
|
||||
deinit { teardown() }
|
||||
|
||||
/// Set the source HDR mastering metadata (drained from `PunktfunkConnection.nextHdrMeta`). It's
|
||||
/// attached to subsequent decoded HDR pixel buffers. Thread-safe; cheap to call on each update.
|
||||
public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) {
|
||||
metaLock.lock()
|
||||
hdrMeta = meta
|
||||
metaLock.unlock()
|
||||
}
|
||||
|
||||
/// Submit one AU for asynchronous decode, (re)creating the session if `format` changed. The
|
||||
/// caller resolves `format` from the IDR exactly as stage-1 does (`AnnexB.formatDescription`).
|
||||
/// Returns false if the session couldn't be created or the frame couldn't be submitted.
|
||||
@@ -185,6 +199,22 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
let isHDR =
|
||||
CVPixelBufferGetPixelFormatType(imageBuffer)
|
||||
== kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
|
||||
// Attach the source's mastering display + content light level (ST.2086 / CEA-861.3) so the
|
||||
// compositor tone-maps from the real grade rather than inferring from the PQ colourspace
|
||||
// alone. The SEI byte payloads map 1:1 to these CVImageBuffer attachment keys.
|
||||
if isHDR {
|
||||
metaLock.lock()
|
||||
let meta = hdrMeta
|
||||
metaLock.unlock()
|
||||
if let meta {
|
||||
CVBufferSetAttachment(
|
||||
imageBuffer, kCVImageBufferMasteringDisplayColorVolumeKey,
|
||||
meta.masteringDisplayColorVolume() as CFData, .shouldPropagate)
|
||||
CVBufferSetAttachment(
|
||||
imageBuffer, kCVImageBufferContentLightLevelInfoKey,
|
||||
meta.contentLightLevelInfo() as CFData, .shouldPropagate)
|
||||
}
|
||||
}
|
||||
onDecoded(
|
||||
ReadyFrame(ptsNs: ptsNs, decodedNs: decodedNs, pixelBuffer: imageBuffer, isHDR: isHDR))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Locks the DualSense raw-HID rumble report layout to the SDL / Linux hid-playstation spec.
|
||||
// The motors can only be confirmed on a physical pad, but these guard against a silent byte
|
||||
// error in the offsets, enable flags, lengths, and the Bluetooth CRC32 — the parts most likely
|
||||
// to regress unnoticed. macOS-only (DualSenseHID isn't compiled elsewhere).
|
||||
|
||||
#if os(macOS)
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class DualSenseHIDTests: XCTestCase {
|
||||
func testUSBReportLayout() {
|
||||
let r = DualSenseHID.usbReport(low: 0xAA, high: 0xBB)
|
||||
XCTAssertEqual(r.count, 48)
|
||||
XCTAssertEqual(r[0], 0x02) // report id
|
||||
XCTAssertEqual(r[1], 0x03) // flag0: COMPATIBLE_VIBRATION | HAPTICS_SELECT
|
||||
XCTAssertEqual(r[2], 0x00) // flag1 (untouched — leaves lightbar/LEDs alone)
|
||||
XCTAssertEqual(r[3], 0xBB) // motor_right = high
|
||||
XCTAssertEqual(r[4], 0xAA) // motor_left = low
|
||||
XCTAssertEqual(r[39], 0x04) // flag2: COMPATIBLE_VIBRATION2 (payload offset 38 + report id)
|
||||
}
|
||||
|
||||
func testBluetoothReportLayoutAndCRC() {
|
||||
let r = DualSenseHID.bluetoothReport(low: 0xAA, high: 0xBB)
|
||||
XCTAssertEqual(r.count, 78)
|
||||
XCTAssertEqual(r[0], 0x31) // report id
|
||||
XCTAssertEqual(r[1], 0x00) // seq/tag
|
||||
XCTAssertEqual(r[2], 0x10) // magic
|
||||
XCTAssertEqual(r[3], 0x03) // flag0
|
||||
XCTAssertEqual(r[5], 0xBB) // motor_right = high (payload offset 2 + 3-byte BT header)
|
||||
XCTAssertEqual(r[6], 0xAA) // motor_left = low
|
||||
XCTAssertEqual(r[41], 0x04) // flag2 (payload offset 38 + 3)
|
||||
|
||||
// Trailing CRC32 = standard CRC32 over (0xA2 seed + report[0..<74]), little-endian.
|
||||
let expected = DualSenseHID.crc32(seed: 0xA2, r[0..<74])
|
||||
let stored = UInt32(r[74]) | (UInt32(r[75]) << 8) | (UInt32(r[76]) << 16) | (UInt32(r[77]) << 24)
|
||||
XCTAssertEqual(stored, expected)
|
||||
}
|
||||
|
||||
func testCRC32MatchesStandardCheckVector() {
|
||||
// The canonical CRC32 check value: CRC32("123456789") == 0xCBF43926. Our helper folds a
|
||||
// seed byte in first, so feed seed='1' and the rest — proving poly/reflection/init/final.
|
||||
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
|
||||
XCTAssertEqual(crc, 0xCBF4_3926)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,7 +1,7 @@
|
||||
// Integration: the Swift wrapper against a real punktfunk/1 host over QUIC + UDP on loopback —
|
||||
// the Swift twin of punktfunk-host's m3.rs::c_abi_connection_roundtrip, this time through the
|
||||
// statically linked xcframework. Driven by clients/apple/test-loopback.sh, which builds and
|
||||
// starts `punktfunk-host m3-host --source synthetic` and sets PUNKTFUNK_LOOPBACK_PORT.
|
||||
// starts `punktfunk-host punktfunk1-host --source synthetic` and sets PUNKTFUNK_LOOPBACK_PORT.
|
||||
|
||||
import XCTest
|
||||
@testable import PunktfunkKit
|
||||
@@ -11,7 +11,7 @@ final class LoopbackIntegrationTests: XCTestCase {
|
||||
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
|
||||
let port = UInt16(portStr)
|
||||
else {
|
||||
throw XCTSkip("needs a running m3-host — use clients/apple/test-loopback.sh")
|
||||
throw XCTSkip("needs a running punktfunk1-host — use clients/apple/test-loopback.sh")
|
||||
}
|
||||
|
||||
let conn = try PunktfunkConnection(
|
||||
@@ -139,7 +139,7 @@ final class LoopbackIntegrationTests: XCTestCase {
|
||||
guard let portStr = env["PUNKTFUNK_PAIRING_PORT"], let port = UInt16(portStr),
|
||||
let pin = env["PUNKTFUNK_PAIRING_PIN"]
|
||||
else {
|
||||
throw XCTSkip("needs an armed m3-host — use clients/apple/test-loopback.sh")
|
||||
throw XCTSkip("needs an armed punktfunk1-host — use clients/apple/test-loopback.sh")
|
||||
}
|
||||
|
||||
let identity = try generateIdentity()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//
|
||||
// Run (host side, on the Linux box):
|
||||
// PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
|
||||
// punktfunk-host m3-host --source virtual --seconds 120
|
||||
// punktfunk-host punktfunk1-host --source virtual --seconds 120
|
||||
// Then here:
|
||||
// PUNKTFUNK_REMOTE_HOST=192.168.1.70 swift test --filter RemoteFirstLightTests
|
||||
|
||||
@@ -54,7 +54,7 @@ final class RemoteFirstLightTests: XCTestCase {
|
||||
func testRemoteAudioBothDirections() throws {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard let host = env["PUNKTFUNK_REMOTE_HOST"] else {
|
||||
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start m3-host --source virtual there)")
|
||||
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start punktfunk1-host --source virtual there)")
|
||||
}
|
||||
let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777
|
||||
|
||||
@@ -106,7 +106,7 @@ final class RemoteFirstLightTests: XCTestCase {
|
||||
func testRemoteStreamDecodesToPixels() throws {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard let host = env["PUNKTFUNK_REMOTE_HOST"] else {
|
||||
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start m3-host --source virtual there)")
|
||||
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start punktfunk1-host --source virtual there)")
|
||||
}
|
||||
let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777
|
||||
// PUNKTFUNK_REMOTE_COMPOSITOR=kwin|gamescope|… asks the host for a specific
|
||||
|
||||
@@ -22,10 +22,10 @@ trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" 2>/dev/null || true' EXIT
|
||||
# The open host also scripts a feedback burst (rumble + DualSense hidout) right after the
|
||||
# handshake, so the Swift test can assert the host→client feedback planes end to end.
|
||||
HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" PUNKTFUNK_TEST_FEEDBACK=1 \
|
||||
target/release/punktfunk-host m3-host --port "$PORT" --source synthetic --frames 300 &
|
||||
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 300 &
|
||||
HOST_PID=$!
|
||||
HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \
|
||||
target/release/punktfunk-host m3-host --port "$PAIR_PORT" --source synthetic --frames 300 \
|
||||
target/release/punktfunk-host punktfunk1-host --port "$PAIR_PORT" --source synthetic --frames 300 \
|
||||
--require-pairing >"$PAIR_LOG" 2>&1 &
|
||||
PAIR_PID=$!
|
||||
sleep 1
|
||||
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
# App Store screenshot driver for the Punktfunk Apple client.
|
||||
#
|
||||
# Launches the app in "shot mode" (PUNKTFUNK_SHOT_SCENE=<name> → one mock-populated screen,
|
||||
# full-bleed; see Sources/PunktfunkClient/Screenshots/) once per scene per device, and lets the OS
|
||||
# capture the REAL rendered UI:
|
||||
# • macOS → `screencapture` of the app's borderless window.
|
||||
# • iOS/iPadOS/tvOS → a booted Simulator + `xcrun simctl io booted screenshot` (native pixels =
|
||||
# the exact App Store size for that device).
|
||||
#
|
||||
# The captured pixels are exactly App Store Connect's required sizes:
|
||||
# mac 2880×1800 (a 1× display yields 1440×900 — also accepted)
|
||||
# iphone-6.9 1320×2868 (portrait) / 2868×1320 (the landscape hero)
|
||||
# ipad-13 2064×2752 (portrait) / 2752×2064 (the landscape hero)
|
||||
# appletv 1920×1080
|
||||
#
|
||||
# Requirements:
|
||||
# • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant
|
||||
# for your terminal (System Settings → Privacy & Security → Screen Recording).
|
||||
# • iOS/iPadOS/tvOS targets: full Xcode (xcodebuild + Simulators), not just Command Line Tools.
|
||||
#
|
||||
# Usage:
|
||||
# tools/screenshots.sh all # every platform this machine can build
|
||||
# tools/screenshots.sh macos # just macOS
|
||||
# tools/screenshots.sh ios ipad tvos # specific platforms
|
||||
# OUT=~/Desktop/shots tools/screenshots.sh all
|
||||
# PUNKTFUNK_SHOT_HERO=~/frame.png tools/screenshots.sh ios # real captured frame for the hero
|
||||
#
|
||||
# Keep SCENES in sync with ShotScenes.all.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APPLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$APPLE_DIR"
|
||||
|
||||
OUT="${OUT:-$APPLE_DIR/screenshots}"
|
||||
BUNDLE_ID="io.unom.punktfunk"
|
||||
SCENES=(01-stream 02-hosts 03-pair 04-trust 05-settings)
|
||||
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
log() { printf '\033[1;36m[shots]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[shots]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31m[shots]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
require_xcode() {
|
||||
xcrun --find simctl >/dev/null 2>&1 \
|
||||
|| die "Full Xcode required for simulator capture (have Command Line Tools only).
|
||||
Install Xcode, then: sudo xcode-select -s /Applications/Xcode.app"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------- macOS
|
||||
|
||||
shoot_macos() {
|
||||
log "macOS — building (swift build -c release)…"
|
||||
swift build -c release >/dev/null
|
||||
local bin=".build/release/PunktfunkClient"
|
||||
[ -x "$bin" ] || die "build produced no $bin"
|
||||
|
||||
for scene in "${SCENES[@]}"; do
|
||||
local logf; logf="$(mktemp)"
|
||||
PUNKTFUNK_SHOT_SCENE="$scene" "$bin" >"$logf" 2>&1 &
|
||||
local pid=$!
|
||||
# Wait for the window to exist and the scene to settle.
|
||||
local win=""
|
||||
for _ in $(seq 1 50); do
|
||||
win="$(grep -o 'PF_SHOT_WINDOW=[0-9]*' "$logf" | head -1 | cut -d= -f2 || true)"
|
||||
[ -n "$win" ] && grep -q PF_SHOT_READY "$logf" && break
|
||||
sleep 0.2
|
||||
done
|
||||
if [ -z "$win" ]; then
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
warn "macOS/$scene: app never reported a window — skipping"; cat "$logf" >&2; continue
|
||||
fi
|
||||
local dest="$OUT/mac-$scene.png"
|
||||
if screencapture -x -o -l"$win" "$dest" 2>/dev/null && [ -s "$dest" ]; then
|
||||
log "macOS/$scene → $dest ($(pixels "$dest"))"
|
||||
else
|
||||
warn "macOS/$scene: screencapture failed — grant your terminal Screen Recording permission
|
||||
(System Settings → Privacy & Security → Screen Recording), then re-run."
|
||||
fi
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
rm -f "$logf"
|
||||
done
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ iOS / iPadOS / tvOS
|
||||
|
||||
# $1 device-type regex (matches both existing device names and the device-type catalog)
|
||||
# $2 scheme $3 sdk $4 file prefix $5 runtime platform (iOS|tvOS — for the create fallback)
|
||||
shoot_sim() {
|
||||
require_xcode
|
||||
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5"
|
||||
|
||||
# Reuse an existing device of this type; else create a throwaway one against the newest
|
||||
# available runtime for the platform. CI runners commonly ship a runtime but not every device
|
||||
# (the iPhone 16 Pro Max is absent on ours), so create-on-demand is what makes it reproducible.
|
||||
local udid
|
||||
udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)"
|
||||
if [ -z "$udid" ]; then
|
||||
local devtype rt
|
||||
devtype="$(xcrun simctl list devicetypes | grep -E "$match" \
|
||||
| grep -oE 'com\.apple\.CoreSimulator\.SimDeviceType\.[A-Za-z0-9.-]+' | head -1 || true)"
|
||||
rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \
|
||||
| grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)"
|
||||
if [ -n "$devtype" ] && [ -n "$rt" ]; then
|
||||
udid="$(xcrun simctl create "pf-shot-$prefix" "$devtype" "$rt" 2>/dev/null || true)"
|
||||
[ -n "$udid" ] && log "$prefix — created Simulator $udid ($devtype)"
|
||||
fi
|
||||
fi
|
||||
[ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created
|
||||
(needs a $platform runtime + a matching device type — check 'xcrun simctl list')."
|
||||
log "$prefix — Simulator $udid"
|
||||
xcrun simctl boot "$udid" 2>/dev/null || true
|
||||
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true
|
||||
|
||||
log "$prefix — building ($scheme)…"
|
||||
local dd; dd="$(mktemp -d)"
|
||||
xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \
|
||||
-sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \
|
||||
CODE_SIGNING_ALLOWED=NO build >/dev/null \
|
||||
|| die "$prefix: xcodebuild failed"
|
||||
local app; app="$(find "$dd/Build/Products" -maxdepth 2 -name '*.app' -type d | head -1)"
|
||||
[ -n "$app" ] || die "$prefix: no .app built"
|
||||
xcrun simctl install "$udid" "$app"
|
||||
|
||||
for scene in "${SCENES[@]}"; do
|
||||
xcrun simctl terminate "$udid" "$BUNDLE_ID" 2>/dev/null || true
|
||||
SIMCTL_CHILD_PUNKTFUNK_SHOT_SCENE="$scene" \
|
||||
${PUNKTFUNK_SHOT_HERO:+SIMCTL_CHILD_PUNKTFUNK_SHOT_HERO="$PUNKTFUNK_SHOT_HERO"} \
|
||||
xcrun simctl launch "$udid" "$BUNDLE_ID" >/dev/null
|
||||
sleep "$SETTLE"
|
||||
local dest="$OUT/$prefix-$scene.png"
|
||||
xcrun simctl io "$udid" screenshot "$dest" >/dev/null
|
||||
log "$prefix/$scene → $dest ($(pixels "$dest"))"
|
||||
done
|
||||
xcrun simctl terminate "$udid" "$BUNDLE_ID" 2>/dev/null || true
|
||||
rm -rf "$dd"
|
||||
}
|
||||
|
||||
pixels() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null | awk '/pixel/{print $2}' | paste -sd× -; }
|
||||
|
||||
# ---------------------------------------------------------------------------- dispatch
|
||||
|
||||
[ $# -gt 0 ] || set -- all
|
||||
for target in "$@"; do
|
||||
case "$target" in
|
||||
macos) shoot_macos ;;
|
||||
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS ;;
|
||||
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS ;;
|
||||
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS ;;
|
||||
all)
|
||||
shoot_macos
|
||||
if xcrun --find simctl >/dev/null 2>&1; then
|
||||
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS
|
||||
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS
|
||||
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS
|
||||
else
|
||||
warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)."
|
||||
fi
|
||||
;;
|
||||
*) die "unknown target '$target' (use: all macos ios ipad tvos)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log "Done. Screenshots in $OUT"
|
||||
ls -1 "$OUT" 2>/dev/null || true
|
||||
+40
-18
@@ -8,28 +8,44 @@ Because Decky plugins run inside Steam's CEF, the panel is built from real Steam
|
||||
primitives (`@decky/ui`: `PanelSection`, `PanelSectionRow`, `ButtonItem`, `Field`,
|
||||
`Spinner`) — so it looks and feels native to Gaming Mode.
|
||||
|
||||
> **Spike / launcher only.** This is a minimal but functional first cut: discover hosts,
|
||||
> connect, disconnect. It launches the existing native GTK4 client
|
||||
> (`punktfunk-client`) over the top of Gaming Mode. An in-stream overlay (latency / bitrate
|
||||
> HUD, mid-session controls) and a fuller real-Steam-components UI are the next steps.
|
||||
> Runtime behavior on a real Deck is **untested** — only the build is verified here.
|
||||
> **Full Gaming-Mode client.** Discovery, a fullscreen page, in-UI SPAKE2 PIN pairing,
|
||||
> stream settings, and a stream that actually launches fullscreen under gamescope (via a
|
||||
> Steam shortcut, MoonDeck-style). The video itself is the existing GTK4 flatpak client
|
||||
> (`io.unom.Punktfunk`) — the plugin discovers, pairs, configures, and *launches it the
|
||||
> right way* so gamescope focuses it. The Steam-shortcut launch + pairing need a real Deck
|
||||
> in Gaming Mode to fully confirm.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Refresh** — browses the LAN over mDNS for punktfunk/1 hosts (the `_punktfunk._udp`
|
||||
service) via the backend `discover()`.
|
||||
2. **Lists discovered hosts** — name, `ip:port`, and a lock icon for whether pairing is
|
||||
required (`pair=required` in the host's TXT record).
|
||||
3. **Connect** — selecting a host calls `connect(host, port)`, which launches
|
||||
`punktfunk-client --connect host:port`; a toast and the status line reflect the result.
|
||||
4. **Disconnect** — `disconnect()` terminates the launched client.
|
||||
1. **Discover** — browses the LAN over mDNS for punktfunk/1 hosts (`_punktfunk._udp`,
|
||||
backend `discover()` via `avahi-browse`). Shown in both the QAM panel and a **fullscreen
|
||||
page** (Decky route `/punktfunk`, via `routerHook.addRoute`).
|
||||
2. **Pair** — for a `pair=required` host: a gamepad-navigable PIN keypad. The operator arms
|
||||
pairing on the host (it shows a 4-digit PIN), the user enters it on the Deck, and the
|
||||
backend runs the SPAKE2 ceremony headlessly via the flatpak client's `--pair` mode
|
||||
(`pair()`), persisting the host as paired so the stream then connects silently.
|
||||
3. **Stream** — launches fullscreen in Gaming Mode. The plugin registers ONE hidden
|
||||
non-Steam shortcut pointing at `bin/punktfunkrun.sh`, passes `PF_HOST` as the shortcut's
|
||||
Steam launch options, and starts it with `SteamClient.Apps.RunGame` — so gamescope
|
||||
focuses + fullscreens it. (A flatpak launched directly from the backend is invisible:
|
||||
gamescope only focuses the process tree Steam launched via `reaper` — gamescope#484.)
|
||||
The wrapper then execs `flatpak run io.unom.Punktfunk --connect <host>`.
|
||||
4. **Settings** — resolution / refresh / bitrate / gamepad / mic, written to the client's
|
||||
`client-gtk-settings.json` (`get_settings`/`set_settings`), which the launched client reads.
|
||||
|
||||
To leave the stream: the in-client controller chord (**L1+R1+Start+Select**) or close the
|
||||
"game" from the Steam overlay — exiting the client ends the Steam game and returns to
|
||||
Gaming Mode automatically.
|
||||
|
||||
## Architecture
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/index.tsx` | Frontend QAM panel (`@decky/ui` + `@decky/api`). |
|
||||
| `main.py` | Backend `Plugin` class: `discover` / `connect` / `disconnect` / `status` exposed over the Decky bridge. |
|
||||
| `src/index.tsx` | Frontend: QAM panel + the `/punktfunk` fullscreen page (host list, PIN keypad modal, settings). |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. |
|
||||
| `src/backend.ts` | Typed `callable` bridges to `main.py`. |
|
||||
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut targets (so the window is focusable). |
|
||||
| `main.py` | Backend: `discover` / `pair` / `runner_info` / `get_settings` / `set_settings` / `kill_stream`. |
|
||||
| `plugin.json` | Decky plugin manifest. |
|
||||
| `decky.pyi` | Type stub for the injected `decky` module (vendored from the template). |
|
||||
|
||||
@@ -65,7 +81,7 @@ argv and a clear `client-not-found` error surface to the UI. The child PID is tr
|
||||
installed and runnable on the Deck — via `.deb`/RPM/flatpak, or symlinked into
|
||||
`~/.local/bin`.
|
||||
- **avahi** (`avahi-daemon` + `avahi-browse`) for discovery — present on SteamOS/Bazzite.
|
||||
- A punktfunk/1 host on the LAN (`punktfunk-host serve --native` or `m3-host`).
|
||||
- A punktfunk/1 host on the LAN (`punktfunk-host serve` or `punktfunk1-host`).
|
||||
|
||||
## Build
|
||||
|
||||
@@ -126,7 +142,13 @@ shows up in the Quick Access Menu.
|
||||
|
||||
## Limitations / next steps
|
||||
|
||||
- Launcher only — no in-stream overlay yet; the client owns the full session once launched.
|
||||
- **Needs on-Deck validation in Gaming Mode**: the Steam-shortcut launch (`AddShortcut` /
|
||||
`RunGame` / the `gameId` encoding) and the headless pairing env are coded to MoonDeck's
|
||||
proven pattern but verified only at build time here.
|
||||
- mDNS discovery depends on `avahi-browse`; no manual "add host by IP" entry yet.
|
||||
- Pairing (PIN ceremony) is handled by the launched client, not the panel.
|
||||
- Not yet tested on real Deck hardware.
|
||||
- No in-stream overlay (latency/bitrate HUD) inside the plugin — the client owns the session
|
||||
once launched; leave it with the L1+R1+Start+Select chord.
|
||||
- Pairing requires the operator to **arm pairing on the host** (so it shows the PIN); the
|
||||
plugin can't arm it remotely (no host mgmt token on the Deck).
|
||||
- Settings are written to the flatpak's sandbox config path; if the client ever moves its
|
||||
config location, that path mapping must follow.
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates.
|
||||
#
|
||||
# WHY A WRAPPER SCRIPT (load-bearing, from MoonDeck's hard-won knowledge): the stream client
|
||||
# must be a descendant of the process Steam launches via `reaper`, or gamescope never gives
|
||||
# its window focus/fullscreen in Gaming Mode (gamescope detects the "current app" by AppID,
|
||||
# which only attaches to reaper's descendants — see gamescope#484). So the Decky plugin
|
||||
# launches THIS script through SteamClient.Apps.RunGame; the script then execs the flatpak
|
||||
# client, which inherits the shortcut's AppID and is focused. Launching the flatpak directly
|
||||
# from the (root) Decky backend produces an unfocused, invisible window.
|
||||
#
|
||||
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
|
||||
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
|
||||
# every host:
|
||||
# PF_HOST host[:port] to connect to (required)
|
||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||
#
|
||||
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
|
||||
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
|
||||
set -u
|
||||
|
||||
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||
FLATPAK="${PF_FLATPAK:-flatpak}"
|
||||
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
|
||||
# exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and
|
||||
# Gaming Mode reclaims focus automatically (no manual refocus needed).
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST"
|
||||
+202
-185
@@ -1,160 +1,95 @@
|
||||
"""
|
||||
punktfunk Decky plugin — backend.
|
||||
|
||||
Bridges the Gaming-Mode Quick Access panel (``src/index.tsx``) to two host-side
|
||||
operations:
|
||||
The Gaming-Mode UI (``src/index.tsx``) calls these methods over the Decky bridge. The actual
|
||||
STREAM is NOT launched here — it is launched by the frontend through Steam
|
||||
(SteamClient.Apps.RunGame on a hidden non-Steam shortcut that points at ``bin/punktfunkrun.sh``),
|
||||
because gamescope only focuses/fullscreens windows in the process tree Steam launched via
|
||||
``reaper``. A flatpak spawned from this backend would be invisible/unfocused (gamescope#484).
|
||||
The backend's jobs are the things Steam can't do:
|
||||
|
||||
* **discover()** — browse the LAN over mDNS for punktfunk/1 hosts advertising the
|
||||
``_punktfunk._udp`` service, returning name / ip:port / pairing-requirement / cert
|
||||
fingerprint for each. Implemented by shelling out to ``avahi-browse`` (SteamOS, Bazzite
|
||||
and most Linux distros ship ``avahi-daemon``); see :func:`Plugin.discover`.
|
||||
* **connect(host, port)** / **disconnect()** — launch / kill the native GTK4 client
|
||||
(``punktfunk-client --connect host:port``). The child PID is tracked so a later
|
||||
:func:`Plugin.disconnect` (or plugin unload) can terminate it.
|
||||
* **discover()** — browse the LAN over mDNS (``avahi-browse``) for ``_punktfunk._udp`` hosts.
|
||||
* **pair(host, port, pin, name)** — run the SPAKE2 PIN ceremony headlessly via the flatpak
|
||||
client's ``--pair`` mode, capturing the result. Pairing uses the SAME flatpak (so the same
|
||||
identity store the stream uses), so once paired the stream connects silently.
|
||||
* **runner_info()** — the absolute path to the launch wrapper + the flatpak app id, handed to
|
||||
the frontend so it can create/point the Steam shortcut.
|
||||
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
|
||||
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
|
||||
* **kill_stream()** — force-stop a wedged stream (``flatpak kill``).
|
||||
|
||||
The TXT-record keys parsed here (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the
|
||||
host advert in ``crates/punktfunk-host/src/discovery.rs``.
|
||||
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the host
|
||||
advert in ``crates/punktfunk-host/src/discovery.rs``.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import decky
|
||||
|
||||
# The native punktfunk/1 client binary (the GTK4/libadwaita Linux client, crate
|
||||
# ``punktfunk-client-linux``). It is resolved at runtime from PATH and a handful of common
|
||||
# install locations (see :func:`_resolve_client`). If none exist we fall back to this bare
|
||||
# name and let the spawn fail loudly — install the client on the Deck (.deb / RPM / flatpak)
|
||||
# or symlink it into ~/.local/bin.
|
||||
#
|
||||
# On SteamOS (read-only /usr, image-based) the settled install path is the flatpak
|
||||
# ``io.unom.Punktfunk`` (packaging/flatpak/), launched via ``flatpak run`` — see the flatpak
|
||||
# fallback in :func:`_resolve_client`.
|
||||
CLIENT_BINARY = "punktfunk-client"
|
||||
# Flatpak application id of the GTK client (packaging/flatpak/io.unom.Punktfunk.yml).
|
||||
APP_ID = "io.unom.Punktfunk"
|
||||
|
||||
# Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host).
|
||||
SERVICE_TYPE = "_punktfunk._udp"
|
||||
|
||||
# Candidate locations probed (in order) when the binary is not on PATH. ``$HOME`` is the
|
||||
# effective user's home as provided by decky.
|
||||
_CLIENT_CANDIDATES = [
|
||||
"/usr/bin/punktfunk-client",
|
||||
"/usr/local/bin/punktfunk-client",
|
||||
str(Path(decky.HOME) / ".local" / "bin" / "punktfunk-client"),
|
||||
# Flatpak: launched via `flatpak run` rather than a path — handled in _resolve_client.
|
||||
]
|
||||
# The flatpak client persists identity / known-hosts / settings under HOME/.config/punktfunk;
|
||||
# inside the flatpak sandbox HOME is ~/.var/app/<APP_ID>, so the real on-disk location is this.
|
||||
# The backend writes settings here so the (sandboxed) client reads them.
|
||||
def _client_config_dir() -> Path:
|
||||
return Path(decky.DECKY_USER_HOME) / ".var" / "app" / APP_ID / ".config" / "punktfunk"
|
||||
|
||||
|
||||
def _resolve_client() -> list[str]:
|
||||
"""Return the argv prefix used to launch the native client.
|
||||
def _settings_path() -> Path:
|
||||
return _client_config_dir() / "client-gtk-settings.json"
|
||||
|
||||
Resolution order: PATH → well-known absolute paths → flatpak (if the app id is
|
||||
installed) → bare binary name (so the eventual spawn fails with a clear error).
|
||||
"""
|
||||
on_path = shutil.which(CLIENT_BINARY)
|
||||
if on_path:
|
||||
return [on_path]
|
||||
|
||||
for candidate in _CLIENT_CANDIDATES:
|
||||
if Path(candidate).exists():
|
||||
return [candidate]
|
||||
def _runner_path() -> str:
|
||||
"""Absolute path to the launch wrapper shipped with the plugin (bin/punktfunkrun.sh)."""
|
||||
return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh")
|
||||
|
||||
# Flatpak fallback — the canonical install path on the Steam Deck (SteamOS /usr is
|
||||
# read-only; the flatpak bundles the libadwaita + SDL3 the system lacks). The app id is
|
||||
# the one the flatpak manifest publishes (packaging/flatpak/io.unom.Punktfunk.yml). If it
|
||||
# is not installed, `flatpak run <id>` fails and surfaces as a spawn error the user can
|
||||
# act on (install the bundle: `flatpak install --user punktfunk-client-*.flatpak`).
|
||||
flatpak = shutil.which("flatpak")
|
||||
if flatpak:
|
||||
return [flatpak, "run", "io.unom.Punktfunk"]
|
||||
|
||||
decky.logger.warning(
|
||||
"punktfunk-client not found on PATH or in %s; falling back to bare name",
|
||||
_CLIENT_CANDIDATES,
|
||||
def _flatpak() -> str | None:
|
||||
return shutil.which("flatpak") or (
|
||||
"/usr/bin/flatpak" if Path("/usr/bin/flatpak").exists() else None
|
||||
)
|
||||
return [CLIENT_BINARY]
|
||||
|
||||
|
||||
def _parse_avahi_browse(stdout: str) -> list[dict]:
|
||||
"""Parse ``avahi-browse -rpt`` output into a list of host dicts.
|
||||
|
||||
``avahi-browse -r`` resolves services; ``-p`` makes the output parseable (one record
|
||||
per line, semicolon-separated, fields escaped with ``\\``); ``-t`` terminates after the
|
||||
initial cache dump instead of running forever.
|
||||
|
||||
Resolved records start with ``=`` and have the columns::
|
||||
|
||||
=;iface;protocol;name;type;domain;hostname;address;port;txt
|
||||
|
||||
where ``txt`` is a space-separated list of ``"key=value"`` tokens, each already wrapped
|
||||
in double quotes by avahi, e.g. ``"proto=punktfunk/1" "fp=ab12..." "pair=required"``.
|
||||
|
||||
We dedup on the host advert ``id`` TXT key (a host re-advertises across interfaces /
|
||||
IPv4+IPv6, producing several ``=`` lines for one logical host); when ``id`` is absent we
|
||||
fall back to ``host:port``.
|
||||
"""
|
||||
out: dict[str, dict] = {}
|
||||
for raw in stdout.splitlines():
|
||||
line = raw.strip()
|
||||
if not line.startswith("="):
|
||||
continue
|
||||
# Split on unescaped ';'. avahi escapes literal ';' inside fields as '\;', so a
|
||||
# simple replace-guard split is adequate for the fixed 10-column layout.
|
||||
parts = line.replace("\\;", "\x00").split(";")
|
||||
parts = [p.replace("\x00", ";") for p in parts]
|
||||
if len(parts) < 9:
|
||||
continue
|
||||
|
||||
name = parts[3]
|
||||
# parts[4] is the service type, parts[5] the domain.
|
||||
address = parts[7]
|
||||
port_str = parts[8]
|
||||
txt = parts[9] if len(parts) > 9 else ""
|
||||
|
||||
try:
|
||||
port = int(port_str)
|
||||
except ValueError:
|
||||
port = 0
|
||||
|
||||
# Parse TXT tokens: each is a quoted "key=value".
|
||||
props: dict[str, str] = {}
|
||||
for token in _split_txt(txt):
|
||||
if "=" in token:
|
||||
k, v = token.split("=", 1)
|
||||
props[k] = v
|
||||
|
||||
# Only surface actual punktfunk/1 adverts.
|
||||
if props.get("proto") and not props["proto"].startswith("punktfunk/"):
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"host": address,
|
||||
"port": port,
|
||||
"pair": props.get("pair", "optional"),
|
||||
"fp": props.get("fp", ""),
|
||||
"proto": props.get("proto", ""),
|
||||
}
|
||||
key = props.get("id") or f"{address}:{port}"
|
||||
# Prefer an IPv4 record over IPv6 for the user-facing host string when both exist.
|
||||
existing = out.get(key)
|
||||
if existing is None or (":" in existing["host"] and ":" not in address):
|
||||
out[key] = entry
|
||||
|
||||
return list(out.values())
|
||||
def _flatpak_env() -> dict:
|
||||
"""Environment for a headless ``flatpak run`` from the backend (no display needed for
|
||||
pairing). Reconstruct the user-session bits flatpak wants; the backend may not inherit
|
||||
them. Harmless if some are already set."""
|
||||
env = dict(os.environ)
|
||||
# Decky Loader is a PyInstaller binary: it prepends its bundled libs (an older libssl) to
|
||||
# LD_LIBRARY_PATH (its /tmp/_MEI* unpack dir), and that env leaks into our subprocess. The
|
||||
# SYSTEM flatpak's libcurl needs OPENSSL_3.3.0 from the SYSTEM libssl, so the bundled libssl
|
||||
# breaks it ("libssl.so.3: version OPENSSL_3.3.0 not found"). Restore the pre-bundle value
|
||||
# PyInstaller saved as <VAR>_ORIG, or drop the var so the dynamic loader uses system libraries.
|
||||
for var in ("LD_LIBRARY_PATH", "LD_PRELOAD"):
|
||||
orig = env.pop(f"{var}_ORIG", None)
|
||||
if orig:
|
||||
env[var] = orig
|
||||
else:
|
||||
env.pop(var, None)
|
||||
env.setdefault("HOME", decky.DECKY_USER_HOME)
|
||||
uid = os.environ.get("PF_UID") or "1000"
|
||||
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}")
|
||||
env.setdefault(
|
||||
"DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{uid}/bus"
|
||||
)
|
||||
# Ensure flatpak can find the user installation.
|
||||
env.setdefault(
|
||||
"PATH", "/usr/bin:/bin:" + env.get("PATH", "")
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
def _split_txt(txt: str) -> list[str]:
|
||||
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting.
|
||||
|
||||
avahi prints each TXT item wrapped in double quotes and space-separated, e.g.::
|
||||
|
||||
"proto=punktfunk/1" "fp=ab12cd" "pair=required" "id=host-1"
|
||||
|
||||
A value can legitimately contain spaces, so we split on the quote boundaries rather
|
||||
than on whitespace.
|
||||
"""
|
||||
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
|
||||
tokens: list[str] = []
|
||||
cur: list[str] = []
|
||||
in_quote = False
|
||||
@@ -171,23 +106,64 @@ def _split_txt(txt: str) -> list[str]:
|
||||
return tokens
|
||||
|
||||
|
||||
class Plugin:
|
||||
# Tracks the launched native client so disconnect()/_unload can terminate it.
|
||||
_client: asyncio.subprocess.Process | None = None
|
||||
_connected_host: str | None = None
|
||||
def _parse_avahi_browse(stdout: str) -> list[dict]:
|
||||
"""Parse ``avahi-browse -rpt`` output into a list of host dicts (deduped on the TXT ``id``)."""
|
||||
out: dict[str, dict] = {}
|
||||
for raw in stdout.splitlines():
|
||||
line = raw.strip()
|
||||
if not line.startswith("="):
|
||||
continue
|
||||
parts = line.replace("\\;", "\x00").split(";")
|
||||
parts = [p.replace("\x00", ";") for p in parts]
|
||||
if len(parts) < 9:
|
||||
continue
|
||||
|
||||
name = parts[3]
|
||||
address = parts[7]
|
||||
port_str = parts[8]
|
||||
txt = parts[9] if len(parts) > 9 else ""
|
||||
|
||||
try:
|
||||
port = int(port_str)
|
||||
except ValueError:
|
||||
port = 0
|
||||
|
||||
props: dict[str, str] = {}
|
||||
for token in _split_txt(txt):
|
||||
if "=" in token:
|
||||
k, v = token.split("=", 1)
|
||||
props[k] = v
|
||||
|
||||
if props.get("proto") and not props["proto"].startswith("punktfunk/"):
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"host": address,
|
||||
"port": port,
|
||||
"pair": props.get("pair", "optional"),
|
||||
"fp": props.get("fp", ""),
|
||||
"proto": props.get("proto", ""),
|
||||
}
|
||||
key = props.get("id") or f"{address}:{port}"
|
||||
existing = out.get(key)
|
||||
# Prefer IPv4 over IPv6 for the user-facing host string.
|
||||
if existing is None or (":" in existing["host"] and ":" not in address):
|
||||
out[key] = entry
|
||||
|
||||
return list(out.values())
|
||||
|
||||
|
||||
class Plugin:
|
||||
async def discover(self) -> list[dict]:
|
||||
"""Browse the LAN for punktfunk/1 hosts. Returns ``[{name, host, port, pair, fp}]``."""
|
||||
avahi = shutil.which("avahi-browse")
|
||||
if not avahi:
|
||||
decky.logger.error("avahi-browse not found; install avahi for host discovery")
|
||||
return []
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
avahi,
|
||||
"-rpt",
|
||||
SERVICE_TYPE,
|
||||
avahi, "-rpt", SERVICE_TYPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -197,78 +173,119 @@ class Plugin:
|
||||
proc.kill()
|
||||
decky.logger.warning("avahi-browse timed out")
|
||||
return []
|
||||
except Exception: # noqa: BLE001 - surface any spawn failure as "no hosts"
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("avahi-browse failed")
|
||||
return []
|
||||
|
||||
if stderr:
|
||||
decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace"))
|
||||
|
||||
hosts = _parse_avahi_browse(stdout.decode(errors="replace"))
|
||||
decky.logger.info("discovered %d punktfunk host(s)", len(hosts))
|
||||
return hosts
|
||||
|
||||
async def connect(self, host: str, port: int) -> dict:
|
||||
"""Launch the native client against ``host:port``. Returns ``{ok, host, error?}``."""
|
||||
# Tear down any prior session first.
|
||||
await self.disconnect()
|
||||
async def pair(self, host: str, port: int, pin: str, name: str = "Steam Deck") -> dict:
|
||||
"""Run the SPAKE2 PIN ceremony headlessly via the flatpak client's ``--pair`` mode.
|
||||
|
||||
argv = _resolve_client() + ["--connect", f"{host}:{port}"]
|
||||
decky.logger.info("launching client: %s", " ".join(argv))
|
||||
The user arms pairing on the HOST (which displays a 4-digit PIN) and enters it here.
|
||||
On success the flatpak persists the host to its known-hosts as paired, so a later
|
||||
stream connects silently. Returns ``{ok, fp?, error?}``.
|
||||
"""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
return {"ok": False, "error": "flatpak-not-found"}
|
||||
argv = [
|
||||
flatpak, "run", "--arch=x86_64", APP_ID,
|
||||
"--pair", str(pin).strip(),
|
||||
"--connect", f"{host}:{port}",
|
||||
"--name", name,
|
||||
"--host-label", host,
|
||||
]
|
||||
decky.logger.info("pairing: %s", " ".join(argv[:6] + ["<pin>", "--connect", f"{host}:{port}"]))
|
||||
try:
|
||||
self._client = await asyncio.create_subprocess_exec(
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
decky.logger.error("client binary not found: %s", argv[0])
|
||||
return {"ok": False, "host": f"{host}:{port}", "error": "client-not-found"}
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=100.0)
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "error": "pairing timed out"}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
decky.logger.exception("failed to launch client")
|
||||
return {"ok": False, "host": f"{host}:{port}", "error": str(exc)}
|
||||
decky.logger.exception("pairing failed to launch")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
self._connected_host = f"{host}:{port}"
|
||||
decky.logger.info("client launched (pid %s) -> %s", self._client.pid, self._connected_host)
|
||||
return {"ok": True, "host": self._connected_host}
|
||||
out = stdout.decode(errors="replace")
|
||||
err = stderr.decode(errors="replace")
|
||||
if proc.returncode == 0 and "paired " in out:
|
||||
fp = ""
|
||||
for tok in out.split():
|
||||
if tok.startswith("fp="):
|
||||
fp = tok[3:]
|
||||
decky.logger.info("paired %s:%s", host, port)
|
||||
return {"ok": True, "fp": fp}
|
||||
decky.logger.warning("pairing failed (rc=%s): %s", proc.returncode, err.strip() or out.strip())
|
||||
# Surface the client's own one-line reason (wrong PIN / not armed) to the UI.
|
||||
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
|
||||
return {"ok": False, "error": reason}
|
||||
|
||||
async def disconnect(self) -> dict:
|
||||
"""Terminate the launched native client, if any."""
|
||||
proc = self._client
|
||||
self._client = None
|
||||
host = self._connected_host
|
||||
self._connected_host = None
|
||||
if proc is None or proc.returncode is not None:
|
||||
return {"ok": True, "host": None}
|
||||
|
||||
decky.logger.info("disconnecting client (pid %s)", proc.pid)
|
||||
async def runner_info(self) -> dict:
|
||||
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam
|
||||
shortcut. Also (re)asserts the script's exec bit — packaging can drop it."""
|
||||
path = _runner_path()
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
decky.logger.warning("client did not exit; killing (pid %s)", proc.pid)
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("error terminating client")
|
||||
return {"ok": True, "host": host}
|
||||
st = os.stat(path)
|
||||
os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
except OSError:
|
||||
decky.logger.warning("could not chmod runner %s", path)
|
||||
return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()}
|
||||
|
||||
async def status(self) -> dict:
|
||||
"""Return the current connection status for UI refresh on panel open."""
|
||||
connected = self._client is not None and self._client.returncode is None
|
||||
return {"connected": connected, "host": self._connected_host if connected else None}
|
||||
async def get_settings(self) -> dict:
|
||||
"""Read the flatpak client's stream settings (resolution/bitrate/gamepad…)."""
|
||||
try:
|
||||
return json.loads(_settings_path().read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
# The client's own defaults (native display, host-default bitrate, auto pad).
|
||||
return {
|
||||
"width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0,
|
||||
"gamepad": "auto", "compositor": "auto",
|
||||
"inhibit_shortcuts": True, "mic_enabled": False,
|
||||
}
|
||||
|
||||
async def set_settings(self, settings: dict) -> dict:
|
||||
"""Write the stream settings JSON the (sandboxed) client reads on launch."""
|
||||
try:
|
||||
d = _client_config_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
_settings_path().write_text(json.dumps(settings, indent=2))
|
||||
return {"ok": True}
|
||||
except OSError as exc:
|
||||
decky.logger.exception("could not write settings")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
async def kill_stream(self) -> dict:
|
||||
"""Force-stop a wedged stream client (``flatpak kill``)."""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
return {"ok": False, "error": "flatpak-not-found"}
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
flatpak, "kill", APP_ID,
|
||||
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
await asyncio.wait_for(proc.wait(), timeout=10.0)
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("flatpak kill failed")
|
||||
return {"ok": False}
|
||||
return {"ok": True}
|
||||
|
||||
# ---- Decky lifecycle ----
|
||||
|
||||
async def _main(self):
|
||||
decky.logger.info("punktfunk plugin loaded")
|
||||
decky.logger.info("punktfunk plugin loaded (runner=%s)", _runner_path())
|
||||
|
||||
async def _unload(self):
|
||||
decky.logger.info("punktfunk plugin unloading; tearing down client")
|
||||
await self.disconnect()
|
||||
decky.logger.info("punktfunk plugin unloading")
|
||||
|
||||
async def _uninstall(self):
|
||||
decky.logger.info("punktfunk plugin uninstalled")
|
||||
|
||||
@@ -20,9 +20,12 @@ VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
DEST="$STAGE/$NAME"
|
||||
mkdir -p "$DEST/dist"
|
||||
mkdir -p "$DEST/dist" "$DEST/bin"
|
||||
cp dist/index.js "$DEST/dist/index.js" # ship the bundle only, not the sourcemap
|
||||
cp main.py plugin.json package.json LICENSE "$DEST/"
|
||||
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
|
||||
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
|
||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||
[ -f decky.pyi ] && cp decky.pyi "$DEST/"
|
||||
[ -f README.md ] && cp README.md "$DEST/"
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Bridge to the Python backend (main.py) + shared types.
|
||||
import { callable } from "@decky/api";
|
||||
|
||||
export interface Host {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
pair: string; // "required" | "optional"
|
||||
fp: string;
|
||||
}
|
||||
|
||||
export interface PairResult {
|
||||
ok: boolean;
|
||||
fp?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RunnerInfo {
|
||||
runner: string; // absolute path to bin/punktfunkrun.sh
|
||||
app_id: string; // flatpak app id
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface StreamSettings {
|
||||
width: number; // 0 = native
|
||||
height: number; // 0 = native
|
||||
refresh_hz: number; // 0 = native
|
||||
bitrate_kbps: number; // 0 = host default
|
||||
gamepad: string; // "auto" | "xbox360" | "dualsense"
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
inhibit_shortcuts: boolean;
|
||||
mic_enabled: boolean;
|
||||
}
|
||||
|
||||
export const discover = callable<[], Host[]>("discover");
|
||||
export const pair = callable<
|
||||
[host: string, port: number, pin: string, name: string],
|
||||
PairResult
|
||||
>("pair");
|
||||
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
||||
export const getSettings = callable<[], StreamSettings>("get_settings");
|
||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||
"set_settings",
|
||||
);
|
||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||
+343
-111
@@ -1,131 +1,364 @@
|
||||
import {
|
||||
ButtonItem,
|
||||
Dropdown,
|
||||
Field,
|
||||
Focusable,
|
||||
DialogButton,
|
||||
ModalRoot,
|
||||
Navigation,
|
||||
PanelSection,
|
||||
PanelSectionRow,
|
||||
SliderField,
|
||||
Spinner,
|
||||
ToggleField,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||
import { FC, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
callable,
|
||||
definePlugin,
|
||||
toaster,
|
||||
} from "@decky/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FaTv, FaSyncAlt, FaStop, FaLock, FaLockOpen } from "react-icons/fa";
|
||||
FaTv,
|
||||
FaSyncAlt,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPlay,
|
||||
FaArrowLeft,
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
discover,
|
||||
getSettings,
|
||||
pair,
|
||||
setSettings,
|
||||
Host,
|
||||
StreamSettings,
|
||||
} from "./backend";
|
||||
import { launchStream } from "./steam";
|
||||
|
||||
// ---- Backend bridge (see main.py) ----
|
||||
const ROUTE = "/punktfunk";
|
||||
|
||||
interface Host {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
pair: string; // "required" | "optional"
|
||||
fp: string;
|
||||
}
|
||||
|
||||
interface ConnectResult {
|
||||
ok: boolean;
|
||||
host: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Status {
|
||||
connected: boolean;
|
||||
host: string | null;
|
||||
}
|
||||
|
||||
const discover = callable<[], Host[]>("discover");
|
||||
const connect = callable<[host: string, port: number], ConnectResult>("connect");
|
||||
const disconnect = callable<[], { ok: boolean; host: string | null }>("disconnect");
|
||||
const getStatus = callable<[], Status>("status");
|
||||
|
||||
function Content() {
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Discovery hook — shared by the QAM panel and the full page.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
function useHosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [busyHost, setBusyHost] = useState<string | null>(null);
|
||||
const [connectedHost, setConnectedHost] = useState<string | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
const refresh = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
const found = await discover();
|
||||
setHosts(found);
|
||||
toaster.toast({
|
||||
title: "punktfunk",
|
||||
body:
|
||||
found.length === 0
|
||||
? "No hosts found on the LAN"
|
||||
: `Found ${found.length} host${found.length === 1 ? "" : "s"}`,
|
||||
});
|
||||
setHosts(await discover());
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` });
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onConnect = async (h: Host) => {
|
||||
const target = `${h.host}:${h.port}`;
|
||||
setBusyHost(target);
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { hosts, scanning, refresh };
|
||||
}
|
||||
|
||||
async function startStream(h: Host) {
|
||||
try {
|
||||
await launchStream(h.host, h.port);
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "punktfunk", body: `Starting stream — ${h.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "punktfunk", body: `Launch failed: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode).
|
||||
// The host displays the PIN after the operator arms pairing; the user enters it here.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const PairModal: FC<{
|
||||
host: Host;
|
||||
closeModal?: () => void;
|
||||
onPaired: () => void;
|
||||
}> = ({ host, closeModal, onPaired }) => {
|
||||
const [pin, setPin] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d));
|
||||
const back = () => setPin((p) => p.slice(0, -1));
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await connect(h.host, h.port);
|
||||
const res = await pair(host.host, host.port, pin, "Steam Deck");
|
||||
if (res.ok) {
|
||||
setConnectedHost(res.host);
|
||||
toaster.toast({ title: "punktfunk", body: `Connecting to ${h.name}` });
|
||||
toaster.toast({ title: "punktfunk", body: `Paired with ${host.name}` });
|
||||
onPaired();
|
||||
closeModal?.();
|
||||
} else {
|
||||
toaster.toast({
|
||||
title: "punktfunk",
|
||||
body:
|
||||
res.error === "client-not-found"
|
||||
? "punktfunk-client is not installed"
|
||||
: `Connect failed: ${res.error ?? "unknown"}`,
|
||||
});
|
||||
setError(res.error ?? "pairing failed");
|
||||
setPin("");
|
||||
}
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "punktfunk", body: `Connect failed: ${e}` });
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusyHost(null);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDisconnect = async () => {
|
||||
try {
|
||||
await disconnect();
|
||||
setConnectedHost(null);
|
||||
toaster.toast({ title: "punktfunk", body: "Disconnected" });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "punktfunk", body: `Disconnect failed: ${e}` });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
|
||||
Pair with {host.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
|
||||
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "2.2em",
|
||||
letterSpacing: "0.4em",
|
||||
textAlign: "center",
|
||||
fontFamily: "monospace",
|
||||
minHeight: "1.4em",
|
||||
marginBottom: "0.6em",
|
||||
}}
|
||||
>
|
||||
{pin.padEnd(4, "•")}
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Focusable
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "0.5em",
|
||||
}}
|
||||
>
|
||||
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
|
||||
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
|
||||
{d}
|
||||
</DialogButton>
|
||||
))}
|
||||
<DialogButton disabled={busy} onClick={back}>
|
||||
⌫
|
||||
</DialogButton>
|
||||
<DialogButton disabled={busy} onClick={() => press("0")}>
|
||||
0
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
disabled={busy || pin.length !== 4}
|
||||
onClick={submit}
|
||||
>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Settings section — resolution / refresh / bitrate / gamepad, written to the client's JSON.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
[1280, 720, "1280 × 720"],
|
||||
[1920, 1080, "1920 × 1080"],
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
];
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
const GAMEPADS = ["auto", "xbox360", "dualsense"];
|
||||
|
||||
const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
|
||||
// On panel open: sync the current connection status and do an initial scan.
|
||||
useEffect(() => {
|
||||
getStatus()
|
||||
.then((s) => setConnectedHost(s.connected ? s.host : null))
|
||||
.catch(() => {});
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
void getSettings().then(setS);
|
||||
}, []);
|
||||
|
||||
const patch = (p: Partial<StreamSettings>) => {
|
||||
setS((cur) => {
|
||||
if (!cur) return cur;
|
||||
const next = { ...cur, ...p };
|
||||
void setSettings(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!s) return <Spinner style={{ height: "1.5em" }} />;
|
||||
|
||||
const resIdx = Math.max(
|
||||
0,
|
||||
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelSection title="Status">
|
||||
<PanelSectionRow>
|
||||
<Field label="State" focusable={false}>
|
||||
{connectedHost ? `Connected — ${connectedHost}` : "Idle"}
|
||||
</Field>
|
||||
</PanelSectionRow>
|
||||
{connectedHost && (
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={onDisconnect}>
|
||||
<FaStop style={{ marginRight: "0.5em" }} />
|
||||
Disconnect
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
</PanelSection>
|
||||
<Field
|
||||
label="Resolution"
|
||||
description="The host creates a virtual output at exactly this size"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Dropdown
|
||||
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
|
||||
selectedOption={resIdx}
|
||||
onChange={(o) => {
|
||||
const [w, h] = RESOLUTIONS[o.data as number];
|
||||
patch({ width: w, height: h });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Refresh rate" childrenContainerWidth="max">
|
||||
<Dropdown
|
||||
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
|
||||
selectedOption={s.refresh_hz}
|
||||
onChange={(o) => patch({ refresh_hz: o.data as number })}
|
||||
/>
|
||||
</Field>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="Mbit/s · 0 = host default"
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<Field label="Gamepad type" childrenContainerWidth="max">
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({
|
||||
data: g,
|
||||
label: g === "auto" ? "Automatic" : g === "xbox360" ? "Xbox 360" : "DualSense",
|
||||
}))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</Field>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
checked={s.mic_enabled}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// One host row on the full page.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostRow: FC<{ host: Host }> = ({ host }) => {
|
||||
const pairRequired = host.pair === "required";
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{pairRequired ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={`${host.host}:${host.port}${pairRequired ? " · pairing required" : ""}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em" }}>
|
||||
{pairRequired && (
|
||||
<DialogButton
|
||||
style={{ minWidth: "5em" }}
|
||||
onClick={() =>
|
||||
showModal(<PairModal host={host} onPaired={() => {}} />)
|
||||
}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The fullscreen page (registered as the /punktfunk route).
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "40px",
|
||||
height: "calc(100% - 40px)",
|
||||
overflowY: "auto",
|
||||
padding: "0 2.5em 2.5em",
|
||||
}}
|
||||
>
|
||||
<Focusable style={{ display: "flex", alignItems: "center", gap: "1em", marginBottom: "1em" }}>
|
||||
<DialogButton
|
||||
style={{ width: "3em", minWidth: "3em" }}
|
||||
onClick={() => Navigation.NavigateBack()}
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</DialogButton>
|
||||
<div className={staticClasses.Title} style={{ flex: 1 }}>
|
||||
punktfunk
|
||||
</div>
|
||||
<DialogButton style={{ width: "10em" }} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
|
||||
<div style={{ fontSize: "1.1em", fontWeight: "bold", margin: "0.5em 0" }}>Hosts</div>
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<Field focusable={false}>No hosts discovered on the LAN.</Field>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} />
|
||||
))}
|
||||
|
||||
<div style={{ fontSize: "1.1em", fontWeight: "bold", margin: "1.5em 0 0.5em" }}>
|
||||
Stream settings
|
||||
</div>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const QamPanel: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelSection title="punktfunk">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => {
|
||||
Navigation.Navigate(ROUTE);
|
||||
Navigation.CloseSideMenus();
|
||||
}}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.5em" }} />
|
||||
Open punktfunk
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
|
||||
{scanning ? (
|
||||
@@ -133,39 +366,37 @@ function Content() {
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
{scanning ? "Scanning…" : "Refresh hosts"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false}>No hosts discovered yet.</Field>
|
||||
<Field focusable={false}>No hosts found.</Field>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
|
||||
{hosts.map((h) => {
|
||||
const target = `${h.host}:${h.port}`;
|
||||
const isBusy = busyHost === target;
|
||||
const pairRequired = h.pair === "required";
|
||||
return (
|
||||
<PanelSectionRow key={h.fp || target}>
|
||||
<PanelSectionRow key={h.fp || `${h.host}:${h.port}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
disabled={isBusy}
|
||||
onClick={() => onConnect(h)}
|
||||
onClick={() =>
|
||||
pairRequired
|
||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||
: startStream(h)
|
||||
}
|
||||
label={
|
||||
<span>
|
||||
{pairRequired ? (
|
||||
<FaLock style={{ marginRight: "0.4em" }} />
|
||||
) : (
|
||||
<FaLockOpen style={{ marginRight: "0.4em" }} />
|
||||
)}
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{pairRequired ? <FaLock /> : <FaLockOpen />}
|
||||
{h.name}
|
||||
</span>
|
||||
}
|
||||
description={`${target}${pairRequired ? " · pairing required" : ""}`}
|
||||
description={`${h.host}:${h.port}`}
|
||||
>
|
||||
{isBusy ? "Connecting…" : "Connect"}
|
||||
{pairRequired ? "Pair & Stream" : "Stream"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
);
|
||||
@@ -173,16 +404,17 @@ function Content() {
|
||||
</PanelSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute(ROUTE, PunktfunkPage, { exact: true });
|
||||
return {
|
||||
name: "punktfunk",
|
||||
titleView: <div>punktfunk</div>,
|
||||
content: <Content />,
|
||||
titleView: <div className={staticClasses.Title}>punktfunk</div>,
|
||||
content: <QamPanel />,
|
||||
icon: <FaTv />,
|
||||
onDismount() {
|
||||
// The backend tears the client down on _unload; nothing frontend-side to clean up.
|
||||
routerHook.removeRoute(ROUTE);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Launch the stream as a Steam game so gamescope focuses + fullscreens it.
|
||||
//
|
||||
// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to
|
||||
// the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see
|
||||
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE
|
||||
// hidden non-Steam shortcut that points at our wrapper script (bin/punktfunkrun.sh), pass the
|
||||
// per-session host as the shortcut's Steam launch options, and start it with RunGame. The
|
||||
// wrapper then execs `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
||||
|
||||
import { runnerInfo } from "./backend";
|
||||
|
||||
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
|
||||
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
||||
// decky-frontend-lib SteamClient.Apps typings.
|
||||
declare const SteamClient: {
|
||||
Apps: {
|
||||
AddShortcut(
|
||||
name: string,
|
||||
exePath: string,
|
||||
startDir: string,
|
||||
launchOptions: string,
|
||||
): Promise<number>;
|
||||
SetShortcutName(appId: number, name: string): void;
|
||||
SetShortcutExe(appId: number, exe: string): void;
|
||||
SetShortcutStartDir(appId: number, dir: string): void;
|
||||
SetAppLaunchOptions(appId: number, options: string): void;
|
||||
SetAppHidden(appId: number, hidden: boolean): void;
|
||||
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
|
||||
TerminateApp(gameId: string, _b: boolean): void;
|
||||
};
|
||||
};
|
||||
|
||||
const SHORTCUT_NAME = "punktfunk";
|
||||
|
||||
// The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the
|
||||
// standard non-Steam-game encoding (appid << 32 | 0x02000000). MoonDeck/decky tools use this.
|
||||
function gameIdFromAppId(appId: number): string {
|
||||
return ((BigInt(appId) << 32n) | 0x02000000n).toString();
|
||||
}
|
||||
|
||||
// Persist our shortcut appId across reloads so we reuse ONE shortcut instead of churning the
|
||||
// library (the appId is stable for the life of the shortcut).
|
||||
const STORAGE_KEY = "punktfunk:shortcutAppId";
|
||||
|
||||
function rememberAppId(appId: number) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(appId));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
function recallAppId(): number | null {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
return v ? Number(v) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure exactly one hidden "punktfunk" shortcut exists pointing at the wrapper script, and
|
||||
* return its appId. Reuses the remembered one when its exe still matches the current runner
|
||||
* path (the plugin dir can change across reinstalls).
|
||||
*/
|
||||
async function ensureShortcut(): Promise<number> {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||
}
|
||||
|
||||
const remembered = recallAppId();
|
||||
if (remembered != null) {
|
||||
// Re-point the existing shortcut at the current runner path (cheap + idempotent).
|
||||
SteamClient.Apps.SetShortcutExe(remembered, info.runner);
|
||||
SteamClient.Apps.SetShortcutStartDir(
|
||||
remembered,
|
||||
info.runner.replace(/\/[^/]*$/, ""),
|
||||
);
|
||||
return remembered;
|
||||
}
|
||||
|
||||
const appId = await SteamClient.Apps.AddShortcut(
|
||||
SHORTCUT_NAME,
|
||||
info.runner,
|
||||
info.runner.replace(/\/[^/]*$/, ""), // start dir = the bin/ dir
|
||||
"",
|
||||
);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
// Hide it from the library — it's an implementation detail, launched programmatically.
|
||||
SteamClient.Apps.SetAppHidden(appId, true);
|
||||
rememberAppId(appId);
|
||||
return appId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a stream to `host:port` fullscreen in Gaming Mode. Encodes the target into the
|
||||
* shortcut's launch options (so one generic shortcut serves every host), then RunGame.
|
||||
*/
|
||||
export async function launchStream(host: string, port: number): Promise<void> {
|
||||
const appId = await ensureShortcut();
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`);
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
|
||||
/** Stop the running stream shortcut (best-effort; the in-stream chord/back also works). */
|
||||
export function stopStream(): void {
|
||||
const appId = recallAppId();
|
||||
if (appId != null) {
|
||||
SteamClient.Apps.TerminateApp(gameIdFromAppId(appId), false);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ path = "src/main.rs"
|
||||
# Everything is Linux-gated so `cargo build --workspace` stays green on macOS (the Mac
|
||||
# client lives in clients/apple); on other platforms this builds as a stub binary.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
|
||||
# UI shell. GraphicsOffload needs GTK ≥ 4.14; black-background ≥ 4.16. AlertDialog/
|
||||
# PreferencesDialog need libadwaita ≥ 1.5.
|
||||
@@ -36,6 +36,11 @@ pub fn run() -> glib::ExitCode {
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
// Headless pairing path (no GTK window): `--pair <PIN> --connect host[:port] [--name N]`.
|
||||
// Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting.
|
||||
if let Some(pin) = arg_value("--pair") {
|
||||
return headless_pair(&pin);
|
||||
}
|
||||
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||
app.connect_activate(build_ui);
|
||||
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
||||
@@ -43,6 +48,66 @@ pub fn run() -> glib::ExitCode {
|
||||
app.run_with_args(&[] as &[&str])
|
||||
}
|
||||
|
||||
/// The value following `flag` in argv, if present (`--flag value`).
|
||||
fn arg_value(flag: &str) -> Option<String> {
|
||||
std::env::args()
|
||||
.skip_while(|a| a != flag)
|
||||
.nth(1)
|
||||
.filter(|v| !v.starts_with("--"))
|
||||
}
|
||||
|
||||
/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the
|
||||
/// known-hosts store as paired, so a later `--connect` connects silently. Same identity
|
||||
/// store the streaming path uses (same binary), so pairing here makes the stream work.
|
||||
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
|
||||
fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!("--pair requires --connect host[:port]");
|
||||
return glib::ExitCode::FAILURE;
|
||||
};
|
||||
let (addr, port) = match target.rsplit_once(':') {
|
||||
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
|
||||
None => (target.clone(), 9777),
|
||||
};
|
||||
// The label the HOST stores this client under (its paired-devices list).
|
||||
let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string());
|
||||
|
||||
let identity = match crate::trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
match NativeClient::pair(
|
||||
&addr,
|
||||
port,
|
||||
(&identity.0, &identity.1),
|
||||
pin.trim(),
|
||||
&name,
|
||||
std::time::Duration::from_secs(90),
|
||||
) {
|
||||
Ok(fp) => {
|
||||
let fp_hex = crate::trust::hex(&fp);
|
||||
let mut known = KnownHosts::load();
|
||||
known.upsert(KnownHost {
|
||||
name: arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
||||
addr: addr.clone(),
|
||||
port,
|
||||
fp_hex: fp_hex.clone(),
|
||||
paired: true,
|
||||
});
|
||||
let _ = known.save();
|
||||
println!("paired {addr}:{port} fp={fp_hex}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--connect host[:port]` — skip the hosts page and start a session immediately
|
||||
/// (scripting + headless testing). Trust follows the same rules as a manual entry: a host
|
||||
/// already pinned at this address connects silently on its stored pin; an unknown host is
|
||||
@@ -308,7 +373,8 @@ fn speed_test(app: Rc<App>, req: ConnectRequest) {
|
||||
},
|
||||
CompositorPref::Auto,
|
||||
GamepadPref::Auto,
|
||||
0,
|
||||
0, // bitrate_kbps (host default)
|
||||
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
||||
None, // launch: speed-test probe connect, no game
|
||||
pin,
|
||||
Some(identity),
|
||||
@@ -468,6 +534,7 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||
&app.window,
|
||||
connector,
|
||||
frames.take().expect("Connected delivered once"),
|
||||
app.gamepad.escape_events(),
|
||||
handle.stop.clone(),
|
||||
inhibit,
|
||||
&title,
|
||||
@@ -27,11 +27,51 @@ const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI;
|
||||
const ACCEL_LSB_PER_G: f32 = 10_000.0;
|
||||
const G: f32 = 9.80665;
|
||||
|
||||
/// The controller "escape" chord (Moonlight convention): L1 + R1 + Start + Select held
|
||||
/// together. Intercepted by the client to leave fullscreen + release input capture — the
|
||||
/// Deck has no F11 key and fullscreen hides the window chrome, so with a controller this
|
||||
/// is the only way out. Four simultaneous buttons that no game uses as a deliberate
|
||||
/// combo, so it can't be triggered by normal play. Still forwarded to the host (the user
|
||||
/// is leaving anyway); we only also raise the escape signal.
|
||||
const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PadInfo {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub is_dualsense: bool,
|
||||
/// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a
|
||||
/// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything
|
||||
/// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path.
|
||||
pub pref: GamepadPref,
|
||||
}
|
||||
|
||||
impl PadInfo {
|
||||
/// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger
|
||||
/// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`).
|
||||
fn is_dualsense(&self) -> bool {
|
||||
self.pref == GamepadPref::DualSense
|
||||
}
|
||||
|
||||
/// A short controller-kind label for the Settings list (`""` for a plain Xbox/standard pad).
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
match self.pref {
|
||||
GamepadPref::DualSense => "DualSense",
|
||||
GamepadPref::DualShock4 => "DualShock 4",
|
||||
GamepadPref::XboxOne => "Xbox One",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
|
||||
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
use sdl3::gamepad::GamepadType as T;
|
||||
match t {
|
||||
T::PS5 => GamepadPref::DualSense,
|
||||
T::PS4 => GamepadPref::DualShock4,
|
||||
T::XboxOne => GamepadPref::XboxOne,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
|
||||
enum Ctl {
|
||||
@@ -46,6 +86,9 @@ pub struct GamepadService {
|
||||
active: Arc<Mutex<Option<PadInfo>>>,
|
||||
pinned: Arc<Mutex<Option<u32>>>,
|
||||
ctl: Sender<Ctl>,
|
||||
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
|
||||
/// fullscreen + release capture.
|
||||
escape_rx: async_channel::Receiver<()>,
|
||||
}
|
||||
|
||||
impl GamepadService {
|
||||
@@ -54,11 +97,12 @@ impl GamepadService {
|
||||
let active = Arc::new(Mutex::new(None));
|
||||
let pinned = Arc::new(Mutex::new(None));
|
||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||
let (escape_tx, escape_rx) = async_channel::unbounded();
|
||||
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-gamepad".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx) {
|
||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx) {
|
||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||
}
|
||||
})
|
||||
@@ -70,9 +114,16 @@ impl GamepadService {
|
||||
active,
|
||||
pinned,
|
||||
ctl,
|
||||
escape_rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// A receiver that yields one `()` each time the controller escape chord is pressed.
|
||||
/// A fresh clone per call (shared mpmc channel); the stream page spawns a future on it.
|
||||
pub fn escape_events(&self) -> async_channel::Receiver<()> {
|
||||
self.escape_rx.clone()
|
||||
}
|
||||
|
||||
pub fn pads(&self) -> Vec<PadInfo> {
|
||||
self.pads.lock().unwrap().clone()
|
||||
}
|
||||
@@ -101,8 +152,7 @@ impl GamepadService {
|
||||
/// (Swift parity); no pad connected leaves the host's own default.
|
||||
pub fn auto_pref(&self) -> GamepadPref {
|
||||
match self.active() {
|
||||
Some(p) if p.is_dualsense => GamepadPref::DualSense,
|
||||
Some(_) => GamepadPref::Xbox360,
|
||||
Some(p) => p.pref,
|
||||
None => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
@@ -210,6 +260,10 @@ struct Worker {
|
||||
last_axis: [i32; 6],
|
||||
held_buttons: Vec<u32>,
|
||||
last_accel: [i16; 3],
|
||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
/// The escape chord is fully held — latched so it fires once, not every poll.
|
||||
chord_armed: bool,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
@@ -224,10 +278,9 @@ impl Worker {
|
||||
Some(PadInfo {
|
||||
id,
|
||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||
is_dualsense: matches!(
|
||||
pref: pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
sdl3::gamepad::GamepadType::PS5
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -250,6 +303,26 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it
|
||||
/// fires once per press). Called after each button-down updates `held_buttons`.
|
||||
fn maybe_fire_escape(&mut self) {
|
||||
if self.chord_armed {
|
||||
return;
|
||||
}
|
||||
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
||||
self.chord_armed = true;
|
||||
let _ = self.escape_tx.try_send(());
|
||||
tracing::info!("gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen");
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-arm once the chord is broken (any of its buttons released).
|
||||
fn rearm_escape(&mut self) {
|
||||
if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
||||
self.chord_armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
||||
fn set_sensors(&mut self, enabled: bool) {
|
||||
let Some(id) = self.active_id() else { return };
|
||||
@@ -270,6 +343,7 @@ fn run(
|
||||
active_out: &Mutex<Option<PadInfo>>,
|
||||
pinned_out: &Mutex<Option<u32>>,
|
||||
ctl: &Receiver<Ctl>,
|
||||
escape_tx: &async_channel::Sender<()>,
|
||||
) -> Result<(), String> {
|
||||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
||||
// own thread.
|
||||
@@ -288,6 +362,8 @@ fn run(
|
||||
last_axis: [i32::MIN; 6],
|
||||
held_buttons: Vec::new(),
|
||||
last_accel: [0; 3],
|
||||
escape_tx: escape_tx.clone(),
|
||||
chord_armed: false,
|
||||
};
|
||||
|
||||
let publish = |w: &Worker| {
|
||||
@@ -372,6 +448,7 @@ fn run(
|
||||
bit,
|
||||
1,
|
||||
);
|
||||
w.maybe_fire_escape();
|
||||
}
|
||||
}
|
||||
Event::ControllerButtonUp { which, button, .. }
|
||||
@@ -385,6 +462,7 @@ fn run(
|
||||
bit,
|
||||
0,
|
||||
);
|
||||
w.rearm_escape();
|
||||
}
|
||||
}
|
||||
Event::ControllerAxisMotion {
|
||||
@@ -488,13 +566,23 @@ fn run(
|
||||
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
||||
if pad == 0 {
|
||||
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
|
||||
let _ = p.set_rumble(low, high, 5_000);
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
|
||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
||||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||
// client-render.
|
||||
if let Err(e) = p.set_rumble(low, high, 5_000) {
|
||||
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: rendered");
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: received but no active pad to render");
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||
let Some(id) = w.active_id() else { continue };
|
||||
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense);
|
||||
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense());
|
||||
let Some(pad) = w.opened.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
@@ -96,6 +96,7 @@ fn pump(
|
||||
params.compositor,
|
||||
params.gamepad,
|
||||
params.bitrate_kbps,
|
||||
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
||||
None, // launch: the Linux client has no library picker yet
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user