Compare commits
84 Commits
dda-parity
...
22a9ce4229
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
# 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.
|
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
|
||||||
#
|
#
|
||||||
@@ -41,12 +41,12 @@ jobs:
|
|||||||
- name: Android SDK
|
- name: Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
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
|
# 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.
|
# 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 —
|
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
|
||||||
# "Failed to find package"); AGP auto-installs the compileSdk-37 platform during the build.
|
# auto-download it if needed during the build.
|
||||||
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0" "ndk;28.2.13676358" "cmake;3.22.1"
|
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
|
||||||
|
|
||||||
- name: Caches (cargo + gradle)
|
- name: Caches (cargo + gradle)
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -65,4 +65,53 @@ jobs:
|
|||||||
|
|
||||||
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
||||||
working-directory: clients/android
|
working-directory: clients/android
|
||||||
|
env:
|
||||||
|
VERSION_CODE: ${{ github.run_number }}
|
||||||
run: ./gradlew :app:assembleDebug --stacktrace
|
run: ./gradlew :app:assembleDebug --stacktrace
|
||||||
|
|
||||||
|
- name: Build Release (signed AAB + universal APK)
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
working-directory: clients/android
|
||||||
|
env:
|
||||||
|
VERSION_CODE: ${{ github.run_number }}
|
||||||
|
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).
|
||||||
|
- name: Publish AAB + APK to Gitea generic registry
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
env:
|
||||||
|
REGISTRY: git.unom.io
|
||||||
|
OWNER: unom
|
||||||
|
PKG: punktfunk-android
|
||||||
|
VERSION: ${{ github.run_number }}
|
||||||
|
REGISTRY_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/$VERSION"
|
||||||
|
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$AAB" "$base/punktfunk-android-r$VERSION.aab"
|
||||||
|
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/punktfunk-android-r$VERSION.apk"
|
||||||
|
echo "Published artifacts (versionCode=$VERSION):"
|
||||||
|
echo " $base/punktfunk-android-r$VERSION.aab"
|
||||||
|
echo " $base/punktfunk-android-r$VERSION.apk"
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
- name: Upload to Google Play (Internal Testing)
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
env:
|
||||||
|
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||||
|
run: |
|
||||||
|
python3 clients/android/ci/play-upload.py \
|
||||||
|
--package io.unom.punktfunk \
|
||||||
|
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||||
|
--track internal --status completed
|
||||||
|
|||||||
@@ -76,12 +76,16 @@ jobs:
|
|||||||
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
|
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
|
||||||
STAGE="$RUNNER_TEMP/decky"
|
STAGE="$RUNNER_TEMP/decky"
|
||||||
DEST="$STAGE/$PLUGIN"
|
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/plugin.json "$DEST/"
|
||||||
cp clients/decky/package.json "$DEST/"
|
cp clients/decky/package.json "$DEST/"
|
||||||
cp clients/decky/main.py "$DEST/"
|
cp clients/decky/main.py "$DEST/"
|
||||||
cp clients/decky/dist/index.js "$DEST/dist/"
|
cp clients/decky/dist/index.js "$DEST/dist/"
|
||||||
cp clients/decky/README.md "$DEST/"
|
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.
|
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
|
||||||
cp LICENSE-MIT "$DEST/LICENSE"
|
cp LICENSE-MIT "$DEST/LICENSE"
|
||||||
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ on:
|
|||||||
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
||||||
# docs/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
# docs/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
||||||
paths:
|
paths:
|
||||||
- 'crates/punktfunk-client-linux/**'
|
- 'clients/linux/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
- 'packaging/flatpak/**'
|
- 'packaging/flatpak/**'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
@@ -40,6 +40,8 @@ env:
|
|||||||
APP_ID: io.unom.Punktfunk
|
APP_ID: io.unom.Punktfunk
|
||||||
MANIFEST: packaging/flatpak/io.unom.Punktfunk.yml
|
MANIFEST: packaging/flatpak/io.unom.Punktfunk.yml
|
||||||
PACKAGE: punktfunk-client-flatpak # generic-registry package name
|
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:
|
jobs:
|
||||||
build-publish:
|
build-publish:
|
||||||
@@ -61,7 +63,9 @@ jobs:
|
|||||||
- name: Tooling
|
- name: Tooling
|
||||||
run: |
|
run: |
|
||||||
# flatpak-cargo-generator.py (master) needs aiohttp + tomlkit (NOT the old `toml`).
|
# 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.
|
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
|
||||||
flatpak remote-add --user --if-not-exists flathub \
|
flatpak remote-add --user --if-not-exists flathub \
|
||||||
https://dl.flathub.org/repo/flathub.flatpakrepo
|
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||||
@@ -85,10 +89,17 @@ jobs:
|
|||||||
# flatpak builds with no network; vendor every crate from Cargo.lock into
|
# flatpak builds with no network; vendor every crate from Cargo.lock into
|
||||||
# cargo-sources.json next to the manifest (referenced by the manifest's
|
# cargo-sources.json next to the manifest (referenced by the manifest's
|
||||||
# punktfunk-client module).
|
# 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: |
|
run: |
|
||||||
curl -fsSL -o /tmp/flatpak-cargo-generator.py \
|
curl -fsSL -o /tmp/flatpak-cargo-generator.py \
|
||||||
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/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
|
-o packaging/flatpak/cargo-sources.json
|
||||||
|
|
||||||
- name: Build the flatpak (install deps from Flathub, offline build)
|
- name: Build the flatpak (install deps from Flathub, offline build)
|
||||||
@@ -97,14 +108,19 @@ jobs:
|
|||||||
# runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus
|
# runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus
|
||||||
# the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the
|
# the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the
|
||||||
# container-safe path (no FUSE).
|
# container-safe path (no FUSE).
|
||||||
|
# --default-branch=stable pins the ref to app/io.unom.Punktfunk/x86_64/stable so the
|
||||||
|
# hosted .flatpakref (Branch=stable) matches deterministically (manifest sets no branch).
|
||||||
flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||||
|
--default-branch=stable \
|
||||||
--install-deps-from=flathub \
|
--install-deps-from=flathub \
|
||||||
--repo="$PWD/repo" \
|
--repo="$PWD/repo" \
|
||||||
"$PWD/build-dir" "$MANIFEST"
|
"$PWD/build-dir" "$MANIFEST"
|
||||||
|
|
||||||
- name: Export single-file bundle
|
- name: Export single-file bundle
|
||||||
run: |
|
run: |
|
||||||
flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID"
|
# Branch must be passed explicitly now that the repo ref is `stable` (--default-branch
|
||||||
|
# above); build-bundle otherwise defaults to `master` and errors "Refspec … not found".
|
||||||
|
flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID" stable
|
||||||
ls -lh "$BUNDLE"
|
ls -lh "$BUNDLE"
|
||||||
|
|
||||||
- name: Publish to the Gitea generic registry
|
- name: Publish to the Gitea generic registry
|
||||||
@@ -125,6 +141,77 @@ jobs:
|
|||||||
"$BASE/latest/punktfunk-client.flatpak"
|
"$BASE/latest/punktfunk-client.flatpak"
|
||||||
echo "published $BASE/latest/punktfunk-client.flatpak"
|
echo "published $BASE/latest/punktfunk-client.flatpak"
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
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: |
|
||||||
|
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" stable \
|
||||||
|
--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
|
||||||
|
cat > "site/${APP_ID}.flatpakref" <<EOF
|
||||||
|
[Flatpak Ref]
|
||||||
|
Name=$APP_ID
|
||||||
|
Branch=stable
|
||||||
|
Url=$REPO_URL/repo/
|
||||||
|
Title=Punktfunk
|
||||||
|
Homepage=https://punktfunk.unom.io
|
||||||
|
IsRuntime=false
|
||||||
|
GPGKey=$GPGKEY
|
||||||
|
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||||
|
EOF
|
||||||
|
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>
|
||||||
|
<pre>flatpak install --user $REPO_URL/${APP_ID}.flatpakref
|
||||||
|
flatpak run $APP_ID</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/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 (tags only)
|
- name: Attach bundle to the Gitea release (tags only)
|
||||||
if: startsWith(gitea.ref, 'refs/tags/')
|
if: startsWith(gitea.ref, 'refs/tags/')
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# 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
|
||||||
|
# SudoVDA virtual-display driver) 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):
|
||||||
|
# host-win-vX.Y.Z tag -> X.Y.Z (a real host release; own tag namespace, off host-v*/win-v*/v*
|
||||||
|
# to avoid the version-shadow bug class — see deb.yml).
|
||||||
|
# main push / dispatch -> 0.2.<run_number> (rolling; climbs monotonically 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.
|
||||||
|
#
|
||||||
|
# NVENC: the host builds with --features nvenc; the only link need is nvencodeapi.lib, synthesised
|
||||||
|
# from a 2-export .def with llvm-dlltool (no GPU/SDK at build time). The resulting exe is NVIDIA-only
|
||||||
|
# by design — CI never launches it, so no GPU is needed here.
|
||||||
|
name: windows-host
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'crates/punktfunk-host/**'
|
||||||
|
- 'crates/punktfunk-core/**'
|
||||||
|
- 'packaging/windows/**'
|
||||||
|
- 'scripts/windows/host.env.example'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- '.gitea/workflows/windows-host.yml'
|
||||||
|
tags: ['host-win-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
|
||||||
|
$v = if ($env:GITHUB_REF -like 'refs/tags/host-win-v*') {
|
||||||
|
$env:GITHUB_REF_NAME -replace '^host-win-v', ''
|
||||||
|
} else {
|
||||||
|
"0.2.$($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)
|
||||||
|
shell: pwsh
|
||||||
|
run: cargo build --release -p punktfunk-host --features nvenc
|
||||||
|
|
||||||
|
- 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 -- -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: 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)" }
|
||||||
|
# On a tagged release, also refresh the stable `latest/` alias (delete-then-reupload, like
|
||||||
|
# flatpak.yml/decky.yml) so there's a predictable download URL.
|
||||||
|
if ($env:GITHUB_REF -like 'refs/tags/host-win-v*') {
|
||||||
|
$aliases = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
|
||||||
|
foreach ($f in $files) {
|
||||||
|
$alias = $aliases[$f]; if (-not $alias) { continue }
|
||||||
|
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/latest/$alias" 2>$null
|
||||||
|
Publish-File $f "$base/latest/$alias"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
# Build the punktfunk Windows client as a signed MSIX and publish it to Gitea's generic package
|
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
|
||||||
# registry, so Windows boxes can download + install a real package (Start tile, clean
|
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start
|
||||||
# install/uninstall) instead of a loose exe. Runs on the self-hosted Windows runner (host mode;
|
# tile, clean install/uninstall) instead of a loose exe. Runs on the self-hosted Windows runner
|
||||||
# scripts/ci/setup-windows-runner.ps1) — the MSVC/WinUI/FFmpeg toolchain + the Windows SDK's
|
# (host mode; scripts/ci/setup-windows-runner.ps1) — the MSVC/WinUI/FFmpeg toolchain + the Windows
|
||||||
# makeappx/signtool are baked into the runner's daemon env, same as windows.yml.
|
# 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)
|
# 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
|
# Packaging internals: clients/windows/packaging/README.md.
|
||||||
# gotchas baked into the daemon env + windows.yml: see that workflow.
|
|
||||||
#
|
#
|
||||||
# Versioning — MSIX requires a strictly 4-part numeric version (no ~/- suffixes), so:
|
# 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,
|
# 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
|
# kept off the host's `host-v*` and the Apple `v*` to avoid the
|
||||||
# version-shadow class of bug — see deb.yml).
|
# version-shadow class of bug — see deb.yml).
|
||||||
# main push / dispatch -> 0.2.<run_number>.0 (rolling; climbs monotonically by run number).
|
# main push / dispatch -> 0.2.<run_number>.0 (rolling; climbs monotonically by run number).
|
||||||
|
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||||
#
|
#
|
||||||
# Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets
|
# Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets
|
||||||
# are set (a real or shared code-signing .pfx whose subject DN == Publisher), the package is signed
|
# are set (a real or shared code-signing .pfx whose subject DN == Publisher), the package is signed
|
||||||
@@ -25,7 +29,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'crates/punktfunk-client-windows/**'
|
- 'clients/windows/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
@@ -41,17 +45,33 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
package:
|
package:
|
||||||
runs-on: windows-amd64
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Configure + version
|
- name: Configure + version
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
# windows-reactor's build.rs unwraps CARGO_WORKSPACE_DIR; CARGO_TARGET_DIR=C:\t dodges the
|
# windows-reactor's build.rs unwraps CARGO_WORKSPACE_DIR; CARGO_TARGET_DIR (per-arch, short)
|
||||||
# MAX_PATH wall in the CMake-from-source crates (see windows.yml). Both via GITHUB_ENV.
|
# 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_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
|
"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/win-v*') {
|
$parts = if ($env:GITHUB_REF -like 'refs/tags/win-v*') {
|
||||||
($env:GITHUB_REF_NAME -replace '^win-v', '').Split('.')
|
($env:GITHUB_REF_NAME -replace '^win-v', '').Split('.')
|
||||||
} else {
|
} else {
|
||||||
@@ -60,11 +80,11 @@ jobs:
|
|||||||
while ($parts.Count -lt 4) { $parts += '0' }
|
while ($parts.Count -lt 4) { $parts += '0' }
|
||||||
$v = ($parts[0..3] -join '.')
|
$v = ($parts[0..3] -join '.')
|
||||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"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)
|
- name: Build (release)
|
||||||
shell: pwsh
|
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
|
- name: Pack + sign MSIX
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -72,8 +92,9 @@ jobs:
|
|||||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
& crates/punktfunk-client-windows/packaging/pack-msix.ps1 `
|
& clients/windows/packaging/pack-msix.ps1 `
|
||||||
-Version $env:MSIX_VERSION -TargetDir C:\t\release -OutDir C:\t\msix
|
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||||
|
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
|
||||||
|
|
||||||
- name: Publish to Gitea generic registry
|
- name: Publish to Gitea generic registry
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
# Windows client CI — runs on the self-hosted Windows runner (home-windows-1, host mode; see
|
# 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
|
# 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,
|
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
|
||||||
# CARGO_HOME, CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Two
|
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
|
||||||
# per-checkout vars are set in a step:
|
# (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
|
# - CARGO_WORKSPACE_DIR windows-reactor's build.rs unwraps it + stages the Win App SDK
|
||||||
# NuGets/winmd under it (from GITHUB_WORKSPACE).
|
# 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\,
|
# C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\,
|
||||||
# so the default target\ path blows past Windows' MAX_PATH (260) inside the
|
# so the default target\ path blows past Windows' MAX_PATH (260) inside the
|
||||||
# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then
|
# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then
|
||||||
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
|
# 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
|
# 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
|
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (the
|
||||||
@@ -24,14 +36,14 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'crates/punktfunk-client-windows/**'
|
- 'clients/windows/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- '.gitea/workflows/windows.yml'
|
- '.gitea/workflows/windows.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'crates/punktfunk-client-windows/**'
|
- 'clients/windows/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
@@ -41,7 +53,11 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: windows-amd64
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -49,24 +65,31 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
"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
|
rustc --version
|
||||||
cargo --version
|
cargo --version
|
||||||
node --version
|
Write-Output "target ${{ matrix.target }} target-dir $td ffmpeg $ff"
|
||||||
Write-Output "workspace: $env:GITHUB_WORKSPACE"
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo build -p punktfunk-client-windows
|
run: cargo build -p punktfunk-client-windows --target ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Clippy (-D warnings)
|
- name: Clippy (-D warnings)
|
||||||
shell: pwsh
|
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
|
- name: Rustfmt check
|
||||||
|
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo fmt -p punktfunk-client-windows -- --check
|
run: cargo fmt -p punktfunk-client-windows -- --check
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
|
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo test -p punktfunk-client-windows
|
run: cargo test -p punktfunk-client-windows --target ${{ matrix.target }}
|
||||||
|
|||||||
@@ -20,3 +20,11 @@ xcuserdata/
|
|||||||
# Windows App SDK staging by windows-reactor build.rs
|
# Windows App SDK staging by windows-reactor build.rs
|
||||||
/temp/
|
/temp/
|
||||||
/winmd/
|
/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
|
||||||
|
|||||||
@@ -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
|
## 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 +
|
proptests, C ABI harness all green; 13 adversarial-review findings fixed +
|
||||||
regression-tested (`a913042`).
|
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
|
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
|
`~/.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
|
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
|
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
|
||||||
back-channel; validated live — pad created/destroyed with the session). Management REST API +
|
back-channel; validated live — pad created/destroyed with the session). Management REST API +
|
||||||
checked-in OpenAPI doc (`mgmt.rs`).
|
checked-in OpenAPI doc (`mgmt.rs`).
|
||||||
- **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
|
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
|
(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
|
`--max-sessions`). QUIC datagrams carry the side planes, demuxed by first byte: input
|
||||||
0xC8 (incl. **gamepads** — incremental events accumulated into the uinput xpad), **Opus
|
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:**
|
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
|
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
|
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
|
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
|
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
|
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
|
(no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores
|
||||||
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
|
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
|
||||||
default; `--allow-tofu`/`--open` accept unpaired clients).
|
default; `--allow-tofu`/`--open` accept unpaired clients).
|
||||||
**LAN auto-discovery**: both `serve --native` and `m3-host` advertise the native service over
|
**LAN auto-discovery**: both `serve --native` and `punktfunk1-host` advertise the native service over
|
||||||
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
|
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
|
||||||
pin)/`pair`(required|optional)/`id`; `punktfunk-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).
|
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
|
||||||
**Mid-stream mode renegotiation**: `Reconfigure` on the still-open control stream — the
|
**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
|
host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on
|
||||||
@@ -58,7 +58,7 @@ 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
|
(`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
|
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).
|
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).
|
working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad).
|
||||||
The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect`
|
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`/
|
(pin/TOFU) + `next_au`/`next_audio`/`next_rumble`/`next_hidout`/`send_input`/
|
||||||
@@ -66,10 +66,21 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
|
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
|
||||||
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
|
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
|
||||||
env > uinput Xbox 360; DualSense (UHID) only on Linux hosts.
|
env > uinput Xbox 360; DualSense (UHID) only on Linux hosts.
|
||||||
|
- **Windows host: implemented and shipping (NVIDIA-only, 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`), NVENC encode (`--features nvenc`), 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, and is published by `windows-host.yml`. **HDR (10-bit)**: WGC captures the HDR
|
||||||
|
desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), NVENC forces HEVC Main10 + BT.2020 PQ,
|
||||||
|
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). Newer/less battle-tested than
|
||||||
|
the Linux host; no AMD/Intel/software encode path. Packaging: `packaging/windows/`.
|
||||||
|
|
||||||
## What's left
|
## 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 →
|
(2026-06-10).** PunktfunkKit compiles and is tested on macOS (AnnexB → VideoToolbox →
|
||||||
`AVSampleBufferDisplayLayer`, GCMouse/GCKeyboard capture, `PunktfunkClient` app shell);
|
`AVSampleBufferDisplayLayer`, GCMouse/GCKeyboard capture, `PunktfunkClient` app shell);
|
||||||
validated live Mac ↔ this box at 720p60 — vkcube on glass, input injected via gamescope
|
validated live Mac ↔ this box at 720p60 — vkcube on glass, input injected via gamescope
|
||||||
@@ -85,13 +96,15 @@ 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
|
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
|
||||||
motion sign/scale derived, not yet live-verified. Tests: `swift test` in
|
motion sign/scale derived, not yet live-verified. Tests: `swift test` in
|
||||||
`clients/apple` (unit + real-codec round trip),
|
`clients/apple` (unit + real-codec round trip),
|
||||||
`test-loopback.sh` (Swift client vs synthetic 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),
|
includes the pairing ceremony + `--require-pairing` gate),
|
||||||
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
||||||
[`clients/apple/README.md`](clients/apple/README.md). Next: stage 2 presenter
|
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter**
|
||||||
(`VTDecompressionSession` + `CAMetalLayer` frame pacing), glass-to-glass numbers via
|
(`VTDecompressionSession` + `CAMetalLayer`) is built and live-validated on glass behind the opt-in
|
||||||
`tools/latency-probe` (scaffold), iOS variant.
|
`punktfunk.presenter` flag (~11 ms p50 capture→present), to become the default after a few
|
||||||
**Linux stage 1 done, first light 2026-06-12** (`crates/punktfunk-client-linux`, binary
|
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;
|
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
||||||
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
||||||
PIN dialogs (identity shared with client-rs), FFmpeg software HEVC decode (LOW_DELAY,
|
PIN dialogs (identity shared with client-rs), FFmpeg software HEVC decode (LOW_DELAY,
|
||||||
@@ -108,48 +121,79 @@ 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
|
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
|
→ 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 —
|
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
|
no VAAPI device (NVIDIA) or mid-session VAAPI error → software decode. **First AMD test
|
||||||
Intel/AMD client box to live-verify the hw path. Next: the stage-2 raw-Wayland
|
(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) —
|
presenter (wp_presentation feedback, tearing-control, Vulkan Video on NVIDIA) —
|
||||||
**wgpu/winit rejected** (no dmabuf import / presentation feedback / shortcuts-inhibit).
|
**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
|
`punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like
|
||||||
framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The
|
framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The
|
||||||
video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain** (WARP fallback for
|
video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain** (WARP fallback for
|
||||||
the GPU-less dev box; runtime-compiled fullscreen-triangle shaders, Contain-fit letterbox),
|
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
|
driven by reactor's per-frame `on_rendering`. **FFmpeg HEVC decode with a D3D11VA
|
||||||
is the follow-up), **WASAPI** render + mic capture, **SDL3** gamepads (rumble/lightbar/DualSense),
|
zero-copy hardware path** (`gpu.rs` shares one D3D11 device — hardware+`VIDEO_SUPPORT`, WARP
|
||||||
`mdns-sd` discovery, and the full trust surface — all **in-app**: host list (live mDNS + saved +
|
fallback, multithread-protected — between the decoder and presenter; the decoder outputs
|
||||||
manual), settings (resolution/refresh/mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch
|
NV12/P010 `ID3D11Texture2D` array slices with `BIND_SHADER_RESOURCE` and the presenter samples
|
||||||
re-pair. **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor
|
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) +
|
exposes no raw key/pointer events; native Windows VK + absolute mouse (client-rect Contain-fit) +
|
||||||
wheel, Ctrl+Alt+Shift+Q capture toggle. `--headless`/`--discover` keep CLI paths. Builds + clippy
|
wheel, Ctrl+Alt+Shift+Q capture toggle. `--headless`/`--discover` keep CLI paths. Builds + clippy
|
||||||
+ fmt green on `x86_64-pc-windows-msvc` (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
|
dep pinned to commit `b4129fcc`; `windows` pinned to the SAME commit so `IDXGISwapChain1` unifies
|
||||||
with `set_swap_chain`); its `build.rs` downloads the Win App SDK NuGets + needs `CARGO_WORKSPACE_DIR`
|
with `set_swap_chain`); its `build.rs` downloads the Win App SDK NuGets + needs `CARGO_WORKSPACE_DIR`
|
||||||
set (in the VM build env; `/temp`+`/winmd` gitignored). Gotcha: `CARGO_HOME` must be an ASCII path
|
set (in the VM build env; `/temp`+`/winmd` gitignored). Gotcha: `CARGO_HOME` must be an ASCII path
|
||||||
— the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. Next: **on-glass
|
— the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. Next: **on-glass
|
||||||
validation** (the dev VM is headless/Session-0 → the WinUI window needs a display: RDP or the RTX
|
validation** of the D3D11VA decode + HDR present + GUI on the RTX box (the dev VM is
|
||||||
box), D3D11VA hw decode + 10-bit/HDR present, RAWINPUT relative-mouse pointer-lock, and a per-host
|
headless/Session-0/WARP → the WinUI window + hardware decode need a real display+GPU: RDP or the
|
||||||
speed test in the UI.
|
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`), `NsdManager` mDNS discovery, 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
|
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
||||||
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
||||||
at high res).
|
at high res).
|
||||||
3. **punktfunk/1 protocol growth**: concurrent sessions (today: one at a time, extras wait
|
3. **punktfunk/1 protocol growth.** **Done:** unified host (`serve --native` runs GameStream + the
|
||||||
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 /
|
punktfunk/1 QUIC host in one process) with native pairing driven over the mgmt API /
|
||||||
web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
|
web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
|
||||||
**Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
|
**Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
|
||||||
`pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts
|
`pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts
|
||||||
unpaired clients); clients render TOFU only for a `pair=optional` host and force re-pairing on a
|
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
|
fingerprint change. **Done:** concurrent sessions — the accept loop spawns each session
|
||||||
approves a new one).
|
(`--max-concurrent`, default 4, an NVENC bound), each with its own virtual output + encoder, sharing
|
||||||
4. **M2 polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
|
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),
|
-highbitdepth 1` already encodes Main10 from 8-bit input on this box),
|
||||||
reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented
|
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
|
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).
|
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),
|
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
|
gamescope 3.16.22 installed system-wide (no PATH override), gnome-shell installed (Mutter
|
||||||
@@ -176,7 +220,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
|
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
|
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,
|
`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
|
## Layout
|
||||||
|
|
||||||
@@ -187,11 +234,16 @@ crates/punktfunk-host/
|
|||||||
vdisplay/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
|
vdisplay/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
|
||||||
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
||||||
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
|
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
|
||||||
capture.rs · encode.rs · audio.rs · m0.rs · m3.rs · mgmt.rs · native_pairing.rs
|
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs
|
||||||
crates/punktfunk-client-rs/ punktfunk/1 reference client (M3 headless test/measurement tool)
|
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
||||||
crates/punktfunk-client-linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
||||||
|
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
|
||||||
|
clients/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,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends
|
||||||
web/ TanStack web console over the mgmt API (status · devices · pairing)
|
web/ TanStack web console over the mgmt API (status · devices · pairing)
|
||||||
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)
|
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
||||||
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
|
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
|
||||||
include/punktfunk_core.h generated C header
|
include/punktfunk_core.h generated C header
|
||||||
@@ -209,7 +261,7 @@ include/punktfunk_core.h generated C header
|
|||||||
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block, Moonlight-compatible) and GF(2¹⁶)
|
- **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
|
Leopard (≤65535 shards/block) — punktfunk/1 negotiates the latter, removing the ~1 Gbps
|
||||||
ceiling.
|
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;
|
before allocating (`ReassemblerLimits`); AES-GCM per-direction nonce salts + seq-as-AAD;
|
||||||
ABI `struct_size` checks. Regression tests exist — keep them green.
|
ABI `struct_size` checks. Regression tests exist — keep them green.
|
||||||
- **PipeWire consumer discipline**: our capture streams set `node.dont-reconnect` and tear
|
- **PipeWire consumer discipline**: our capture streams set `node.dont-reconnect` and tear
|
||||||
@@ -234,8 +286,8 @@ PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve
|
|||||||
|
|
||||||
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
|
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
|
||||||
# across sessions — bound it with --max-sessions):
|
# across sessions — bound it with --max-sessions):
|
||||||
cargo run -rp punktfunk-host -- m3-host --source virtual --seconds 10 --max-sessions 1
|
cargo run -rp punktfunk-host -- punktfunk1-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-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
|
Pinned crate facts: `ashpd` 0.13 + `pipewire` 0.9 (must match ashpd's) + `ffmpeg-next` 8.x
|
||||||
@@ -244,7 +296,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`,
|
FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKTFUNK_VIDEO_SOURCE=virtual|portal`,
|
||||||
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1`, `PUNKTFUNK_GAMESCOPE_APP=...`,
|
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1`, `PUNKTFUNK_GAMESCOPE_APP=...`,
|
||||||
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
|
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
|
||||||
test), `PUNKTFUNK_FEC_PCT=N`.
|
test), `PUNKTFUNK_FEC_PCT=N`, `PUNKTFUNK_DSCP=1` (opt-in DSCP/SO_PRIORITY media QoS on the data +
|
||||||
|
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -2540,11 +2540,12 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
"opus",
|
"opus",
|
||||||
@@ -2571,20 +2572,6 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "punktfunk-client-rs"
|
|
||||||
version = "0.0.1"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"mdns-sd",
|
|
||||||
"opus",
|
|
||||||
"punktfunk-core",
|
|
||||||
"quinn",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
@@ -2655,6 +2642,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"khronos-egl",
|
"khronos-egl",
|
||||||
"libc",
|
"libc",
|
||||||
|
"libloading",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"nvidia-video-codec-sdk",
|
"nvidia-video-codec-sdk",
|
||||||
"openh264",
|
"openh264",
|
||||||
@@ -2693,6 +2681,20 @@ dependencies = [
|
|||||||
"xkbcommon",
|
"xkbcommon",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "punktfunk-probe"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"mdns-sd",
|
||||||
|
"opus",
|
||||||
|
"punktfunk-core",
|
||||||
|
"quinn",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ resolver = "2"
|
|||||||
members = [
|
members = [
|
||||||
"crates/punktfunk-core",
|
"crates/punktfunk-core",
|
||||||
"crates/punktfunk-host",
|
"crates/punktfunk-host",
|
||||||
"crates/punktfunk-client-rs",
|
"clients/probe",
|
||||||
"crates/punktfunk-client-linux",
|
"clients/linux",
|
||||||
"crates/punktfunk-client-windows",
|
"clients/windows",
|
||||||
"crates/punktfunk-android",
|
"clients/android/native",
|
||||||
"tools/latency-probe",
|
"tools/latency-probe",
|
||||||
"tools/loss-harness",
|
"tools/loss-harness",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,95 +1,142 @@
|
|||||||
# punktfunk
|
# punktfunk
|
||||||
|
|
||||||
*A ground-up low-latency desktop streaming stack, built Linux-first, with a shared Rust
|
**Low-latency desktop and game streaming, Linux-first.** Run the host on a Linux machine — or a
|
||||||
protocol core and native clients per platform.*
|
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
|
📖 **Documentation: [docs.punktfunk.unom.io](https://docs.punktfunk.unom.io)** — start with
|
||||||
host** that speaks the existing Moonlight protocol (every Moonlight/Artemis client works
|
[How It Works](https://docs.punktfunk.unom.io/docs/how-it-works) or the
|
||||||
day one), then break the ~1 Gbps FEC wall with a **GF(2¹⁶) Leopard-RS** transport as a
|
[Quick Start](https://docs.punktfunk.unom.io/docs/quickstart).
|
||||||
negotiated extension. See [`docs/implementation-plan.md`](docs/implementation-plan.md).
|
|
||||||
|
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
|
## Status
|
||||||
|
|
||||||
| Milestone | State |
|
| Component | State |
|
||||||
|-----------|-------|
|
|-----------|-------|
|
||||||
| **M1 — `punktfunk-core` + C ABI** | ✅ done & hardened (FEC, packetization, AES-GCM, session, adversarial-review fixes, `punktfunk_core.h`) |
|
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
||||||
| **M2 — GameStream host → stock Moonlight** | ✅ live end-to-end: pairing, RTSP, audio, per-client virtual output at native res, GPU zero-copy NVENC, gamepads |
|
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
||||||
| **M3 — `punktfunk/1` native protocol** | ✅ validated live: QUIC control + GF(2¹⁶) FEC/AES data plane, SPAKE2 PIN pairing, mid-stream mode renegotiation |
|
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
||||||
| **M4 — client decode + present (Apple)** | 🟡 macOS first light: AnnexB→VideoToolbox HEVC on glass + input/pairing over `punktfunk/1` (`clients/apple`); iOS + presenter next |
|
| **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 |
|
||||||
| **Web console + management API** | ✅ TanStack web console (`web/`) over the OpenAPI mgmt API: host status, paired devices, on-demand native pairing (arm → show PIN) |
|
| **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
|
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
|
||||||
(RTX 5070 Ti & RTX 4090, driver 595): trust-on-first-use pairing that persists, an app
|
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
|
||||||
catalog, RTSP/ENet/audio, and **video at the client's exact resolution and refresh** via a
|
and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin,
|
||||||
per-session virtual output (KWin, gamescope, Mutter, Sway backends), encoded with GPU
|
gamescope, Mutter, and Sway/wlroots backends), encoded with GPU **zero-copy** (dmabuf → CUDA/Vulkan →
|
||||||
**zero-copy** (dmabuf → CUDA/Vulkan → NVENC) at up to 5120×1440@240. The native
|
NVENC) up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a
|
||||||
**`punktfunk/1`** protocol adds a QUIC control plane and a GF(2¹⁶) Leopard-FEC + AES-GCM data
|
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→reassembled at 720p120), with
|
||||||
plane (p50 ~0.8 ms capture→reassembled at 720p120). Its trust model is **SPAKE2 PIN pairing by
|
mid-stream mode renegotiation and a wall-clock skew handshake so latency stays valid across machines.
|
||||||
default** — a new host requires the PIN ceremony; trust-on-first-use is an explicit host opt-in
|
Both protocols run from **one process** (`punktfunk-host serve --native`) and are managed through a
|
||||||
(`m3-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs. Both
|
REST API and web console. Builds against FFmpeg 7 or 8.
|
||||||
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).
|
|
||||||
|
|
||||||
## 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.
|
## Install the host
|
||||||
Per-distro setup (add the repo, first-run, web console) lives in the linked READMEs.
|
|
||||||
|
|
||||||
| Distro | One-command happy path | Details |
|
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
|
||||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [`packaging/debian/README.md`](packaging/debian/README.md) |
|
Windows host (NVIDIA-only) also ships as a signed installer.
|
||||||
| **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) |
|
|
||||||
|
|
||||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status);
|
| Platform | Install | Guide |
|
||||||
`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.
|
| **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 --native` inside your desktop session, 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
|
||||||
|
|
||||||
```
|
| Streaming to… | Use |
|
||||||
crates/
|
|---|---|
|
||||||
punktfunk-core/ protocol · FEC · pacing · crypto · quic — the C ABI (lib + cdylib + staticlib)
|
| Mac, iPhone, iPad, Apple TV | The **Apple app** (`clients/apple`) — also on TestFlight |
|
||||||
punktfunk-host/ Linux host: vdisplay · capture · encode · inject · gamestream · m3 · mgmt · native_pairing
|
| Linux desktop / laptop, Steam Deck | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
|
||||||
punktfunk-client-rs/ punktfunk/1 reference client (M3 headless; M4 adds decode+present)
|
| Android phone or TV | The **Android app** (`clients/android`) |
|
||||||
clients/{apple,android}/ native client scaffolds (import punktfunk_core.h); apple = macOS first light
|
| Windows | Native **`punktfunk-client`** (signed MSIX) or **Moonlight** |
|
||||||
web/ TanStack web console (host status · paired devices · pairing) over the mgmt API
|
| Anything else (browser, old phone, smart TV) | **Moonlight** over GameStream |
|
||||||
packaging/ Fedora/Bazzite RPM · bootc image · COPR (see packaging/bazzite/README.md)
|
|
||||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
Each client discovers hosts on the network automatically and does a one-time
|
||||||
tools/{latency-probe,loss-harness}/ measurement (plan §10)
|
[PIN pairing](https://docs.punktfunk.unom.io/docs/pairing). Per-device install steps:
|
||||||
docs/{implementation-plan,roadmap,windows-host,dualsense-haptics}.md
|
**[/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
|
||||||
```
|
|
||||||
|
|
||||||
## Build & test (from source)
|
## Build & test (from source)
|
||||||
|
|
||||||
For development, or as an install fallback where no package is available:
|
For development, or as an install fallback where no package is available:
|
||||||
|
|
||||||
```sh
|
```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 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)
|
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
|
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
|
## Design invariants
|
||||||
|
|
||||||
- **One core, linked everywhere.** Protocol/FEC/crypto/pacing live in `punktfunk-core` exactly
|
- **One core, linked everywhere.** Protocol, FEC, and crypto live in `punktfunk-core` exactly once,
|
||||||
once, exposed over a stable, versioned C ABI (`punktfunk_abi_version()`, `PunktfunkConfig`
|
exposed over a stable, versioned C ABI (`punktfunk_abi_version()`, `PunktfunkConfig` carries its own
|
||||||
carries its own `struct_size`).
|
`struct_size`). Every native client links the same core.
|
||||||
- **No async on the hot path.** The per-frame pipeline uses native threads only;
|
- **No async on the hot path.** The per-frame pipeline uses native threads only; `tokio`/`quinn` are
|
||||||
`tokio`/`quinn` are gated behind the off-by-default `quic` feature (control plane only).
|
gated behind the off-by-default `quic` feature (control plane only).
|
||||||
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block) for Moonlight compat;
|
- **Native client resolution, no scaling.** Each session gets a virtual output at exactly the
|
||||||
GF(2¹⁶) (≤65535 shards/block, SIMD, O(n log n)) to push past ~1 Gbps.
|
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
|
## 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.
|
# Native libraries produced by cargo-ndk — regenerated by the :kit cargoNdk* tasks.
|
||||||
**/src/main/jniLibs/
|
**/src/main/jniLibs/
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
*.jks
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ machine, trust logic) instead of re-porting it into Kotlin.
|
|||||||
|
|
||||||
| Side | Owns |
|
| 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 |
|
| **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 |
|
||||||
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, `NsdManager` discovery, Keystore identity, permissions |
|
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, `NsdManager` discovery, Keystore identity, permissions |
|
||||||
|
|
||||||
The single seam is `io.unom.punktfunk.kit.NativeBridge` ⇄ `Java_io_unom_punktfunk_kit_NativeBridge_*`.
|
The single seam is `io.unom.punktfunk.kit.NativeBridge` ⇄ `Java_io_unom_punktfunk_kit_NativeBridge_*`.
|
||||||
@@ -19,21 +19,26 @@ The single seam is `io.unom.punktfunk.kit.NativeBridge` ⇄ `Java_io_unom_punktf
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
crates/punktfunk-android/ Rust cdylib (workspace member)
|
clients/android/native/ Rust cdylib (workspace member) — links punktfunk-core directly
|
||||||
src/lib.rs JNI_OnLoad + abiVersion/coreVersion (native-link proof)
|
src/lib.rs JNI seam (connect/pair, input, plane getters, abi/core version)
|
||||||
src/session.rs session handle lifecycle (connect/close); plane pumps = TODO
|
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)
|
clients/android/ Gradle project (this dir)
|
||||||
settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew
|
settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew
|
||||||
app/ :app — Compose application (MainActivity)
|
app/ :app — Compose UI: Connect / Settings / Stream screens (phone + TV)
|
||||||
kit/ :kit — Android library: NativeBridge + the cargo-ndk build
|
kit/ :kit — NativeBridge · discovery (NsdManager) · Gamepad · Keymap ·
|
||||||
build.gradle.kts cargoNdk{Debug,Release} → src/main/jniLibs/<abi>/*.so
|
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`
|
- Android SDK + **NDK r30** (`30.0.14904198`), `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)
|
**`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`
|
- 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 ·
|
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
|
**Android Studio:** open `clients/android` — it uses its bundled JBR 21 automatically. The
|
||||||
`cargoNdk*` task builds the `.so` as part of the normal build.
|
`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
|
```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
|
cd clients/android
|
||||||
./gradlew :app:assembleDebug # cargo-ndk cross-compiles libpunktfunk_android.so first
|
./gradlew :app:assembleDebug # cargo-ndk cross-compiles libpunktfunk_android.so first
|
||||||
./gradlew :app:installDebug # onto a running emulator/device
|
./gradlew :app:installDebug # onto a running emulator/device
|
||||||
@@ -55,15 +61,22 @@ cd clients/android
|
|||||||
# Emulators (created during env setup): emulator -avd pf_phone | emulator -avd pf_tv
|
# Emulators (created during env setup): emulator -avd pf_phone | emulator -avd pf_tv
|
||||||
```
|
```
|
||||||
|
|
||||||
The debug APK lands in `app/build/outputs/apk/debug/`. The scaffold screen calls
|
The debug APK lands in `app/build/outputs/apk/debug/`. Launch it, pick a host from the list, pair,
|
||||||
`NativeBridge.abiVersion()` across JNI — a live ABI version proves the whole native stack is wired.
|
and stream.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- **Scaffold (done):** Gradle modules, cargo-ndk wiring, JNI native-link proof, phone+TV-installable
|
A working native client (phone + Android TV), at parity with the Linux and Apple apps for the core
|
||||||
manifest. `crates/punktfunk-core` `rcgen` switched to the `ring` backend so the client `.so` is
|
streaming experience:
|
||||||
aws-lc-free.
|
|
||||||
- **Next (M4 Android stage 1):** video decode (`AMediaCodec` async → `SurfaceView`), audio
|
- **Video** — `AMediaCodec` hardware HEVC decode → `SurfaceView`, including **HDR10** (Main10 /
|
||||||
(Opus + Oboe + jitter ring), input capture → `send_input`, pairing/identity (Keystore-wrapped),
|
BT.2020 PQ), with low-latency decode tuning and a live stats HUD.
|
||||||
mDNS discovery, the phone/TV Compose UI. The Rust-side homes are stubbed in
|
- **Audio** — Opus + Oboe playback with a jitter ring, plus mic uplink to the host.
|
||||||
`crates/punktfunk-android/src/session.rs` with port pointers to `crates/punktfunk-client-linux`.
|
- **Input** — game controllers (buttons + axes) with rumble and HID feedback; D-pad /
|
||||||
|
game-controller focus navigation for the couch (TV + phone).
|
||||||
|
- **Discovery & trust** — `NsdManager` mDNS host list, SPAKE2 PIN pairing and TOFU, with a
|
||||||
|
Keystore-wrapped client identity and a known-host store.
|
||||||
|
- **UI** — Compose host list / settings / stream screens, Material You theming.
|
||||||
|
- **Shipping** — built for `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing).
|
||||||
|
|
||||||
|
`crates/punktfunk-core` uses the `ring` `rcgen` backend so the client `.so` is aws-lc-free.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
// AGP 9 built-in Kotlin: NO org.jetbrains.kotlin.android. The Compose compiler plugin is
|
// AGP 9 built-in Kotlin: NO org.jetbrains.kotlin.android. The Compose compiler plugin is
|
||||||
@@ -12,17 +14,47 @@ android {
|
|||||||
compileSdk = 37 // Android 17 — required by androidx.core 1.19.0; targetSdk stays 36 for now.
|
compileSdk = 37 // Android 17 — required by androidx.core 1.19.0; targetSdk stays 36 for now.
|
||||||
|
|
||||||
defaultConfig {
|
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"
|
applicationId = "io.unom.punktfunk"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
||||||
versionName = "0.0.1"
|
versionCode = vCode?.toInt() ?: 1
|
||||||
|
versionName = "0.0.2" // bumped for first Play Store release
|
||||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
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 {
|
buildTypes {
|
||||||
release {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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,507 @@
|
|||||||
|
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.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 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()) {
|
||||||
|
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() && discovered.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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discovered.isNotEmpty()) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
SectionLabel("Discovered on the network")
|
||||||
|
}
|
||||||
|
items(discovered, 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: when we're scanning but nothing's turned up yet, show it's
|
||||||
|
// working rather than looking idle/empty.
|
||||||
|
if (nearbyGranted && 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 = 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. */
|
||||||
|
fun hasNearbyPermission(context: Context): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
@@ -1,101 +1,19 @@
|
|||||||
package io.unom.punktfunk
|
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.os.Bundle
|
||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.SurfaceHolder
|
|
||||||
import android.view.SurfaceView
|
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.SystemBarStyle
|
import androidx.activity.SystemBarStyle
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
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.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.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.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.Gamepad
|
||||||
import io.unom.punktfunk.kit.GamepadFeedback
|
|
||||||
import io.unom.punktfunk.kit.Keymap
|
import io.unom.punktfunk.kit.Keymap
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
|
||||||
import io.unom.punktfunk.kit.discovery.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() {
|
class MainActivity : ComponentActivity() {
|
||||||
/**
|
/**
|
||||||
@@ -118,7 +36,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||||
)
|
)
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
PunktfunkTheme {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) { App() }
|
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)
|
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 {
|
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)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ data class Settings(
|
|||||||
val compositor: Int = 0,
|
val compositor: Int = 0,
|
||||||
val gamepad: Int = 0,
|
val gamepad: Int = 0,
|
||||||
val micEnabled: Boolean = false,
|
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. */
|
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
||||||
@@ -31,6 +33,7 @@ class SettingsStore(context: Context) {
|
|||||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||||
|
statsHudEnabled = prefs.getBoolean(K_HUD, true),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: Settings) {
|
fun save(s: Settings) {
|
||||||
@@ -42,6 +45,7 @@ class SettingsStore(context: Context) {
|
|||||||
.putInt(K_COMPOSITOR, s.compositor)
|
.putInt(K_COMPOSITOR, s.compositor)
|
||||||
.putInt(K_GAMEPAD, s.gamepad)
|
.putInt(K_GAMEPAD, s.gamepad)
|
||||||
.putBoolean(K_MIC, s.micEnabled)
|
.putBoolean(K_MIC, s.micEnabled)
|
||||||
|
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +57,7 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_COMPOSITOR = "compositor"
|
const val K_COMPOSITOR = "compositor"
|
||||||
const val K_GAMEPAD = "gamepad"
|
const val K_GAMEPAD = "gamepad"
|
||||||
const val K_MIC = "mic_enabled"
|
const val K_MIC = "mic_enabled"
|
||||||
|
const val K_HUD = "stats_hud_enabled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,21 +5,25 @@ import android.content.pm.PackageManager
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
|
||||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -29,13 +33,15 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream settings. Edits are persisted immediately via [onChange]; [onBack] returns to the connect
|
* Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted
|
||||||
* screen. Resolution/refresh "Native" resolve from the device display at connect time.
|
* immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native"
|
||||||
|
* resolve from the device display at connect time.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
|
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
|
||||||
@@ -48,13 +54,23 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
|
|
||||||
BackHandler(onBack = 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(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
|
||||||
val (nw, nh, nhz) = nativeDisplayMode(context)
|
val (nw, nh, nhz) = nativeDisplayMode(context)
|
||||||
|
|
||||||
|
SettingsGroup("Display") {
|
||||||
SettingDropdown(
|
SettingDropdown(
|
||||||
label = "Resolution",
|
label = "Resolution",
|
||||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
|
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
|
||||||
@@ -65,7 +81,7 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
|
|
||||||
SettingDropdown(
|
SettingDropdown(
|
||||||
label = "Refresh rate",
|
label = "Refresh rate",
|
||||||
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl (${nhz} Hz)" else lbl) },
|
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
|
||||||
selected = s.hz,
|
selected = s.hz,
|
||||||
) { hz -> update(s.copy(hz = hz)) }
|
) { hz -> update(s.copy(hz = hz)) }
|
||||||
|
|
||||||
@@ -74,9 +90,11 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
options = BITRATE_OPTIONS,
|
options = BITRATE_OPTIONS,
|
||||||
selected = s.bitrateKbps,
|
selected = s.bitrateKbps,
|
||||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsGroup("Host") {
|
||||||
SettingDropdown(
|
SettingDropdown(
|
||||||
label = "Compositor (virtual-display host backend)",
|
label = "Compositor",
|
||||||
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||||
selected = s.compositor,
|
selected = s.compositor,
|
||||||
) { c -> update(s.copy(compositor = c)) }
|
) { c -> update(s.copy(compositor = c)) }
|
||||||
@@ -86,20 +104,12 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||||
selected = s.gamepad,
|
selected = s.gamepad,
|
||||||
) { g -> update(s.copy(gamepad = g)) }
|
) { g -> update(s.copy(gamepad = g)) }
|
||||||
|
|
||||||
// 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(
|
|
||||||
|
SettingsGroup("Audio") {
|
||||||
|
ToggleRow(
|
||||||
|
title = "Microphone",
|
||||||
|
subtitle = "Send your mic to the host's virtual microphone",
|
||||||
checked = s.micEnabled,
|
checked = s.micEnabled,
|
||||||
onCheckedChange = { on ->
|
onCheckedChange = { on ->
|
||||||
when {
|
when {
|
||||||
@@ -111,11 +121,65 @@ 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 labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */
|
/** A titled group of settings rendered inside an outlined card. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A labelled value that opens a menu on click. Uses a clickable [Surface] + [DropdownMenu] rather
|
||||||
|
* than `ExposedDropdownMenuBox` — that component's read-only text field traps D-pad / controller
|
||||||
|
* focus (directional keys never leave it), so you can't navigate past it on a TV. Calls [onSelect]
|
||||||
|
* on a pick. A primary-colour border marks D-pad focus.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun <T> SettingDropdown(
|
private fun <T> SettingDropdown(
|
||||||
label: String,
|
label: String,
|
||||||
@@ -124,20 +188,35 @@ private fun <T> SettingDropdown(
|
|||||||
onSelect: (T) -> Unit,
|
onSelect: (T) -> Unit,
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
val selectedLabel = options.firstOrNull { it.first == selected }?.second
|
val selectedLabel = options.firstOrNull { it.first == selected }?.second
|
||||||
?: options.firstOrNull()?.second.orEmpty()
|
?: options.firstOrNull()?.second.orEmpty()
|
||||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
OutlinedTextField(
|
Surface(
|
||||||
value = selectedLabel,
|
onClick = { expanded = true },
|
||||||
onValueChange = {},
|
shape = MaterialTheme.shapes.small,
|
||||||
readOnly = true,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
label = { Text(label) },
|
border = if (focused) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
.fillMaxWidth()
|
||||||
.fillMaxWidth(),
|
.onFocusChanged { focused = it.isFocused },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
Text(selectedLabel, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
options.forEach { (value, lbl) ->
|
options.forEach { (value, lbl) ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(lbl) },
|
text = { Text(lbl) },
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
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.input.pointer.positionChange
|
||||||
|
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
|
||||||
|
|
||||||
|
@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 virtual-trackpad overlay: 1-finger drag → relative mouse move; tap → left click;
|
||||||
|
// 2-finger drag → scroll; 3-finger tap → toggle the stats HUD. (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)
|
||||||
|
} else if (!moved && maxFingers >= 3) {
|
||||||
|
showStats = !showStats // quick in-stream HUD toggle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live stats overlay — mirrors the Apple client's HUD. Reads the 10-double layout from
|
||||||
|
* [NativeBridge.nativeVideoStats]:
|
||||||
|
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped]`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||||
|
if (s.size < 10) return
|
||||||
|
val w = s[6].toInt()
|
||||||
|
val h = s[7].toInt()
|
||||||
|
val hz = s[8].toInt()
|
||||||
|
val latValid = s[4] != 0.0
|
||||||
|
val skew = s[5] != 0.0
|
||||||
|
val dropped = s[9].toLong()
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
|
||||||
|
color = Color.White,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
if (latValid) {
|
||||||
|
val tag = if (skew) "" else " (same-host)"
|
||||||
|
Text(
|
||||||
|
"capture→client ${"%.1f".format(s[2])}/${"%.1f".format(s[3])} ms p50/p95$tag",
|
||||||
|
color = Color.White,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (dropped > 0) {
|
||||||
|
Text(
|
||||||
|
"dropped $dropped",
|
||||||
|
color = Color(0xFFFFB0B0),
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,185 @@
|
|||||||
|
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 Forget. Tapping the card connects.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun HostCard(
|
||||||
|
name: String,
|
||||||
|
address: String,
|
||||||
|
status: HostStatus,
|
||||||
|
enabled: Boolean,
|
||||||
|
onConnect: () -> Unit,
|
||||||
|
onForget: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
// 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) {
|
||||||
|
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 }) {
|
||||||
|
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
|
// 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.
|
// 2026.05.01 · compileSdk 37 · targetSdk 36 · minSdk 31.
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "9.2.0" apply false
|
id("com.android.application") version "9.2.1" apply false
|
||||||
id("com.android.library") version "9.2.0" apply false
|
id("com.android.library") version "9.2.1" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" 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")
|
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 {
|
android {
|
||||||
namespace = "io.unom.punktfunk.kit"
|
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).
|
// 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
|
// 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`.
|
// /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) =
|
fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||||
tasks.register<Exec>(taskName) {
|
tasks.register<Exec>(taskName) {
|
||||||
group = "rust"
|
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
|
workingDir = repoRoot
|
||||||
val sdk = androidSdkDir()
|
val sdk = androidSdkDir()
|
||||||
// A GUI Android Studio launch does not source the login shell, so make cargo, the NDK, and
|
// 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.
|
// (pure C) so the android .so links it instead of looking for the host's libopus.so.
|
||||||
environment("LIBOPUS_STATIC", "1")
|
environment("LIBOPUS_STATIC", "1")
|
||||||
environment("LIBOPUS_NO_PKG", "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(
|
val cmd = mutableListOf(
|
||||||
"cargo", "ndk",
|
"$cargoBin/cargo", "ndk",
|
||||||
"-t", "arm64-v8a", "-t", "x86_64",
|
"-t", "arm64-v8a", "-t", "x86_64",
|
||||||
// Link against the minSdk-31 sysroot so libaaudio (API 26+) is found.
|
// Link against the minSdk-31 sysroot so libaaudio (API 26+) is found.
|
||||||
"--platform", "31",
|
"--platform", "31",
|
||||||
"-o", file("src/main/jniLibs").absolutePath,
|
"-o", file("src/main/jniLibs").absolutePath,
|
||||||
"build", "-p", "punktfunk-android",
|
"build", "-p", "punktfunk-client-android",
|
||||||
)
|
)
|
||||||
if (release) cmd += "--release"
|
if (release) cmd += "--release"
|
||||||
commandLine(cmd)
|
commandLine(cmd)
|
||||||
|
|||||||
@@ -78,9 +78,11 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
/** Idempotent. Stops + joins the poll threads (must complete before the session handle is freed). */
|
/** Idempotent. Stops + joins the poll threads (must complete before the session handle is freed). */
|
||||||
fun stop() {
|
fun stop() {
|
||||||
running = false
|
running = false
|
||||||
|
rumbleThread?.interrupt()
|
||||||
|
hidoutThread?.interrupt()
|
||||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||||
runCatching { rumbleThread?.join(500) }
|
runCatching { rumbleThread?.join(200) }
|
||||||
runCatching { hidoutThread?.join(500) }
|
runCatching { hidoutThread?.join(200) }
|
||||||
rumbleThread = null
|
rumbleThread = null
|
||||||
hidoutThread = null
|
hidoutThread = null
|
||||||
runCatching { lightsSession?.close() }
|
runCatching { lightsSession?.close() }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package io.unom.punktfunk.kit
|
|||||||
/**
|
/**
|
||||||
* The single JNI seam to `libpunktfunk_android.so` (the Rust-heavy client core).
|
* 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.
|
* all protocol logic lives in Rust (`punktfunk-core` + the connector); Kotlin only marshals.
|
||||||
*/
|
*/
|
||||||
object NativeBridge {
|
object NativeBridge {
|
||||||
@@ -75,6 +75,14 @@ object NativeBridge {
|
|||||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||||
external fun nativeStopVideo(handle: Long)
|
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
|
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust. No-op
|
||||||
* if already started. Best-effort — a failure leaves video streaming.
|
* if already started. Best-effort — a failure leaves video streaming.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "punktfunk-android"
|
name = "punktfunk-client-android"
|
||||||
description = "punktfunk Android client — JNI bridge ('nativecore') over punktfunk-core (Rust-heavy client model)"
|
description = "punktfunk Android client — JNI bridge ('nativecore') over punktfunk-core (Rust-heavy client model)"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
@@ -16,7 +16,7 @@ crate-type = ["cdylib"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
# The whole protocol/transport/FEC/crypto + the embeddable NativeClient connector. `quic` pulls
|
# 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).
|
# 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"
|
jni = "0.21"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
|
||||||
@@ -28,7 +28,9 @@ android_logger = "0.14"
|
|||||||
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
|
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
|
||||||
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
||||||
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
||||||
ndk = { version = "0.9", features = ["media", "audio"] }
|
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
|
# 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 —
|
# 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.
|
# 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,260 @@
|
|||||||
|
//! 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);
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ mod feedback;
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod mic;
|
mod mic;
|
||||||
mod session;
|
mod session;
|
||||||
|
mod stats;
|
||||||
|
|
||||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
||||||
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
|
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
|
||||||
@@ -11,10 +11,10 @@
|
|||||||
//! Kotlin side), `nativeConnect` with identity + pin (TOFU / pinned), and `nativePair` (SPAKE2 PIN).
|
//! 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
|
//! 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::objects::{JObject, JString};
|
||||||
use jni::sys::{jboolean, jint, jlong};
|
use jni::sys::{jboolean, jdoubleArray, jint, jlong, jsize};
|
||||||
use jni::JNIEnv;
|
use jni::JNIEnv;
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||||
@@ -40,6 +40,8 @@ pub(crate) struct SessionHandle {
|
|||||||
struct VideoThread {
|
struct VideoThread {
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
join: Option<JoinHandle<()>>,
|
join: Option<JoinHandle<()>>,
|
||||||
|
/// Live decode stats, written by the decode thread and drained ~1 Hz by `nativeVideoStats`.
|
||||||
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionHandle {
|
impl SessionHandle {
|
||||||
@@ -182,6 +184,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
CompositorPref::from_u8(compositor_pref.clamp(0, u8::MAX as jint) as u8),
|
CompositorPref::from_u8(compositor_pref.clamp(0, u8::MAX as jint) as u8),
|
||||||
GamepadPref::from_u8(gamepad_pref.clamp(0, u8::MAX as jint) as u8),
|
GamepadPref::from_u8(gamepad_pref.clamp(0, u8::MAX as jint) as u8),
|
||||||
bitrate_kbps.max(0) as u32, // 0 = host default
|
bitrate_kbps.max(0) as u32, // 0 = host default
|
||||||
|
// Advertise 10-bit + HDR: the host (e.g. Windows) only upgrades to a Main10 / BT.2020 PQ
|
||||||
|
// encode when the client sets these. AMediaCodec decodes Main10 from the SPS and the decode
|
||||||
|
// loop signals the Surface's HDR dataspace from the reported colour (see crate::decode).
|
||||||
|
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR,
|
||||||
None, // launch: default app
|
None, // launch: default app
|
||||||
pin, // Some → Crypto on host-fp mismatch
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||||
@@ -330,13 +336,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
let stats = Arc::new(crate::stats::VideoStats::new());
|
||||||
let client = h.client.clone();
|
let client = h.client.clone();
|
||||||
let sd = shutdown.clone();
|
let sd = shutdown.clone();
|
||||||
|
let st = stats.clone();
|
||||||
let join = std::thread::Builder::new()
|
let join = std::thread::Builder::new()
|
||||||
.name("pf-decode".into())
|
.name("pf-decode".into())
|
||||||
.spawn(move || crate::decode::run(client, window, sd))
|
.spawn(move || crate::decode::run(client, window, sd, st))
|
||||||
.ok();
|
.ok();
|
||||||
*guard = Some(VideoThread { shutdown, join });
|
*guard = Some(VideoThread {
|
||||||
|
shutdown,
|
||||||
|
join,
|
||||||
|
stats,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
|
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
|
||||||
@@ -354,6 +366,50 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
|
||||||
|
/// Returns 10 doubles
|
||||||
|
/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]`
|
||||||
|
/// (the two flags are 1.0/0.0), or `null` when no decode thread is running. Poll ~1 Hz from the UI;
|
||||||
|
/// each call resets the measurement window. Not android-gated — pure `jni` + connector reads, so it
|
||||||
|
/// links on the host build too (Kotlin only ever calls it on device).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||||
|
env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
) -> jdoubleArray {
|
||||||
|
if handle == 0 {
|
||||||
|
return std::ptr::null_mut();
|
||||||
|
}
|
||||||
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
let snap = match h.video.lock().unwrap().as_ref() {
|
||||||
|
Some(vt) => vt.stats.drain(),
|
||||||
|
None => return std::ptr::null_mut(), // not streaming → no stats
|
||||||
|
};
|
||||||
|
let mode = h.client.mode();
|
||||||
|
let buf: [f64; 10] = [
|
||||||
|
snap.fps,
|
||||||
|
snap.mbps,
|
||||||
|
snap.lat_p50_ms,
|
||||||
|
snap.lat_p95_ms,
|
||||||
|
if snap.lat_valid { 1.0 } else { 0.0 },
|
||||||
|
if snap.skew_corrected { 1.0 } else { 0.0 },
|
||||||
|
mode.width as f64,
|
||||||
|
mode.height as f64,
|
||||||
|
mode.refresh_hz as f64,
|
||||||
|
h.client.frames_dropped() as f64,
|
||||||
|
];
|
||||||
|
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => return std::ptr::null_mut(),
|
||||||
|
};
|
||||||
|
if env.set_double_array_region(&arr, 0, &buf).is_err() {
|
||||||
|
return std::ptr::null_mut();
|
||||||
|
}
|
||||||
|
arr.into_raw()
|
||||||
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
|
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
|
||||||
/// started or on a `0` handle. Best-effort: a failure leaves video streaming.
|
/// started or on a `0` handle. Best-effort: a failure leaves video streaming.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
@@ -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;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
@@ -463,6 +464,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
@@ -500,6 +502,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
@@ -529,6 +532,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
|
|||||||
@@ -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
|
linked as `PunktfunkCore.xcframework`); this package is the Swift shell: decode
|
||||||
(VideoToolbox), present (SwiftUI), input capture.
|
(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 →
|
`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
|
`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
|
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
|
**DualSense feedback** (`nextHidOutput()` — lightbar, player LEDs, adaptive-trigger
|
||||||
effects), input incl. gamepads + DualSense touchpad/motion (`sendTouchpad`/`sendMotion`),
|
effects), input incl. gamepads + DualSense touchpad/motion (`sendTouchpad`/`sendMotion`),
|
||||||
and **cert pinning + TOFU** (`pinSHA256:`/`hostFingerprint`) — see
|
and **cert pinning + TOFU** (`pinSHA256:`/`hostFingerprint`) — see
|
||||||
`m3.rs::tests::c_abi_connection_roundtrip` (three sequential sessions: TOFU, pinned
|
`punktfunk1.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:
|
reconnect, wrong-pin rejection). The host (`punktfunk-host punktfunk1-host`) is a persistent listener:
|
||||||
reconnect at will during development.
|
reconnect at will during development.
|
||||||
|
|
||||||
What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3):
|
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
|
# (synthetic source — runs on macOS), streams
|
||||||
# byte-verified frames into the Swift client
|
# 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:
|
# persistent listener, reconnect at will:
|
||||||
# PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
|
# 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_HOST=<box-ip> swift test --filter RemoteFirstLightTests # headless
|
||||||
# (+ PUNKTFUNK_REMOTE_PORT / PUNKTFUNK_REMOTE_COMPOSITOR=gamescope|kwin|… /
|
# (+ PUNKTFUNK_REMOTE_PORT / PUNKTFUNK_REMOTE_COMPOSITOR=gamescope|kwin|… /
|
||||||
# PUNKTFUNK_REMOTE_PIN=<arming-pin> for the remote pairing test)
|
# PUNKTFUNK_REMOTE_PIN=<arming-pin> for the remote pairing test)
|
||||||
|
|||||||
@@ -81,24 +81,50 @@ struct AddHostSheet: View {
|
|||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
#endif
|
#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 {
|
HStack {
|
||||||
Button("Cancel", role: .cancel) { dismiss() }
|
Button("Cancel", role: .cancel) { dismiss() }
|
||||||
#if !os(tvOS)
|
|
||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
#endif
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Add Host") { add() }
|
Button("Add Host") { add() }
|
||||||
.buttonStyle(.borderedProminent)
|
.glassProminentButtonStyle()
|
||||||
#if !os(tvOS)
|
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
#endif
|
|
||||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
|
||||||
.controlSize(.large)
|
|
||||||
#endif
|
|
||||||
.padding(16)
|
.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)
|
#if os(macOS)
|
||||||
.frame(width: 380)
|
.frame(width: 380)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|||||||
@@ -205,7 +205,13 @@ struct ContentView: View {
|
|||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.headline.weight(.semibold))
|
.font(.headline.weight(.semibold))
|
||||||
.frame(width: 36, height: 36)
|
.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)
|
.buttonStyle(.plain)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// session. The home appears/disappears as the stream swaps in and out.
|
||||||
.onAppear { discovery.start() }
|
.onAppear { discovery.start() }
|
||||||
@@ -217,7 +217,7 @@ struct HomeView: View {
|
|||||||
Text("Add your punktfunk host with the + button.")
|
Text("Add your punktfunk host with the + button.")
|
||||||
} actions: {
|
} actions: {
|
||||||
Button("Add Host") { showAddHost = true }
|
Button("Add Host") { showAddHost = true }
|
||||||
.buttonStyle(.borderedProminent)
|
.glassProminentButtonStyle()
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ struct HostCardView: View {
|
|||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
||||||
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
// 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))
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||||
.overlay {
|
.overlay {
|
||||||
if isMostRecent {
|
if isMostRecent {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ struct LibraryView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(maxWidth: 420)
|
.frame(maxWidth: 420)
|
||||||
Button("Retry") { Task { await load() } }
|
Button("Retry") { Task { await load() } }
|
||||||
.buttonStyle(.borderedProminent)
|
.glassProminentButtonStyle()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ struct PairSheet: View {
|
|||||||
.padding(.trailing, 8)
|
.padding(.trailing, 8)
|
||||||
}
|
}
|
||||||
Button("Pair & Connect") { runCeremony() }
|
Button("Pair & Connect") { runCeremony() }
|
||||||
.buttonStyle(.borderedProminent)
|
.glassProminentButtonStyle()
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
#endif
|
#endif
|
||||||
@@ -165,6 +165,15 @@ struct PairSheet: View {
|
|||||||
.frame(width: 400)
|
.frame(width: 400)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
#endif
|
#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)
|
.interactiveDismissDisabled(busy)
|
||||||
.onDisappear { token.cancelled = true } // any other dismissal path
|
.onDisappear { token.cancelled = true } // any other dismissal path
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ struct PunktfunkClientApp: App {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup("Punktfunkempfänger") {
|
WindowGroup("Punktfunk") {
|
||||||
ContentView()
|
ContentView()
|
||||||
}
|
}
|
||||||
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
||||||
|
|||||||
@@ -91,14 +91,14 @@ struct SpeedTestSheet: View {
|
|||||||
bitrateKbps = rec
|
bitrateKbps = rec
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.glassProminentButtonStyle()
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
if case .failed = phase {
|
if case .failed = phase {
|
||||||
Button("Retry") { run() }
|
Button("Retry") { run() }
|
||||||
.buttonStyle(.borderedProminent)
|
.glassProminentButtonStyle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +112,12 @@ struct SpeedTestSheet: View {
|
|||||||
.frame(width: 420)
|
.frame(width: 420)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
#endif
|
#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() }
|
.onAppear { run() }
|
||||||
.onDisappear { token.cancelled = true }
|
.onDisappear { token.cancelled = true }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ struct StreamHUDView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.padding(10)
|
.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)
|
.padding(10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ struct TrustCardView: View {
|
|||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
#endif
|
#endif
|
||||||
Button("Trust & Connect", action: onTrust)
|
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)
|
.buttonStyle(.borderedProminent)
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
@@ -58,7 +62,9 @@ struct TrustCardView: View {
|
|||||||
}
|
}
|
||||||
.padding(28)
|
.padding(28)
|
||||||
.frame(maxWidth: 440)
|
.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.
|
/// 64 hex chars → four groups per line, two lines — easy to eyeball against the log.
|
||||||
|
|||||||
@@ -58,7 +58,13 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
private var controller: GCController?
|
private var controller: GCController?
|
||||||
private var low: Motor?
|
private var low: Motor?
|
||||||
private var high: 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
|
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?) {
|
func retarget(_ c: GCController?) {
|
||||||
queue.async {
|
queue.async {
|
||||||
@@ -70,8 +76,14 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
|
|
||||||
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
||||||
queue.async {
|
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)")
|
||||||
|
}
|
||||||
guard !self.broken else { 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()
|
self.setup()
|
||||||
}
|
}
|
||||||
if self.high != nil {
|
if self.high != nil {
|
||||||
@@ -92,7 +104,15 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
||||||
/// engine otherwise, driven by whichever amplitude is stronger.
|
/// engine otherwise, driven by whichever amplitude is stronger.
|
||||||
private func setup() {
|
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
|
let localities = haptics.supportedLocalities
|
||||||
if localities.contains(.leftHandle), localities.contains(.rightHandle) {
|
if localities.contains(.leftHandle), localities.contains(.rightHandle) {
|
||||||
low = makeMotor(haptics, .leftHandle)
|
low = makeMotor(haptics, .leftHandle)
|
||||||
@@ -100,13 +120,28 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
} else {
|
} else {
|
||||||
low = makeMotor(haptics, .default)
|
low = makeMotor(haptics, .default)
|
||||||
}
|
}
|
||||||
if low == nil && high == nil {
|
if low == nil, high == nil {
|
||||||
broken = true // no usable engine (e.g. Siri Remote) — stay silent
|
// 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? {
|
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
|
||||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
||||||
|
// 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 {
|
do {
|
||||||
try engine.start()
|
try engine.start()
|
||||||
let event = CHHapticEvent(
|
let event = CHHapticEvent(
|
||||||
@@ -141,14 +176,20 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
motor = m
|
motor = m
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("haptic update failed — rumble disabled: \(error, privacy: .public)")
|
// A transient failure (the engine stopped/reset between its handler firing and now).
|
||||||
|
// Tear down so the next nonzero amplitude rebuilds — do NOT latch rumble off for the
|
||||||
|
// session (that was the old "spotty" behaviour).
|
||||||
|
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
|
||||||
teardown()
|
teardown()
|
||||||
broken = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func teardown() {
|
private func teardown() {
|
||||||
for m in [low, high].compactMap({ $0 }) {
|
for m in [low, high].compactMap({ $0 }) {
|
||||||
|
// 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)
|
try? m.player.stop(atTime: CHHapticTimeImmediate)
|
||||||
m.engine.stop()
|
m.engine.stop()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -362,6 +362,21 @@ public final class PunktfunkConnection {
|
|||||||
_ = punktfunk_connection_request_keyframe(h)
|
_ = 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).
|
/// The currently active session mode (updated by accepted `requestMode` switches).
|
||||||
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||||
abiLock.lock()
|
abiLock.lock()
|
||||||
|
|||||||
@@ -113,8 +113,21 @@ public final class Stage2Pipeline {
|
|||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
let thread = Thread {
|
let thread = Thread {
|
||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
|
var lastFramesDropped = connection.framesDropped()
|
||||||
while token.isLive {
|
while token.isLive {
|
||||||
do {
|
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()
|
||||||
|
}
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||||
|
|||||||
@@ -46,27 +46,44 @@ final class StreamPump {
|
|||||||
let thread = Thread {
|
let thread = Thread {
|
||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
var lastKeyframeRequest = Date.distantPast
|
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 {
|
while token.isLive {
|
||||||
do {
|
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 }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
format = f // refreshed on every IDR (mode changes included)
|
||||||
}
|
}
|
||||||
if layer.status == .failed {
|
if layer.status == .failed {
|
||||||
// Decode wedged: flush and re-gate on the next in-band parameter sets
|
// Decode wedged hard (the cold-first-connect case — a lost/corrupt opening
|
||||||
// (resuming with a delta frame can't recover), AND ask the host for a
|
// IDR): flush and re-gate on the next in-band parameter sets (resuming with
|
||||||
// fresh IDR. With the host's infinite GOP the next keyframe could be
|
// a delta frame can't recover), AND ask the host for a fresh IDR. Throttled:
|
||||||
// far off, so without the request the picture stays frozen — the
|
// the layer stays .failed across several polls until the IDR lands.
|
||||||
// intermittent first-connect freeze. Throttled: the layer stays .failed
|
|
||||||
// across several polls until the IDR lands, and one request suffices.
|
|
||||||
layer.flush()
|
layer.flush()
|
||||||
format = AnnexB.formatDescription(fromIDR: au.data)
|
format = AnnexB.formatDescription(fromIDR: au.data)
|
||||||
let now = Date()
|
requestKeyframeThrottled()
|
||||||
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
|
|
||||||
connection.requestKeyframe()
|
|
||||||
lastKeyframeRequest = now
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
guard let f = format,
|
guard let f = format,
|
||||||
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Integration: the Swift wrapper against a real punktfunk/1 host over QUIC + UDP on loopback —
|
// 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
|
// 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
|
// 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
|
import XCTest
|
||||||
@testable import PunktfunkKit
|
@testable import PunktfunkKit
|
||||||
@@ -11,7 +11,7 @@ final class LoopbackIntegrationTests: XCTestCase {
|
|||||||
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
|
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
|
||||||
let port = UInt16(portStr)
|
let port = UInt16(portStr)
|
||||||
else {
|
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(
|
let conn = try PunktfunkConnection(
|
||||||
@@ -139,7 +139,7 @@ final class LoopbackIntegrationTests: XCTestCase {
|
|||||||
guard let portStr = env["PUNKTFUNK_PAIRING_PORT"], let port = UInt16(portStr),
|
guard let portStr = env["PUNKTFUNK_PAIRING_PORT"], let port = UInt16(portStr),
|
||||||
let pin = env["PUNKTFUNK_PAIRING_PIN"]
|
let pin = env["PUNKTFUNK_PAIRING_PIN"]
|
||||||
else {
|
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()
|
let identity = try generateIdentity()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//
|
//
|
||||||
// Run (host side, on the Linux box):
|
// Run (host side, on the Linux box):
|
||||||
// PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
|
// 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:
|
// Then here:
|
||||||
// PUNKTFUNK_REMOTE_HOST=192.168.1.70 swift test --filter RemoteFirstLightTests
|
// PUNKTFUNK_REMOTE_HOST=192.168.1.70 swift test --filter RemoteFirstLightTests
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ final class RemoteFirstLightTests: XCTestCase {
|
|||||||
func testRemoteAudioBothDirections() throws {
|
func testRemoteAudioBothDirections() throws {
|
||||||
let env = ProcessInfo.processInfo.environment
|
let env = ProcessInfo.processInfo.environment
|
||||||
guard let host = env["PUNKTFUNK_REMOTE_HOST"] else {
|
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
|
let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ final class RemoteFirstLightTests: XCTestCase {
|
|||||||
func testRemoteStreamDecodesToPixels() throws {
|
func testRemoteStreamDecodesToPixels() throws {
|
||||||
let env = ProcessInfo.processInfo.environment
|
let env = ProcessInfo.processInfo.environment
|
||||||
guard let host = env["PUNKTFUNK_REMOTE_HOST"] else {
|
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
|
let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777
|
||||||
// PUNKTFUNK_REMOTE_COMPOSITOR=kwin|gamescope|… asks the host for a specific
|
// 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
|
# 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.
|
# 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 \
|
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=$!
|
HOST_PID=$!
|
||||||
HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \
|
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 &
|
--require-pairing >"$PAIR_LOG" 2>&1 &
|
||||||
PAIR_PID=$!
|
PAIR_PID=$!
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|||||||
@@ -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`,
|
primitives (`@decky/ui`: `PanelSection`, `PanelSectionRow`, `ButtonItem`, `Field`,
|
||||||
`Spinner`) — so it looks and feels native to Gaming Mode.
|
`Spinner`) — so it looks and feels native to Gaming Mode.
|
||||||
|
|
||||||
> **Spike / launcher only.** This is a minimal but functional first cut: discover hosts,
|
> **Full Gaming-Mode client.** Discovery, a fullscreen page, in-UI SPAKE2 PIN pairing,
|
||||||
> connect, disconnect. It launches the existing native GTK4 client
|
> stream settings, and a stream that actually launches fullscreen under gamescope (via a
|
||||||
> (`punktfunk-client`) over the top of Gaming Mode. An in-stream overlay (latency / bitrate
|
> Steam shortcut, MoonDeck-style). The video itself is the existing GTK4 flatpak client
|
||||||
> HUD, mid-session controls) and a fuller real-Steam-components UI are the next steps.
|
> (`io.unom.Punktfunk`) — the plugin discovers, pairs, configures, and *launches it the
|
||||||
> Runtime behavior on a real Deck is **untested** — only the build is verified here.
|
> right way* so gamescope focuses it. The Steam-shortcut launch + pairing need a real Deck
|
||||||
|
> in Gaming Mode to fully confirm.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
1. **Refresh** — browses the LAN over mDNS for punktfunk/1 hosts (the `_punktfunk._udp`
|
1. **Discover** — browses the LAN over mDNS for punktfunk/1 hosts (`_punktfunk._udp`,
|
||||||
service) via the backend `discover()`.
|
backend `discover()` via `avahi-browse`). Shown in both the QAM panel and a **fullscreen
|
||||||
2. **Lists discovered hosts** — name, `ip:port`, and a lock icon for whether pairing is
|
page** (Decky route `/punktfunk`, via `routerHook.addRoute`).
|
||||||
required (`pair=required` in the host's TXT record).
|
2. **Pair** — for a `pair=required` host: a gamepad-navigable PIN keypad. The operator arms
|
||||||
3. **Connect** — selecting a host calls `connect(host, port)`, which launches
|
pairing on the host (it shows a 4-digit PIN), the user enters it on the Deck, and the
|
||||||
`punktfunk-client --connect host:port`; a toast and the status line reflect the result.
|
backend runs the SPAKE2 ceremony headlessly via the flatpak client's `--pair` mode
|
||||||
4. **Disconnect** — `disconnect()` terminates the launched client.
|
(`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
|
## Architecture
|
||||||
|
|
||||||
| File | Role |
|
| File | Role |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `src/index.tsx` | Frontend QAM panel (`@decky/ui` + `@decky/api`). |
|
| `src/index.tsx` | Frontend: QAM panel + the `/punktfunk` fullscreen page (host list, PIN keypad modal, settings). |
|
||||||
| `main.py` | Backend `Plugin` class: `discover` / `connect` / `disconnect` / `status` exposed over the Decky bridge. |
|
| `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. |
|
| `plugin.json` | Decky plugin manifest. |
|
||||||
| `decky.pyi` | Type stub for the injected `decky` module (vendored from the template). |
|
| `decky.pyi` | Type stub for the injected `decky` module (vendored from the template). |
|
||||||
|
|
||||||
@@ -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
|
installed and runnable on the Deck — via `.deb`/RPM/flatpak, or symlinked into
|
||||||
`~/.local/bin`.
|
`~/.local/bin`.
|
||||||
- **avahi** (`avahi-daemon` + `avahi-browse`) for discovery — present on SteamOS/Bazzite.
|
- **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 --native` or `punktfunk1-host`).
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -126,7 +142,13 @@ shows up in the Quick Access Menu.
|
|||||||
|
|
||||||
## Limitations / next steps
|
## 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.
|
- 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.
|
- No in-stream overlay (latency/bitrate HUD) inside the plugin — the client owns the session
|
||||||
- Not yet tested on real Deck hardware.
|
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.
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -1,160 +1,95 @@
|
|||||||
"""
|
"""
|
||||||
punktfunk Decky plugin — backend.
|
punktfunk Decky plugin — backend.
|
||||||
|
|
||||||
Bridges the Gaming-Mode Quick Access panel (``src/index.tsx``) to two host-side
|
The Gaming-Mode UI (``src/index.tsx``) calls these methods over the Decky bridge. The actual
|
||||||
operations:
|
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
|
* **discover()** — browse the LAN over mDNS (``avahi-browse``) for ``_punktfunk._udp`` hosts.
|
||||||
``_punktfunk._udp`` service, returning name / ip:port / pairing-requirement / cert
|
* **pair(host, port, pin, name)** — run the SPAKE2 PIN ceremony headlessly via the flatpak
|
||||||
fingerprint for each. Implemented by shelling out to ``avahi-browse`` (SteamOS, Bazzite
|
client's ``--pair`` mode, capturing the result. Pairing uses the SAME flatpak (so the same
|
||||||
and most Linux distros ship ``avahi-daemon``); see :func:`Plugin.discover`.
|
identity store the stream uses), so once paired the stream connects silently.
|
||||||
* **connect(host, port)** / **disconnect()** — launch / kill the native GTK4 client
|
* **runner_info()** — the absolute path to the launch wrapper + the flatpak app id, handed to
|
||||||
(``punktfunk-client --connect host:port``). The child PID is tracked so a later
|
the frontend so it can create/point the Steam shortcut.
|
||||||
:func:`Plugin.disconnect` (or plugin unload) can terminate it.
|
* **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
|
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the host
|
||||||
host advert in ``crates/punktfunk-host/src/discovery.rs``.
|
advert in ``crates/punktfunk-host/src/discovery.rs``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import decky
|
import decky
|
||||||
|
|
||||||
# The native punktfunk/1 client binary (the GTK4/libadwaita Linux client, crate
|
# Flatpak application id of the GTK client (packaging/flatpak/io.unom.Punktfunk.yml).
|
||||||
# ``punktfunk-client-linux``). It is resolved at runtime from PATH and a handful of common
|
APP_ID = "io.unom.Punktfunk"
|
||||||
# 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"
|
|
||||||
|
|
||||||
# Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host).
|
# Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host).
|
||||||
SERVICE_TYPE = "_punktfunk._udp"
|
SERVICE_TYPE = "_punktfunk._udp"
|
||||||
|
|
||||||
# Candidate locations probed (in order) when the binary is not on PATH. ``$HOME`` is the
|
# The flatpak client persists identity / known-hosts / settings under HOME/.config/punktfunk;
|
||||||
# effective user's home as provided by decky.
|
# inside the flatpak sandbox HOME is ~/.var/app/<APP_ID>, so the real on-disk location is this.
|
||||||
_CLIENT_CANDIDATES = [
|
# The backend writes settings here so the (sandboxed) client reads them.
|
||||||
"/usr/bin/punktfunk-client",
|
def _client_config_dir() -> Path:
|
||||||
"/usr/local/bin/punktfunk-client",
|
return Path(decky.DECKY_USER_HOME) / ".var" / "app" / APP_ID / ".config" / "punktfunk"
|
||||||
str(Path(decky.HOME) / ".local" / "bin" / "punktfunk-client"),
|
|
||||||
# Flatpak: launched via `flatpak run` rather than a path — handled in _resolve_client.
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_client() -> list[str]:
|
def _settings_path() -> Path:
|
||||||
"""Return the argv prefix used to launch the native client.
|
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:
|
def _runner_path() -> str:
|
||||||
if Path(candidate).exists():
|
"""Absolute path to the launch wrapper shipped with the plugin (bin/punktfunkrun.sh)."""
|
||||||
return [candidate]
|
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(
|
def _flatpak() -> str | None:
|
||||||
"punktfunk-client not found on PATH or in %s; falling back to bare name",
|
return shutil.which("flatpak") or (
|
||||||
_CLIENT_CANDIDATES,
|
"/usr/bin/flatpak" if Path("/usr/bin/flatpak").exists() else None
|
||||||
)
|
)
|
||||||
return [CLIENT_BINARY]
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_avahi_browse(stdout: str) -> list[dict]:
|
def _flatpak_env() -> dict:
|
||||||
"""Parse ``avahi-browse -rpt`` output into a list of host dicts.
|
"""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
|
||||||
``avahi-browse -r`` resolves services; ``-p`` makes the output parseable (one record
|
them. Harmless if some are already set."""
|
||||||
per line, semicolon-separated, fields escaped with ``\\``); ``-t`` terminates after the
|
env = dict(os.environ)
|
||||||
initial cache dump instead of running forever.
|
# 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
|
||||||
Resolved records start with ``=`` and have the columns::
|
# 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
|
||||||
=;iface;protocol;name;type;domain;hostname;address;port;txt
|
# PyInstaller saved as <VAR>_ORIG, or drop the var so the dynamic loader uses system libraries.
|
||||||
|
for var in ("LD_LIBRARY_PATH", "LD_PRELOAD"):
|
||||||
where ``txt`` is a space-separated list of ``"key=value"`` tokens, each already wrapped
|
orig = env.pop(f"{var}_ORIG", None)
|
||||||
in double quotes by avahi, e.g. ``"proto=punktfunk/1" "fp=ab12..." "pair=required"``.
|
if orig:
|
||||||
|
env[var] = orig
|
||||||
We dedup on the host advert ``id`` TXT key (a host re-advertises across interfaces /
|
else:
|
||||||
IPv4+IPv6, producing several ``=`` lines for one logical host); when ``id`` is absent we
|
env.pop(var, None)
|
||||||
fall back to ``host:port``.
|
env.setdefault("HOME", decky.DECKY_USER_HOME)
|
||||||
"""
|
uid = os.environ.get("PF_UID") or "1000"
|
||||||
out: dict[str, dict] = {}
|
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}")
|
||||||
for raw in stdout.splitlines():
|
env.setdefault(
|
||||||
line = raw.strip()
|
"DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{uid}/bus"
|
||||||
if not line.startswith("="):
|
)
|
||||||
continue
|
# Ensure flatpak can find the user installation.
|
||||||
# Split on unescaped ';'. avahi escapes literal ';' inside fields as '\;', so a
|
env.setdefault(
|
||||||
# simple replace-guard split is adequate for the fixed 10-column layout.
|
"PATH", "/usr/bin:/bin:" + env.get("PATH", "")
|
||||||
parts = line.replace("\\;", "\x00").split(";")
|
)
|
||||||
parts = [p.replace("\x00", ";") for p in parts]
|
return env
|
||||||
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 _split_txt(txt: str) -> list[str]:
|
def _split_txt(txt: str) -> list[str]:
|
||||||
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting.
|
"""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.
|
|
||||||
"""
|
|
||||||
tokens: list[str] = []
|
tokens: list[str] = []
|
||||||
cur: list[str] = []
|
cur: list[str] = []
|
||||||
in_quote = False
|
in_quote = False
|
||||||
@@ -171,23 +106,64 @@ def _split_txt(txt: str) -> list[str]:
|
|||||||
return tokens
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
class Plugin:
|
def _parse_avahi_browse(stdout: str) -> list[dict]:
|
||||||
# Tracks the launched native client so disconnect()/_unload can terminate it.
|
"""Parse ``avahi-browse -rpt`` output into a list of host dicts (deduped on the TXT ``id``)."""
|
||||||
_client: asyncio.subprocess.Process | None = None
|
out: dict[str, dict] = {}
|
||||||
_connected_host: str | None = None
|
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]:
|
async def discover(self) -> list[dict]:
|
||||||
"""Browse the LAN for punktfunk/1 hosts. Returns ``[{name, host, port, pair, fp}]``."""
|
"""Browse the LAN for punktfunk/1 hosts. Returns ``[{name, host, port, pair, fp}]``."""
|
||||||
avahi = shutil.which("avahi-browse")
|
avahi = shutil.which("avahi-browse")
|
||||||
if not avahi:
|
if not avahi:
|
||||||
decky.logger.error("avahi-browse not found; install avahi for host discovery")
|
decky.logger.error("avahi-browse not found; install avahi for host discovery")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
avahi,
|
avahi, "-rpt", SERVICE_TYPE,
|
||||||
"-rpt",
|
|
||||||
SERVICE_TYPE,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
@@ -197,78 +173,119 @@ class Plugin:
|
|||||||
proc.kill()
|
proc.kill()
|
||||||
decky.logger.warning("avahi-browse timed out")
|
decky.logger.warning("avahi-browse timed out")
|
||||||
return []
|
return []
|
||||||
except Exception: # noqa: BLE001 - surface any spawn failure as "no hosts"
|
except Exception: # noqa: BLE001
|
||||||
decky.logger.exception("avahi-browse failed")
|
decky.logger.exception("avahi-browse failed")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if stderr:
|
if stderr:
|
||||||
decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace"))
|
decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace"))
|
||||||
|
|
||||||
hosts = _parse_avahi_browse(stdout.decode(errors="replace"))
|
hosts = _parse_avahi_browse(stdout.decode(errors="replace"))
|
||||||
decky.logger.info("discovered %d punktfunk host(s)", len(hosts))
|
decky.logger.info("discovered %d punktfunk host(s)", len(hosts))
|
||||||
return hosts
|
return hosts
|
||||||
|
|
||||||
async def connect(self, host: str, port: int) -> dict:
|
async def pair(self, host: str, port: int, pin: str, name: str = "Steam Deck") -> dict:
|
||||||
"""Launch the native client against ``host:port``. Returns ``{ok, host, error?}``."""
|
"""Run the SPAKE2 PIN ceremony headlessly via the flatpak client's ``--pair`` mode.
|
||||||
# Tear down any prior session first.
|
|
||||||
await self.disconnect()
|
|
||||||
|
|
||||||
argv = _resolve_client() + ["--connect", f"{host}:{port}"]
|
The user arms pairing on the HOST (which displays a 4-digit PIN) and enters it here.
|
||||||
decky.logger.info("launching client: %s", " ".join(argv))
|
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:
|
try:
|
||||||
self._client = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*argv,
|
*argv,
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=_flatpak_env(),
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=100.0)
|
||||||
decky.logger.error("client binary not found: %s", argv[0])
|
|
||||||
return {"ok": False, "host": f"{host}:{port}", "error": "client-not-found"}
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
decky.logger.exception("failed to launch client")
|
|
||||||
return {"ok": False, "host": f"{host}:{port}", "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}
|
|
||||||
|
|
||||||
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)
|
|
||||||
try:
|
|
||||||
proc.terminate()
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
decky.logger.warning("client did not exit; killing (pid %s)", proc.pid)
|
return {"ok": False, "error": "pairing timed out"}
|
||||||
proc.kill()
|
except Exception as exc: # noqa: BLE001
|
||||||
await proc.wait()
|
decky.logger.exception("pairing failed to launch")
|
||||||
except ProcessLookupError:
|
return {"ok": False, "error": str(exc)}
|
||||||
pass
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
decky.logger.exception("error terminating client")
|
|
||||||
return {"ok": True, "host": host}
|
|
||||||
|
|
||||||
async def status(self) -> dict:
|
out = stdout.decode(errors="replace")
|
||||||
"""Return the current connection status for UI refresh on panel open."""
|
err = stderr.decode(errors="replace")
|
||||||
connected = self._client is not None and self._client.returncode is None
|
if proc.returncode == 0 and "paired " in out:
|
||||||
return {"connected": connected, "host": self._connected_host if connected else None}
|
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 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:
|
||||||
|
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 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 ----
|
# ---- Decky lifecycle ----
|
||||||
|
|
||||||
async def _main(self):
|
async def _main(self):
|
||||||
decky.logger.info("punktfunk plugin loaded")
|
decky.logger.info("punktfunk plugin loaded (runner=%s)", _runner_path())
|
||||||
|
|
||||||
async def _unload(self):
|
async def _unload(self):
|
||||||
decky.logger.info("punktfunk plugin unloading; tearing down client")
|
decky.logger.info("punktfunk plugin unloading")
|
||||||
await self.disconnect()
|
|
||||||
|
|
||||||
async def _uninstall(self):
|
async def _uninstall(self):
|
||||||
decky.logger.info("punktfunk plugin uninstalled")
|
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)"
|
STAGE="$(mktemp -d)"
|
||||||
DEST="$STAGE/$NAME"
|
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 dist/index.js "$DEST/dist/index.js" # ship the bundle only, not the sourcemap
|
||||||
cp main.py plugin.json package.json LICENSE "$DEST/"
|
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 decky.pyi ] && cp decky.pyi "$DEST/"
|
||||||
[ -f README.md ] && cp README.md "$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");
|
||||||
@@ -1,131 +1,364 @@
|
|||||||
import {
|
import {
|
||||||
ButtonItem,
|
ButtonItem,
|
||||||
|
Dropdown,
|
||||||
Field,
|
Field,
|
||||||
|
Focusable,
|
||||||
|
DialogButton,
|
||||||
|
ModalRoot,
|
||||||
|
Navigation,
|
||||||
PanelSection,
|
PanelSection,
|
||||||
PanelSectionRow,
|
PanelSectionRow,
|
||||||
|
SliderField,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
ToggleField,
|
||||||
|
showModal,
|
||||||
|
staticClasses,
|
||||||
} from "@decky/ui";
|
} from "@decky/ui";
|
||||||
|
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||||
|
import { FC, useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
callable,
|
FaTv,
|
||||||
definePlugin,
|
FaSyncAlt,
|
||||||
toaster,
|
FaLock,
|
||||||
} from "@decky/api";
|
FaLockOpen,
|
||||||
import { useEffect, useState } from "react";
|
FaPlay,
|
||||||
import { FaTv, FaSyncAlt, FaStop, FaLock, FaLockOpen } from "react-icons/fa";
|
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;
|
// Discovery hook — shared by the QAM panel and the full page.
|
||||||
host: string;
|
// ----------------------------------------------------------------------------------------
|
||||||
port: number;
|
function useHosts() {
|
||||||
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() {
|
|
||||||
const [hosts, setHosts] = useState<Host[]>([]);
|
const [hosts, setHosts] = useState<Host[]>([]);
|
||||||
const [scanning, setScanning] = useState(false);
|
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);
|
setScanning(true);
|
||||||
try {
|
try {
|
||||||
const found = await discover();
|
setHosts(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"}`,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` });
|
toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` });
|
||||||
} finally {
|
} finally {
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const onConnect = async (h: Host) => {
|
|
||||||
const target = `${h.host}:${h.port}`;
|
|
||||||
setBusyHost(target);
|
|
||||||
try {
|
|
||||||
const res = await connect(h.host, h.port);
|
|
||||||
if (res.ok) {
|
|
||||||
setConnectedHost(res.host);
|
|
||||||
toaster.toast({ title: "punktfunk", body: `Connecting to ${h.name}` });
|
|
||||||
} else {
|
|
||||||
toaster.toast({
|
|
||||||
title: "punktfunk",
|
|
||||||
body:
|
|
||||||
res.error === "client-not-found"
|
|
||||||
? "punktfunk-client is not installed"
|
|
||||||
: `Connect failed: ${res.error ?? "unknown"}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toaster.toast({ title: "punktfunk", body: `Connect failed: ${e}` });
|
|
||||||
} finally {
|
|
||||||
setBusyHost(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDisconnect = async () => {
|
|
||||||
try {
|
|
||||||
await disconnect();
|
|
||||||
setConnectedHost(null);
|
|
||||||
toaster.toast({ title: "punktfunk", body: "Disconnected" });
|
|
||||||
} catch (e) {
|
|
||||||
toaster.toast({ title: "punktfunk", body: `Disconnect failed: ${e}` });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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 pair(host.host, host.port, pin, "Steam Deck");
|
||||||
|
if (res.ok) {
|
||||||
|
toaster.toast({ title: "punktfunk", body: `Paired with ${host.name}` });
|
||||||
|
onPaired();
|
||||||
|
closeModal?.();
|
||||||
|
} else {
|
||||||
|
setError(res.error ?? "pairing failed");
|
||||||
|
setPin("");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PanelSection title="Status">
|
<Field
|
||||||
<PanelSectionRow>
|
label="Resolution"
|
||||||
<Field label="State" focusable={false}>
|
description="The host creates a virtual output at exactly this size"
|
||||||
{connectedHost ? `Connected — ${connectedHost}` : "Idle"}
|
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>
|
||||||
</PanelSectionRow>
|
<Field label="Refresh rate" childrenContainerWidth="max">
|
||||||
{connectedHost && (
|
<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 })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------------------
|
||||||
|
// 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>
|
<PanelSectionRow>
|
||||||
<ButtonItem layout="below" onClick={onDisconnect}>
|
<ButtonItem
|
||||||
<FaStop style={{ marginRight: "0.5em" }} />
|
layout="below"
|
||||||
Disconnect
|
onClick={() => {
|
||||||
|
Navigation.Navigate(ROUTE);
|
||||||
|
Navigation.CloseSideMenus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTv style={{ marginRight: "0.5em" }} />
|
||||||
|
Open punktfunk
|
||||||
</ButtonItem>
|
</ButtonItem>
|
||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
)}
|
|
||||||
</PanelSection>
|
|
||||||
|
|
||||||
<PanelSection title="Hosts">
|
|
||||||
<PanelSectionRow>
|
<PanelSectionRow>
|
||||||
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
|
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
|
||||||
{scanning ? (
|
{scanning ? (
|
||||||
@@ -133,39 +366,37 @@ function Content() {
|
|||||||
) : (
|
) : (
|
||||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||||
)}
|
)}
|
||||||
{scanning ? "Scanning…" : "Refresh"}
|
{scanning ? "Scanning…" : "Refresh hosts"}
|
||||||
</ButtonItem>
|
</ButtonItem>
|
||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Hosts">
|
||||||
{hosts.length === 0 && !scanning && (
|
{hosts.length === 0 && !scanning && (
|
||||||
<PanelSectionRow>
|
<PanelSectionRow>
|
||||||
<Field focusable={false}>No hosts discovered yet.</Field>
|
<Field focusable={false}>No hosts found.</Field>
|
||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hosts.map((h) => {
|
{hosts.map((h) => {
|
||||||
const target = `${h.host}:${h.port}`;
|
|
||||||
const isBusy = busyHost === target;
|
|
||||||
const pairRequired = h.pair === "required";
|
const pairRequired = h.pair === "required";
|
||||||
return (
|
return (
|
||||||
<PanelSectionRow key={h.fp || target}>
|
<PanelSectionRow key={h.fp || `${h.host}:${h.port}`}>
|
||||||
<ButtonItem
|
<ButtonItem
|
||||||
layout="below"
|
layout="below"
|
||||||
disabled={isBusy}
|
onClick={() =>
|
||||||
onClick={() => onConnect(h)}
|
pairRequired
|
||||||
|
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||||
|
: startStream(h)
|
||||||
|
}
|
||||||
label={
|
label={
|
||||||
<span>
|
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||||
{pairRequired ? (
|
{pairRequired ? <FaLock /> : <FaLockOpen />}
|
||||||
<FaLock style={{ marginRight: "0.4em" }} />
|
|
||||||
) : (
|
|
||||||
<FaLockOpen style={{ marginRight: "0.4em" }} />
|
|
||||||
)}
|
|
||||||
{h.name}
|
{h.name}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
description={`${target}${pairRequired ? " · pairing required" : ""}`}
|
description={`${h.host}:${h.port}`}
|
||||||
>
|
>
|
||||||
{isBusy ? "Connecting…" : "Connect"}
|
{pairRequired ? "Pair & Stream" : "Stream"}
|
||||||
</ButtonItem>
|
</ButtonItem>
|
||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
);
|
);
|
||||||
@@ -173,16 +404,17 @@ function Content() {
|
|||||||
</PanelSection>
|
</PanelSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default definePlugin(() => {
|
export default definePlugin(() => {
|
||||||
|
routerHook.addRoute(ROUTE, PunktfunkPage, { exact: true });
|
||||||
return {
|
return {
|
||||||
name: "punktfunk",
|
name: "punktfunk",
|
||||||
titleView: <div>punktfunk</div>,
|
titleView: <div className={staticClasses.Title}>punktfunk</div>,
|
||||||
content: <Content />,
|
content: <QamPanel />,
|
||||||
icon: <FaTv />,
|
icon: <FaTv />,
|
||||||
onDismount() {
|
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
|
# 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.
|
# client lives in clients/apple); on other platforms this builds as a stub binary.
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[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/
|
# UI shell. GraphicsOffload needs GTK ≥ 4.14; black-background ≥ 4.16. AlertDialog/
|
||||||
# PreferencesDialog need libadwaita ≥ 1.5.
|
# 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()),
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
)
|
)
|
||||||
.init();
|
.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();
|
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||||
app.connect_activate(build_ui);
|
app.connect_activate(build_ui);
|
||||||
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
||||||
@@ -43,6 +48,66 @@ pub fn run() -> glib::ExitCode {
|
|||||||
app.run_with_args(&[] as &[&str])
|
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
|
/// `--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
|
/// (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
|
/// 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,
|
CompositorPref::Auto,
|
||||||
GamepadPref::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
|
None, // launch: speed-test probe connect, no game
|
||||||
pin,
|
pin,
|
||||||
Some(identity),
|
Some(identity),
|
||||||
@@ -468,6 +534,7 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
&app.window,
|
&app.window,
|
||||||
connector,
|
connector,
|
||||||
frames.take().expect("Connected delivered once"),
|
frames.take().expect("Connected delivered once"),
|
||||||
|
app.gamepad.escape_events(),
|
||||||
handle.stop.clone(),
|
handle.stop.clone(),
|
||||||
inhibit,
|
inhibit,
|
||||||
&title,
|
&title,
|
||||||
@@ -27,6 +27,14 @@ 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 ACCEL_LSB_PER_G: f32 = 10_000.0;
|
||||||
const G: f32 = 9.80665;
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PadInfo {
|
pub struct PadInfo {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
@@ -46,6 +54,9 @@ pub struct GamepadService {
|
|||||||
active: Arc<Mutex<Option<PadInfo>>>,
|
active: Arc<Mutex<Option<PadInfo>>>,
|
||||||
pinned: Arc<Mutex<Option<u32>>>,
|
pinned: Arc<Mutex<Option<u32>>>,
|
||||||
ctl: Sender<Ctl>,
|
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 {
|
impl GamepadService {
|
||||||
@@ -54,11 +65,12 @@ impl GamepadService {
|
|||||||
let active = Arc::new(Mutex::new(None));
|
let active = Arc::new(Mutex::new(None));
|
||||||
let pinned = Arc::new(Mutex::new(None));
|
let pinned = Arc::new(Mutex::new(None));
|
||||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||||
|
let (escape_tx, escape_rx) = async_channel::unbounded();
|
||||||
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
||||||
if let Err(e) = std::thread::Builder::new()
|
if let Err(e) = std::thread::Builder::new()
|
||||||
.name("punktfunk-gamepad".into())
|
.name("punktfunk-gamepad".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx) {
|
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx) {
|
||||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -70,9 +82,16 @@ impl GamepadService {
|
|||||||
active,
|
active,
|
||||||
pinned,
|
pinned,
|
||||||
ctl,
|
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> {
|
pub fn pads(&self) -> Vec<PadInfo> {
|
||||||
self.pads.lock().unwrap().clone()
|
self.pads.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
@@ -210,6 +229,10 @@ struct Worker {
|
|||||||
last_axis: [i32; 6],
|
last_axis: [i32; 6],
|
||||||
held_buttons: Vec<u32>,
|
held_buttons: Vec<u32>,
|
||||||
last_accel: [i16; 3],
|
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 {
|
impl Worker {
|
||||||
@@ -250,6 +273,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).
|
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
||||||
fn set_sensors(&mut self, enabled: bool) {
|
fn set_sensors(&mut self, enabled: bool) {
|
||||||
let Some(id) = self.active_id() else { return };
|
let Some(id) = self.active_id() else { return };
|
||||||
@@ -270,6 +313,7 @@ fn run(
|
|||||||
active_out: &Mutex<Option<PadInfo>>,
|
active_out: &Mutex<Option<PadInfo>>,
|
||||||
pinned_out: &Mutex<Option<u32>>,
|
pinned_out: &Mutex<Option<u32>>,
|
||||||
ctl: &Receiver<Ctl>,
|
ctl: &Receiver<Ctl>,
|
||||||
|
escape_tx: &async_channel::Sender<()>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
||||||
// own thread.
|
// own thread.
|
||||||
@@ -288,6 +332,8 @@ fn run(
|
|||||||
last_axis: [i32::MIN; 6],
|
last_axis: [i32::MIN; 6],
|
||||||
held_buttons: Vec::new(),
|
held_buttons: Vec::new(),
|
||||||
last_accel: [0; 3],
|
last_accel: [0; 3],
|
||||||
|
escape_tx: escape_tx.clone(),
|
||||||
|
chord_armed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let publish = |w: &Worker| {
|
let publish = |w: &Worker| {
|
||||||
@@ -372,6 +418,7 @@ fn run(
|
|||||||
bit,
|
bit,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
w.maybe_fire_escape();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::ControllerButtonUp { which, button, .. }
|
Event::ControllerButtonUp { which, button, .. }
|
||||||
@@ -385,6 +432,7 @@ fn run(
|
|||||||
bit,
|
bit,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
w.rearm_escape();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::ControllerAxisMotion {
|
Event::ControllerAxisMotion {
|
||||||
@@ -488,7 +536,17 @@ fn run(
|
|||||||
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
||||||
if pad == 0 {
|
if pad == 0 {
|
||||||
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,6 +96,7 @@ fn pump(
|
|||||||
params.compositor,
|
params.compositor,
|
||||||
params.gamepad,
|
params.gamepad,
|
||||||
params.bitrate_kbps,
|
params.bitrate_kbps,
|
||||||
|
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
||||||
None, // launch: the Linux client has no library picker yet
|
None, // launch: the Linux client has no library picker yet
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Client identity, the known-hosts (pinned fingerprint) store, and app settings.
|
//! Client identity, the known-hosts (pinned fingerprint) store, and app settings.
|
||||||
//!
|
//!
|
||||||
//! The identity shares `~/.config/punktfunk/client-{cert,key}.pem` with `punktfunk-client-rs`
|
//! The identity shares `~/.config/punktfunk/client-{cert,key}.pem` with `punktfunk-probe`
|
||||||
//! so a box pairs once whichever client it uses.
|
//! so a box pairs once whichever client it uses.
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
@@ -129,6 +129,7 @@ pub fn new(
|
|||||||
window: &adw::ApplicationWindow,
|
window: &adw::ApplicationWindow,
|
||||||
connector: Arc<NativeClient>,
|
connector: Arc<NativeClient>,
|
||||||
frames: async_channel::Receiver<DecodedFrame>,
|
frames: async_channel::Receiver<DecodedFrame>,
|
||||||
|
escape_rx: async_channel::Receiver<()>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
inhibit_shortcuts: bool,
|
inhibit_shortcuts: bool,
|
||||||
title: &str,
|
title: &str,
|
||||||
@@ -159,10 +160,21 @@ pub fn new(
|
|||||||
hint.set_margin_bottom(24);
|
hint.set_margin_bottom(24);
|
||||||
hint.set_visible(false);
|
hint.set_visible(false);
|
||||||
|
|
||||||
|
// Flashed when entering fullscreen — the only exit affordances once the header bar is
|
||||||
|
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the
|
||||||
|
// only way out on a Steam Deck).
|
||||||
|
let fs_hint = gtk::Label::new(Some("F11 · L1 + R1 + Start + Select — exit fullscreen"));
|
||||||
|
fs_hint.add_css_class("osd");
|
||||||
|
fs_hint.set_halign(gtk::Align::Center);
|
||||||
|
fs_hint.set_valign(gtk::Align::Start);
|
||||||
|
fs_hint.set_margin_top(12);
|
||||||
|
fs_hint.set_visible(false);
|
||||||
|
|
||||||
let overlay = gtk::Overlay::new();
|
let overlay = gtk::Overlay::new();
|
||||||
overlay.set_child(Some(&offload));
|
overlay.set_child(Some(&offload));
|
||||||
overlay.add_overlay(&stats_label);
|
overlay.add_overlay(&stats_label);
|
||||||
overlay.add_overlay(&hint);
|
overlay.add_overlay(&hint);
|
||||||
|
overlay.add_overlay(&fs_hint);
|
||||||
overlay.set_focusable(true);
|
overlay.set_focusable(true);
|
||||||
|
|
||||||
let capture = Rc::new(Capture {
|
let capture = Rc::new(Capture {
|
||||||
@@ -198,8 +210,17 @@ pub fn new(
|
|||||||
// the page dies — the window outlives every session.)
|
// the page dies — the window outlives every session.)
|
||||||
let fs_handler = {
|
let fs_handler = {
|
||||||
let toolbar = toolbar.clone();
|
let toolbar = toolbar.clone();
|
||||||
|
let fs_hint = fs_hint.clone();
|
||||||
window.connect_fullscreened_notify(move |w| {
|
window.connect_fullscreened_notify(move |w| {
|
||||||
toolbar.set_reveal_top_bars(!w.is_fullscreen());
|
let fs = w.is_fullscreen();
|
||||||
|
toolbar.set_reveal_top_bars(!fs);
|
||||||
|
if fs {
|
||||||
|
fs_hint.set_visible(true);
|
||||||
|
let fs_hint = fs_hint.clone();
|
||||||
|
glib::timeout_add_seconds_local_once(4, move || fs_hint.set_visible(false));
|
||||||
|
} else {
|
||||||
|
fs_hint.set_visible(false);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -404,18 +425,39 @@ pub fn new(
|
|||||||
let cap = capture.clone();
|
let cap = capture.clone();
|
||||||
overlay.connect_unmap(move |_| cap.release());
|
overlay.connect_unmap(move |_| cap.release());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Controller escape chord (gamepad service) → leave fullscreen + release capture. The
|
||||||
|
// chord is the only fullscreen exit a controller has (no F11 key; fullscreen hides the
|
||||||
|
// chrome). Aborted on page-hidden so a stale future can't act on the shared window.
|
||||||
|
let escape_future = {
|
||||||
|
let window = window.clone();
|
||||||
|
let cap = capture.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
while escape_rx.recv().await.is_ok() {
|
||||||
|
if window.is_fullscreen() {
|
||||||
|
window.unfullscreen();
|
||||||
|
}
|
||||||
|
cap.release();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// The page's `hidden` fires once navigation away completes (back button, pop on
|
// The page's `hidden` fires once navigation away completes (back button, pop on
|
||||||
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
|
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
|
||||||
{
|
{
|
||||||
let window = window.clone();
|
let window = window.clone();
|
||||||
let stop_h = stop.clone();
|
let stop_h = stop.clone();
|
||||||
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
||||||
|
let escape_future = RefCell::new(Some(escape_future));
|
||||||
page.connect_hidden(move |_| {
|
page.connect_hidden(move |_| {
|
||||||
tracing::debug!("stream page hidden — ending session");
|
tracing::debug!("stream page hidden — ending session");
|
||||||
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
||||||
window.disconnect(fs);
|
window.disconnect(fs);
|
||||||
window.disconnect(active);
|
window.disconnect(active);
|
||||||
}
|
}
|
||||||
|
if let Some(f) = escape_future.borrow_mut().take() {
|
||||||
|
f.abort();
|
||||||
|
}
|
||||||
if window.is_fullscreen() {
|
if window.is_fullscreen() {
|
||||||
window.unfullscreen();
|
window.unfullscreen();
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,8 @@ pub struct CpuFrame {
|
|||||||
pub struct DmabufFrame {
|
pub struct DmabufFrame {
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
/// DRM fourcc of the layer (NV12 for 8-bit VAAPI output).
|
/// Combined DRM fourcc of the whole surface (NV12 for 8-bit VAAPI output), derived
|
||||||
|
/// from the decoder's software format — NOT the per-plane component formats.
|
||||||
pub fourcc: u32,
|
pub fourcc: u32,
|
||||||
pub modifier: u64,
|
pub modifier: u64,
|
||||||
pub planes: Vec<DmabufPlane>,
|
pub planes: Vec<DmabufPlane>,
|
||||||
@@ -292,12 +293,31 @@ impl VaapiDecoder {
|
|||||||
|
|
||||||
/// Map the VAAPI surface to DRM PRIME (zero copy) and lift the descriptor into a
|
/// Map the VAAPI surface to DRM PRIME (zero copy) and lift the descriptor into a
|
||||||
/// `DmabufFrame`. The mapped frame keeps the surface alive via its buffer refs.
|
/// `DmabufFrame`. The mapped frame keeps the surface alive via its buffer refs.
|
||||||
|
///
|
||||||
|
/// FFmpeg's VAAPI export uses `VA_EXPORT_SURFACE_SEPARATE_LAYERS`, so an NV12 surface
|
||||||
|
/// comes back as TWO layers (`R8` luma + `GR88` chroma), each one plane — NOT a single
|
||||||
|
/// `NV12` layer. The previous code took `layers[0]` only: GTK then saw an `R8`
|
||||||
|
/// single-plane texture with the chroma dropped, painting the screen green. The fix:
|
||||||
|
/// derive the COMBINED fourcc from the decoder's software pixel format (NV12 →
|
||||||
|
/// `DRM_FORMAT_NV12`) and flatten every plane across every layer in order (Y then UV).
|
||||||
unsafe fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
|
unsafe fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
|
||||||
use ffmpeg::ffi;
|
use ffmpeg::ffi;
|
||||||
unsafe {
|
unsafe {
|
||||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 {
|
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 {
|
||||||
bail!("decoder returned a software frame (no VAAPI surface)");
|
bail!("decoder returned a software frame (no VAAPI surface)");
|
||||||
}
|
}
|
||||||
|
// The real pixel layout lives on the hardware frames context, not the
|
||||||
|
// DRM-PRIME layer formats (those are the per-plane R8/GR88 component formats).
|
||||||
|
let sw_format = {
|
||||||
|
let hwfc = (*self.frame).hw_frames_ctx;
|
||||||
|
if hwfc.is_null() {
|
||||||
|
bail!("VAAPI frame without a hardware frames context");
|
||||||
|
}
|
||||||
|
(*((*hwfc).data as *const ffi::AVHWFramesContext)).sw_format
|
||||||
|
};
|
||||||
|
let fourcc = drm_fourcc_for(sw_format)
|
||||||
|
.ok_or_else(|| anyhow!("unsupported VAAPI output format {sw_format:?}"))?;
|
||||||
|
|
||||||
let drm = ffi::av_frame_alloc();
|
let drm = ffi::av_frame_alloc();
|
||||||
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
|
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
|
||||||
let r = ffi::av_hwframe_map(drm, self.frame, ffi::AV_HWFRAME_MAP_READ as i32);
|
let r = ffi::av_hwframe_map(drm, self.frame, ffi::AV_HWFRAME_MAP_READ as i32);
|
||||||
@@ -309,11 +329,14 @@ impl VaapiDecoder {
|
|||||||
let desc = (*drm).data[0] as *const ffi::AVDRMFrameDescriptor;
|
let desc = (*drm).data[0] as *const ffi::AVDRMFrameDescriptor;
|
||||||
let guard = DrmFrameGuard(drm);
|
let guard = DrmFrameGuard(drm);
|
||||||
let d = &*desc;
|
let d = &*desc;
|
||||||
if d.nb_layers < 1 {
|
if d.nb_layers < 1 || d.nb_objects < 1 {
|
||||||
bail!("DRM descriptor without layers");
|
bail!("DRM descriptor without layers/objects");
|
||||||
}
|
}
|
||||||
let layer = &d.layers[0];
|
|
||||||
let mut planes = Vec::with_capacity(layer.nb_planes as usize);
|
// Flatten planes across ALL layers, in declared order — the combined fourcc's
|
||||||
|
// plane order (Y, then UV for NV12) matches the layer order FFmpeg emits.
|
||||||
|
let mut planes = Vec::new();
|
||||||
|
for layer in &d.layers[..d.nb_layers as usize] {
|
||||||
for p in &layer.planes[..layer.nb_planes as usize] {
|
for p in &layer.planes[..layer.nb_planes as usize] {
|
||||||
let obj = &d.objects[p.object_index as usize];
|
let obj = &d.objects[p.object_index as usize];
|
||||||
planes.push(DmabufPlane {
|
planes.push(DmabufPlane {
|
||||||
@@ -322,11 +345,19 @@ impl VaapiDecoder {
|
|||||||
stride: p.pitch as u32,
|
stride: p.pitch as u32,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole surface shares one tiling modifier (one BO on radeonsi); GTK takes
|
||||||
|
// a single modifier for the texture.
|
||||||
|
let modifier = d.objects[0].format_modifier;
|
||||||
|
|
||||||
|
log_descriptor_once(d, sw_format, fourcc, modifier);
|
||||||
|
|
||||||
Ok(DmabufFrame {
|
Ok(DmabufFrame {
|
||||||
width: (*self.frame).width as u32,
|
width: (*self.frame).width as u32,
|
||||||
height: (*self.frame).height as u32,
|
height: (*self.frame).height as u32,
|
||||||
fourcc: layer.format,
|
fourcc,
|
||||||
modifier: d.objects[0].format_modifier,
|
modifier,
|
||||||
planes,
|
planes,
|
||||||
guard,
|
guard,
|
||||||
})
|
})
|
||||||
@@ -334,6 +365,50 @@ impl VaapiDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `fourcc(a,b,c,d)` — the DRM FourCC packing (little-endian, `a | b<<8 | c<<16 | d<<24`).
|
||||||
|
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
|
||||||
|
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The combined DRM FourCC for a decoder software pixel format. The host streams 8-bit
|
||||||
|
/// 4:2:0 (NV12); P010 is here for the eventual 10-bit/HDR path.
|
||||||
|
fn drm_fourcc_for(sw: ffmpeg_next::ffi::AVPixelFormat) -> Option<u32> {
|
||||||
|
use ffmpeg_next::ffi::AVPixelFormat::*;
|
||||||
|
Some(match sw {
|
||||||
|
AV_PIX_FMT_NV12 => fourcc(b'N', b'V', b'1', b'2'),
|
||||||
|
AV_PIX_FMT_P010LE => fourcc(b'P', b'0', b'1', b'0'),
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-time dump of the DRM descriptor layout (objects, layers, planes, modifier) — so a
|
||||||
|
/// new client/driver combination's real layout is visible in the logs without a debugger.
|
||||||
|
fn log_descriptor_once(
|
||||||
|
d: &ffmpeg_next::ffi::AVDRMFrameDescriptor,
|
||||||
|
sw: ffmpeg_next::ffi::AVPixelFormat,
|
||||||
|
fourcc: u32,
|
||||||
|
modifier: u64,
|
||||||
|
) {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||||
|
if !ONCE.swap(false, Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let layers: Vec<(u32, i32)> = d.layers[..d.nb_layers.max(0) as usize]
|
||||||
|
.iter()
|
||||||
|
.map(|l| (l.format, l.nb_planes))
|
||||||
|
.collect();
|
||||||
|
tracing::info!(
|
||||||
|
sw_format = ?sw,
|
||||||
|
chosen_fourcc = format_args!("{:#010x}", fourcc),
|
||||||
|
nb_objects = d.nb_objects,
|
||||||
|
nb_layers = d.nb_layers,
|
||||||
|
?layers,
|
||||||
|
modifier = format_args!("{:#018x}", modifier),
|
||||||
|
"VAAPI dmabuf descriptor layout (first frame)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for VaapiDecoder {
|
impl Drop for VaapiDecoder {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
use ffmpeg::ffi;
|
use ffmpeg::ffi;
|
||||||
@@ -345,3 +420,24 @@ impl Drop for VaapiDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Lock the DRM FourCC magic numbers against typos — these are the exact values
|
||||||
|
/// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green.
|
||||||
|
#[test]
|
||||||
|
fn drm_fourcc_constants() {
|
||||||
|
assert_eq!(fourcc(b'N', b'V', b'1', b'2'), 0x3231_564e);
|
||||||
|
assert_eq!(fourcc(b'P', b'0', b'1', b'0'), 0x3031_3050);
|
||||||
|
assert_eq!(
|
||||||
|
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NV12),
|
||||||
|
Some(0x3231_564e)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_RGBA),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "punktfunk-client-rs"
|
name = "punktfunk-probe"
|
||||||
description = "punktfunk reference client (M4): VAAPI decode + wgpu/Vulkan present"
|
description = "punktfunk reference/probe client: headless punktfunk/1 client for testing + latency measurement"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
@@ -9,7 +9,7 @@ authors.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||||
quinn = "0.11"
|
quinn = "0.11"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "macros"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! `punktfunk-client-rs` — the reference client for `punktfunk/1` (M3): QUIC control plane, UDP data
|
//! `punktfunk-probe` — the reference client for `punktfunk/1` (M3): QUIC control plane, UDP data
|
||||||
//! plane, input over QUIC datagrams. Two modes, decided by the host's Welcome:
|
//! plane, input over QUIC datagrams. Two modes, decided by the host's Welcome:
|
||||||
//!
|
//!
|
||||||
//! * **verification** (`frames > 0`, synthetic host): byte-checks deterministic test frames;
|
//! * **verification** (`frames > 0`, synthetic host): byte-checks deterministic test frames;
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
//! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and
|
//! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and
|
||||||
//! exits without connecting.
|
//! exits without connecting.
|
||||||
//!
|
//!
|
||||||
//! Usage: `punktfunk-client-rs [--connect HOST:PORT] [--mode WxHxFPS] [--out FILE] [--input-test]
|
//! Usage: `punktfunk-probe [--connect HOST:PORT] [--mode WxHxFPS] [--out FILE] [--input-test]
|
||||||
//! [--pin HEX] [--compositor NAME] [--gamepad NAME] | --discover [SECS]`
|
//! [--pin HEX] [--compositor NAME] [--gamepad NAME] | --discover [SECS]`
|
||||||
//! (M4 adds VAAPI decode + wgpu present on this skeleton.)
|
//! (M4 adds VAAPI decode + wgpu present on this skeleton.)
|
||||||
|
|
||||||
@@ -45,7 +45,8 @@ use punktfunk_core::config::Role;
|
|||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
use punktfunk_core::packet::FLAG_PROBE;
|
use punktfunk_core::packet::FLAG_PROBE;
|
||||||
use punktfunk_core::quic::{
|
use punktfunk_core::quic::{
|
||||||
endpoint, io, Hello, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, Start, Welcome,
|
endpoint, io, window_loss_ppm, Hello, LossReport, ProbeRequest, ProbeResult, Reconfigure,
|
||||||
|
Reconfigured, Start, Welcome,
|
||||||
};
|
};
|
||||||
use punktfunk_core::transport::UdpTransport;
|
use punktfunk_core::transport::UdpTransport;
|
||||||
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
|
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
|
||||||
@@ -193,7 +194,7 @@ fn parse_args() -> Args {
|
|||||||
pin,
|
pin,
|
||||||
remode,
|
remode,
|
||||||
pair: get("--pair").map(String::from),
|
pair: get("--pair").map(String::from),
|
||||||
name: get("--name").unwrap_or("punktfunk-client-rs").to_string(),
|
name: get("--name").unwrap_or("punktfunk-probe").to_string(),
|
||||||
compositor,
|
compositor,
|
||||||
gamepad,
|
gamepad,
|
||||||
bitrate_kbps: get("--bitrate").and_then(|s| s.parse().ok()).unwrap_or(0),
|
bitrate_kbps: get("--bitrate").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||||
@@ -337,7 +338,7 @@ fn discover(secs: u64) -> Result<()> {
|
|||||||
println!("{row}");
|
println!("{row}");
|
||||||
}
|
}
|
||||||
println!(
|
println!(
|
||||||
"\nconnect with: punktfunk-client-rs --connect <addr:port> [--pin <fp> | --pair <PIN>]"
|
"\nconnect with: punktfunk-probe --connect <addr:port> [--pin <fp> | --pair <PIN>]"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -433,13 +434,15 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Speed-test accumulators: the data-plane loop folds each FLAG_PROBE filler AU in here; the
|
// Packet-level receive counters mirrored from `session.stats()` by the data-plane loop. The
|
||||||
// --speed-test reporter below reads them once the host's ProbeResult lands. first/last hold
|
// speed test reads their delta over the burst window so throughput/loss reflect every delivered
|
||||||
// now_ns timestamps of the receive window (0 = unset).
|
// wire packet (graceful past the FEC budget), not just fully-reassembled probe AUs.
|
||||||
let probe_recv_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
let rx_wire_packets = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||||
let probe_recv_packets = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
let rx_wire_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||||
let probe_first_ns = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
// Adaptive-FEC loss feedback: the data loop publishes a windowed loss estimate here; in normal
|
||||||
let probe_last_ns = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
// stream mode (no speed test / remode) a control-stream task relays it to the host as a
|
||||||
|
// LossReport so it can size FEC to the link. u32::MAX = "no fresh sample this window".
|
||||||
|
let loss_ppm = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(u32::MAX));
|
||||||
|
|
||||||
// Mid-stream renegotiation test: after a delay, ask the host to switch modes on the
|
// Mid-stream renegotiation test: after a delay, ask the host to switch modes on the
|
||||||
// still-open control stream. The stream then carries new-mode AUs (IDR + in-band
|
// still-open control stream. The stream then carries new-mode AUs (IDR + in-band
|
||||||
@@ -470,19 +473,25 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if let Some((target_kbps, duration_ms)) = args.speed_test {
|
} else if let Some((target_kbps, duration_ms)) = args.speed_test {
|
||||||
// Bandwidth probe: after the stream warms up, ask the host to burst FLAG_PROBE filler;
|
// Bandwidth probe: after the stream warms up, ask the host to burst FLAG_PROBE filler; measure
|
||||||
// measure what arrives vs. what it reports sending.
|
// delivered WIRE packets (session-stat delta) vs. what the host reports putting on the wire.
|
||||||
let mut ss = send;
|
let mut ss = send;
|
||||||
let mut sr = recv;
|
let mut sr = recv;
|
||||||
let (pb, pp, pf, pl) = (
|
let (rxp, rxb) = (rx_wire_packets.clone(), rx_wire_bytes.clone());
|
||||||
probe_recv_bytes.clone(),
|
// Per-packet wire size to express delivered bytes as link bytes (header + shard + crypto);
|
||||||
probe_recv_packets.clone(),
|
// every shard is zero-padded to shard_payload so all data packets are this exact size.
|
||||||
probe_first_ns.clone(),
|
let crypto_overhead = if welcome.encrypt {
|
||||||
probe_last_ns.clone(),
|
punktfunk_core::packet::CRYPTO_OVERHEAD as u64
|
||||||
);
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
use std::sync::atomic::Ordering::Relaxed;
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // let the stream warm up
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // let the stream warm up
|
||||||
|
// Baseline the packet-level counters right before the burst (video is paused during it,
|
||||||
|
// so the delta is pure probe traffic plus a sliver of resumed video in the settle).
|
||||||
|
let base_pkts = rxp.load(Relaxed);
|
||||||
|
let base_bytes = rxb.load(Relaxed);
|
||||||
tracing::info!(target_kbps, duration_ms, "requesting speed-test probe");
|
tracing::info!(target_kbps, duration_ms, "requesting speed-test probe");
|
||||||
if io::write_msg(
|
if io::write_msg(
|
||||||
&mut ss,
|
&mut ss,
|
||||||
@@ -505,37 +514,65 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// The reliable result can beat the last UDP shards — let them reassemble.
|
// The reliable result can beat the last UDP shards — let the tail arrive before reading.
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
// Keep this short: video resumes the instant the burst ends, so a long settle counts
|
||||||
let recv_bytes = pb.load(Relaxed);
|
// resumed-video packets against the probe (inflating recv past the host's wire count).
|
||||||
let recv_packets = pp.load(Relaxed);
|
tokio::time::sleep(std::time::Duration::from_millis(60)).await;
|
||||||
let (first, last) = (pf.load(Relaxed), pl.load(Relaxed));
|
let recv_packets = rxp.load(Relaxed).saturating_sub(base_pkts);
|
||||||
let window_ms = if first > 0 && last > first {
|
// bytes_received counts plaintext (header + shard); add per-packet crypto back for the
|
||||||
(last - first) / 1_000_000
|
// true on-wire byte count.
|
||||||
|
let recv_wire_bytes =
|
||||||
|
rxb.load(Relaxed).saturating_sub(base_bytes) + recv_packets * crypto_overhead;
|
||||||
|
// The host's burst duration is the rate denominator (it sent for this long).
|
||||||
|
let window_ms = res.duration_ms.max(1) as u64;
|
||||||
|
let throughput_kbps = recv_wire_bytes.saturating_mul(8) / window_ms;
|
||||||
|
// Link loss: wire packets the host put out that didn't arrive. host_drop: wire packets
|
||||||
|
// the host couldn't even hand to the kernel (send buffer too small / can't keep up).
|
||||||
|
let link_loss = if res.wire_packets_sent > 0 {
|
||||||
|
(res.wire_packets_sent as i64 - recv_packets as i64).max(0) as f64
|
||||||
|
/ res.wire_packets_sent as f64
|
||||||
|
* 100.0
|
||||||
} else {
|
} else {
|
||||||
0
|
0.0
|
||||||
};
|
};
|
||||||
let throughput_kbps = recv_bytes
|
let offered_wire = res.wire_packets_sent + res.send_dropped;
|
||||||
.saturating_mul(8)
|
let host_drop = if offered_wire > 0 {
|
||||||
.checked_div(window_ms)
|
res.send_dropped as f64 / offered_wire as f64 * 100.0
|
||||||
.unwrap_or(0);
|
|
||||||
let loss_pct = if res.bytes_sent > 0 {
|
|
||||||
res.bytes_sent.saturating_sub(recv_bytes) as f64 / res.bytes_sent as f64 * 100.0
|
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target_kbps,
|
target_kbps,
|
||||||
host_sent_bytes = res.bytes_sent,
|
target_mbps = target_kbps / 1000,
|
||||||
host_sent_packets = res.packets_sent,
|
delivered_mbps = throughput_kbps / 1000,
|
||||||
recv_bytes,
|
link_loss_pct = format!("{link_loss:.1}%"),
|
||||||
recv_packets,
|
host_drop_pct = format!("{host_drop:.1}%"),
|
||||||
window_ms,
|
wire_pkts_sent = res.wire_packets_sent,
|
||||||
throughput_kbps,
|
wire_pkts_recv = recv_packets,
|
||||||
loss_pct = format!("{loss_pct:.1}%"),
|
send_dropped = res.send_dropped,
|
||||||
"SPEED TEST complete",
|
"SPEED TEST complete",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Normal stream mode: relay the data loop's windowed loss estimate to the host as periodic
|
||||||
|
// LossReports, so it can size FEC to the link (adaptive FEC). The control stream is otherwise
|
||||||
|
// idle here (remode/speed-test own it in their modes).
|
||||||
|
let mut ls = send;
|
||||||
|
let lp = loss_ppm.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
|
||||||
|
let v = lp.swap(u32::MAX, Relaxed);
|
||||||
|
if v != u32::MAX
|
||||||
|
&& io::write_msg(&mut ls, &LossReport { loss_ppm: v }.encode())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break; // control stream gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input plane: scripted events as QUIC datagrams (mouse square + 'A' taps), proving the
|
// Input plane: scripted events as QUIC datagrams (mouse square + 'A' taps), proving the
|
||||||
@@ -810,12 +847,8 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
let cfg = welcome.session_config(Role::Client);
|
let cfg = welcome.session_config(Role::Client);
|
||||||
let expected = welcome.frames;
|
let expected = welcome.frames;
|
||||||
let out_path = args.out.clone();
|
let out_path = args.out.clone();
|
||||||
let (pb, pp, pf, pl) = (
|
let (rxp_dt, rxb_dt) = (rx_wire_packets.clone(), rx_wire_bytes.clone());
|
||||||
probe_recv_bytes.clone(),
|
let lp_dt = loss_ppm.clone();
|
||||||
probe_recv_packets.clone(),
|
|
||||||
probe_first_ns.clone(),
|
|
||||||
probe_last_ns.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Express our receive time in the host clock before differencing against the host-stamped
|
// Express our receive time in the host clock before differencing against the host-stamped
|
||||||
// capture pts. 0 ⇒ same-host or an old host that didn't answer the skew handshake (the latency
|
// capture pts. 0 ⇒ same-host or an old host that didn't answer the skew handshake (the latency
|
||||||
@@ -850,7 +883,32 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
let mut latencies_us: Vec<u64> = Vec::new();
|
let mut latencies_us: Vec<u64> = Vec::new();
|
||||||
let mut last_rx = std::time::Instant::now();
|
let mut last_rx = std::time::Instant::now();
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
|
// Adaptive-FEC loss window: publish a fresh estimate every 750 ms for the LossReport task.
|
||||||
|
let mut last_loss_report = std::time::Instant::now();
|
||||||
|
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
||||||
loop {
|
loop {
|
||||||
|
// Mirror packet-level receive counters for the speed-test reporter (reads their delta),
|
||||||
|
// and publish a windowed loss estimate for the adaptive-FEC LossReport task.
|
||||||
|
{
|
||||||
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
|
let s = session.stats();
|
||||||
|
rxp_dt.store(s.packets_received, Relaxed);
|
||||||
|
rxb_dt.store(s.bytes_received, Relaxed);
|
||||||
|
if last_loss_report.elapsed() >= std::time::Duration::from_millis(750) {
|
||||||
|
lp_dt.store(
|
||||||
|
window_loss_ppm(
|
||||||
|
s.fec_recovered_shards.wrapping_sub(last_recovered),
|
||||||
|
s.packets_received.wrapping_sub(last_received),
|
||||||
|
s.frames_dropped.wrapping_sub(last_dropped),
|
||||||
|
),
|
||||||
|
Relaxed,
|
||||||
|
);
|
||||||
|
last_loss_report = std::time::Instant::now();
|
||||||
|
last_recovered = s.fec_recovered_shards;
|
||||||
|
last_received = s.packets_received;
|
||||||
|
last_dropped = s.frames_dropped;
|
||||||
|
}
|
||||||
|
}
|
||||||
if expected > 0 && ok + mismatched >= expected {
|
if expected > 0 && ok + mismatched >= expected {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -867,15 +925,9 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
match session.poll_frame() {
|
match session.poll_frame() {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
last_rx = std::time::Instant::now();
|
last_rx = std::time::Instant::now();
|
||||||
// Speed-test filler isn't video: fold it into the probe accumulators and skip
|
// Speed-test filler isn't video: it's measured via the packet-level counters
|
||||||
// verification / the --out sink.
|
// mirrored at the loop head — skip verification / the --out sink.
|
||||||
if frame.flags & FLAG_PROBE as u32 != 0 {
|
if frame.flags & FLAG_PROBE as u32 != 0 {
|
||||||
use std::sync::atomic::Ordering::Relaxed;
|
|
||||||
let n = now_ns();
|
|
||||||
let _ = pf.compare_exchange(0, n, Relaxed, Relaxed);
|
|
||||||
pl.store(n, Relaxed);
|
|
||||||
pb.fetch_add(frame.data.len() as u64, Relaxed);
|
|
||||||
pp.fetch_add(1, Relaxed);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
bytes += frame.data.len() as u64;
|
bytes += frame.data.len() as u64;
|
||||||
@@ -13,13 +13,13 @@ name = "punktfunk-client"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
# Everything is Windows-gated so `cargo build --workspace` stays green on Linux/macOS (the
|
# Everything is Windows-gated so `cargo build --workspace` stays green on Linux/macOS (the
|
||||||
# other native clients live in crates/punktfunk-client-linux and clients/apple); on other
|
# other native clients live in clients/linux and clients/apple); on other
|
||||||
# platforms this builds as a stub binary. Mirrors the Linux client's cfg(target_os="linux")
|
# platforms this builds as a stub binary. Mirrors the Linux client's cfg(target_os="linux")
|
||||||
# gating exactly.
|
# gating exactly.
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
# The protocol core, linked directly (no C ABI) — same as the GTK Linux client. NativeClient
|
# The protocol core, linked directly (no C ABI) — same as the GTK Linux client. NativeClient
|
||||||
# is Sync (mutexed plane receivers), so it drops into a UI app cleanly.
|
# is Sync (mutexed plane receivers), so it drops into a UI app cleanly.
|
||||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||||
|
|
||||||
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
|
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
|
||||||
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
|
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
Name="unom.Punktfunk"
|
Name="unom.Punktfunk"
|
||||||
Publisher="{PUBLISHER}"
|
Publisher="{PUBLISHER}"
|
||||||
Version="{VERSION}"
|
Version="{VERSION}"
|
||||||
ProcessorArchitecture="x64" />
|
ProcessorArchitecture="{ARCH}" />
|
||||||
|
|
||||||
<Properties>
|
<Properties>
|
||||||
<DisplayName>Punktfunk</DisplayName>
|
<DisplayName>Punktfunk</DisplayName>
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
# punktfunk Windows client — MSIX packaging
|
# punktfunk Windows client — MSIX packaging
|
||||||
|
|
||||||
The Windows client ships as a **signed MSIX** so Windows boxes get a real package (Start tile,
|
The Windows client ships as **signed MSIX** packages so Windows boxes get a real package (Start
|
||||||
clean install/uninstall) instead of a loose exe. CI builds + publishes it from
|
tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from
|
||||||
[`.gitea/workflows/windows-msix.yml`](../../../.gitea/workflows/windows-msix.yml) to Gitea's
|
[`.gitea/workflows/windows-msix.yml`](../../../.gitea/workflows/windows-msix.yml) to Gitea's
|
||||||
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
|
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
|
||||||
touches the client and on `win-v*` release tags.
|
touches the client and on `win-v*` release tags.
|
||||||
|
|
||||||
|
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
|
||||||
|
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
|
||||||
|
cross-compiled (the x64 MSVC toolset ships the ARM64 cross compiler; the matrix points `FFMPEG_DIR`
|
||||||
|
at the runner's ARM64 FFmpeg tree, `C:\Users\Public\ffmpeg-arm64`). Artifacts are arch-suffixed
|
||||||
|
(`..._x64.msix` / `..._arm64.msix`, each with its matching `.cer`); `pack-msix.ps1 -Arch x64|arm64`
|
||||||
|
stamps the manifest `ProcessorArchitecture` and names the output. See
|
||||||
|
[`windows.yml`](../../../.gitea/workflows/windows.yml) for the cross-build rationale.
|
||||||
|
|
||||||
## What's in the package
|
## What's in the package
|
||||||
|
|
||||||
`pack-msix.ps1` assembles a layout from a `cargo build --release` and runs `makeappx` + `signtool`:
|
`pack-msix.ps1` assembles a layout from a `cargo build --release` and runs `makeappx` + `signtool`:
|
||||||
@@ -47,8 +55,9 @@ trusted with no further prompt:
|
|||||||
```powershell
|
```powershell
|
||||||
# once per machine (elevated): trust the publisher
|
# once per machine (elevated): trust the publisher
|
||||||
Import-Certificate -FilePath .\punktfunk-codesign.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople
|
Import-Certificate -FilePath .\punktfunk-codesign.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople
|
||||||
# then install (and re-run for each upgrade — no re-trust needed)
|
# then install the package for your CPU (and re-run for each upgrade — no re-trust needed)
|
||||||
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_x64.msix
|
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_x64.msix # Intel/AMD
|
||||||
|
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_arm64.msix # ARM64 (Snapdragon, etc.)
|
||||||
```
|
```
|
||||||
|
|
||||||
The matching `.cer` is also published next to each `.msix` in the registry, so it's always at hand.
|
The matching `.cer` is also published next to each `.msix` in the registry, so it's always at hand.
|
||||||
@@ -70,9 +79,16 @@ it changes the package identity → a one-time reinstall).
|
|||||||
On the Windows runner / dev VM (MSVC + Windows SDK present), after a release build:
|
On the Windows runner / dev VM (MSVC + Windows SDK present), after a release build:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cargo build --release -p punktfunk-client-windows
|
# x64
|
||||||
pwsh -File crates/punktfunk-client-windows/packaging/pack-msix.ps1 `
|
cargo build --release -p punktfunk-client-windows --target x86_64-pc-windows-msvc
|
||||||
-Version 0.2.0.0 -TargetDir C:\t\release -OutDir C:\t\msix
|
pwsh -File clients/windows/packaging/pack-msix.ps1 `
|
||||||
|
-Version 0.2.0.0 -TargetDir C:\t\x86_64-pc-windows-msvc\release -OutDir C:\t\msix
|
||||||
|
|
||||||
|
# arm64 (cross-compiled; point FFMPEG_DIR at the ARM64 tree)
|
||||||
|
$env:FFMPEG_DIR = 'C:\Users\Public\ffmpeg-arm64'
|
||||||
|
cargo build --release -p punktfunk-client-windows --target aarch64-pc-windows-msvc
|
||||||
|
pwsh -File clients/windows/packaging/pack-msix.ps1 `
|
||||||
|
-Version 0.2.0.0 -Arch arm64 -TargetDir C:\t\aarch64-pc-windows-msvc\release -OutDir C:\t\msix
|
||||||
```
|
```
|
||||||
|
|
||||||
Validated end-to-end on the build VM (pack → sign → `Add-AppxPackage` → framework-dependency
|
Validated end-to-end on the build VM (pack → sign → `Add-AppxPackage` → framework-dependency
|
||||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -18,12 +18,17 @@
|
|||||||
Run on the Windows runner (or the dev VM) with the MSVC/Windows SDK present.
|
Run on the Windows runner (or the dev VM) with the MSVC/Windows SDK present.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
pwsh -File pack-msix.ps1 -Version 0.2.137.0 -TargetDir C:\t\release -FfmpegBin C:\Users\Public\ffmpeg\bin -OutDir C:\t\msix
|
# x64 (default arch):
|
||||||
|
pwsh -File pack-msix.ps1 -Version 0.2.137.0 -TargetDir C:\t\x86_64-pc-windows-msvc\release -OutDir C:\t\msix
|
||||||
|
# arm64 (point -TargetDir + FFMPEG_DIR at the ARM64 build/tree):
|
||||||
|
$env:FFMPEG_DIR='C:\Users\Public\ffmpeg-arm64'
|
||||||
|
pwsh -File pack-msix.ps1 -Version 0.2.137.0 -Arch arm64 -TargetDir C:\t-a64\aarch64-pc-windows-msvc\release -OutDir C:\t-a64\msix
|
||||||
#>
|
#>
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, e.g. 0.2.137.0
|
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, e.g. 0.2.137.0
|
||||||
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release output dir (has the exe)
|
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release output dir (has the exe)
|
||||||
|
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64', # package ProcessorArchitecture + artifact suffix
|
||||||
[string]$FfmpegBin = $(if ($env:FFMPEG_DIR) { Join-Path $env:FFMPEG_DIR 'bin' } else { 'C:\Users\Public\ffmpeg\bin' }),
|
[string]$FfmpegBin = $(if ($env:FFMPEG_DIR) { Join-Path $env:FFMPEG_DIR 'bin' } else { 'C:\Users\Public\ffmpeg\bin' }),
|
||||||
[string]$OutDir = (Join-Path $TargetDir 'msix'),
|
[string]$OutDir = (Join-Path $TargetDir 'msix'),
|
||||||
[string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN
|
[string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN
|
||||||
@@ -79,8 +84,8 @@ $ff | ForEach-Object { Copy-Item $_.FullName (Join-Path $layout $_.Name) -Force
|
|||||||
# tile/store assets
|
# tile/store assets
|
||||||
Copy-Item (Join-Path $assets '*') (Join-Path $layout 'Assets') -Force
|
Copy-Item (Join-Path $assets '*') (Join-Path $layout 'Assets') -Force
|
||||||
|
|
||||||
# manifest with version + publisher substituted
|
# manifest with version + publisher + architecture substituted
|
||||||
$manifest = (Get-Content -Raw $manifestTemplate).Replace('{VERSION}', $Version).Replace('{PUBLISHER}', $Publisher)
|
$manifest = (Get-Content -Raw $manifestTemplate).Replace('{VERSION}', $Version).Replace('{PUBLISHER}', $Publisher).Replace('{ARCH}', $Arch)
|
||||||
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
|
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
|
||||||
|
|
||||||
Write-Host "layout assembled at $layout :"
|
Write-Host "layout assembled at $layout :"
|
||||||
@@ -88,13 +93,13 @@ Get-ChildItem $layout -Recurse -File | ForEach-Object { " $($_.FullName.Substri
|
|||||||
|
|
||||||
# --- pack ---
|
# --- pack ---
|
||||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||||
$msix = Join-Path $OutDir "punktfunk-client-windows_${Version}_x64.msix"
|
$msix = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}.msix"
|
||||||
& $makeappx pack /o /d $layout /p $msix
|
& $makeappx pack /o /d $layout /p $msix
|
||||||
if ($LASTEXITCODE -ne 0) { throw "makeappx pack failed ($LASTEXITCODE)" }
|
if ($LASTEXITCODE -ne 0) { throw "makeappx pack failed ($LASTEXITCODE)" }
|
||||||
|
|
||||||
# --- signing cert (supplied stable pfx OR ephemeral self-signed) ---
|
# --- signing cert (supplied stable pfx OR ephemeral self-signed) ---
|
||||||
$pfxPath = Join-Path $OutDir 'signing.pfx'
|
$pfxPath = Join-Path $OutDir 'signing.pfx'
|
||||||
$cerPath = Join-Path $OutDir "punktfunk-client-windows_${Version}_x64.cer"
|
$cerPath = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}.cer"
|
||||||
if ($PfxBase64) {
|
if ($PfxBase64) {
|
||||||
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
|
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
|
||||||
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
|
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
|
||||||
@@ -14,9 +14,9 @@
|
|||||||
use crate::discovery::{self, DiscoveredHost};
|
use crate::discovery::{self, DiscoveredHost};
|
||||||
use crate::gamepad::GamepadService;
|
use crate::gamepad::GamepadService;
|
||||||
use crate::present::Presenter;
|
use crate::present::Presenter;
|
||||||
use crate::session::{self, SessionEvent, SessionParams};
|
use crate::session::{self, SessionEvent, SessionParams, Stats};
|
||||||
use crate::trust::{self, KnownHost, KnownHosts, Settings};
|
use crate::trust::{self, KnownHost, KnownHosts, Settings};
|
||||||
use crate::video::DecodedFrame;
|
use crate::video::{DecodedFrame, DecoderPref};
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
@@ -31,6 +31,14 @@ const RESOLUTIONS: &[(u32, u32)] = &[
|
|||||||
(3840, 2160),
|
(3840, 2160),
|
||||||
];
|
];
|
||||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||||
|
/// Decode backend presets: `(stored value, display label)`.
|
||||||
|
const DECODERS: &[(&str, &str)] = &[
|
||||||
|
("auto", "Automatic (GPU, fall back to CPU)"),
|
||||||
|
("hardware", "Hardware (GPU / D3D11VA)"),
|
||||||
|
("software", "Software (CPU)"),
|
||||||
|
];
|
||||||
|
/// Bitrate presets in Mb/s; `0` = host default.
|
||||||
|
const BITRATES_MBPS: &[u32] = &[0, 10, 20, 30, 50, 80, 150];
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
enum Screen {
|
enum Screen {
|
||||||
@@ -51,6 +59,56 @@ struct Target {
|
|||||||
pair_optional: bool,
|
pair_optional: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stable app services handed to the page components as props. Each routed screen that uses
|
||||||
|
/// hooks (`hosts_page`/`pair_page`/`stream_page`) is mounted as its own `component(...)`, so
|
||||||
|
/// its hooks live in an isolated slot list — calling them on the shared parent `cx` would
|
||||||
|
/// change the hook order whenever the screen changes (reactor's Rules-of-Hooks guard aborts).
|
||||||
|
///
|
||||||
|
/// `Svc` compares equal by `ctx` identity (it never meaningfully changes across renders), so a
|
||||||
|
/// page whose props are just `Svc` re-renders only via its own state hooks, never spuriously
|
||||||
|
/// from the parent.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Svc {
|
||||||
|
ctx: Arc<AppCtx>,
|
||||||
|
set_screen: AsyncSetState<Screen>,
|
||||||
|
set_status: AsyncSetState<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for Svc {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
Arc::ptr_eq(&self.ctx, &other.ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Props for the hosts page: the services plus the changing discovery/status data that must
|
||||||
|
/// drive its re-render (compared by value, so a new host list or error refreshes the page).
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct HostsProps {
|
||||||
|
svc: Svc,
|
||||||
|
hosts: Vec<DiscoveredHost>,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for HostsProps {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.svc == other.svc && self.hosts == other.hosts && self.status == other.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Props for the stream page: the services plus the live stats that drive the HUD overlay
|
||||||
|
/// (compared by value, so each new sample re-renders the overlay).
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct StreamProps {
|
||||||
|
svc: Svc,
|
||||||
|
stats: Stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for StreamProps {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.svc == other.svc && self.stats == other.stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// UI-thread-only present context: the D3D11 presenter plus the decoded-frame receiver.
|
/// UI-thread-only present context: the D3D11 presenter plus the decoded-frame receiver.
|
||||||
struct PresentCtx {
|
struct PresentCtx {
|
||||||
presenter: Presenter,
|
presenter: Presenter,
|
||||||
@@ -68,6 +126,9 @@ thread_local! {
|
|||||||
struct Shared {
|
struct Shared {
|
||||||
handoff: Mutex<Option<(Arc<NativeClient>, async_channel::Receiver<DecodedFrame>)>>,
|
handoff: Mutex<Option<(Arc<NativeClient>, async_channel::Receiver<DecodedFrame>)>>,
|
||||||
target: Mutex<Target>,
|
target: Mutex<Target>,
|
||||||
|
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
|
||||||
|
/// by the stream page's HUD poll thread to drive the overlay.
|
||||||
|
stats: Mutex<Stats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppCtx {
|
pub struct AppCtx {
|
||||||
@@ -136,10 +197,61 @@ fn page(children: Vec<Element>) -> Element {
|
|||||||
scroll_view(col).into()
|
scroll_view(col).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A clickable host row: name + address/badge + chevron.
|
/// A rounded square "monogram" for a host, the first letter on an accent fill — a clean leading
|
||||||
|
/// visual that avoids depending on an icon font being installed.
|
||||||
|
fn avatar(name: &str) -> Border {
|
||||||
|
let initial = name
|
||||||
|
.chars()
|
||||||
|
.find(|c| c.is_alphanumeric())
|
||||||
|
.map(|c| c.to_uppercase().to_string())
|
||||||
|
.unwrap_or_else(|| "?".into());
|
||||||
|
border(
|
||||||
|
text_block(initial)
|
||||||
|
.font_size(17.0)
|
||||||
|
.semibold()
|
||||||
|
.foreground(ThemeRef::AccentText)
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
|
)
|
||||||
|
.background(ThemeRef::Accent)
|
||||||
|
.corner_radius(10.0)
|
||||||
|
.width(40.0)
|
||||||
|
.height(40.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pill chip colour intent.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Pill {
|
||||||
|
Accent,
|
||||||
|
Good,
|
||||||
|
Neutral,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small rounded status chip (paired/PIN/HDR/etc.).
|
||||||
|
fn pill(text: &str, kind: Pill) -> Border {
|
||||||
|
let (bg, fg) = match kind {
|
||||||
|
Pill::Accent => (ThemeRef::Accent, ThemeRef::AccentText),
|
||||||
|
Pill::Good => (ThemeRef::SystemSuccessBackground, ThemeRef::SystemSuccess),
|
||||||
|
Pill::Neutral => (ThemeRef::SubtleFill, ThemeRef::SecondaryText),
|
||||||
|
};
|
||||||
|
border(text_block(text).font_size(11.0).semibold().foreground(fg))
|
||||||
|
.background(bg)
|
||||||
|
.corner_radius(10.0)
|
||||||
|
.padding(edges(9.0, 3.0, 9.0, 3.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clickable host row: monogram + name/address + status pill + chevron.
|
||||||
fn host_card(name: &str, sub: &str, badge: &str, on_tap: impl Fn() + 'static) -> Element {
|
fn host_card(name: &str, sub: &str, badge: &str, on_tap: impl Fn() + 'static) -> Element {
|
||||||
|
let kind = match badge {
|
||||||
|
"Paired" => Pill::Good,
|
||||||
|
"Open" => Pill::Neutral,
|
||||||
|
_ => Pill::Accent, // Trusted / PIN
|
||||||
|
};
|
||||||
card(
|
card(
|
||||||
grid((
|
grid((
|
||||||
|
avatar(name)
|
||||||
|
.grid_column(0)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
vstack((
|
vstack((
|
||||||
text_block(name).font_size(15.0).semibold(),
|
text_block(name).font_size(15.0).semibold(),
|
||||||
text_block(sub)
|
text_block(sub)
|
||||||
@@ -147,21 +259,25 @@ fn host_card(name: &str, sub: &str, badge: &str, on_tap: impl Fn() + 'static) ->
|
|||||||
.foreground(ThemeRef::SecondaryText),
|
.foreground(ThemeRef::SecondaryText),
|
||||||
))
|
))
|
||||||
.spacing(2.0)
|
.spacing(2.0)
|
||||||
.grid_column(0)
|
|
||||||
.vertical_alignment(VerticalAlignment::Center),
|
|
||||||
text_block(badge)
|
|
||||||
.font_size(12.0)
|
|
||||||
.foreground(ThemeRef::SecondaryText)
|
|
||||||
.grid_column(1)
|
.grid_column(1)
|
||||||
.vertical_alignment(VerticalAlignment::Center)
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
.margin(edges(0.0, 0.0, 12.0, 0.0)),
|
.margin(edges(12.0, 0.0, 0.0, 0.0)),
|
||||||
|
pill(badge, kind)
|
||||||
|
.grid_column(2)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
|
.margin(edges(0.0, 0.0, 10.0, 0.0)),
|
||||||
text_block("\u{203A}")
|
text_block("\u{203A}")
|
||||||
.font_size(18.0)
|
.font_size(18.0)
|
||||||
.foreground(ThemeRef::SecondaryText)
|
.foreground(ThemeRef::SecondaryText)
|
||||||
.grid_column(2)
|
.grid_column(3)
|
||||||
.vertical_alignment(VerticalAlignment::Center),
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
))
|
))
|
||||||
.columns([GridLength::Star(1.0), GridLength::Auto, GridLength::Auto]),
|
.columns([
|
||||||
|
GridLength::Auto,
|
||||||
|
GridLength::Star(1.0),
|
||||||
|
GridLength::Auto,
|
||||||
|
GridLength::Auto,
|
||||||
|
]),
|
||||||
)
|
)
|
||||||
.on_tapped(on_tap)
|
.on_tapped(on_tap)
|
||||||
.into()
|
.into()
|
||||||
@@ -173,6 +289,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
let (screen, set_screen) = cx.use_async_state(Screen::Hosts);
|
let (screen, set_screen) = cx.use_async_state(Screen::Hosts);
|
||||||
let (hosts, set_hosts) = cx.use_async_state(Vec::<DiscoveredHost>::new());
|
let (hosts, set_hosts) = cx.use_async_state(Vec::<DiscoveredHost>::new());
|
||||||
let (status, set_status) = cx.use_async_state(String::new());
|
let (status, set_status) = cx.use_async_state(String::new());
|
||||||
|
let (stats, set_stats) = cx.use_async_state(Stats::default());
|
||||||
|
|
||||||
// Continuous LAN discovery (spawned once).
|
// Continuous LAN discovery (spawned once).
|
||||||
cx.use_effect((), {
|
cx.use_effect((), {
|
||||||
@@ -193,38 +310,82 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// HUD stats: the session event loop writes `shared.stats`; this poll thread mirrors it into
|
||||||
|
// root state so the stream page gets it as a *prop*. (A child component's own async-state
|
||||||
|
// update is pruned when its props are unchanged — only a prop change re-renders it, exactly
|
||||||
|
// like discovery → hosts above.)
|
||||||
|
cx.use_effect((), {
|
||||||
|
let shared = ctx.shared.clone();
|
||||||
|
let set_stats = set_stats.clone();
|
||||||
|
move || {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("pf-hud".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let mut last = Stats::default();
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||||
|
let s = *shared.stats.lock().unwrap();
|
||||||
|
if s != last {
|
||||||
|
last = s;
|
||||||
|
set_stats.call(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each hook-using screen is mounted as its own component so its hooks are isolated from
|
||||||
|
// root's (root's own hooks above stay a stable prefix regardless of which screen renders).
|
||||||
|
let svc = Svc {
|
||||||
|
ctx: ctx.clone(),
|
||||||
|
set_screen: set_screen.clone(),
|
||||||
|
set_status: set_status.clone(),
|
||||||
|
};
|
||||||
match screen {
|
match screen {
|
||||||
Screen::Hosts => hosts_page(cx, ctx, &hosts, &status, &set_screen, &set_status),
|
Screen::Hosts => component(hosts_page, HostsProps { svc, hosts, status }),
|
||||||
Screen::Connecting => vstack((
|
Screen::Connecting => {
|
||||||
|
let target_name = ctx.shared.target.lock().unwrap().name.clone();
|
||||||
|
let headline = if target_name.is_empty() {
|
||||||
|
"Connecting\u{2026}".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Connecting to {target_name}\u{2026}")
|
||||||
|
};
|
||||||
|
vstack((
|
||||||
ProgressRing::indeterminate()
|
ProgressRing::indeterminate()
|
||||||
.width(48.0)
|
.width(48.0)
|
||||||
.height(48.0)
|
.height(48.0)
|
||||||
.horizontal_alignment(HorizontalAlignment::Center),
|
.horizontal_alignment(HorizontalAlignment::Center),
|
||||||
text_block("Connecting\u{2026}")
|
text_block(headline)
|
||||||
.font_size(16.0)
|
.font_size(18.0)
|
||||||
|
.semibold()
|
||||||
.horizontal_alignment(HorizontalAlignment::Center),
|
.horizontal_alignment(HorizontalAlignment::Center),
|
||||||
text_block(status.clone())
|
text_block(if status.is_empty() {
|
||||||
|
"Negotiating the session and creating the virtual display\u{2026}".to_string()
|
||||||
|
} else {
|
||||||
|
status.clone()
|
||||||
|
})
|
||||||
.foreground(ThemeRef::SecondaryText)
|
.foreground(ThemeRef::SecondaryText)
|
||||||
.horizontal_alignment(HorizontalAlignment::Center),
|
.horizontal_alignment(HorizontalAlignment::Center),
|
||||||
))
|
))
|
||||||
.spacing(16.0)
|
.spacing(16.0)
|
||||||
.horizontal_alignment(HorizontalAlignment::Center)
|
.horizontal_alignment(HorizontalAlignment::Center)
|
||||||
.vertical_alignment(VerticalAlignment::Center)
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
.into(),
|
.into()
|
||||||
|
}
|
||||||
|
// settings_page uses no hooks (it never touches `cx`), so calling it inline is sound.
|
||||||
Screen::Settings => settings_page(ctx, &set_screen),
|
Screen::Settings => settings_page(ctx, &set_screen),
|
||||||
Screen::Pair => pair_page(cx, ctx, &set_screen, &set_status),
|
Screen::Pair => component(pair_page, svc),
|
||||||
Screen::Stream => stream_page(cx, ctx),
|
Screen::Stream => component(stream_page, StreamProps { svc, stats }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hosts_page(
|
fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||||
cx: &mut RenderCx,
|
let ctx = &props.svc.ctx;
|
||||||
ctx: &Arc<AppCtx>,
|
let hosts = props.hosts.as_slice();
|
||||||
hosts: &[DiscoveredHost],
|
let status = props.status.as_str();
|
||||||
status: &str,
|
let set_screen = &props.svc.set_screen;
|
||||||
set_screen: &AsyncSetState<Screen>,
|
let set_status = &props.svc.set_status;
|
||||||
set_status: &AsyncSetState<String>,
|
|
||||||
) -> Element {
|
|
||||||
let (manual, set_manual) = cx.use_state(String::new());
|
let (manual, set_manual) = cx.use_state(String::new());
|
||||||
let known = KnownHosts::load();
|
let known = KnownHosts::load();
|
||||||
|
|
||||||
@@ -242,6 +403,7 @@ fn hosts_page(
|
|||||||
.grid_column(0)
|
.grid_column(0)
|
||||||
.vertical_alignment(VerticalAlignment::Center),
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
button("Settings")
|
button("Settings")
|
||||||
|
.icon(SymbolGlyph::Setting)
|
||||||
.on_click({
|
.on_click({
|
||||||
let ss = set_screen.clone();
|
let ss = set_screen.clone();
|
||||||
move || ss.call(Screen::Settings)
|
move || ss.call(Screen::Settings)
|
||||||
@@ -255,7 +417,13 @@ fn hosts_page(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if !status.is_empty() {
|
if !status.is_empty() {
|
||||||
body.push(card(text_block(status.to_string()).foreground(ThemeRef::SystemCritical)).into());
|
body.push(
|
||||||
|
InfoBar::new("Couldn't connect")
|
||||||
|
.message(status.to_string())
|
||||||
|
.error()
|
||||||
|
.is_closable(false)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saved (trusted/paired) hosts.
|
// Saved (trusted/paired) hosts.
|
||||||
@@ -354,6 +522,7 @@ fn hosts_page(
|
|||||||
.vertical_alignment(VerticalAlignment::Center),
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
button("Connect")
|
button("Connect")
|
||||||
.accent()
|
.accent()
|
||||||
|
.icon(SymbolGlyph::Forward)
|
||||||
.on_click(connect_manual)
|
.on_click(connect_manual)
|
||||||
.grid_column(1)
|
.grid_column(1)
|
||||||
.margin(edges(8.0, 0.0, 0.0, 0.0)),
|
.margin(edges(8.0, 0.0, 0.0, 0.0)),
|
||||||
@@ -430,6 +599,8 @@ fn connect(
|
|||||||
gamepad: gamepad_pref,
|
gamepad: gamepad_pref,
|
||||||
bitrate_kbps: s.bitrate_kbps,
|
bitrate_kbps: s.bitrate_kbps,
|
||||||
mic_enabled: s.mic_enabled,
|
mic_enabled: s.mic_enabled,
|
||||||
|
hdr_enabled: s.hdr_enabled,
|
||||||
|
decoder: DecoderPref::from_name(&s.decoder),
|
||||||
pin,
|
pin,
|
||||||
identity: ctx.identity.clone(),
|
identity: ctx.identity.clone(),
|
||||||
});
|
});
|
||||||
@@ -459,6 +630,7 @@ fn connect(
|
|||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
}
|
}
|
||||||
gamepad.attach(connector.clone());
|
gamepad.attach(connector.clone());
|
||||||
|
*shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers
|
||||||
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
|
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
|
||||||
ss.call(Screen::Stream);
|
ss.call(Screen::Stream);
|
||||||
}
|
}
|
||||||
@@ -483,7 +655,7 @@ fn connect(
|
|||||||
ss.call(Screen::Hosts);
|
ss.call(Screen::Hosts);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(SessionEvent::Stats(_)) => {}
|
Ok(SessionEvent::Stats(s)) => *shared.stats.lock().unwrap() = s,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
gamepad.detach();
|
gamepad.detach();
|
||||||
ss.call(Screen::Hosts);
|
ss.call(Screen::Hosts);
|
||||||
@@ -493,12 +665,10 @@ fn connect(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pair_page(
|
fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||||
cx: &mut RenderCx,
|
let ctx = &props.ctx;
|
||||||
ctx: &Arc<AppCtx>,
|
let set_screen = &props.set_screen;
|
||||||
set_screen: &AsyncSetState<Screen>,
|
let set_status = &props.set_status;
|
||||||
set_status: &AsyncSetState<String>,
|
|
||||||
) -> Element {
|
|
||||||
let (code, set_code) = cx.use_state(String::new());
|
let (code, set_code) = cx.use_state(String::new());
|
||||||
let target = ctx.shared.target.lock().unwrap().clone();
|
let target = ctx.shared.target.lock().unwrap().clone();
|
||||||
|
|
||||||
@@ -510,9 +680,13 @@ fn pair_page(
|
|||||||
code.clone(),
|
code.clone(),
|
||||||
target.clone(),
|
target.clone(),
|
||||||
);
|
);
|
||||||
button("Pair & Connect").accent().on_click(move || {
|
button("Pair & Connect")
|
||||||
|
.accent()
|
||||||
|
.icon(SymbolGlyph::Accept)
|
||||||
|
.on_click(move || {
|
||||||
let pin = code2.trim().to_string();
|
let pin = code2.trim().to_string();
|
||||||
let (ctx3, ss, st, target3) = (ctx2.clone(), ss.clone(), st.clone(), target2.clone());
|
let (ctx3, ss, st, target3) =
|
||||||
|
(ctx2.clone(), ss.clone(), st.clone(), target2.clone());
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let name =
|
let name =
|
||||||
std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into());
|
std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into());
|
||||||
@@ -546,28 +720,47 @@ fn pair_page(
|
|||||||
};
|
};
|
||||||
let cancel_btn = {
|
let cancel_btn = {
|
||||||
let ss = set_screen.clone();
|
let ss = set_screen.clone();
|
||||||
button("Cancel").on_click(move || ss.call(Screen::Hosts))
|
button("Cancel")
|
||||||
|
.icon(SymbolGlyph::Cancel)
|
||||||
|
.on_click(move || ss.call(Screen::Hosts))
|
||||||
};
|
};
|
||||||
|
|
||||||
let content = card(vstack((
|
let content = card(vstack((
|
||||||
|
grid((
|
||||||
|
avatar(&target.name)
|
||||||
|
.grid_column(0)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
|
vstack((
|
||||||
text_block(format!("Pair with {}", target.name))
|
text_block(format!("Pair with {}", target.name))
|
||||||
.font_size(20.0)
|
.font_size(20.0)
|
||||||
.semibold(),
|
.semibold(),
|
||||||
text_block(
|
text_block(format!("{}:{}", target.addr, target.port))
|
||||||
"Arm pairing on the host (its console or web console), then enter the 4-digit PIN it \
|
.font_size(12.0)
|
||||||
shows.",
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(2.0)
|
||||||
|
.grid_column(1)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
|
.margin(edges(12.0, 0.0, 0.0, 0.0)),
|
||||||
|
))
|
||||||
|
.columns([GridLength::Auto, GridLength::Star(1.0)]),
|
||||||
|
InfoBar::new("Arm pairing on the host")
|
||||||
|
.message(
|
||||||
|
"On the host's console or web console, start pairing — it shows a 4-digit PIN. \
|
||||||
|
Enter it below within 90 seconds.",
|
||||||
)
|
)
|
||||||
.foreground(ThemeRef::SecondaryText)
|
.informational()
|
||||||
.max_width(440.0),
|
.is_closable(false),
|
||||||
text_box(code)
|
text_box(code)
|
||||||
.placeholder("PIN")
|
.placeholder("PIN")
|
||||||
|
.font_size(28.0)
|
||||||
.on_changed(move |s| set_code.call(s)),
|
.on_changed(move |s| set_code.call(s)),
|
||||||
hstack((pair_btn, cancel_btn)).spacing(8.0),
|
hstack((pair_btn, cancel_btn)).spacing(8.0),
|
||||||
))
|
))
|
||||||
.spacing(14.0))
|
.spacing(16.0))
|
||||||
.max_width(480.0)
|
.max_width(480.0)
|
||||||
.horizontal_alignment(HorizontalAlignment::Center)
|
.horizontal_alignment(HorizontalAlignment::Center)
|
||||||
.margin(edges(0.0, 80.0, 0.0, 0.0));
|
.margin(edges(0.0, 60.0, 0.0, 0.0));
|
||||||
|
|
||||||
page(vec![content.into()])
|
page(vec![content.into()])
|
||||||
}
|
}
|
||||||
@@ -624,10 +817,69 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
s.save();
|
s.save();
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
let dec_i = DECODERS
|
||||||
|
.iter()
|
||||||
|
.position(|&(v, _)| v == s.decoder)
|
||||||
|
.unwrap_or(0) as i32;
|
||||||
|
let dec_names: Vec<String> = DECODERS.iter().map(|&(_, l)| l.to_string()).collect();
|
||||||
|
let decoder_combo = {
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
ComboBox::new(dec_names)
|
||||||
|
.header("Video decoder")
|
||||||
|
.selected_index(dec_i)
|
||||||
|
.on_selection_changed(move |i: i32| {
|
||||||
|
let (v, _) = DECODERS[(i.max(0) as usize).min(DECODERS.len() - 1)];
|
||||||
|
let mut s = ctx.settings.lock().unwrap();
|
||||||
|
s.decoder = v.to_string();
|
||||||
|
s.save();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let br_i = BITRATES_MBPS
|
||||||
|
.iter()
|
||||||
|
.position(|&m| m * 1000 == s.bitrate_kbps)
|
||||||
|
.unwrap_or(0) as i32;
|
||||||
|
let br_names: Vec<String> = BITRATES_MBPS
|
||||||
|
.iter()
|
||||||
|
.map(|&m| {
|
||||||
|
if m == 0 {
|
||||||
|
"Automatic".into()
|
||||||
|
} else {
|
||||||
|
format!("{m} Mb/s")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let bitrate_combo = {
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
ComboBox::new(br_names)
|
||||||
|
.header("Bitrate")
|
||||||
|
.selected_index(br_i)
|
||||||
|
.on_selection_changed(move |i: i32| {
|
||||||
|
let m = BITRATES_MBPS[(i.max(0) as usize).min(BITRATES_MBPS.len() - 1)];
|
||||||
|
let mut s = ctx.settings.lock().unwrap();
|
||||||
|
s.bitrate_kbps = m * 1000;
|
||||||
|
s.save();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let hdr_toggle = {
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
ToggleSwitch::new(s.hdr_enabled)
|
||||||
|
.header("HDR (10-bit, BT.2020 PQ)")
|
||||||
|
.on_content("On")
|
||||||
|
.off_content("Off")
|
||||||
|
.on_changed(move |on: bool| {
|
||||||
|
let mut s = ctx.settings.lock().unwrap();
|
||||||
|
s.hdr_enabled = on;
|
||||||
|
s.save();
|
||||||
|
})
|
||||||
|
};
|
||||||
let mic_toggle = {
|
let mic_toggle = {
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
check_box(s.mic_enabled)
|
ToggleSwitch::new(s.mic_enabled)
|
||||||
.label("Stream microphone to the host")
|
.header("Stream microphone to the host")
|
||||||
|
.on_content("On")
|
||||||
|
.off_content("Off")
|
||||||
.on_changed(move |on: bool| {
|
.on_changed(move |on: bool| {
|
||||||
let mut s = ctx.settings.lock().unwrap();
|
let mut s = ctx.settings.lock().unwrap();
|
||||||
s.mic_enabled = on;
|
s.mic_enabled = on;
|
||||||
@@ -643,6 +895,7 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
.vertical_alignment(VerticalAlignment::Center),
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
button("Back")
|
button("Back")
|
||||||
.accent()
|
.accent()
|
||||||
|
.icon(SymbolGlyph::Back)
|
||||||
.on_click({
|
.on_click({
|
||||||
let ss = set_screen.clone();
|
let ss = set_screen.clone();
|
||||||
move || ss.call(Screen::Hosts)
|
move || ss.call(Screen::Hosts)
|
||||||
@@ -655,7 +908,7 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
|
|
||||||
let stream_card = card(
|
let stream_card = card(
|
||||||
vstack((
|
vstack((
|
||||||
text_block("Stream").font_size(15.0).semibold(),
|
text_block("Display").font_size(15.0).semibold(),
|
||||||
text_block("The host creates a virtual display at exactly this mode.")
|
text_block("The host creates a virtual display at exactly this mode.")
|
||||||
.font_size(12.0)
|
.font_size(12.0)
|
||||||
.foreground(ThemeRef::SecondaryText),
|
.foreground(ThemeRef::SecondaryText),
|
||||||
@@ -665,13 +918,31 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
.spacing(10.0),
|
.spacing(10.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let video_card = card(
|
||||||
|
vstack((
|
||||||
|
text_block("Video").font_size(15.0).semibold(),
|
||||||
|
text_block(
|
||||||
|
"Hardware decode (D3D11VA) is zero-copy and far lighter than software — keep it on \
|
||||||
|
Automatic unless debugging.",
|
||||||
|
)
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
decoder_combo,
|
||||||
|
bitrate_combo,
|
||||||
|
hdr_toggle,
|
||||||
|
))
|
||||||
|
.spacing(10.0),
|
||||||
|
);
|
||||||
|
|
||||||
let audio_card =
|
let audio_card =
|
||||||
card(vstack((text_block("Audio").font_size(15.0).semibold(), mic_toggle)).spacing(10.0));
|
card(vstack((text_block("Audio").font_size(15.0).semibold(), mic_toggle)).spacing(10.0));
|
||||||
|
|
||||||
page(vec![
|
page(vec![
|
||||||
header.into(),
|
header.into(),
|
||||||
section("STREAM"),
|
section("DISPLAY"),
|
||||||
stream_card.into(),
|
stream_card.into(),
|
||||||
|
section("VIDEO"),
|
||||||
|
video_card.into(),
|
||||||
section("AUDIO"),
|
section("AUDIO"),
|
||||||
audio_card.into(),
|
audio_card.into(),
|
||||||
])
|
])
|
||||||
@@ -680,15 +951,17 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
// --- stream page --------------------------------------------------------------------------
|
// --- stream page --------------------------------------------------------------------------
|
||||||
|
|
||||||
fn present_newest(ctx: &mut PresentCtx) {
|
fn present_newest(ctx: &mut PresentCtx) {
|
||||||
|
// Drain to the newest decoded frame (drop any backlog) and hand it to the presenter by value —
|
||||||
|
// the GPU zero-copy path retains the decoder surface across re-presents, so ownership matters.
|
||||||
let mut newest = None;
|
let mut newest = None;
|
||||||
while let Ok(f) = ctx.frames.try_recv() {
|
while let Ok(f) = ctx.frames.try_recv() {
|
||||||
newest = Some(f);
|
newest = Some(f);
|
||||||
}
|
}
|
||||||
let cpu = newest.as_ref().map(|DecodedFrame::Cpu(c)| c);
|
ctx.presenter.present(newest);
|
||||||
ctx.presenter.present(cpu);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stream_page(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||||
|
let ctx = &props.svc.ctx;
|
||||||
// Take the connector + frames handoff once on mount; keep the connector alive (and for input)
|
// Take the connector + frames handoff once on mount; keep the connector alive (and for input)
|
||||||
// in a use_ref, stash frames for `on_ready`, install the input hooks (and remove on unmount).
|
// in a use_ref, stash frames for `on_ready`, install the input hooks (and remove on unmount).
|
||||||
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
|
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
|
||||||
@@ -710,7 +983,7 @@ fn stream_page(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
cx.use_effect((), {
|
cx.use_effect((), {
|
||||||
let rendering = rendering.clone();
|
let rendering = rendering.clone();
|
||||||
move || {
|
move || {
|
||||||
if let Ok(r) = on_rendering(|| {
|
if let Ok(r) = on_rendering(move || {
|
||||||
PRESENT.with(|cell| {
|
PRESENT.with(|cell| {
|
||||||
if let Some(ctx) = cell.borrow_mut().as_mut() {
|
if let Some(ctx) = cell.borrow_mut().as_mut() {
|
||||||
present_newest(ctx);
|
present_newest(ctx);
|
||||||
@@ -722,6 +995,8 @@ fn stream_page(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mode = connector_ref.borrow().as_ref().map(|c| c.mode());
|
||||||
|
grid((
|
||||||
swap_chain_panel()
|
swap_chain_panel()
|
||||||
.on_ready(|panel| match Presenter::new(1280, 720) {
|
.on_ready(|panel| match Presenter::new(1280, 720) {
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
@@ -746,6 +1021,63 @@ fn stream_page(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
ctx.presenter.resize(w as u32, h as u32);
|
ctx.presenter.resize(w as u32, h as u32);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
}),
|
||||||
|
hud_overlay(&props.stats, mode),
|
||||||
|
))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small chip for the dark HUD: coloured text on a translucent dark fill.
|
||||||
|
fn hud_chip(text: &str, color: Color) -> Border {
|
||||||
|
border(
|
||||||
|
text_block(text)
|
||||||
|
.font_size(11.0)
|
||||||
|
.semibold()
|
||||||
|
.foreground(color),
|
||||||
|
)
|
||||||
|
.background(Color::rgb(38, 38, 38))
|
||||||
|
.corner_radius(8.0)
|
||||||
|
.padding(edges(8.0, 2.0, 8.0, 2.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The streaming HUD overlay (top-right), mirroring the Apple client: a chip row (mode · decode
|
||||||
|
/// path · HDR), the fps/throughput/latency line, and the release-cursor hint. Layered over the
|
||||||
|
/// `SwapChainPanel` in the same grid cell.
|
||||||
|
fn hud_overlay(stats: &Stats, mode: Option<Mode>) -> Element {
|
||||||
|
let res = mode
|
||||||
|
.map(|m| format!("{}\u{00D7}{}@{}", m.width, m.height, m.refresh_hz))
|
||||||
|
.unwrap_or_else(|| "\u{2014}".into());
|
||||||
|
let mut chips: Vec<Element> = vec![hud_chip(&res, Color::rgb(235, 235, 235)).into()];
|
||||||
|
chips.push(if stats.hardware {
|
||||||
|
hud_chip("GPU decode", Color::rgb(120, 220, 150)).into()
|
||||||
|
} else {
|
||||||
|
hud_chip("CPU decode", Color::rgb(240, 190, 90)).into()
|
||||||
|
});
|
||||||
|
if stats.hdr {
|
||||||
|
chips.push(hud_chip("HDR", Color::rgb(255, 205, 90)).into());
|
||||||
|
}
|
||||||
|
let line = format!(
|
||||||
|
"{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} {:.1} ms p50 \u{00B7} decode {:.1} ms",
|
||||||
|
stats.fps, stats.mbps, stats.latency_ms, stats.decode_ms
|
||||||
|
);
|
||||||
|
border(
|
||||||
|
vstack((
|
||||||
|
hstack(chips).spacing(6.0),
|
||||||
|
text_block(line)
|
||||||
|
.font_size(11.0)
|
||||||
|
.foreground(Color::rgb(210, 210, 210)),
|
||||||
|
text_block("Ctrl+Alt+Shift+Q releases the mouse")
|
||||||
|
.font_size(11.0)
|
||||||
|
.foreground(Color::rgb(150, 150, 150)),
|
||||||
|
))
|
||||||
|
.spacing(6.0),
|
||||||
|
)
|
||||||
|
.background(Color::rgb(0, 0, 0))
|
||||||
|
.corner_radius(10.0)
|
||||||
|
.padding(uniform(10.0))
|
||||||
|
.opacity(0.82)
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Right)
|
||||||
|
.vertical_alignment(VerticalAlignment::Top)
|
||||||
|
.margin(uniform(12.0))
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
@@ -499,7 +499,17 @@ fn run(
|
|||||||
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
||||||
if pad == 0 {
|
if pad == 0 {
|
||||||
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//! The single Direct3D 11 device shared by the video decoder (D3D11VA hardware decode) and the
|
||||||
|
//! presenter (the `SwapChainPanel` composition swapchain + the present draw).
|
||||||
|
//!
|
||||||
|
//! Zero-copy hardware decode requires FFmpeg to decode HEVC into `ID3D11Texture2D`s created by the
|
||||||
|
//! **same** device the presenter binds as shader resources and draws with — a texture from one
|
||||||
|
//! device can't be sampled by another. So the device is created once, here, and both subsystems
|
||||||
|
//! pull it from a process-global `OnceLock` (initialised on whichever thread asks first: the
|
||||||
|
//! session pump when it builds the decoder, or the UI thread when it builds the presenter).
|
||||||
|
//!
|
||||||
|
//! **Thread-safety.** windows-rs COM interfaces are deliberately `!Send`/`!Sync` — thread-safety
|
||||||
|
//! is per-object, not universal. An `ID3D11Device` and its immediate context become free-threaded
|
||||||
|
//! once `ID3D11Multithread::SetMultithreadProtected(TRUE)` is set, which FFmpeg's D3D11VA backend
|
||||||
|
//! does inside `av_hwdevice_ctx_init` (it installs an `ID3D11Multithread`-based default lock when we
|
||||||
|
//! leave `AVD3D11VADeviceContext.lock` null). The decoder then uses FFmpeg's separate
|
||||||
|
//! `ID3D11VideoContext` for decode while the presenter uses the immediate context for draw; under
|
||||||
|
//! multithread protection D3D serialises the two internally, and decode/draw touch disjoint context
|
||||||
|
//! state. That makes the `unsafe impl Send + Sync` below sound for exactly this usage.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use windows::core::Interface;
|
||||||
|
use windows::Win32::Graphics::Direct3D::{
|
||||||
|
D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_WARP, D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_11_1,
|
||||||
|
};
|
||||||
|
use windows::Win32::Graphics::Direct3D11::{
|
||||||
|
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread,
|
||||||
|
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SharedDevice {
|
||||||
|
pub device: ID3D11Device,
|
||||||
|
pub context: ID3D11DeviceContext,
|
||||||
|
/// True when this is a real GPU (hardware) adapter — a precondition for D3D11VA decode. WARP
|
||||||
|
/// (the GPU-less dev box) creates fine for present but cannot hardware-decode HEVC, so the
|
||||||
|
/// decoder skips straight to the software path there.
|
||||||
|
pub hardware: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sound for our usage — see the module docs: the device + immediate context are free-threaded under
|
||||||
|
// the multithread protection FFmpeg installs, and decode (video context) / present (immediate
|
||||||
|
// context) never share mutable context state.
|
||||||
|
unsafe impl Send for SharedDevice {}
|
||||||
|
unsafe impl Sync for SharedDevice {}
|
||||||
|
|
||||||
|
static SHARED: OnceLock<Option<SharedDevice>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// The process-wide shared D3D11 device, created on first call. `None` only if D3D11 device
|
||||||
|
/// creation fails for both a hardware adapter and WARP (effectively never — WARP is always present).
|
||||||
|
pub fn shared() -> Option<&'static SharedDevice> {
|
||||||
|
SHARED.get_or_init(create).as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create() -> Option<SharedDevice> {
|
||||||
|
match create_device() {
|
||||||
|
Ok(d) => Some(d),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "shared D3D11 device creation failed — no present/decode");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_device() -> Result<SharedDevice> {
|
||||||
|
// Preference order: a hardware adapter with video support (enables D3D11VA); the same without
|
||||||
|
// the VIDEO flag (a driver that rejects it still presents + software-decodes); finally WARP for
|
||||||
|
// the GPU-less box. BGRA_SUPPORT is required for the composition swapchain in every case.
|
||||||
|
let attempts = [
|
||||||
|
(D3D_DRIVER_TYPE_HARDWARE, true, true),
|
||||||
|
(D3D_DRIVER_TYPE_HARDWARE, false, true),
|
||||||
|
(D3D_DRIVER_TYPE_WARP, false, false),
|
||||||
|
];
|
||||||
|
for (driver, video, hardware) in attempts {
|
||||||
|
let flags = if video {
|
||||||
|
D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT
|
||||||
|
} else {
|
||||||
|
D3D11_CREATE_DEVICE_BGRA_SUPPORT
|
||||||
|
};
|
||||||
|
let mut device = None;
|
||||||
|
let mut context = None;
|
||||||
|
let r = unsafe {
|
||||||
|
D3D11CreateDevice(
|
||||||
|
None,
|
||||||
|
driver,
|
||||||
|
None,
|
||||||
|
flags,
|
||||||
|
Some(&[D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0]),
|
||||||
|
D3D11_SDK_VERSION,
|
||||||
|
Some(&mut device),
|
||||||
|
None,
|
||||||
|
Some(&mut context),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if r.is_ok() {
|
||||||
|
let (device, context) = (device.unwrap(), context.unwrap());
|
||||||
|
// Make the device + immediate context free-threaded: the decoder (D3D11VA video context,
|
||||||
|
// pump thread) and the presenter (immediate context, UI thread) both touch this device.
|
||||||
|
// FFmpeg also sets this during hwdevice init, but doing it up front keeps the
|
||||||
|
// cross-thread `Send`/`Sync` sound from the moment the device exists.
|
||||||
|
if let Ok(mt) = context.cast::<ID3D11Multithread>() {
|
||||||
|
unsafe {
|
||||||
|
let _ = mt.SetMultithreadProtected(true); // returns the prior state; ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
driver = if hardware {
|
||||||
|
"hardware"
|
||||||
|
} else {
|
||||||
|
"WARP (software)"
|
||||||
|
},
|
||||||
|
video,
|
||||||
|
"shared D3D11 device created"
|
||||||
|
);
|
||||||
|
return Ok(SharedDevice {
|
||||||
|
device,
|
||||||
|
context,
|
||||||
|
hardware,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(anyhow!(
|
||||||
|
"D3D11CreateDevice failed for both hardware and WARP"
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
//! Stream input: Win32 low-level keyboard + mouse hooks forwarding to the host while the WinUI
|
||||||
|
//! window is focused and the pointer is captured.
|
||||||
|
//!
|
||||||
|
//! windows-reactor exposes no raw key-down/up or pointer-position/wheel events (only keyboard
|
||||||
|
//! *accelerators* and pointer button-state), which is insufficient for a game stream. So this
|
||||||
|
//! drops below XAML to `WH_KEYBOARD_LL` / `WH_MOUSE_LL`, installed on the UI thread when the
|
||||||
|
//! stream page mounts and removed when it unmounts.
|
||||||
|
//!
|
||||||
|
//! **Pointer lock.** While captured the cursor is *locked* the way a game-streaming client locks
|
||||||
|
//! it (Moonlight/Parsec): the OS cursor is hidden + confined to the window (`ClipCursor`), and
|
||||||
|
//! every physical move is turned into a **relative** delta (`InputKind::MouseMove`) — we read the
|
||||||
|
//! offset from the window centre, ship it (scaled screen→host through the Contain-fit factor, with
|
||||||
|
//! sub-pixel remainder carried so slow drags aren't lost), then warp the cursor back to centre so
|
||||||
|
//! it never reaches a screen edge. This is why the old absolute path froze: swallowing
|
||||||
|
//! `WM_MOUSEMOVE` pinned the OS cursor, so `pt` never travelled and the absolute coordinate
|
||||||
|
//! snapped to one point. Keys carry the native Windows VK directly (the wire contract).
|
||||||
|
//!
|
||||||
|
//! **Ctrl+Alt+Shift+Q** toggles capture — releasing the lock hands the cursor back to the local
|
||||||
|
//! desktop (and re-grabs on the next toggle). Losing foreground also releases the lock so the
|
||||||
|
//! cursor is never stranded.
|
||||||
|
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use punktfunk_core::config::Mode;
|
||||||
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
|
||||||
|
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
||||||
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
|
use windows::Win32::UI::Input::KeyboardAndMouse::VK_Q;
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
CallNextHookEx, ClipCursor, GetClientRect, GetForegroundWindow, SetCursorPos,
|
||||||
|
SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT,
|
||||||
|
LLMHF_INJECTED, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_KEYUP, WM_LBUTTONDOWN,
|
||||||
|
WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL,
|
||||||
|
WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
connector: Arc<NativeClient>,
|
||||||
|
mode: Mode,
|
||||||
|
/// Our window handle, stored as the raw `isize` so `State` is `Send` (`HWND` is not).
|
||||||
|
hwnd: isize,
|
||||||
|
/// User intent: forward input to the host (toggled by Ctrl+Alt+Shift+Q).
|
||||||
|
captured: bool,
|
||||||
|
/// The OS pointer is currently locked (hidden + confined + recentering). Tracks the real
|
||||||
|
/// `ClipCursor`/`ShowCursor` state so we engage/disengage exactly once per transition.
|
||||||
|
locked: bool,
|
||||||
|
/// Lock centre in screen coordinates (the cursor is warped here after every move).
|
||||||
|
center_x: i32,
|
||||||
|
center_y: i32,
|
||||||
|
/// Sub-pixel remainder of the screen→host scale, carried so slow drags aren't truncated away.
|
||||||
|
acc_x: f32,
|
||||||
|
acc_y: f32,
|
||||||
|
/// Modifier state, tracked from the hook's own event stream (see `kbd_proc`).
|
||||||
|
ctrl: bool,
|
||||||
|
alt: bool,
|
||||||
|
shift: bool,
|
||||||
|
held_keys: HashSet<u8>,
|
||||||
|
held_buttons: HashSet<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// `State` carries no `!Send` handle (hwnd is an `isize`), so the static is sound. The hook procs
|
||||||
|
// run on the same UI thread that installs/removes the hooks, so the lock is uncontended.
|
||||||
|
static STATE: Mutex<Option<State>> = Mutex::new(None);
|
||||||
|
static KBD_HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||||
|
static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||||
|
|
||||||
|
/// Install the hooks for a streaming session. Call from the UI thread once the window is shown.
|
||||||
|
pub fn install(connector: Arc<NativeClient>, mode: Mode) {
|
||||||
|
let hwnd = unsafe { GetForegroundWindow() };
|
||||||
|
let mut st = State {
|
||||||
|
connector,
|
||||||
|
mode,
|
||||||
|
hwnd: hwnd.0 as isize,
|
||||||
|
captured: true,
|
||||||
|
locked: false,
|
||||||
|
center_x: 0,
|
||||||
|
center_y: 0,
|
||||||
|
acc_x: 0.0,
|
||||||
|
acc_y: 0.0,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
shift: false,
|
||||||
|
held_keys: HashSet::new(),
|
||||||
|
held_buttons: HashSet::new(),
|
||||||
|
};
|
||||||
|
// Lock immediately (the window is foreground at mount, like Moonlight grabbing on stream start).
|
||||||
|
set_locked(&mut st, true);
|
||||||
|
*STATE.lock().unwrap() = Some(st);
|
||||||
|
unsafe {
|
||||||
|
let hinst = GetModuleHandleW(None).ok();
|
||||||
|
if let Ok(h) = SetWindowsHookExW(WH_KEYBOARD_LL, Some(kbd_proc), hinst.map(Into::into), 0) {
|
||||||
|
KBD_HOOK.store(h.0 as isize, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
if let Ok(h) = SetWindowsHookExW(WH_MOUSE_LL, Some(mouse_proc), hinst.map(Into::into), 0) {
|
||||||
|
MOUSE_HOOK.store(h.0 as isize, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
"stream input hooks installed — pointer locked (Ctrl+Alt+Shift+Q toggles capture)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the hooks, release the pointer lock, and flush any held keys/buttons (so nothing
|
||||||
|
/// sticks down on the host).
|
||||||
|
pub fn uninstall() {
|
||||||
|
unsafe {
|
||||||
|
let k = KBD_HOOK.swap(0, Ordering::SeqCst);
|
||||||
|
if k != 0 {
|
||||||
|
let _ = UnhookWindowsHookEx(HHOOK(k as *mut _));
|
||||||
|
}
|
||||||
|
let m = MOUSE_HOOK.swap(0, Ordering::SeqCst);
|
||||||
|
if m != 0 {
|
||||||
|
let _ = UnhookWindowsHookEx(HHOOK(m as *mut _));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(mut st) = STATE.lock().unwrap().take() {
|
||||||
|
set_locked(&mut st, false); // hand the cursor back to the desktop
|
||||||
|
flush_held(&mut st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release every held key/button on the host, so nothing sticks down when capture is dropped
|
||||||
|
/// (toggled off) or the session ends.
|
||||||
|
fn flush_held(st: &mut State) {
|
||||||
|
let c = st.connector.clone();
|
||||||
|
for vk in st.held_keys.drain() {
|
||||||
|
send(&c, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||||
|
}
|
||||||
|
for b in st.held_buttons.drain() {
|
||||||
|
send(&c, InputKind::MouseButtonUp, b, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Engage or release the pointer lock: confine + hide + recentre on, free + show on off.
|
||||||
|
/// Guarded so the `ClipCursor`/`ShowCursor` calls stay balanced (one each per transition).
|
||||||
|
fn set_locked(st: &mut State, on: bool) {
|
||||||
|
if on == st.locked {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let hwnd = HWND(st.hwnd as *mut _);
|
||||||
|
unsafe {
|
||||||
|
if on {
|
||||||
|
let mut rc = RECT::default();
|
||||||
|
if GetClientRect(hwnd, &mut rc).is_ok() {
|
||||||
|
let mut tl = POINT {
|
||||||
|
x: rc.left,
|
||||||
|
y: rc.top,
|
||||||
|
};
|
||||||
|
let mut br = POINT {
|
||||||
|
x: rc.right,
|
||||||
|
y: rc.bottom,
|
||||||
|
};
|
||||||
|
let _ = ClientToScreen(hwnd, &mut tl);
|
||||||
|
let _ = ClientToScreen(hwnd, &mut br);
|
||||||
|
let clip = RECT {
|
||||||
|
left: tl.x,
|
||||||
|
top: tl.y,
|
||||||
|
right: br.x,
|
||||||
|
bottom: br.y,
|
||||||
|
};
|
||||||
|
let _ = ClipCursor(Some(&clip as *const RECT));
|
||||||
|
st.center_x = (tl.x + br.x) / 2;
|
||||||
|
st.center_y = (tl.y + br.y) / 2;
|
||||||
|
let _ = SetCursorPos(st.center_x, st.center_y);
|
||||||
|
}
|
||||||
|
let _ = ShowCursor(false);
|
||||||
|
st.acc_x = 0.0;
|
||||||
|
st.acc_y = 0.0;
|
||||||
|
} else {
|
||||||
|
let _ = ClipCursor(None);
|
||||||
|
let _ = ShowCursor(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.locked = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||||
|
let _ = c.send_input(&InputEvent {
|
||||||
|
kind,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
flags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||||
|
if code == HC_ACTION as i32 {
|
||||||
|
let kb = unsafe { &*(lparam.0 as *const KBDLLHOOKSTRUCT) };
|
||||||
|
let msg = wparam.0 as u32;
|
||||||
|
let up = msg == WM_KEYUP || msg == WM_SYSKEYUP;
|
||||||
|
let vk = kb.vkCode as u16;
|
||||||
|
let mut guard = STATE.lock().unwrap();
|
||||||
|
if let Some(st) = guard.as_mut() {
|
||||||
|
// Track modifier state from the hook's own event stream — reliable even while we
|
||||||
|
// swallow these keys (GetAsyncKeyState doesn't reflect keys suppressed by our own LL
|
||||||
|
// hook, which is why the shortcut never fired). Handles the generic + L/R vk codes.
|
||||||
|
match kb.vkCode {
|
||||||
|
0x11 | 0xA2 | 0xA3 => st.ctrl = !up, // (L/R)CONTROL
|
||||||
|
0x12 | 0xA4 | 0xA5 => st.alt = !up, // (L/R)MENU (Alt)
|
||||||
|
0x10 | 0xA0 | 0xA1 => st.shift = !up, // (L/R)SHIFT
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let foreground = unsafe { GetForegroundWindow() }.0 as isize == st.hwnd;
|
||||||
|
if foreground {
|
||||||
|
// Capture toggle: Ctrl+Alt+Shift+Q (consumed locally, never forwarded).
|
||||||
|
if !up && vk == VK_Q.0 && st.ctrl && st.alt && st.shift {
|
||||||
|
let on = !st.captured;
|
||||||
|
st.captured = on;
|
||||||
|
set_locked(st, on); // grab/release the cursor immediately
|
||||||
|
if !on {
|
||||||
|
flush_held(st); // release held keys/buttons so nothing sticks on the host
|
||||||
|
}
|
||||||
|
tracing::info!(captured = on, "capture toggled (Ctrl+Alt+Shift+Q)");
|
||||||
|
return LRESULT(1);
|
||||||
|
}
|
||||||
|
if st.captured {
|
||||||
|
let v = vk as u8;
|
||||||
|
if up {
|
||||||
|
if st.held_keys.remove(&v) {
|
||||||
|
send(&st.connector, InputKind::KeyUp, v as u32, 0, 0, 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
st.held_keys.insert(v);
|
||||||
|
send(&st.connector, InputKind::KeyDown, v as u32, 0, 0, 0);
|
||||||
|
}
|
||||||
|
return LRESULT(1); // swallow so it reaches the host, not the local OS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unsafe { CallNextHookEx(None, code, wparam, lparam) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-area size in pixels (for the screen→host relative-motion scale).
|
||||||
|
fn client_size(hwnd: isize) -> (f32, f32) {
|
||||||
|
let mut rc = RECT::default();
|
||||||
|
if unsafe { GetClientRect(HWND(hwnd as *mut _), &mut rc) }.is_ok() {
|
||||||
|
(
|
||||||
|
(rc.right - rc.left).max(1) as f32,
|
||||||
|
(rc.bottom - rc.top).max(1) as f32,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(1.0, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||||
|
if code == HC_ACTION as i32 {
|
||||||
|
let ms = unsafe { &*(lparam.0 as *const MSLLHOOKSTRUCT) };
|
||||||
|
let msg = wparam.0 as u32;
|
||||||
|
let injected = (ms.flags & LLMHF_INJECTED) != 0;
|
||||||
|
let mut guard = STATE.lock().unwrap();
|
||||||
|
if let Some(st) = guard.as_mut() {
|
||||||
|
let foreground = unsafe { GetForegroundWindow() }.0 as isize == st.hwnd;
|
||||||
|
let want_lock = st.captured && foreground;
|
||||||
|
if want_lock != st.locked {
|
||||||
|
set_locked(st, want_lock); // sync to focus changes (e.g. lost foreground)
|
||||||
|
}
|
||||||
|
if st.locked {
|
||||||
|
// Skip the synthetic move our own SetCursorPos recentre generates.
|
||||||
|
if injected {
|
||||||
|
return unsafe { CallNextHookEx(None, code, wparam, lparam) };
|
||||||
|
}
|
||||||
|
let c = st.connector.clone();
|
||||||
|
match msg {
|
||||||
|
WM_MOUSEMOVE => {
|
||||||
|
let dx = (ms.pt.x - st.center_x) as f32;
|
||||||
|
let dy = (ms.pt.y - st.center_y) as f32;
|
||||||
|
if dx != 0.0 || dy != 0.0 {
|
||||||
|
// screen px → host px: the Contain-fit display scale's inverse, so the
|
||||||
|
// host cursor tracks the physical mouse 1:1 on screen at any window size.
|
||||||
|
let (ww, wh) = client_size(st.hwnd);
|
||||||
|
let (vw, vh) =
|
||||||
|
(st.mode.width.max(1) as f32, st.mode.height.max(1) as f32);
|
||||||
|
let s = (ww / vw).min(wh / vh).max(0.01);
|
||||||
|
st.acc_x += dx / s;
|
||||||
|
st.acc_y += dy / s;
|
||||||
|
let (hx, hy) = (st.acc_x.trunc() as i32, st.acc_y.trunc() as i32);
|
||||||
|
st.acc_x -= hx as f32;
|
||||||
|
st.acc_y -= hy as f32;
|
||||||
|
if hx != 0 || hy != 0 {
|
||||||
|
send(&c, InputKind::MouseMove, 0, hx, hy, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = unsafe { SetCursorPos(st.center_x, st.center_y) };
|
||||||
|
}
|
||||||
|
WM_LBUTTONDOWN => button(st, 1, true),
|
||||||
|
WM_LBUTTONUP => button(st, 1, false),
|
||||||
|
WM_RBUTTONDOWN => button(st, 3, true),
|
||||||
|
WM_RBUTTONUP => button(st, 3, false),
|
||||||
|
WM_MBUTTONDOWN => button(st, 2, true),
|
||||||
|
WM_MBUTTONUP => button(st, 2, false),
|
||||||
|
WM_XBUTTONDOWN => button(st, 3 + ((ms.mouseData >> 16) as u16 as u32), true),
|
||||||
|
WM_XBUTTONUP => button(st, 3 + ((ms.mouseData >> 16) as u16 as u32), false),
|
||||||
|
WM_MOUSEWHEEL => send(
|
||||||
|
&c,
|
||||||
|
InputKind::MouseScroll,
|
||||||
|
0,
|
||||||
|
(ms.mouseData >> 16) as i16 as i32,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
WM_MOUSEHWHEEL => send(
|
||||||
|
&c,
|
||||||
|
InputKind::MouseScroll,
|
||||||
|
1,
|
||||||
|
(ms.mouseData >> 16) as i16 as i32,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return LRESULT(1); // swallow inside the locked window
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unsafe { CallNextHookEx(None, code, wparam, lparam) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn button(st: &mut State, id: u32, down: bool) {
|
||||||
|
let c = st.connector.clone();
|
||||||
|
if down {
|
||||||
|
st.held_buttons.insert(id);
|
||||||
|
send(&c, InputKind::MouseButtonDown, id, 0, 0, 0);
|
||||||
|
} else if st.held_buttons.remove(&id) {
|
||||||
|
send(&c, InputKind::MouseButtonUp, id, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
//! punktfunk-client (open the WinUI 3 window: host list, settings, pairing)
|
//! punktfunk-client (open the WinUI 3 window: host list, settings, pairing)
|
||||||
//! punktfunk-client --discover (list punktfunk hosts on the LAN)
|
//! punktfunk-client --discover (list punktfunk hosts on the LAN)
|
||||||
//! punktfunk-client --headless --connect host[:port] [--pin HEX] [--pair PIN] [--mode WxHxHz]
|
//! punktfunk-client --headless --connect host[:port] [--pin HEX] [--pair PIN] [--mode WxHxHz]
|
||||||
//! [--bitrate MBPS] [--mic] (no window; count frames + print stats)
|
//! [--bitrate MBPS] [--mic] [--decoder auto|hardware|software] [--no-hdr]
|
||||||
|
//! (no window; count frames + print stats)
|
||||||
|
|
||||||
// Link as a GUI (windows) subsystem binary so the default windowed launch (MSIX / double-click)
|
// Link as a GUI (windows) subsystem binary so the default windowed launch (MSIX / double-click)
|
||||||
// does NOT pop a console window. The CLI paths (--headless/--discover) reattach to the launching
|
// does NOT pop a console window. The CLI paths (--headless/--discover) reattach to the launching
|
||||||
@@ -26,6 +27,8 @@ mod discovery;
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod gamepad;
|
mod gamepad;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
mod gpu;
|
||||||
|
#[cfg(windows)]
|
||||||
mod input;
|
mod input;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod present;
|
mod present;
|
||||||
@@ -83,7 +86,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `--headless --connect host[:port] …`: connect from the CLI, count frames, print stats — the
|
/// `--headless --connect host[:port] …`: connect from the CLI, count frames, print stats — the
|
||||||
/// Windows analogue of `punktfunk-client-rs`.
|
/// Windows analogue of `punktfunk-probe`.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn run_headless_cli(args: &[String], identity: (String, String)) {
|
fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||||
@@ -162,7 +165,11 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(%host, port, ?mode, tofu = pin.is_none(), "connecting (headless)");
|
let decoder = arg("--decoder")
|
||||||
|
.map(|d| crate::video::DecoderPref::from_name(&d))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
tracing::info!(%host, port, ?mode, tofu = pin.is_none(), ?decoder, "connecting (headless)");
|
||||||
let handle = session::start(session::SessionParams {
|
let handle = session::start(session::SessionParams {
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
@@ -171,6 +178,8 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
|||||||
gamepad: GamepadPref::Auto,
|
gamepad: GamepadPref::Auto,
|
||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
mic_enabled: flag("--mic"),
|
mic_enabled: flag("--mic"),
|
||||||
|
hdr_enabled: !flag("--no-hdr"),
|
||||||
|
decoder,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
});
|
});
|
||||||
@@ -241,18 +250,18 @@ fn discover_and_print() {
|
|||||||
std::thread::sleep(Duration::from_millis(100));
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
if seen.is_empty() {
|
if seen.is_empty() {
|
||||||
println!(" (none found — is a host running with --native / m3-host?)");
|
println!(" (none found — is a host running with --native / punktfunk1-host?)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WinUI 3 / Direct3D11 / WASAPI / SDL3 are Windows turf; this stub keeps `cargo build
|
/// WinUI 3 / Direct3D11 / WASAPI / SDL3 are Windows turf; this stub keeps `cargo build
|
||||||
/// --workspace` green on Linux/macOS (the other native clients live in
|
/// --workspace` green on Linux/macOS (the other native clients live in
|
||||||
/// crates/punktfunk-client-linux and clients/apple).
|
/// clients/linux and clients/apple).
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
fn main() {
|
fn main() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"punktfunk-client-windows is Windows-only — the Linux client lives in \
|
"punktfunk-client-windows is Windows-only — the Linux client lives in \
|
||||||
crates/punktfunk-client-linux, the macOS client in clients/apple"
|
clients/linux, the macOS client in clients/apple"
|
||||||
);
|
);
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
//! Direct3D11 presenter for a WinUI 3 `SwapChainPanel`. It draws a decoded frame Contain-fit into a
|
||||||
|
//! **composition** flip-model swapchain, which the reactor stream page binds to the panel via
|
||||||
|
//! `SwapChainPanelHandle::set_swap_chain`.
|
||||||
|
//!
|
||||||
|
//! Two frame sources, one swapchain:
|
||||||
|
//!
|
||||||
|
//! * **GPU (zero-copy)** — [`crate::video::GpuFrame`] is a decoder-owned NV12/P010 `ID3D11Texture2D`
|
||||||
|
//! array slice (D3D11VA). We create per-plane shader-resource views over the slice and convert
|
||||||
|
//! YUV→RGB in a pixel shader: NV12 via BT.709 (`ps_nv12`), P010 via BT.2020 with the PQ transfer
|
||||||
|
//! left intact (`ps_p010`). No CPU copy. The decoder uses the **same** shared device
|
||||||
|
//! ([`crate::gpu`]) so the texture is bindable here.
|
||||||
|
//! * **CPU upload** — [`crate::video::CpuFrame`] is packed RGBA (SDR) or X2BGR10 (HDR) from the
|
||||||
|
//! software decoder; we upload it into a dynamic texture and draw it with a passthrough shader
|
||||||
|
//! (`ps_rgba`). The fallback path.
|
||||||
|
//!
|
||||||
|
//! **HDR10**: when a frame is BT.2020 PQ the swapchain flips to `R10G10B10A2` +
|
||||||
|
//! `DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020` (+ HDR10 metadata) via `ResizeBuffers`/
|
||||||
|
//! `SetColorSpace1`; the shader output is already PQ-encoded so the compositor maps PQ→display. SDR
|
||||||
|
//! stays 8-bit B8G8R8A8.
|
||||||
|
//!
|
||||||
|
//! All `windows` types here come from the same windows-rs commit as `windows-reactor`, so the
|
||||||
|
//! `IDXGISwapChain1` handed to `set_swap_chain` satisfies reactor's `windows_core::Interface`.
|
||||||
|
|
||||||
|
use crate::video::{DecodedFrame, GpuFrame};
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use windows::core::{Interface, PCSTR};
|
||||||
|
use windows::Win32::Graphics::Direct3D::Fxc::{D3DCompile, D3DCOMPILE_OPTIMIZATION_LEVEL3};
|
||||||
|
use windows::Win32::Graphics::Direct3D::{
|
||||||
|
ID3DBlob, D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, D3D_SRV_DIMENSION_TEXTURE2DARRAY,
|
||||||
|
};
|
||||||
|
use windows::Win32::Graphics::Direct3D11::*;
|
||||||
|
use windows::Win32::Graphics::Dxgi::Common::*;
|
||||||
|
use windows::Win32::Graphics::Dxgi::*;
|
||||||
|
|
||||||
|
// One vertex shader (fullscreen triangle) + three pixel shaders, selected per frame source. tex0 is
|
||||||
|
// RGBA (passthrough) or the luma plane; tex1 is the chroma plane. The YUV→RGB matrices fold the
|
||||||
|
// limited→full range scale into the coefficients; for P010 the R16 sample is rescaled (×65535/65472)
|
||||||
|
// to undo the 10-bits-in-the-high-bits packing, then converted with BT.2020 NCL, PQ preserved.
|
||||||
|
const SHADER_HLSL: &str = r#"
|
||||||
|
struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; };
|
||||||
|
VSOut vs_main(uint vid : SV_VertexID) {
|
||||||
|
float2 uv = float2((vid << 1) & 2, vid & 2);
|
||||||
|
VSOut o;
|
||||||
|
o.pos = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1);
|
||||||
|
o.uv = uv;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
Texture2D tex0 : register(t0);
|
||||||
|
Texture2D tex1 : register(t1);
|
||||||
|
SamplerState smp : register(s0);
|
||||||
|
|
||||||
|
float4 ps_rgba(VSOut i) : SV_Target { return tex0.Sample(smp, i.uv); }
|
||||||
|
|
||||||
|
float4 ps_nv12(VSOut i) : SV_Target {
|
||||||
|
float y = tex0.Sample(smp, i.uv).r;
|
||||||
|
float2 uv = tex1.Sample(smp, i.uv).rg;
|
||||||
|
float yy = (y - 0.0627451) * 1.164384; // (Y-16/255)*255/219
|
||||||
|
float u = uv.x - 0.5;
|
||||||
|
float v = uv.y - 0.5; // BT.709 limited, chroma scale folded
|
||||||
|
float r = yy + 1.792741 * v;
|
||||||
|
float g = yy - 0.213249 * u - 0.532909 * v;
|
||||||
|
float b = yy + 2.112402 * u;
|
||||||
|
return float4(saturate(float3(r, g, b)), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
float4 ps_p010(VSOut i) : SV_Target {
|
||||||
|
const float S = 65535.0 / 65472.0; // undo P010 high-bit packing → exact 10-bit / 1023
|
||||||
|
float y = tex0.Sample(smp, i.uv).r * S;
|
||||||
|
float2 uv = tex1.Sample(smp, i.uv).rg * S;
|
||||||
|
float yy = (y - 0.0625611) * 1.167808; // (Y-64/1023)*1023/876
|
||||||
|
float u = uv.x - 0.5;
|
||||||
|
float v = uv.y - 0.5; // BT.2020 NCL limited, chroma scale folded; PQ kept
|
||||||
|
float r = yy + 1.683611 * v;
|
||||||
|
float g = yy - 0.187877 * u - 0.652337 * v;
|
||||||
|
float b = yy + 2.148072 * u;
|
||||||
|
return float4(saturate(float3(r, g, b)), 1.0);
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// A bound GPU frame: per-plane SRVs over the decoder's texture-array slice, plus the `GpuFrame`
|
||||||
|
/// itself kept alive so the decoder won't recycle the slice while we re-present it.
|
||||||
|
struct GpuView {
|
||||||
|
y: ID3D11ShaderResourceView,
|
||||||
|
c: ID3D11ShaderResourceView,
|
||||||
|
/// Held only for its `Drop` (returns the decoder surface to the reuse pool) — never read.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
frame: GpuFrame,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current draw source.
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum Mode {
|
||||||
|
Empty,
|
||||||
|
Rgba,
|
||||||
|
Nv12,
|
||||||
|
P010,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Presenter {
|
||||||
|
device: ID3D11Device,
|
||||||
|
context: ID3D11DeviceContext,
|
||||||
|
vs: ID3D11VertexShader,
|
||||||
|
ps_rgba: ID3D11PixelShader,
|
||||||
|
ps_nv12: ID3D11PixelShader,
|
||||||
|
ps_p010: ID3D11PixelShader,
|
||||||
|
sampler: ID3D11SamplerState,
|
||||||
|
swap: IDXGISwapChain1,
|
||||||
|
rtv: Option<ID3D11RenderTargetView>,
|
||||||
|
/// CPU-upload texture + SRV + dimensions; recreated when the decoded size/format changes.
|
||||||
|
cpu_tex: Option<(ID3D11Texture2D, ID3D11ShaderResourceView, u32, u32)>,
|
||||||
|
/// Bound zero-copy GPU frame (held to keep its decoder surface alive).
|
||||||
|
gpu: Option<GpuView>,
|
||||||
|
mode: Mode,
|
||||||
|
/// Source frame dimensions, for the Contain-fit letterbox.
|
||||||
|
src_w: u32,
|
||||||
|
src_h: u32,
|
||||||
|
/// Panel (swapchain) size in pixels, updated on resize.
|
||||||
|
panel_w: u32,
|
||||||
|
panel_h: u32,
|
||||||
|
/// Whether the swapchain is currently in 10-bit HDR10 (R10G10B10A2 + ST.2084) mode.
|
||||||
|
hdr: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Presenter {
|
||||||
|
/// Create the presenter on the process-wide shared D3D11 device (the one the decoder uses), plus
|
||||||
|
/// the composition swapchain + shaders, sized to the panel.
|
||||||
|
pub fn new(width: u32, height: u32) -> Result<Presenter> {
|
||||||
|
let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?;
|
||||||
|
let device = shared.device.clone();
|
||||||
|
let context = shared.context.clone();
|
||||||
|
let (vs, ps_rgba, ps_nv12, ps_p010, sampler) = build_pipeline(&device)?;
|
||||||
|
let swap = create_composition_swapchain(&device, width.max(1), height.max(1))?;
|
||||||
|
Ok(Presenter {
|
||||||
|
device,
|
||||||
|
context,
|
||||||
|
vs,
|
||||||
|
ps_rgba,
|
||||||
|
ps_nv12,
|
||||||
|
ps_p010,
|
||||||
|
sampler,
|
||||||
|
swap,
|
||||||
|
rtv: None,
|
||||||
|
cpu_tex: None,
|
||||||
|
gpu: None,
|
||||||
|
mode: Mode::Empty,
|
||||||
|
src_w: 1,
|
||||||
|
src_h: 1,
|
||||||
|
panel_w: width.max(1),
|
||||||
|
panel_h: height.max(1),
|
||||||
|
hdr: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The DXGI swapchain to hand to `SwapChainPanelHandle::set_swap_chain`.
|
||||||
|
pub fn swap_chain(&self) -> &IDXGISwapChain1 {
|
||||||
|
&self.swap
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize the back buffers to the panel's new size (drops the stale RTV).
|
||||||
|
pub fn resize(&mut self, width: u32, height: u32) {
|
||||||
|
if width == 0 || height == 0 || (width == self.panel_w && height == self.panel_h) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.rtv = None; // release all back-buffer refs before ResizeBuffers
|
||||||
|
unsafe {
|
||||||
|
let _ = self.swap.ResizeBuffers(
|
||||||
|
0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
DXGI_FORMAT_UNKNOWN,
|
||||||
|
DXGI_SWAP_CHAIN_FLAG(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.panel_w = width;
|
||||||
|
self.panel_h = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Present one decoded frame (Contain-fit) — or, when `frame` is `None`, re-present the last one
|
||||||
|
/// (or black). Called from the reactor `on_rendering` per-frame callback on the UI thread. Takes
|
||||||
|
/// the frame by value so the GPU path can retain the decoder surface across re-presents.
|
||||||
|
pub fn present(&mut self, frame: Option<DecodedFrame>) {
|
||||||
|
match frame {
|
||||||
|
Some(DecodedFrame::Cpu(c)) => {
|
||||||
|
if c.hdr != self.hdr {
|
||||||
|
self.set_hdr(c.hdr);
|
||||||
|
}
|
||||||
|
if let Err(e) = self.upload(&c) {
|
||||||
|
tracing::warn!(error = %e, "frame upload failed");
|
||||||
|
} else {
|
||||||
|
self.mode = Mode::Rgba;
|
||||||
|
self.src_w = c.width;
|
||||||
|
self.src_h = c.height;
|
||||||
|
self.gpu = None; // drop any held GPU frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(DecodedFrame::Gpu(g)) => {
|
||||||
|
if g.hdr != self.hdr {
|
||||||
|
self.set_hdr(g.hdr);
|
||||||
|
}
|
||||||
|
match self.bind_gpu(g) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "GPU frame bind failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
self.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build per-plane SRVs over the decoded texture-array slice and retain the frame.
|
||||||
|
fn bind_gpu(&mut self, g: GpuFrame) -> Result<()> {
|
||||||
|
let tex: ID3D11Texture2D = unsafe {
|
||||||
|
let raw = g.texture_ptr();
|
||||||
|
ID3D11Texture2D::from_raw_borrowed(&raw)
|
||||||
|
.ok_or_else(|| anyhow!("null D3D11 texture"))?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
// NV12: R8 luma + R8G8 chroma. P010: R16 luma + R16G16 chroma (10 bits in the high bits).
|
||||||
|
let (fy, fc) = if g.hdr {
|
||||||
|
(DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R16G16_UNORM)
|
||||||
|
} else {
|
||||||
|
(DXGI_FORMAT_R8_UNORM, DXGI_FORMAT_R8G8_UNORM)
|
||||||
|
};
|
||||||
|
let y = self.array_srv(&tex, fy, g.index)?;
|
||||||
|
let c = self.array_srv(&tex, fc, g.index)?;
|
||||||
|
self.mode = if g.hdr { Mode::P010 } else { Mode::Nv12 };
|
||||||
|
self.src_w = g.width;
|
||||||
|
self.src_h = g.height;
|
||||||
|
self.gpu = Some(GpuView { y, c, frame: g });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shader-resource view over a single slice of a texture array, reinterpreting the plane
|
||||||
|
/// format (the NV12/P010 sub-format trick D3D11 allows on video textures).
|
||||||
|
fn array_srv(
|
||||||
|
&self,
|
||||||
|
tex: &ID3D11Texture2D,
|
||||||
|
format: DXGI_FORMAT,
|
||||||
|
slice: u32,
|
||||||
|
) -> Result<ID3D11ShaderResourceView> {
|
||||||
|
let desc = D3D11_SHADER_RESOURCE_VIEW_DESC {
|
||||||
|
Format: format,
|
||||||
|
ViewDimension: D3D_SRV_DIMENSION_TEXTURE2DARRAY,
|
||||||
|
Anonymous: D3D11_SHADER_RESOURCE_VIEW_DESC_0 {
|
||||||
|
Texture2DArray: D3D11_TEX2D_ARRAY_SRV {
|
||||||
|
MostDetailedMip: 0,
|
||||||
|
MipLevels: 1,
|
||||||
|
FirstArraySlice: slice,
|
||||||
|
ArraySize: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
let mut srv = None;
|
||||||
|
self.device
|
||||||
|
.CreateShaderResourceView(tex, Some(&desc), Some(&mut srv))
|
||||||
|
.context("CreateShaderResourceView (array slice)")?;
|
||||||
|
srv.ok_or_else(|| anyhow!("null SRV"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&mut self) {
|
||||||
|
let Ok(rtv) = self.rtv() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (pw, ph) = (self.panel_w, self.panel_h);
|
||||||
|
// Resolve the current source's shader + the (up to two) SRVs to bind — cheap interface
|
||||||
|
// clones. Each arm yields `Option<(&pixel_shader, [Option<SRV>; 2])>`.
|
||||||
|
let binding = match self.mode {
|
||||||
|
Mode::Rgba => self
|
||||||
|
.cpu_tex
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, srv, _, _)| (&self.ps_rgba, [Some(srv.clone()), None])),
|
||||||
|
Mode::Nv12 => self
|
||||||
|
.gpu
|
||||||
|
.as_ref()
|
||||||
|
.map(|g| (&self.ps_nv12, [Some(g.y.clone()), Some(g.c.clone())])),
|
||||||
|
Mode::P010 => self
|
||||||
|
.gpu
|
||||||
|
.as_ref()
|
||||||
|
.map(|g| (&self.ps_p010, [Some(g.y.clone()), Some(g.c.clone())])),
|
||||||
|
Mode::Empty => None,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
let c = &self.context;
|
||||||
|
c.ClearRenderTargetView(&rtv, &[0.0, 0.0, 0.0, 1.0]);
|
||||||
|
if let Some((ps, srvs)) = binding {
|
||||||
|
// Contain-fit viewport: scale to the smaller axis, centre, letterbox the rest.
|
||||||
|
let (ww, wh, vfw, vfh) = (
|
||||||
|
pw as f32,
|
||||||
|
ph as f32,
|
||||||
|
self.src_w.max(1) as f32,
|
||||||
|
self.src_h.max(1) as f32,
|
||||||
|
);
|
||||||
|
let scale = (ww / vfw).min(wh / vfh);
|
||||||
|
let (dw, dh) = (vfw * scale, vfh * scale);
|
||||||
|
let (ox, oy) = ((ww - dw) / 2.0, (wh - dh) / 2.0);
|
||||||
|
c.OMSetRenderTargets(Some(&[Some(rtv.clone())]), None);
|
||||||
|
let vp = D3D11_VIEWPORT {
|
||||||
|
TopLeftX: ox,
|
||||||
|
TopLeftY: oy,
|
||||||
|
Width: dw,
|
||||||
|
Height: dh,
|
||||||
|
MinDepth: 0.0,
|
||||||
|
MaxDepth: 1.0,
|
||||||
|
};
|
||||||
|
c.RSSetViewports(Some(&[vp]));
|
||||||
|
c.IASetInputLayout(None);
|
||||||
|
c.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||||
|
c.VSSetShader(&self.vs, None);
|
||||||
|
c.PSSetShader(ps, None);
|
||||||
|
c.PSSetShaderResources(0, Some(&srvs));
|
||||||
|
c.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
|
||||||
|
c.Draw(3, 0);
|
||||||
|
}
|
||||||
|
let _ = self.swap.Present(1, DXGI_PRESENT(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch the swapchain between 8-bit SDR (B8G8R8A8, BT.709) and 10-bit HDR10 (R10G10B10A2,
|
||||||
|
/// ST.2084 PQ BT.2020). `ResizeBuffers` changes the back-buffer format in place, so the panel
|
||||||
|
/// binding (`set_swap_chain`) stays valid — no rebind. Both frame sources already produce
|
||||||
|
/// PQ-encoded BT.2020 for HDR, so the colour space is all the compositor needs.
|
||||||
|
fn set_hdr(&mut self, on: bool) {
|
||||||
|
self.rtv = None; // release back-buffer refs before ResizeBuffers
|
||||||
|
self.cpu_tex = None; // CPU texture format changes (R10G10B10A2 vs R8G8B8A8)
|
||||||
|
let format = if on {
|
||||||
|
DXGI_FORMAT_R10G10B10A2_UNORM
|
||||||
|
} else {
|
||||||
|
DXGI_FORMAT_B8G8R8A8_UNORM
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
if let Err(e) = self.swap.ResizeBuffers(
|
||||||
|
0,
|
||||||
|
self.panel_w,
|
||||||
|
self.panel_h,
|
||||||
|
format,
|
||||||
|
DXGI_SWAP_CHAIN_FLAG(0),
|
||||||
|
) {
|
||||||
|
tracing::warn!(error = %e, "ResizeBuffers for HDR switch failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let colorspace = if on {
|
||||||
|
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020
|
||||||
|
} else {
|
||||||
|
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709
|
||||||
|
};
|
||||||
|
if let Ok(sc3) = self.swap.cast::<IDXGISwapChain3>() {
|
||||||
|
// Only set a colour space the swapchain accepts for present (on an SDR desktop the
|
||||||
|
// DWM still tone-maps HDR10 → SDR, so leaving the default there is fine).
|
||||||
|
if let Ok(support) = sc3.CheckColorSpaceSupport(colorspace) {
|
||||||
|
if support & DXGI_SWAP_CHAIN_COLOR_SPACE_SUPPORT_FLAG_PRESENT.0 as u32 != 0 {
|
||||||
|
let _ = sc3.SetColorSpace1(colorspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
if let Ok(sc4) = self.swap.cast::<IDXGISwapChain4>() {
|
||||||
|
let md = hdr10_metadata();
|
||||||
|
let bytes = std::slice::from_raw_parts(
|
||||||
|
&md as *const DXGI_HDR_METADATA_HDR10 as *const u8,
|
||||||
|
std::mem::size_of::<DXGI_HDR_METADATA_HDR10>(),
|
||||||
|
);
|
||||||
|
let _ = sc4.SetHDRMetaData(DXGI_HDR_METADATA_TYPE_HDR10, Some(bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.hdr = on;
|
||||||
|
tracing::info!(hdr = on, "swapchain colour mode switched");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload(&mut self, frame: &crate::video::CpuFrame) -> Result<()> {
|
||||||
|
let (w, h) = (frame.width, frame.height);
|
||||||
|
let need_new = !matches!(&self.cpu_tex, Some((_, _, tw, th)) if *tw == w && *th == h);
|
||||||
|
if need_new {
|
||||||
|
let format = if self.hdr {
|
||||||
|
DXGI_FORMAT_R10G10B10A2_UNORM
|
||||||
|
} else {
|
||||||
|
DXGI_FORMAT_R8G8B8A8_UNORM
|
||||||
|
};
|
||||||
|
let desc = D3D11_TEXTURE2D_DESC {
|
||||||
|
Width: w,
|
||||||
|
Height: h,
|
||||||
|
MipLevels: 1,
|
||||||
|
ArraySize: 1,
|
||||||
|
Format: format,
|
||||||
|
SampleDesc: DXGI_SAMPLE_DESC {
|
||||||
|
Count: 1,
|
||||||
|
Quality: 0,
|
||||||
|
},
|
||||||
|
Usage: D3D11_USAGE_DYNAMIC,
|
||||||
|
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||||
|
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||||
|
MiscFlags: 0,
|
||||||
|
};
|
||||||
|
let texture = unsafe {
|
||||||
|
let mut t = None;
|
||||||
|
self.device
|
||||||
|
.CreateTexture2D(&desc, None, Some(&mut t))
|
||||||
|
.context("CreateTexture2D")?;
|
||||||
|
t.unwrap()
|
||||||
|
};
|
||||||
|
let srv = unsafe {
|
||||||
|
let mut s = None;
|
||||||
|
self.device
|
||||||
|
.CreateShaderResourceView(&texture, None, Some(&mut s))
|
||||||
|
.context("CreateShaderResourceView")?;
|
||||||
|
s.unwrap()
|
||||||
|
};
|
||||||
|
self.cpu_tex = Some((texture, srv, w, h));
|
||||||
|
}
|
||||||
|
let (texture, _, _, _) = self.cpu_tex.as_ref().unwrap();
|
||||||
|
unsafe {
|
||||||
|
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||||
|
self.context
|
||||||
|
.Map(texture, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
|
||||||
|
.context("Map video texture")?;
|
||||||
|
let dst = mapped.pData as *mut u8;
|
||||||
|
let dst_pitch = mapped.RowPitch as usize;
|
||||||
|
let src_pitch = frame.stride;
|
||||||
|
let row_bytes = (w as usize) * 4;
|
||||||
|
for y in 0..h as usize {
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
frame.pixels.as_ptr().add(y * src_pitch),
|
||||||
|
dst.add(y * dst_pitch),
|
||||||
|
row_bytes.min(src_pitch),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.context.Unmap(texture, 0);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rtv(&mut self) -> Result<ID3D11RenderTargetView> {
|
||||||
|
if self.rtv.is_none() {
|
||||||
|
let back: ID3D11Texture2D = unsafe { self.swap.GetBuffer(0).context("GetBuffer")? };
|
||||||
|
let rtv = unsafe {
|
||||||
|
let mut v = None;
|
||||||
|
self.device
|
||||||
|
.CreateRenderTargetView(&back, None, Some(&mut v))
|
||||||
|
.context("CreateRenderTargetView")?;
|
||||||
|
v.unwrap()
|
||||||
|
};
|
||||||
|
self.rtv = Some(rtv);
|
||||||
|
}
|
||||||
|
Ok(self.rtv.clone().unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A composition flip-model swapchain (no HWND) for binding to a XAML `SwapChainPanel`.
|
||||||
|
fn create_composition_swapchain(
|
||||||
|
device: &ID3D11Device,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> Result<IDXGISwapChain1> {
|
||||||
|
let dxdev: IDXGIDevice = device.cast().context("IDXGIDevice cast")?;
|
||||||
|
let factory: IDXGIFactory2 = unsafe {
|
||||||
|
let adapter = dxdev.GetAdapter().context("GetAdapter")?;
|
||||||
|
adapter.GetParent().context("GetParent (IDXGIFactory2)")?
|
||||||
|
};
|
||||||
|
let desc = DXGI_SWAP_CHAIN_DESC1 {
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||||
|
Stereo: false.into(),
|
||||||
|
SampleDesc: DXGI_SAMPLE_DESC {
|
||||||
|
Count: 1,
|
||||||
|
Quality: 0,
|
||||||
|
},
|
||||||
|
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||||
|
BufferCount: 2,
|
||||||
|
Scaling: DXGI_SCALING_STRETCH,
|
||||||
|
SwapEffect: DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL,
|
||||||
|
// IGNORE (opaque), not PREMULTIPLIED: the video fills the panel and the HDR `X2BGR10`
|
||||||
|
// upload leaves the 2 padding/alpha bits 0 — premultiplied alpha would then make HDR frames
|
||||||
|
// transparent. Opaque is correct for a full-frame video surface either way.
|
||||||
|
AlphaMode: DXGI_ALPHA_MODE_IGNORE,
|
||||||
|
Flags: 0,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
factory
|
||||||
|
.CreateSwapChainForComposition(device, &desc, None)
|
||||||
|
.context("CreateSwapChainForComposition")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_pipeline(
|
||||||
|
device: &ID3D11Device,
|
||||||
|
) -> Result<(
|
||||||
|
ID3D11VertexShader,
|
||||||
|
ID3D11PixelShader,
|
||||||
|
ID3D11PixelShader,
|
||||||
|
ID3D11PixelShader,
|
||||||
|
ID3D11SamplerState,
|
||||||
|
)> {
|
||||||
|
let vs_blob = compile(SHADER_HLSL, "vs_main", "vs_5_0")?;
|
||||||
|
let rgba_blob = compile(SHADER_HLSL, "ps_rgba", "ps_5_0")?;
|
||||||
|
let nv12_blob = compile(SHADER_HLSL, "ps_nv12", "ps_5_0")?;
|
||||||
|
let p010_blob = compile(SHADER_HLSL, "ps_p010", "ps_5_0")?;
|
||||||
|
unsafe {
|
||||||
|
let mut vs = None;
|
||||||
|
device
|
||||||
|
.CreateVertexShader(blob_bytes(&vs_blob), None, Some(&mut vs))
|
||||||
|
.context("CreateVertexShader")?;
|
||||||
|
let mut ps_rgba = None;
|
||||||
|
device
|
||||||
|
.CreatePixelShader(blob_bytes(&rgba_blob), None, Some(&mut ps_rgba))
|
||||||
|
.context("CreatePixelShader (rgba)")?;
|
||||||
|
let mut ps_nv12 = None;
|
||||||
|
device
|
||||||
|
.CreatePixelShader(blob_bytes(&nv12_blob), None, Some(&mut ps_nv12))
|
||||||
|
.context("CreatePixelShader (nv12)")?;
|
||||||
|
let mut ps_p010 = None;
|
||||||
|
device
|
||||||
|
.CreatePixelShader(blob_bytes(&p010_blob), None, Some(&mut ps_p010))
|
||||||
|
.context("CreatePixelShader (p010)")?;
|
||||||
|
let sdesc = D3D11_SAMPLER_DESC {
|
||||||
|
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
|
||||||
|
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||||
|
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||||
|
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||||
|
MaxLOD: D3D11_FLOAT32_MAX,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut sampler = None;
|
||||||
|
device
|
||||||
|
.CreateSamplerState(&sdesc, Some(&mut sampler))
|
||||||
|
.context("CreateSamplerState")?;
|
||||||
|
Ok((
|
||||||
|
vs.unwrap(),
|
||||||
|
ps_rgba.unwrap(),
|
||||||
|
ps_nv12.unwrap(),
|
||||||
|
ps_p010.unwrap(),
|
||||||
|
sampler.unwrap(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile(src: &str, entry: &str, target: &str) -> Result<ID3DBlob> {
|
||||||
|
let entry_c = std::ffi::CString::new(entry).unwrap();
|
||||||
|
let target_c = std::ffi::CString::new(target).unwrap();
|
||||||
|
let mut code = None;
|
||||||
|
let mut errors = None;
|
||||||
|
let r = unsafe {
|
||||||
|
D3DCompile(
|
||||||
|
src.as_ptr() as *const _,
|
||||||
|
src.len(),
|
||||||
|
PCSTR::null(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
PCSTR(entry_c.as_ptr() as *const u8),
|
||||||
|
PCSTR(target_c.as_ptr() as *const u8),
|
||||||
|
D3DCOMPILE_OPTIMIZATION_LEVEL3,
|
||||||
|
0,
|
||||||
|
&mut code,
|
||||||
|
Some(&mut errors),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if r.is_err() {
|
||||||
|
let msg = errors
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| unsafe {
|
||||||
|
let p = b.GetBufferPointer() as *const u8;
|
||||||
|
let n = b.GetBufferSize();
|
||||||
|
String::from_utf8_lossy(std::slice::from_raw_parts(p, n)).to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
return Err(anyhow!("D3DCompile {entry}: {msg}"));
|
||||||
|
}
|
||||||
|
code.ok_or_else(|| anyhow!("D3DCompile produced no bytecode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blob_bytes(blob: &ID3DBlob) -> &[u8] {
|
||||||
|
unsafe {
|
||||||
|
let p = blob.GetBufferPointer() as *const u8;
|
||||||
|
let n = blob.GetBufferSize();
|
||||||
|
std::slice::from_raw_parts(p, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic HDR10 mastering metadata: BT.2020 primaries + D65 white, a 1000-nit mastering display,
|
||||||
|
/// MaxCLL 1000 / MaxFALL 400. The protocol doesn't carry the stream's real mastering metadata yet
|
||||||
|
/// (host follow-up), so these are sane defaults the display tone-maps from.
|
||||||
|
fn hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 {
|
||||||
|
DXGI_HDR_METADATA_HDR10 {
|
||||||
|
RedPrimary: [35400, 14600],
|
||||||
|
GreenPrimary: [8500, 39850],
|
||||||
|
BluePrimary: [6550, 2300],
|
||||||
|
WhitePoint: [15635, 16450],
|
||||||
|
MaxMasteringLuminance: 1000,
|
||||||
|
MinMasteringLuminance: 1, // 0.0001-nit units → 0.0001 nits
|
||||||
|
MaxContentLightLevel: 1000,
|
||||||
|
MaxFrameAverageLightLevel: 400,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
//! (software-only here) and the audio backend (WASAPI). The pump body is identical.
|
//! (software-only here) and the audio backend (WASAPI). The pump body is identical.
|
||||||
|
|
||||||
use crate::audio;
|
use crate::audio;
|
||||||
use crate::video::{DecodedFrame, Decoder};
|
use crate::video::{DecodedFrame, Decoder, DecoderPref};
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||||
use punktfunk_core::PunktfunkError;
|
use punktfunk_core::PunktfunkError;
|
||||||
@@ -25,18 +25,26 @@ pub struct SessionParams {
|
|||||||
pub bitrate_kbps: u32,
|
pub bitrate_kbps: u32,
|
||||||
/// Stream the default microphone to the host's virtual mic source.
|
/// Stream the default microphone to the host's virtual mic source.
|
||||||
pub mic_enabled: bool,
|
pub mic_enabled: bool,
|
||||||
|
/// Advertise 10-bit + HDR10 so the host may upgrade HDR content to a Main10/PQ stream.
|
||||||
|
pub hdr_enabled: bool,
|
||||||
|
/// Which video decode backend to use (auto/hardware/software).
|
||||||
|
pub decoder: DecoderPref,
|
||||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||||
pub pin: Option<[u8; 32]>,
|
pub pin: Option<[u8; 32]>,
|
||||||
pub identity: (String, String),
|
pub identity: (String, String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default)]
|
#[derive(Clone, Copy, Default, PartialEq)]
|
||||||
pub struct Stats {
|
pub struct Stats {
|
||||||
pub fps: f32,
|
pub fps: f32,
|
||||||
pub mbps: f32,
|
pub mbps: f32,
|
||||||
pub decode_ms: f32,
|
pub decode_ms: f32,
|
||||||
/// Median capture→decoded latency over the last window (host-clock corrected).
|
/// Median capture→decoded latency over the last window (host-clock corrected).
|
||||||
pub latency_ms: f32,
|
pub latency_ms: f32,
|
||||||
|
/// True when decoding on the GPU (D3D11VA zero-copy) vs. CPU (software).
|
||||||
|
pub hardware: bool,
|
||||||
|
/// True when the stream is BT.2020 PQ HDR10 (last decoded frame).
|
||||||
|
pub hdr: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum SessionEvent {
|
pub enum SessionEvent {
|
||||||
@@ -99,6 +107,15 @@ fn pump(
|
|||||||
params.compositor,
|
params.compositor,
|
||||||
params.gamepad,
|
params.gamepad,
|
||||||
params.bitrate_kbps,
|
params.bitrate_kbps,
|
||||||
|
// Advertise 10-bit + HDR10 (when enabled): the presenter handles BT.2020 PQ frames (P010 on
|
||||||
|
// the GPU path, X2BGR10 on software), so the host may upgrade HDR content to a Main10/PQ
|
||||||
|
// stream — it still only does so for actual HDR content with its own 10-bit gate. 8-bit SDR
|
||||||
|
// is unaffected. A client that turns HDR off advertises `0` and always gets the 8-bit stream.
|
||||||
|
if params.hdr_enabled {
|
||||||
|
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
None, // launch: the Windows client has no library picker yet
|
None, // launch: the Windows client has no library picker yet
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
@@ -128,13 +145,15 @@ fn pump(
|
|||||||
fingerprint: connector.host_fingerprint,
|
fingerprint: connector.host_fingerprint,
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut decoder = match Decoder::new() {
|
let mut decoder = match Decoder::new(params.decoder) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
|
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let mut hardware = decoder.is_hardware();
|
||||||
|
let mut hdr = false;
|
||||||
// Audio is best-effort: a session without it still streams. Gamepads are the
|
// Audio is best-effort: a session without it still streams. Gamepads are the
|
||||||
// app-lifetime service's job (the UI attaches it on Connected).
|
// app-lifetime service's job (the UI attaches it on Connected).
|
||||||
let player = audio::AudioPlayer::spawn()
|
let player = audio::AudioPlayer::spawn()
|
||||||
@@ -174,12 +193,16 @@ fn pump(
|
|||||||
match decoder.decode(&frame.data) {
|
match decoder.decode(&frame.data) {
|
||||||
Ok(Some(decoded)) => {
|
Ok(Some(decoded)) => {
|
||||||
total_frames += 1;
|
total_frames += 1;
|
||||||
|
hdr = decoded.hdr();
|
||||||
|
// The backend can demote D3D11VA → software mid-session on a hardware error.
|
||||||
|
hardware = decoder.is_hardware();
|
||||||
if total_frames == 1 {
|
if total_frames == 1 {
|
||||||
let DecodedFrame::Cpu(c) = &decoded;
|
let (w, h) = decoded.dims();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
width = c.width,
|
width = w,
|
||||||
height = c.height,
|
height = h,
|
||||||
path = "software",
|
path = if hardware { "d3d11va" } else { "software" },
|
||||||
|
hdr,
|
||||||
"first frame decoded"
|
"first frame decoded"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -249,6 +272,8 @@ fn pump(
|
|||||||
0.0
|
0.0
|
||||||
},
|
},
|
||||||
latency_ms: p50 as f32 / 1000.0,
|
latency_ms: p50 as f32 / 1000.0,
|
||||||
|
hardware,
|
||||||
|
hdr,
|
||||||
}));
|
}));
|
||||||
window_start = Instant::now();
|
window_start = Instant::now();
|
||||||
frames_n = 0;
|
frames_n = 0;
|
||||||