Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b57e414618 | |||
| ec40a4062f | |||
| 19c35de3d7 | |||
| aa012c6b45 | |||
| 74c9e46faf | |||
| 95b3496bb5 | |||
| 334f36ce25 | |||
| 88348153f3 | |||
| 4a87cef98c | |||
| fc1e8a8a32 | |||
| 69f4c987f6 | |||
| 468a60c88a | |||
| fad1e01408 | |||
| 04a397be84 | |||
| ccbd7e8880 | |||
| a0546b36b6 | |||
| b71dc94bb2 | |||
| c2bc72a8e9 | |||
| b53710da1a | |||
| c1acfe8b85 | |||
| 2e43fcc27c | |||
| 2aa7ac8c7e | |||
| 6b4f9f86ed | |||
| 8986667b78 | |||
| 62e0367f4b | |||
| 677a4f4cf5 | |||
| fa45608628 | |||
| a7ff1cf312 | |||
| 87435e6547 | |||
| e0f15822ae | |||
| a5dc3134de | |||
| eddcd91f48 | |||
| 23446fa177 | |||
| 980939ed6b | |||
| cfad0cf7ee | |||
| 42b1158ea7 | |||
| 029d1134a9 | |||
| e35b6991e2 | |||
| 913f6ce659 | |||
| d23bd9b0cf | |||
| eda7cac78e | |||
| d73951414c | |||
| b150d79626 | |||
| cb7ddc0411 | |||
| 60816709c4 | |||
| 783c52dfad | |||
| e27718b406 | |||
| 6bc893e394 | |||
| f0d015fc45 | |||
| 2dd17dda80 | |||
| 87f0ce7997 | |||
| bbd98241e4 | |||
| 202f40fd4e | |||
| 8f90563ffd | |||
| 2e6b822fd6 | |||
| f7c5314b5e | |||
| d6669fc3fb | |||
| e292084225 | |||
| c758b0393a | |||
| d6a659a1ee | |||
| 2190dad2ad | |||
| 5b5ec15ead | |||
| c9ff144492 | |||
| 7930d2f0f4 | |||
| 160b67d043 | |||
| 6c4ba77606 | |||
| eeee2782f5 | |||
| b488bd1d99 | |||
| 7e6561aaa2 | |||
| e9c5030190 | |||
| 22c0d92f2e | |||
| 097cc6faf4 | |||
| 8b37badae4 | |||
| 90c2d8b3a0 | |||
| 853e7fe92f | |||
| df496776b0 | |||
| 5310176ab5 | |||
| 76ff616dcf | |||
| ac706ba839 | |||
| 94b5f48d0b | |||
| 139d032e55 | |||
| caa7a1c735 | |||
| 13dc7fc49f | |||
| 57ae00a9c8 | |||
| 882a3d57f6 | |||
| fa28fa19a0 | |||
| 42595b5558 | |||
| 4de543c146 | |||
| 42d1c74663 | |||
| 136f6e8f0e | |||
| 00acf5e44e | |||
| 38078fe7ee | |||
| 69609945a3 | |||
| 8470419433 | |||
| 449a67ce8d | |||
| 09a5957c6d | |||
| c7630ff5dc | |||
| 2c7ded0f3c | |||
| b7048446c4 | |||
| 3039626b87 | |||
| 3f33ed30ae | |||
| 7e31020c1c | |||
| fe54aff658 | |||
| b46aa15afb | |||
| 058630f542 | |||
| e9c1f4083a | |||
| 20f0d2802f | |||
| 6f8fb15c9b | |||
| 89455032a0 | |||
| 0da9d8ec10 | |||
| fbf3fea0c8 | |||
| c52ae119e1 | |||
| 5d7aabe8f0 | |||
| f204a89cef | |||
| 24fa018c70 | |||
| 51a6ca7e02 | |||
| b9fde03f1e | |||
| efb1ba26d7 | |||
| 1320e3dc66 | |||
| 1be83575b6 | |||
| 4d1d20f832 | |||
| 6e875fea44 | |||
| 4f3cd24036 | |||
| af13f0b749 | |||
| d285d4a0b2 | |||
| 04f370999c | |||
| 2c937855b3 | |||
| 8005b11faf | |||
| 01fcb01019 | |||
| 95a08e99c3 | |||
| a3e1ea2b44 | |||
| 6686fcdded |
@@ -0,0 +1,9 @@
|
||||
# Shown on the "new issue" chooser so security reports go to the private channel, not a public issue.
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 🔒 Report a security vulnerability
|
||||
url: https://git.unom.io/unom/punktfunk/src/branch/main/SECURITY.md
|
||||
about: >-
|
||||
Found a security issue? Please report it privately by email to security@punktfunk.com — do not
|
||||
open a public issue, so other users aren't exposed before a fix ships. See SECURITY.md for the
|
||||
full policy.
|
||||
@@ -1,5 +1,5 @@
|
||||
# Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via
|
||||
# cargo-ndk for both shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
|
||||
# cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
|
||||
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
|
||||
#
|
||||
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
fi
|
||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-linux-android x86_64-linux-android
|
||||
"$RUSTUP" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||
|
||||
- name: Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
@@ -78,9 +78,10 @@ jobs:
|
||||
- name: Version + channel
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
||||
*) VN="0.5.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
esac
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
@@ -97,7 +98,7 @@ jobs:
|
||||
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.
|
||||
# AAB for Play; a universal APK (all 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.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from
|
||||
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
|
||||
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
|
||||
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
|
||||
# resolves today — the same sonames an up-to-date Arch box runs.
|
||||
#
|
||||
# Registry (public, unom org) — box setup (once), see packaging/arch/README.md. The registry
|
||||
# SIGNS the DB + packages, so the box imports the registry key first (pacman-key --add +
|
||||
# --lsign-key), then no SigLevel line is needed (pacman's default Required verifies):
|
||||
# [punktfunk] # or [punktfunk-canary] for main-push builds
|
||||
# Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
|
||||
#
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
|
||||
# NOTE: this token + the registry-held private key are the trust root — a token holder can
|
||||
# publish a validly-signed package (the signature attests "via the registry", not "built by CI").
|
||||
name: arch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the
|
||||
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
|
||||
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
|
||||
jobs:
|
||||
build-publish:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: docker.io/library/archlinux:base-devel
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_HOME: /usr/local/cargo
|
||||
steps:
|
||||
# git + nodejs must exist before actions/checkout — base-devel ships neither, and
|
||||
# act_runner runs the action's JS with the CONTAINER's node, it does not inject one.
|
||||
- name: Install build + runtime-dev deps
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed \
|
||||
git nodejs rust clang cmake nasm pkgconf python \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
mesa libglvnd unzip libarchive
|
||||
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
||||
# it's AUR-only on Arch, so bootstrap the official binary.
|
||||
command -v bun >/dev/null || {
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||
}
|
||||
bun --version
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Cache cargo's git dir too, not just the registry: the workspace includes
|
||||
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
|
||||
# them (windows-rs is huge) merely to resolve the workspace, even though nothing Windows
|
||||
# is ever compiled here. Cached, that cost is paid once per runner.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-arch-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-arch-
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the `punktfunk` repo; main push -> <next-minor>-0.<run#> in
|
||||
# `punktfunk-canary` (pkgrel accepts only digits+dots — the run number carries the
|
||||
# monotonic ordering; the commit sha is stamped into the binary via the workflow log).
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||
*) V="$PF_BASE"; R="0.${GITHUB_RUN_NUMBER}"; REPO=punktfunk-canary ;;
|
||||
esac
|
||||
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
|
||||
echo "REPO=$REPO" >> "$GITHUB_ENV"
|
||||
echo "pacman $V-$R -> repo '$REPO'"
|
||||
|
||||
- name: Build packages (makepkg)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# libcuda link stub — same trick as packaging/rpm/build-rpm.sh: the zerocopy FFI
|
||||
# links -lcuda but the builder has no GPU; synthesize every cu* symbol the source
|
||||
# references so a newly-added call can't silently break the link.
|
||||
CU_SYMS="$(grep -rhoE '\bcu[A-Z][A-Za-z0-9_]*' crates/punktfunk-host/src/ | sort -u || true)"
|
||||
if [ -n "$CU_SYMS" ] && [ ! -e /usr/lib/libcuda.so ]; then
|
||||
STUB_C="$(mktemp --suffix=.c)"
|
||||
for s in $CU_SYMS; do printf 'int %s(void){return 0;}\n' "$s" >> "$STUB_C"; done
|
||||
gcc -shared -fPIC -Wl,-soname,libcuda.so.1 -o /usr/lib/libcuda.so.1 "$STUB_C"
|
||||
ln -sf libcuda.so.1 /usr/lib/libcuda.so
|
||||
rm -f "$STUB_C"; ldconfig
|
||||
echo "== libcuda stub: $(printf '%s\n' "$CU_SYMS" | wc -l) symbols =="
|
||||
fi
|
||||
# makepkg refuses to run as root; deps are already installed above (-d skips the
|
||||
# RPM-level check that can't see the script-installed bun anyway).
|
||||
useradd -m builder
|
||||
mkdir -p "$CARGO_HOME" # actions/cache doesn't create it on a cache miss
|
||||
chown -R builder: "$PWD" "$CARGO_HOME"
|
||||
sudo -u builder git config --global --add safe.directory "$PWD"
|
||||
mkdir -p dist && chown builder: dist
|
||||
cd packaging/arch
|
||||
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \
|
||||
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
|
||||
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
|
||||
makepkg -f -d --holdver
|
||||
ls -lh "$GITHUB_WORKSPACE/dist"
|
||||
|
||||
- name: Publish to the Gitea Arch registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
echo "uploading $pkg"
|
||||
NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p')
|
||||
VER=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgver = //p')
|
||||
ARCH=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^arch = //p')
|
||||
# A re-tagged release re-fires this workflow and the registry 409s on duplicate
|
||||
# package versions — delete any prior copy first (404 on the first publish is fine).
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO/$NAME/$VER/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$pkg" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO"
|
||||
done
|
||||
echo "published to $OWNER/arch/$REPO"
|
||||
|
||||
# On a real release, also attach the packages to the unified Gitea Release.
|
||||
- name: Attach packages to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
upsert_asset "$RID" "$pkg"
|
||||
done
|
||||
@@ -28,8 +28,8 @@ jobs:
|
||||
|
||||
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
|
||||
# registry/git are download caches, target/ the incremental build. The target key
|
||||
# carries the rustc version — rust-toolchain.toml pins the floating "stable"
|
||||
# channel, so the file alone wouldn't invalidate stale incremental state.
|
||||
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
|
||||
# from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
|
||||
@@ -36,16 +36,17 @@ jobs:
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
|
||||
# A main push -> 0.5.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||
# below the eventual 0.5.0 tag, it climbs monotonically by run number, and the canary base
|
||||
# stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves
|
||||
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
||||
# A main push -> <next-minor>~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||
# below the eventual tag, it climbs monotonically by run number, and the canary base is
|
||||
# derived one minor AHEAD of the latest stable tag (scripts/ci/pf-version.sh) so a
|
||||
# stable->canary box re-point still moves forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
||||
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||
*) V="0.5.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -63,7 +63,8 @@ jobs:
|
||||
pnpm run build # rollup -> clients/decky/dist/index.js
|
||||
|
||||
- name: Version + channel + stamp
|
||||
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.<run>
|
||||
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> <next-minor>.<run>
|
||||
# (base one minor ahead of the latest stable tag via scripts/ci/pf-version.sh)
|
||||
# (`canary/` alias). Decky reads a plugin's INSTALLED version from package.json (NOT
|
||||
# plugin.json), and the plugin's own update check (clients/decky/main.py check_update)
|
||||
# compares against it — so the build version is STAMPED into package.json here (mirrored
|
||||
@@ -72,9 +73,12 @@ jobs:
|
||||
# (ci10 < ci9), which would break update detection; the run number is monotonic.
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_MAJOR/PF_MINOR (base one minor ahead of latest stable)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;;
|
||||
*) V="0.3.${GITHUB_RUN_NUMBER}"; ALIAS=canary ;;
|
||||
# Canary MUST be a plain monotonic numeric semver (see the note above): <major>.<minor>.<run>,
|
||||
# where major.minor track one minor ahead of the latest stable and the run number climbs.
|
||||
*) V="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"; ALIAS=canary ;;
|
||||
esac
|
||||
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -73,15 +73,17 @@ jobs:
|
||||
|
||||
- name: Version + channel
|
||||
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
|
||||
# 0.5.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo
|
||||
# <next-minor>-ciN.g<sha> on the `canary` branch (base one minor ahead of the latest stable
|
||||
# tag via scripts/ci/pf-version.sh). The two branches live side-by-side in one repo
|
||||
# (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
|
||||
# on a stable box never jumps to a canary build. The generic-registry version string allows
|
||||
# letters/dots/hyphens.
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
|
||||
*) V="0.5.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
||||
*) V="${PF_BASE}-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
|
||||
@@ -106,6 +108,40 @@ jobs:
|
||||
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
|
||||
-o packaging/flatpak/cargo-sources.json
|
||||
|
||||
- name: Seed the local OSTree repo from the live server (keep BOTH channels in the summary)
|
||||
# Each CI run builds only ONE branch (canary on main, stable on a tag). The deploy step's
|
||||
# `flatpak build-update-repo` regenerates the repo SUMMARY from whatever refs are in the
|
||||
# LOCAL repo, and the rsync publishes it (without --delete). A fresh single-branch local
|
||||
# repo therefore produces a single-branch summary that CLOBBERS the other channel on the
|
||||
# server — the exact bug that made `app/io.unom.Punktfunk/x86_64/stable` unresolvable
|
||||
# ("No such ref") after a canary main-push overwrote the post-release summary, even though
|
||||
# the stable commit's objects were still on disk. Fix: mirror the published repo DOWN first,
|
||||
# so the local repo carries every existing branch; the build below then only ADDS this run's
|
||||
# commit and the regenerated+signed summary keeps both channels. No-op on a fresh repo (first
|
||||
# publish) or when the deploy secrets aren't set (the build still produces a valid bundle).
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_SSH_KEY:-}" ]; then
|
||||
echo "::warning::DEPLOY_* not set — no seed; building a fresh single-branch repo."
|
||||
exit 0
|
||||
fi
|
||||
install -d -m700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
mkdir -p "$PWD/repo"
|
||||
# Pull the currently-published repo (all channels' objects + refs) into the repo the build
|
||||
# will extend. No --delete: the local repo starts empty, so this only ADDS. A missing
|
||||
# server repo (very first publish) is fine — we continue with a fresh repo.
|
||||
rsync -az --info=stats1 -e "$SSH" "$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/" \
|
||||
|| echo "::warning::no published repo to seed (first publish?) — continuing fresh"
|
||||
echo "seeded refs:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
|
||||
|
||||
- name: Build the flatpak (install deps from Flathub, offline build)
|
||||
run: |
|
||||
# --install-deps-from=flathub pulls everything the manifest declares: the GNOME 50
|
||||
@@ -177,6 +213,10 @@ jobs:
|
||||
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME"
|
||||
flatpak build-update-repo --generate-static-deltas \
|
||||
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" "$PWD/repo"
|
||||
# The regenerated summary advertises exactly these refs — must include EVERY channel that
|
||||
# has ever published (the seed step ensures the other channel's commit is present). If this
|
||||
# ever shows only one branch on a repo that had two, the seed didn't run — investigate.
|
||||
echo "published summary advertises:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
|
||||
# 2) Build the install descriptors (GPGKey = the committed public key, base64).
|
||||
GPGKEY="$(base64 -w0 packaging/flatpak/unom-flatpak.gpg)"
|
||||
rm -rf site && mkdir -p site
|
||||
@@ -188,9 +228,12 @@ jobs:
|
||||
Comment=unom Flatpak applications
|
||||
GPGKey=$GPGKEY
|
||||
EOF
|
||||
# Two refs, one per channel — both regenerated every run and rsync'd without --delete, so
|
||||
# the server always offers both (the stable ref only resolves once a release has built the
|
||||
# `stable` branch). A box installs ONE; `flatpak update` then tracks that channel's branch.
|
||||
# Two refs, one per channel. Both descriptor files are regenerated every run and rsync'd
|
||||
# without --delete; the repo SUMMARY carries both branches because the build was seeded
|
||||
# from the live repo above (so build-update-repo below re-signs a summary listing every
|
||||
# published channel, not just this run's). The stable ref resolves for good once any
|
||||
# release has built the `stable` branch. A box installs ONE; `flatpak update` then tracks
|
||||
# that channel's branch.
|
||||
write_ref() { # <filename> <branch> <title>
|
||||
cat > "site/$1" <<EOF
|
||||
[Flatpak Ref]
|
||||
|
||||
@@ -14,8 +14,12 @@
|
||||
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
|
||||
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
|
||||
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
|
||||
# The Developer ID DMG is codesigned with the SAME macOS entitlements, so what we test
|
||||
# locally equals what App Store users get.
|
||||
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
|
||||
# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a
|
||||
# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG
|
||||
# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though
|
||||
# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the
|
||||
# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here.
|
||||
#
|
||||
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
|
||||
# step is continue-on-error until they exist):
|
||||
@@ -27,6 +31,15 @@
|
||||
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
|
||||
# .pkg is installer-signed with it.
|
||||
#
|
||||
# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it):
|
||||
# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID,
|
||||
# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner
|
||||
# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the
|
||||
# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch
|
||||
# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app
|
||||
# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships
|
||||
# a launchable app.
|
||||
#
|
||||
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
|
||||
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
|
||||
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
|
||||
@@ -99,13 +112,14 @@ jobs:
|
||||
|
||||
- name: Version from tag
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
||||
*) V="0.5.0" ;; # canary marketing version; the build number disambiguates
|
||||
*) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
||||
echo "version $V build $GITHUB_RUN_NUMBER"
|
||||
echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
|
||||
|
||||
- name: Rust toolchain (mac + iOS + tvOS slices)
|
||||
run: |
|
||||
@@ -155,9 +169,8 @@ jobs:
|
||||
run: |
|
||||
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
|
||||
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
|
||||
# provisioning-profile gate; codesign just needs the (now valid) identity + the
|
||||
# team-prefixed entitlements, no profile (App Sandbox + the network/device
|
||||
# capabilities are self-asserted for Developer ID — no profile entry needed).
|
||||
# provisioning-profile gate at archive time; we re-assert that authorization below by
|
||||
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
|
||||
# Bundle is a single static binary.
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
@@ -172,6 +185,35 @@ jobs:
|
||||
RESOLVED="$RUNNER_TEMP/macos.entitlements"
|
||||
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
|
||||
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
|
||||
|
||||
# keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the
|
||||
# network/device keys are self-asserted for Developer ID, but a keychain access group
|
||||
# must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to
|
||||
# spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163),
|
||||
# SIGKILL before main() — even though the bundle is validly signed and notarized. Embed
|
||||
# a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its
|
||||
# entitlements authorize the access group, exactly like the App Store build's profile
|
||||
# does. Located by profile Name among the profiles installed on the runner (see header).
|
||||
DEVID_PROFILE_NAME="Punktfunk macOS Developer ID"
|
||||
PROFILE_SRC=""
|
||||
for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \
|
||||
"$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do
|
||||
[ -e "$p" ] || continue
|
||||
NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true)
|
||||
[ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break
|
||||
done
|
||||
if [ -n "$PROFILE_SRC" ]; then
|
||||
# Must land BEFORE codesign so it's sealed into the bundle.
|
||||
cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile"
|
||||
echo "embedded Developer ID profile: $PROFILE_SRC"
|
||||
else
|
||||
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
|
||||
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
|
||||
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
|
||||
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
|
||||
/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$RESOLVED" \
|
||||
--sign "Developer ID Application" "$APP"
|
||||
|
||||
@@ -35,8 +35,10 @@ jobs:
|
||||
include:
|
||||
- image: punktfunk-fedora-rpm # Fedora 43 == Bazzite base
|
||||
group: bazzite
|
||||
fedver: 43
|
||||
- image: punktfunk-fedora44-rpm # Fedora 44 == Fedora KDE spin
|
||||
group: fedora-44
|
||||
fedver: 44
|
||||
container:
|
||||
image: git.unom.io/unom/${{ matrix.image }}:latest
|
||||
timeout-minutes: 90
|
||||
@@ -53,6 +55,8 @@ jobs:
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
||||
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
||||
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
|
||||
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
|
||||
command -v bun >/dev/null || {
|
||||
@@ -68,16 +72,17 @@ jobs:
|
||||
restore-keys: cargo-home-
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.5.0-0.ciN.g<sha>
|
||||
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.5.0-1 yet
|
||||
# climbs by run number. The canary base stays one minor ahead of the latest stable so a
|
||||
# stable->canary box re-point still moves forward. The spec %build stamps
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> <next-minor>-0.ciN.g<sha>
|
||||
# in the `<base>-canary` group, whose "0." release sorts below the eventual <next-minor>-1 yet
|
||||
# climbs by run number. The canary base is derived one minor ahead of the latest stable tag
|
||||
# (scripts/ci/pf-version.sh) so a stable->canary box re-point still moves forward. The spec %build stamps
|
||||
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
|
||||
*) V="0.5.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
||||
*) V="$PF_BASE"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
||||
esac
|
||||
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
|
||||
@@ -116,6 +121,27 @@ jobs:
|
||||
done
|
||||
echo "published to $OWNER/rpm/$GROUP"
|
||||
|
||||
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
|
||||
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
|
||||
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
|
||||
- name: Build the sysext image
|
||||
run: |
|
||||
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
|
||||
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
|
||||
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||
|
||||
- name: Publish the sysext feed
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
case "$GROUP" in
|
||||
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up
|
||||
*) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release
|
||||
esac
|
||||
KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \
|
||||
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
|
||||
|
||||
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
|
||||
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
|
||||
# both on the release; canary builds live in the `*-canary` rpm groups (no release page).
|
||||
@@ -131,3 +157,6 @@ jobs:
|
||||
base="$(basename "$rpm" .rpm)"
|
||||
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
|
||||
done
|
||||
for raw in dist-sysext/*.raw; do
|
||||
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
|
||||
done
|
||||
|
||||
@@ -131,11 +131,21 @@ jobs:
|
||||
# dispatched provisioning workflow landing on a different one. Path is relative to the job
|
||||
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
|
||||
run: ../../../scripts/ci/ensure-windows-toolchain.ps1
|
||||
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay)
|
||||
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay + gamepad drivers)
|
||||
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
|
||||
# pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve
|
||||
# against IddCxStub end-to-end (M1 step 2 gate).
|
||||
# pf-vdisplay (the real IddCx driver) + pf-umdf-util (the safe UMDF primitive layer) + the two
|
||||
# gamepad drivers. pf-vdisplay linking proves the IddCx call sites resolve against IddCxStub
|
||||
# end-to-end (M1 step 2 gate); the gamepad drivers prove pf-umdf-util's WDF dispatch links.
|
||||
run: cargo build -v
|
||||
- name: cargo clippy the shipped drivers (-D warnings — enforces the unsafe-audit gates)
|
||||
# The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited
|
||||
# unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a
|
||||
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
||||
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
||||
# toolchain-only probe crate and is excluded.)
|
||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||
- name: cargo fmt --check the safe-layer + gamepad drivers
|
||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
|
||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||
run: |
|
||||
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||
|
||||
@@ -16,15 +16,17 @@
|
||||
# Versioning (free-form; not MSIX's 4-part rule) — single project version:
|
||||
# vX.Y.Z tag -> X.Y.Z (THE release; published + stable `latest/` alias + attached to the
|
||||
# unified Gitea Release).
|
||||
# main push / dispatch -> 0.3.<run_number> (canary; `canary/` alias; climbs by run number).
|
||||
# main push / dispatch -> <next-minor>.<run_number> (canary; `canary/` alias; base one minor
|
||||
# ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs).
|
||||
#
|
||||
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
|
||||
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
||||
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
||||
#
|
||||
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
||||
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export
|
||||
# .def with llvm-dlltool (no GPU/SDK at build time).
|
||||
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
|
||||
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
|
||||
# AMD/Intel-only boxes before main).
|
||||
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
|
||||
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
||||
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
||||
@@ -37,6 +39,7 @@ on:
|
||||
paths:
|
||||
- 'crates/punktfunk-host/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/punktfunk-tray/**'
|
||||
- 'packaging/windows/**'
|
||||
- 'scripts/windows/**'
|
||||
- 'web/**'
|
||||
@@ -100,30 +103,33 @@ jobs:
|
||||
if (-not $env:VBCABLE_DIR) {
|
||||
"VBCABLE_DIR=C:\Users\Public\vbcable" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
$env:GITHUB_REF_NAME -replace '^v', ''
|
||||
} else {
|
||||
"0.3.$($env:GITHUB_RUN_NUMBER)"
|
||||
# Canary: <major>.<minor>.<run> — major.minor track one minor ahead of stable, run climbs monotonically.
|
||||
"$($pf.PF_MAJOR).$($pf.PF_MINOR).$($env:GITHUB_RUN_NUMBER)"
|
||||
}
|
||||
"HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "host version $v"
|
||||
|
||||
- name: Generate NVENC import lib
|
||||
shell: pwsh
|
||||
run: |
|
||||
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
|
||||
"PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build (release, nvenc + amf-qsv)
|
||||
shell: pwsh
|
||||
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
|
||||
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
|
||||
|
||||
- name: Clippy (host, Windows)
|
||||
- name: Build (release, status tray)
|
||||
shell: pwsh
|
||||
# The per-user notification-area companion the installer bundles (punktfunk-tray.exe).
|
||||
run: cargo build --release -p punktfunk-tray
|
||||
|
||||
- name: Clippy (host + tray, Windows)
|
||||
shell: pwsh
|
||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
||||
run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings
|
||||
run: |
|
||||
cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
shell: pwsh
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
|
||||
# Published to the generic registry + the stable `latest/` alias + attached to the
|
||||
# unified Gitea Release alongside every other platform's artifact.
|
||||
# main push / dispatch -> 0.3.<run_number>.0 (canary; climbs monotonically by run number).
|
||||
# main push / dispatch -> <next-minor>.<run_number>.0 (canary; base is one minor ahead of the
|
||||
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
|
||||
# Published to the generic registry + the `canary/` alias.
|
||||
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||
#
|
||||
@@ -78,11 +79,13 @@ jobs:
|
||||
"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 }}
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
|
||||
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
|
||||
} else {
|
||||
@('0', '3', $env:GITHUB_RUN_NUMBER)
|
||||
# Canary: <major>.<minor>.<run>.0 — major.minor track one minor ahead of stable, run climbs monotonically.
|
||||
@($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER)
|
||||
}
|
||||
while ($parts.Count -lt 4) { $parts += '0' }
|
||||
$v = ($parts[0..3] -join '.')
|
||||
|
||||
@@ -31,3 +31,6 @@ xcuserdata/
|
||||
# Python bytecode (e.g. clients/android/ci tooling)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Claude Code project instructions — local to each dev box, not part of the repo.
|
||||
CLAUDE.md
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
# CLAUDE.md — punktfunk
|
||||
|
||||
Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core
|
||||
(`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design:
|
||||
[`design/implementation-plan.md`](design/implementation-plan.md). Status table: `README.md`.
|
||||
|
||||
## Where the work stands
|
||||
|
||||
- **Core (`punktfunk-core` + C ABI): complete and hardened.** FEC recovery, loopback-under-loss,
|
||||
proptests, C ABI harness all green; 13 adversarial-review findings fixed +
|
||||
regression-tested (`a913042`).
|
||||
- **GameStream host: working end-to-end with a stock Moonlight client.** Validated live
|
||||
on this box: pairing (persists across restarts), serverinfo/applist (app catalog from
|
||||
`~/.config/punktfunk/apps.json` → each entry picks a compositor + nested command), RTSP, ENet
|
||||
control, audio, and video at the **client's native resolution and refresh** — the host
|
||||
creates a per-session virtual output via per-compositor `VirtualDisplay` backends:
|
||||
**KWin** (`zkde_screencast stream_virtual_output`, needs KWin ≥ 6.5.6 headless; >60 Hz via
|
||||
custom modes), **gamescope** (spawned headless at WxH@Hz, its PipeWire node captured, needs
|
||||
gamescope ≥ 3.16.22 — older deadlocks on PipeWire ≥ 1.6), **Mutter** (D-Bus
|
||||
`RecordVirtual` virtual monitor; validated live on headless GNOME Shell 50, zero-copy),
|
||||
**Sway/wlroots** (`swaymsg create_output` + custom mode, xdpw portal capture with a
|
||||
managed chooser config; validated live on sway 1.11, zero-copy).
|
||||
Performance work landed and measured: GPU **zero-copy** on all paths (tiled dmabuf →
|
||||
EGL/GL → CUDA; LINEAR dmabuf → **Vulkan bridge** → CUDA → NVENC), auto 2-way NVENC
|
||||
split-encode above ~1 Gpix/s (5K@240), infinite GOP + RFI keyframes (killed the periodic
|
||||
freeze), encode|send thread split with `sendmmsg` batching. Stable 240 fps at 5120×1440.
|
||||
Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS
|
||||
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
|
||||
back-channel; validated live — pad created/destroyed with the session). Management REST API +
|
||||
checked-in OpenAPI doc (`mgmt.rs`). **Web-console performance capture** (`stats_recorder.rs`,
|
||||
design: [`design/stats-capture-plan.md`](design/stats-capture-plan.md)): the operator arms stats
|
||||
recording from the web console, plays, stops, and reviews the run as graphs (per-stage latency
|
||||
breakdown · fps new/repeat · goodput · loss/FEC). A shared `Arc<StatsRecorder>` ring (the hot-path
|
||||
gate is a runtime `AtomicBool`, replacing the startup-only `PUNKTFUNK_PERF`) is fed by **both** the
|
||||
native `virtual_stream` and the GameStream encode loop at their existing ~2 s/~1 s aggregation
|
||||
boundary, and finished captures are saved as on-disk recordings
|
||||
(`~/.config/punktfunk/captures/*.json`) browsable/exportable from the console's **Performance** page
|
||||
(recharts). Endpoints `/api/v1/stats/*` (bearer-only). *Implemented; not yet on-glass validated.*
|
||||
**Web-console log view** (`log_capture.rs`): a `tracing` layer tees DEBUG-and-up (independent of
|
||||
`RUST_LOG`) into a 4096-entry in-memory ring, served cursor-paged at `GET /api/v1/logs`
|
||||
(bearer-only) → the console's **Logs** page (follow/pause · level filter · search). The Windows
|
||||
gamepad drivers now stamp attach/heartbeat marks into their shm sections and the host's
|
||||
`DriverAttach` watcher turns silence into a one-shot diagnosis WARN (driver-store check + CM
|
||||
devnode problem code) — failure-mode table: [`design/gamepad-driver-health.md`](design/gamepad-driver-health.md).
|
||||
The Android client gained Settings → **Connected controllers** (device list + VID/PID + resolved
|
||||
pad type + live input test) for the client end of the same chain. *Log view + driver health:
|
||||
Linux-tested; Windows/Android sides CI/device-validation pending.*
|
||||
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
|
||||
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
|
||||
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
||||
(inexpressible in GameStream), host creates the native virtual output at the client's
|
||||
requested mode. `punktfunk1-host` is a **persistent listener** (sessions back to back;
|
||||
`--max-sessions`). QUIC datagrams carry the side planes, demuxed by first byte: input
|
||||
0xC8 (incl. **gamepads** — incremental events accumulated into the uinput xpad), **Opus
|
||||
audio** 0xC9 (48 kHz stereo, 5 ms, host→client), **rumble** 0xCA (host→client). **Trust:**
|
||||
host serves its persistent identity (`~/.config/punktfunk/cert.pem`, shared with GameStream
|
||||
pairing) and logs the SHA-256 fingerprint; clients pin it, established by a **SPAKE2 PIN pairing
|
||||
ceremony** (host arms pairing and displays a 4-digit PIN; a PAKE binds both cert fingerprints so an
|
||||
attacker gets one online guess, no offline dictionary attack) — PIN pairing is the default for new
|
||||
hosts. **TOFU on first connect** (`endpoint::client_pinned`) stays as an explicit host opt-in
|
||||
(`punktfunk1-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs;
|
||||
clients only offer the TOFU "Trust" path for a host that advertised `pair=optional`, route every
|
||||
other new host straight to the PIN ceremony, and on a pinned-fingerprint change force re-pairing
|
||||
(no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores
|
||||
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
|
||||
default; `--allow-tofu`/`--open` accept unpaired clients).
|
||||
**LAN auto-discovery**: both `serve` and `punktfunk1-host` advertise the native service over
|
||||
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
|
||||
pin)/`pair`(required|optional)/`id`; `punktfunk-probe --discover` lists hosts, Apple clients
|
||||
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
|
||||
**Mid-stream mode renegotiation**: `Reconfigure` on the still-open control stream — the
|
||||
host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on
|
||||
(validated live: one .h265 with 720p and 1080p segments). Measured on-box at 720p120: 1680/1680 frames, **p50 0.83 ms**
|
||||
capture→…→reassembled; audio measured live (~200 pkts/s). A **wall-clock skew handshake**
|
||||
(`ClockProbe`/`ClockEcho`, 8 NTP rounds after `Start`, `clock_offset_ns`) aligns the client to the
|
||||
host clock, so that latency is now valid **cross-machine** (`skew_corrected=true`) — measured GNOME
|
||||
box → dev box over the LAN: **p50 1.30 ms** (the −1.57 ms inter-box clock offset removed).
|
||||
`punktfunk-probe` is the
|
||||
working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad).
|
||||
The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect`
|
||||
(pin/TOFU) + `next_au`/`next_audio`/`next_rumble`/`next_hidout`/`send_input`/
|
||||
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
|
||||
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
|
||||
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
|
||||
env > uinput Xbox 360. Backends: **Xbox 360** (uinput on Linux / the pf-xusb UMDF driver on
|
||||
Windows), **Xbox One/Series** (the same
|
||||
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
|
||||
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
|
||||
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
|
||||
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
|
||||
LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows
|
||||
(UMDF minidriver)** backend — `inject/windows/dualsense_windows.rs` + `inject/windows/dualshock4_windows.rs`, one
|
||||
driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4
|
||||
reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays
|
||||
Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller
|
||||
(DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad
|
||||
dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver
|
||||
(`packaging/windows/drivers/pf-xusb/`, `inject/windows/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB`
|
||||
and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState`
|
||||
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
|
||||
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI
|
||||
(`packaging/windows/drivers/`) and installed by the Inno Setup installer via
|
||||
`punktfunk-host.exe driver install --gamepad`.
|
||||
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
|
||||
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
|
||||
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
|
||||
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
|
||||
the remaining piece.)
|
||||
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
|
||||
behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx
|
||||
**pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`;
|
||||
DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`), GPU encode (NVENC
|
||||
`--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers
|
||||
(`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire
|
||||
convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK;
|
||||
the Windows client derives it from the scancode, NOT the layout-resolved `vkCode`) — the Windows
|
||||
injector resolves them via a fixed table mirroring the Linux `vk_to_evdev` (never through a
|
||||
keyboard layout: the SYSTEM service thread's layout re-reads positions as characters — the
|
||||
German y↔z / ö→ü scramble), while GameStream/Moonlight VKs are layout-semantic
|
||||
(`KEY_FLAG_SEMANTIC_VK`, resolved under the foreground app's layout, Sunshine's model). Linux
|
||||
renders positions under the session compositor's layout (libei) or the virtual keyboard's
|
||||
uploaded keymap (Sway/wlroots — honors `XKB_DEFAULT_LAYOUT` et al., default US); the Android
|
||||
client reads `KeyEvent.scanCode` first so a user-selected physical-keyboard layout can't
|
||||
re-map keycodes semantically. Ships as a **signed
|
||||
Inno Setup installer** that registers a `LocalSystem` SCM service launching into the interactive
|
||||
session for secure-desktop (UAC/lock-screen) capture (`windows/service.rs`), bundles the
|
||||
pf-vdisplay driver + the FFmpeg DLLs (+ VB-CABLE for the virtual mic), and is published by
|
||||
`windows-host.yml`. **Encoder is GPU-aware** (`encode.rs` `open_video` + `windows_resolved_backend`):
|
||||
`PUNKTFUNK_ENCODER=auto` (the host.env default) reads the **selected render adapter's** vendor →
|
||||
**NVENC** (NVIDIA, direct SDK, `encode/windows/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec
|
||||
(`encode/windows/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`,
|
||||
system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a
|
||||
system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is
|
||||
probed per-GPU on AMF/QSV (`windows_codec_support` → `serverinfo`, AV1 gated; cached per selected
|
||||
GPU). **Multi-GPU is first-class** (`gpu.rs`): GPU inventory + a persisted auto/manual preference
|
||||
(`<config>/gpu-settings.json`, stored by stable PCI identity — LUIDs are per-boot) exposed over
|
||||
`GET /api/v1/gpus` + `PUT /api/v1/gpus/preference` and a web-console GPU card (Host page: list,
|
||||
Automatic/Prefer, "In use · backend" badge). One selection — precedence **console preference >
|
||||
`PUNKTFUNK_RENDER_ADAPTER` > max VRAM**, graceful fallback when the preferred GPU is absent —
|
||||
feeds `win_adapter::resolve_render_adapter_luid` (capture ring + IddCx render pin), the encoder
|
||||
vendor auto-detect (previously DXGI adapter 0 — wrong on hybrid boxes like NVIDIA dGPU + Intel
|
||||
Arc iGPU), and the NVENC 4:4:4 probe; a preference change applies to the next session. On Linux a
|
||||
matched manual preference picks the VAAPI render node / NVENC-vs-VAAPI auto choice (auto mode
|
||||
unchanged). *Implemented + unit-tested; not yet on-glass validated on the hybrid box.* **HDR (10-bit)**: WGC
|
||||
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
|
||||
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
|
||||
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
|
||||
host only** (the Linux host stays 8-bit, blocked upstream). **Vulkan-game HDR over the virtual
|
||||
display**: NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an IddCx
|
||||
indirect display (so Vulkan games — Doom: The Dark Ages, id Tech, etc. — say "device does not support
|
||||
HDR"), even though the ICD happily *accepts + presents* a forced HDR swapchain there. A tiny always-on
|
||||
Vulkan **implicit layer** (`packaging/windows/pf-vkhdr-layer/`, `VK_LAYER_PUNKTFUNK_hdr_inject`)
|
||||
injects the `HDR10_ST2084`/scRGB surface formats into `vkGetPhysicalDeviceSurfaceFormats[2]KHR`,
|
||||
self-gated on the display's actual advanced-color state (no-op on SDR / real monitors); bundled +
|
||||
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
|
||||
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
|
||||
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
|
||||
|
||||
## What's left
|
||||
|
||||
1. **Native clients — decode + present: macOS stage 1 done, first light achieved
|
||||
(2026-06-10).** PunktfunkKit compiles and is tested on macOS (AnnexB → VideoToolbox →
|
||||
`AVSampleBufferDisplayLayer`, GCMouse/GCKeyboard capture, `PunktfunkClient` app shell);
|
||||
validated live Mac ↔ this box at 720p60 — vkcube on glass, input injected via gamescope
|
||||
EIS. The app speaks the full ABI v2 trust surface: Keychain-persisted client identity
|
||||
presented on every connect, SPAKE2 PIN pairing UI (host-card context menu + the trust
|
||||
prompt's "Pair with PIN instead…"), TOFU fingerprint prompt. **Gamepads (2026-06-11):**
|
||||
controller discovery + selection in Settings (`GamepadManager` — exactly one pad
|
||||
forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical
|
||||
controller, user-overridable), capture incl. DualSense touchpad/motion
|
||||
(`GamepadCapture`/`GamepadWire`; while streaming, EVERY element's
|
||||
`preferredSystemGestureState` is claimed `.disabled` — share/create reaches the host as
|
||||
select instead of screenshotting locally, PS/Home reaches the host as guide/`BTN_MODE` =
|
||||
the Steam-overlay button — restored `.enabled` on unbind), feedback rendering (rumble → CoreHaptics; lightbar /
|
||||
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
|
||||
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
|
||||
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
|
||||
motion sign/scale derived, not yet live-verified. **Rumble renderer rewritten
|
||||
(2026-07-02, `RumbleRenderer.swift`)** around "rumble is idempotent state, divergence
|
||||
must be bounded": the old per-datagram infinite-duration CoreHaptics players could leak
|
||||
one dropped async `stop` into a forever-buzzing motor (the stuck-rumble-after-menu bug)
|
||||
— now finite self-expiring segments with seamless engine-timeline re-arm, newest-wins
|
||||
dry drain of the 0xCA plane (was 1 datagram/8 ms), dedupe of the host's 500 ms state
|
||||
refreshes, zero-immediate/ramp-throttled rebakes, escalation to `engine.stop()` on a
|
||||
throwing player stop, and a 1.6 s staleness watchdog (`Policy.session`; the settings
|
||||
test panel uses `.manual` = hold). Controller engines use **plain `makePlayer` — never
|
||||
`makeAdvancedPlayer`**: the controller haptics server (gamecontrollerd) advertises
|
||||
`adv players: 0`, and iOS 27 beta 2 hard-drops advanced-player loads (XPC decode fault →
|
||||
CoreHaptics -4811/4097, rumble silently dead). Unit-tested (`RumbleTuningTests`);
|
||||
stuck-rumble repro on-glass revalidation pending. **Gamepad UI (iOS/iPadOS + macOS,
|
||||
2026-07-02 rework):** a connected pad swaps the home for a console-style launcher
|
||||
(`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add
|
||||
Host tile (A connect · Y library · X settings · B back), a controller-navigable
|
||||
settings screen (vertical `GamepadMenuList`, left/right steps values), an add-host
|
||||
flow with an on-screen controller keyboard (`GamepadKeyboard` — no touch needed
|
||||
anywhere), and the coverflow library, all over an animated aurora backdrop
|
||||
(`GamepadScreenBackground`, TimelineView-driven drifting blobs — pure SwiftUI ON
|
||||
PURPOSE: a .metal lib only reliably bundles in one of the two build systems (SPM vs
|
||||
xcodeproj synced folders) these sources compile under). Input is the polled
|
||||
`GamepadMenuInput` (handlers don't fire outside a stream; on (re)start it SNAPSHOTS
|
||||
held buttons so a handoff press never double-fires), haptics dual-channel (device +
|
||||
`MenuHaptics` on the pad). macOS: same screens, settings/add-host as sheets (no
|
||||
fullScreenCover), NSScreen-based mode lists, scroll indicators `.never` (macOS
|
||||
"always show scroll bars" overrides `.hidden`); launcher/settings/add-host/keyboard
|
||||
render-verified live on this Mac via `PUNKTFUNK_FORCE_GAMEPAD_UI=1` (dev hook, forces
|
||||
the mode without a pad). Controller-in-hand on-glass validation still pending on all
|
||||
platforms. **Touch input (iOS/iPadOS, 2026-07-02):** a 3-way model in Settings —
|
||||
**Trackpad** (default; the Android client's gesture vocabulary ported 1:1 in
|
||||
`Input/TouchMouse.swift`: tap=click · two-finger tap=right-click · two-finger drag=scroll ·
|
||||
tap-then-drag=held drag · three-finger tap=HUD toggle, relative ballistics with the same
|
||||
px-based acceleration curve), **Direct pointer** (cursor jumps to the finger), **Touch
|
||||
passthrough** (the previous always-on behavior — real wire touches). Latched per gesture
|
||||
from `DefaultsKey.touchMode`; not yet on-glass validated. Tests: `swift test` in
|
||||
`clients/apple` (unit + real-codec round trip),
|
||||
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
|
||||
includes the pairing ceremony + `--require-pairing` gate),
|
||||
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
||||
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter is now the DEFAULT**
|
||||
(stage-1 is the Metal-unavailable / DEBUG fallback): explicit `VTDecompressionSession` decode →
|
||||
`CAMetalLayer`, presented from the hosting view's **main-runloop `CADisplayLink`** (`renderTick` pops
|
||||
the newest ready frame per vsync; macOS `displaySyncEnabled = false` is the real fullscreen-judder fix,
|
||||
~11 ms p50). *(An off-main `CAMetalDisplayLink` and an off-main blocking-render present thread were
|
||||
both tried and reverted — both measured slower on macOS and iPad.)* **HDR fixed**
|
||||
(`design/apple-stage2-presenter.md`): the "too bright" bug was a missing reference-white anchor — the
|
||||
fix keeps the PQ-passthrough shader and adds `CAEDRMetadata.hdr10(…, opticalOutputScale: 203)` +
|
||||
`wantsExtendedDynamicRangeContent` on the layer (on all platforms; the old `#if os(macOS)` guard left
|
||||
iOS/tvOS EDR half-engaged), routing the 0xCE mastering metadata to the layer (via `setHdrMeta`) instead
|
||||
of a never-composited source buffer. **Mid-session SDR↔HDR** is handled: `render` reconciles the layer
|
||||
per-frame from the decoded `frame.isHDR` (per-mode pixel format `bgra8`/`rgba16Float`), so a game
|
||||
entering HDR mid-stream just reconfigures (last 0xCE grade cached + re-applied; pump drains 0xCE
|
||||
unconditionally). **4:4:4 added**: decode format is a 2×2 `(chroma, HDR)` matrix
|
||||
(`420v/x420/444v/x444`, all biplanar so the shaders are unchanged), advertised (`VIDEO_CAP_444`) only
|
||||
behind a **hardware-required `VTDecompressionSession` probe** (`Stage444Probe`, validated on M3) with a
|
||||
Settings opt-out + a bounded pump backstop for an undecodable 4:4:4 session. *HDR brightness + 4:4:4
|
||||
still need on-glass validation (Windows-HDR / `PUNKTFUNK_444` host).* Next: glass-to-glass numbers via
|
||||
`tools/latency-probe`.
|
||||
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
|
||||
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
||||
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
||||
PIN dialogs (identity shared with client-rs), FFmpeg software HEVC decode (LOW_DELAY,
|
||||
slice threads) → `GtkGraphicsOffload`-wrapped picture, PipeWire playback (mic-player
|
||||
jitter ring inverted), SDL3 gamepad capture + rumble/lightbar feedback, keyboard via
|
||||
exact inverse of the host VK table, absolute mouse + 120-unit scroll. Validated live
|
||||
against `serve` on this box: 1080p60, steady 60 fps, capture→decoded p50
|
||||
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
|
||||
stage 1.5 (2026-06-12 evening)**: capture state machine (click-to-capture,
|
||||
Ctrl+Alt+Shift+Q / focus-loss release, held-state flush), app-lifetime SDL gamepad
|
||||
service (pad pin UI, auto type from the physical pad, DualSense touchpad/motion 0xCC +
|
||||
raw-DS5-effects trigger/player-LED replay — needs a physical pad to live-verify), mic
|
||||
uplink (validated live), per-host speed test, compositor pref, native-display mode
|
||||
default, saved-hosts list, .deb + RPM-subpackage CI (deb.yml/rpm.yml). **VAAPI decode
|
||||
→ DRM-PRIME dmabuf → `GdkDmabufTexture`** (BT.709 color state; Tier-1 zero-copy on
|
||||
Intel/AMD, `PUNKTFUNK_DECODER=software|vaapi` override) with a proven fallback ladder —
|
||||
no VAAPI device (NVIDIA) or mid-session VAAPI error → software decode. **First AMD test
|
||||
(Steam Deck) hit a green-screen bug, fixed:** FFmpeg's VAAPI export uses
|
||||
`SEPARATE_LAYERS`, so NV12 arrives as two single-plane layers (R8 luma + GR88 chroma,
|
||||
one shared fd); the mapper took `layers[0]` only → GTK got a luma-only R8 texture, chroma
|
||||
read as 0 → green field / red whites. Fix derives the combined fourcc from the decoder
|
||||
`sw_format` (→ `DRM_FORMAT_NV12`) and flattens all planes across all layers (mpv's
|
||||
pattern); a first-frame descriptor dump logs the real layout. Awaiting Steam Deck
|
||||
reconfirm. Next: the stage-2 raw-Wayland
|
||||
presenter (wp_presentation feedback, tearing-control, Vulkan Video on NVIDIA) —
|
||||
**wgpu/winit rejected** (no dmabuf import / presentation feedback / shortcuts-inhibit).
|
||||
**Windows stage 1 done 2026-06-15** (`clients/windows`, binary
|
||||
`punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like
|
||||
framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The
|
||||
video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain**, presented from a
|
||||
**dedicated render thread** (`render.rs`, 2026-07-02 rewrite — presenting never touches or is
|
||||
stalled by the XAML thread): frame-latency-waitable swapchain + `SetMaximumFrameLatency(1)`
|
||||
(≤1 queued present, newest-wins drain after the wait, so a stream faster than the display drops
|
||||
backlog before any GPU work), **HiDPI-correct** (pixel-sized buffers + `SetMatrixTransform`
|
||||
96/DPI — DIP-sized buffers were blurry at 125/150 %), Contain-fit letterbox, WARP fallback.
|
||||
**FFmpeg decode with a D3D11VA hardware path on all vendors** (`gpu.rs` shares one D3D11 device
|
||||
between decoder + presenter, adapter picked by console pref `PUNKTFUNK_ADAPTER` > the window's
|
||||
monitor's adapter > default; `PUNKTFUNK_D3D_DEBUG=1` adds the debug layer): the decode pool is
|
||||
**decoder-only bind, sized/aligned by libavcodec itself** (get_format returns `AV_PIX_FMT_D3D11`
|
||||
and lets `hw_device_ctx` drive — three hand-built-frames-context strikes are why: NVIDIA rejects
|
||||
`DECODER|SHADER_RESOURCE` arrays, `BindFlags=0` fails texture creation, and Intel rejects
|
||||
non-128-aligned HEVC surfaces at the first `SubmitDecoderBuffers`), a DXVA **profile probe**
|
||||
before the hwdevice commits software-vs-hardware up front (no burned first IDR), and the
|
||||
presenter copies the decoded slice with ONE display-size-boxed `CopySubresourceRegion` (a planar
|
||||
slice is a single subresource in D3D11 — the old two-copy D3D12-style code silently no-opped =
|
||||
the black screen) into its sampleable NV12/P010 texture → per-plane SRVs + YUV→RGB shaders
|
||||
(NV12/BT.709, P010/BT.2020-PQ). **Software CPU decode is the fallback** (auto-selected,
|
||||
`DecoderPref` override, mid-session demotion + keyframe re-request) and now feeds the SAME
|
||||
shaders (swscale → NV12/P010 planes → two dynamic plane textures) so hw/sw colour math is
|
||||
identical. **HDR10**: the client advertises 10-bit/HDR (Settings toggle, gated on an HDR
|
||||
display), detects PQ in-band (`transfer == SMPTE2084`), and flips the swapchain to
|
||||
`R10G10B10A2` + ST.2084 with HDR10 metadata (0xCE mastering metadata plumbed). **WASAPI** render
|
||||
+ mic capture, **SDL3** gamepads (rumble/lightbar/DualSense), `mdns-sd` discovery, and the full
|
||||
trust surface — all **in-app**: a polished WinUI shell (host tiles w/ monogram + status pills,
|
||||
`InfoBar` errors/hints, `ToggleSwitch` settings, status-chip stream HUD showing GPU/CPU decode +
|
||||
HDR), host list (live mDNS + saved + manual), settings (resolution/refresh/decoder/bitrate/HDR/
|
||||
mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch re-pair. **Live-validated 2026-07-02
|
||||
on the hybrid laptop (Intel Arc Pro iGPU + RTX 3500 Ada) against the local Windows host**:
|
||||
D3D11VA hardware decode 60 fps on BOTH vendors (headless, `PUNKTFUNK_ADAPTER`-forced; NVIDIA
|
||||
0.2 ms decode, Intel 0.2 ms), software path, and the GUI on glass (real decoded desktop pixels,
|
||||
GPU-decode HUD chip, ~18 ms capture→decoded p50 over loopback — dominated by the host's 60 Hz
|
||||
virtual-display capture cadence). HDR-on-glass still pending. **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor
|
||||
exposes no raw key/pointer events; native Windows VK + absolute mouse (client-rect Contain-fit) +
|
||||
wheel, Ctrl+Alt+Shift+Q capture toggle. `--headless`/`--discover` keep CLI paths. Builds + clippy
|
||||
+ fmt green on **`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`** — the latter
|
||||
**cross-compiled off the one x64 runner** (no ARM64 runner; the x64 MSVC toolset's ARM64 cross
|
||||
compiler + a per-arch `FFMPEG_DIR` ARM64 tree, SDL3/libopus build-from-source cross-compile
|
||||
cleanly), and both ship as signed MSIX (`windows-msix.yml` matrix → `..._x64.msix`/`..._arm64.msix`,
|
||||
verified: ARM64 binaries + manifest arch). **windows-reactor is unpublished** (git
|
||||
dep pinned to commit `a4f7b2cb`, bumped 2026-07-02 from `b4129fcc` for `on_pointer_entered`/
|
||||
`on_pointer_exited` hover events — mechanical renames only: `SymbolGlyph`→`Symbol`,
|
||||
`placeholder`→`placeholder_text`, TextBox `on_changed`→`on_text_changed`, ToggleSwitch
|
||||
`on_changed`→`on_toggled`, `on_menu_item_clicked`→`on_item_clicked`, SwapChainPanel
|
||||
`on_ready`→`on_mounted`; `windows` pinned to the SAME commit so `IDXGISwapChain1` unifies with
|
||||
`set_swap_chain`). New-model runtime staging: reactor has NO build.rs anymore — the app's own
|
||||
`build.rs` calls `windows_reactor_setup::as_framework_dependent()` (same-rev build-dep, stages
|
||||
the bootstrap DLL + resources.pri that pack-msix expects) and `main` calls
|
||||
`windows_reactor::bootstrap()` before `App` (packaged MSIX: a no-op, the manifest's
|
||||
`Microsoft.WindowsAppRuntime.2` dependency resolves the runtime). `CARGO_WORKSPACE_DIR` is no
|
||||
longer required (harmless where still set). Gotcha: `CARGO_HOME` must be an ASCII path
|
||||
— the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. **Parity/cleanup
|
||||
batch (2026-07-02)**: `app.rs` split into per-screen `app/` modules (mod=root/router · hosts ·
|
||||
connect · pair · speed · settings · licenses · stream · style; thread-driven state lives in ROOT
|
||||
`use_async_state` and flows down as props — a child's own async-state write does NOT re-render it);
|
||||
"Native display" now resolves the real monitor mode at connect (`MonitorFromWindow` →
|
||||
`EnumDisplaySettingsW`, was hardcoded 1080p60); per-host **speed test** (saved-host card button +
|
||||
`--headless --speed-test`, probe burst → recommended ≈70 % bitrate applied in one tap; bitrate
|
||||
setting is now a free-form NumberBox); **forget host** (ContentDialog confirm →
|
||||
`KnownHosts::remove_by_fp`); settings gained forwarded-controller picker + gamepad type + host
|
||||
compositor + capture-system-shortcuts — the previously-dead `Settings.compositor`/
|
||||
`inhibit_shortcuts` are now honored (off = Alt+Tab/Alt+Esc/Ctrl+Esc/Win act locally);
|
||||
**click-to-recapture** after a Ctrl+Alt+Shift+Q release with the HUD hint tracking capture state;
|
||||
input hook caches lock geometry (no per-move `GetClientRect`), audio jitter-ring trims via
|
||||
`drain`. Validated on the bare-metal RTX box: `--discover` (3 live LAN hosts), synthetic-host
|
||||
loopback E2E (TOFU connect → clock skew → HEVC negotiate → shared-D3D11 + D3D11VA init → WASAPI →
|
||||
session end; synthetic payload isn't decodable so decode output stays unvalidated), speed-test
|
||||
E2E. The WinUI window itself CANNOT be launched from SSH (session-0 → WinAppSDK 0x80070005,
|
||||
pre-existing; needs the console session, e.g. PsExec -i 1). **UX batch (2026-07-02 evening,
|
||||
UIA-smoke-tested on the hybrid laptop)**: host tiles get the WinUI pointer-over fill
|
||||
(`on_pointer_entered`/`exited` → root hover state → `ControlFillSecondary`), Settings is a stock
|
||||
**NavigationView** sidebar (Windows-Settings pattern: Display/Video/Input/Audio/About panes,
|
||||
built-in back arrow, section in root state; the section card is **keyed by section** — an
|
||||
in-place diff across sections re-sets a reused ComboBox's items, clearing WinUI's selection,
|
||||
but skips `selected_index` when the values compare equal → blank selection; the key forces a
|
||||
remount — and the content column rides its own section-switch slide-up tween), new
|
||||
**"Show the stats overlay (HUD)"** toggle
|
||||
(`Settings::show_hud`, applies mid-stream via the 400 ms HUD re-render), the Add-host modal
|
||||
slides up + fades in (root margin/opacity tween, same pattern as screen navigation), and a
|
||||
self-initiated disconnect (Ctrl+Alt+Shift+D → `Ended(None)`) returns to the host list silently
|
||||
instead of raising the error banner.
|
||||
Next: **HDR on-glass validation** (Windows host with `PUNKTFUNK_10BIT` → the HDR laptop
|
||||
display), then RAWINPUT relative-mouse pointer-lock.
|
||||
**Android stage 1 done** (`clients/android`, Kotlin app + `native/` Rust JNI core linking
|
||||
`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/AAudio audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
|
||||
(`feedback.rs`), **native `mdns-sd` mDNS discovery** (`discovery.rs`, polled over JNI — the same
|
||||
browse the Linux/Windows clients use, replacing the flaky per-OEM `NsdManager`; Kotlin keeps only
|
||||
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
|
||||
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
|
||||
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
|
||||
(`ci/play-upload.py`). Touch input is the same 3-way model as iOS (2026-07-02): the existing
|
||||
Trackpad/Direct mouse modes plus new **real multi-touch passthrough**
|
||||
(`streamTouchPassthrough` → `nativeSendTouch` → wire TouchDown/Move/Up), a `TouchMode`
|
||||
Settings dropdown replacing the old trackpad Boolean (migrated on load); not yet
|
||||
on-device validated. Next: real-device gamepad/HDR live-verify, presenter/latency polish.
|
||||
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
||||
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
||||
at high res).
|
||||
3. **punktfunk/1 protocol growth.** **Done:** unified host (`serve --gamestream` runs GameStream + the
|
||||
punktfunk/1 QUIC host in one process; bare `serve` is the secure native-only default — GameStream is
|
||||
opt-in, trusted-LAN only, security-review #5/#9) with native pairing driven over the mgmt API /
|
||||
web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
|
||||
**Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
|
||||
`pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts
|
||||
unpaired clients); clients render TOFU only for a `pair=optional` host and force re-pairing on a
|
||||
fingerprint change. **Done:** concurrent sessions — the accept loop spawns each session
|
||||
(`--max-concurrent`, default 4, an NVENC bound), each with its own virtual output + encoder, sharing
|
||||
the host-lifetime input/audio/mic services (shared-desktop multi-view on kwin/mutter/wlroots).
|
||||
**Done:** delegated pairing approval (§8b-1) — an unpaired device shows up as a pending request in
|
||||
the web console, one click approves + pins it. Next (see roadmap): gamescope multi-user isolation
|
||||
(per-session input/audio = independent desktops); §8b-2 peer-push approval from a paired device's
|
||||
own app.
|
||||
4. **GameStream host polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
|
||||
-highbitdepth 1` already encodes Main10 from 8-bit input on this box),
|
||||
reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented
|
||||
and unit/live-capture tested — both still need a live Moonlight confirmation (select
|
||||
AV1 in a stock client; a real 5.1/7.1 listen incl. FEC under loss).
|
||||
|
||||
Box one-time setup is complete: udev rule + `input` group (gamepads validated live),
|
||||
gamescope 3.16.22 installed system-wide (no PATH override), gnome-shell installed (Mutter
|
||||
backend validated live). All three compositor backends are live-validated.
|
||||
|
||||
## Build / test / run
|
||||
|
||||
```sh
|
||||
cargo build --workspace # green on Linux and macOS
|
||||
cargo test --workspace # unit + loopback + proptest + C ABI harness (~100 tests)
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo fmt --all --check
|
||||
|
||||
cargo run -p loss-harness # FEC loss-resilience sweep (no network needed)
|
||||
bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip proof
|
||||
```
|
||||
|
||||
Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h`
|
||||
(cbindgen from `punktfunk-core/src/abi.rs`) and `api/openapi.json` (regenerate with
|
||||
`cargo run -p punktfunk-host -- openapi > api/openapi.json`; spec lives in `mgmt.rs`).
|
||||
|
||||
CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the
|
||||
workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site
|
||||
build+typecheck; `docker.yml` builds+pushes the web/docs/rust-ci images (host and native
|
||||
clients are deliberately NOT containerized); `apple.yml` builds the xcframework and runs
|
||||
`swift build`/`swift test` on the `macos-arm64` host-mode runner (home-mac-mini-1,
|
||||
provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows:
|
||||
`deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml`
|
||||
(Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG +
|
||||
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner
|
||||
(`home-windows-runner-1`, vmid 210, `windows-amd64:host` label). The runner is reproducible and
|
||||
**owned by `unom/infra`**, not this repo, since it's shared across unom Windows projects going
|
||||
forward: `unom/infra`'s `windows-runner/` Packer template bakes a generic Windows 11 template (OS
|
||||
install + OpenSSH Server + VS Build Tools/NASM/CMake/LLVM + the act_runner/Node/rustup base, no
|
||||
registration) on Proxmox once; `proxmox/windows-runner/` (Terraform, `bpg/proxmox`) full-clones it
|
||||
(agent-based IP discovery, no pre-provisioned DHCP reservation needed) and registers the instance
|
||||
over SSH remote-exec — the same bake-once/clone-fast split `proxmox/unom-1` uses for the Linux CI
|
||||
host, just without a native Windows cloud-init (registration goes over `remote-exec`/SSH instead of
|
||||
`initialization{}`; WinRM was tried first but is deprecated in OpenTofu, so this moved to SSH via
|
||||
Windows' in-box OpenSSH Server). punktfunk layers its own extras on top of that generic runner:
|
||||
`scripts/ci/provision-windows-wdk.ps1` (WDK + cargo-wdk for the UMDF drivers) and
|
||||
`scripts/ci/provision-windows-punktfunk-extras.ps1` (FFmpeg x64/ARM64 trees, Inno Setup, the
|
||||
`aarch64-pc-windows-msvc` rustup target) — both idempotent, and both run automatically at the start
|
||||
of every Windows CI job via the shared `scripts/ci/ensure-windows-toolchain.ps1` step (a fast no-op
|
||||
once already provisioned), rather than a separate manually-dispatched provisioning workflow — that
|
||||
avoided a real footgun once there could be more than one `windows-amd64` runner: a manually
|
||||
dispatched provisioning workflow has no way to target a *specific* runner instance, so it could
|
||||
land on an already-provisioned box instead of the one that actually needed it.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated)
|
||||
crates/punktfunk-host/
|
||||
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
|
||||
vdisplay/linux/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
|
||||
vdisplay/windows/{pf_vdisplay,manager,identity}.rs all-Rust IddCx virtual display (pf-vdisplay)
|
||||
linux/zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
||||
inject/linux/{libei,wlr,gamepad,dualsense,dualshock4,steam_*}.rs Linux input (uinput xpad · UHID pads · virtual Deck)
|
||||
inject/windows/{sendinput,gamepad_windows,dualsense_windows,dualshock4_windows}.rs Windows input (UMDF shared-mem pads)
|
||||
encode/linux/{mod,vaapi}.rs · encode/windows/{nvenc,ffmpeg_win}.rs · encode/sw.rs per-GPU encoders (NVENC/CUDA · VAAPI · AMF/QSV) + GPU-less openh264
|
||||
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
|
||||
windows/{service,install,interactive}.rs SCM service + in-binary driver/web install
|
||||
capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs
|
||||
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
||||
clients/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
|
||||
packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI)
|
||||
web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs)
|
||||
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
|
||||
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
||||
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
|
||||
include/punktfunk_core.h generated C header
|
||||
```
|
||||
|
||||
## Design invariants — do not regress
|
||||
|
||||
- **One core, linked everywhere.** Protocol/FEC/crypto live only in `punktfunk-core`, behind a
|
||||
stable, versioned C ABI. `tokio`/`quinn` exist only behind the `quic` feature (control
|
||||
plane); **no async on the per-frame path** — native threads only.
|
||||
- **Native client resolution, no scaling.** A session gets a virtual output at exactly the
|
||||
client's WxH@Hz via the `VirtualDisplay` trait (`create(mode) → VirtualOutput { node_id,
|
||||
remote_fd, preferred_mode, keepalive }`, RAII teardown). There is no cross-compositor
|
||||
protocol for this — each compositor keeps its own backend.
|
||||
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block, Moonlight-compatible) and GF(2¹⁶)
|
||||
Leopard (≤65535 shards/block) — punktfunk/1 negotiates the latter, removing the ~1 Gbps
|
||||
ceiling.
|
||||
- **Core security hardening stays intact**: reassembler bounds attacker-controlled fields
|
||||
before allocating (`ReassemblerLimits`); AES-GCM per-direction nonce salts + seq-as-AAD;
|
||||
ABI `struct_size` checks. Regression tests exist — keep them green.
|
||||
- **PipeWire consumer discipline**: our capture streams set `node.dont-reconnect` and tear
|
||||
down promptly on negotiation timeout — one wedged link head-blocks the daemon's shared
|
||||
work queue system-wide.
|
||||
|
||||
## Running on this box
|
||||
|
||||
Headless QEMU VM (Ubuntu 26.04, kernel 7.0), passthrough RTX 5070 Ti (driver 595 **open**
|
||||
module — a kernel update silently drops it; reinstall `nvidia-driver-595-open`), no KMS
|
||||
scanout → KWin `--drm` impossible; everything renders offscreen via `renderD128`.
|
||||
|
||||
```sh
|
||||
# compositor session (shell 1, or the systemd unit in scripts/): full headless Plasma.
|
||||
# The script sets XDG_MENU_PREFIX=plasma- & co. — without it plasmashell runs but the
|
||||
# launcher menu is EMPTY (no apps, no System Settings).
|
||||
bash scripts/headless/run-headless-kde.sh 1920x1080
|
||||
|
||||
# host (shell 2): bare `serve` is native-only (secure default); add --gamestream for Moonlight compat.
|
||||
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_VIDEO_SOURCE=virtual \
|
||||
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve --gamestream
|
||||
|
||||
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
|
||||
# across sessions — bound it with --max-sessions):
|
||||
cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 10 --max-sessions 1
|
||||
cargo run -rp punktfunk-probe -- --mode 1280x720x120 --out /tmp/a.h265 --input-test # + --pin HEX
|
||||
```
|
||||
|
||||
Pinned crate facts: `ashpd` 0.13 + `pipewire` 0.9 (must match ashpd's) + `ffmpeg-next` 8.x
|
||||
(`ffmpeg-sys-next` auto-detects the system FFmpeg, so it builds against **FFmpeg 7.x/libavcodec 61
|
||||
or 8.x/libavcodec 62** — validated live on Ubuntu 26.04 (8) and Bazzite F43 (7.1); the zero-copy
|
||||
FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKTFUNK_VIDEO_SOURCE=virtual|portal`,
|
||||
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1|0` (Linux default: ON for
|
||||
VAAPI/AMD/Intel with a one-shot CPU downgrade if the dmabuf offer never negotiates, OFF/opt-in for
|
||||
NVENC), `PUNKTFUNK_VAAPI_LOW_POWER=1|0` (pin the VAAPI entrypoint; auto = full-feature then VDEnc
|
||||
fallback for modern Intel), `PUNKTFUNK_NV12=0` (opt OUT of the default GPU RGB→NV12 convert on the
|
||||
NVIDIA tiled zero-copy path), `PUNKTFUNK_INTRA_REFRESH=1` (opt-in NVENC intra-refresh loss recovery),
|
||||
`PUNKTFUNK_PIN_CLOCKS=1` (opt-in NVML GPU clock floor, root-gated), `PUNKTFUNK_GAMESCOPE_APP=...`,
|
||||
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
|
||||
test — injects N% wire-packet loss on BOTH the GameStream and native video paths, no netem needed), `PUNKTFUNK_FEC_PCT=N`, `PUNKTFUNK_DSCP=1` (opt-in DSCP/SO_PRIORITY media QoS on the data +
|
||||
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy),
|
||||
`PUNKTFUNK_444=1` (full-chroma HEVC 4:4:4, see below).
|
||||
|
||||
**HEVC 4:4:4 (full chroma, Range Extensions)**: opt-in via `PUNKTFUNK_444`, negotiated like 10-bit —
|
||||
the host emits 4:4:4 only when the client advertised `VIDEO_CAP_444` (wire bit `0x04` + ABI
|
||||
`PUNKTFUNK_VIDEO_CAP_444`), the codec is HEVC, the session is single-process, **and** a GPU probe
|
||||
(`encode::can_encode_444`, run before the Welcome) confirms support — else it resolves to 4:2:0 and
|
||||
`Welcome::chroma_format` reflects the real value (honest downgrade; the client reads it via
|
||||
`punktfunk_connection_chroma_format`). **punktfunk/1-native only** — GameStream/Moonlight stays 4:2:0
|
||||
(stock clients can't decode 4:4:4). **NVENC is the implemented path**: Linux `hevc_nvenc` feeds a
|
||||
swscale'd `yuv444p` (RGB-in is always 4:2:0 — verified on the RTX 5070 Ti — so the session forces CPU
|
||||
RGB capture for 4:4:4); Windows NVENC keeps ARGB input + FREXT profile + `chromaFormatIDC=3` and the
|
||||
DDA capturer delivers RGB. VAAPI / AMF / QSV **decline** (probe returns false — no validated 4:4:4
|
||||
hardware in the lab; they'd produce 4:2:0). Software (openh264) is 4:2:0-only. Test with
|
||||
`PUNKTFUNK_CLIENT_444=1 punktfunk-probe --out x.h265` then `ffprobe x.h265` (expect `pix_fmt yuv444p`).
|
||||
*Linux NVENC mechanism validated on the RTX 5070 Ti (ffmpeg CLI); Windows NVENC + 10-bit-4:4:4 not yet
|
||||
on-glass validated.*
|
||||
|
||||
## Conventions
|
||||
|
||||
- Rust 2021, `rustfmt` + `clippy -D warnings` clean before commit.
|
||||
- Match the surrounding code's comment density and naming.
|
||||
- Commit messages end with the Co-Authored-By trailer (see `git log`).
|
||||
- `pkill` caution on this box: match exact comm names (`pkill -x gamescope-wl`,
|
||||
`pkill -x punktfunk-host`) — `pkill -f` self-matches the invoking shell.
|
||||
Generated
+240
-11
@@ -228,6 +228,67 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
@@ -239,6 +300,30 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -434,6 +519,19 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -1854,6 +1952,16 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.15.0"
|
||||
@@ -2002,9 +2110,26 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ksni"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da9eeb3f510b6148ae68f963af2c1fbb0de4d9e4e05f82813cfb319837c3ad2b"
|
||||
dependencies = [
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"futures-channel",
|
||||
"futures-lite",
|
||||
"futures-util",
|
||||
"pastey",
|
||||
"serde",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.5.1"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2080,7 +2205,7 @@ dependencies = [
|
||||
"cookie-factory",
|
||||
"libc",
|
||||
"libspa-sys",
|
||||
"nix",
|
||||
"nix 0.30.1",
|
||||
"nom 8.0.0",
|
||||
"system-deps",
|
||||
]
|
||||
@@ -2136,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.5.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2147,6 +2272,16 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac_address"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
||||
dependencies = [
|
||||
"nix 0.29.0",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -2170,7 +2305,7 @@ checksum = "fb75febbe5fa1837a52fdbd1c735e168286c5c645fc2ddd31526f65c49941c2e"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"flume",
|
||||
"if-addrs",
|
||||
"if-addrs 0.15.0",
|
||||
"log",
|
||||
"mio",
|
||||
"socket-pktinfo",
|
||||
@@ -2268,6 +2403,19 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
@@ -2561,6 +2709,12 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
@@ -2599,6 +2753,17 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pipewire"
|
||||
version = "0.9.2"
|
||||
@@ -2610,7 +2775,7 @@ dependencies = [
|
||||
"libc",
|
||||
"libspa",
|
||||
"libspa-sys",
|
||||
"nix",
|
||||
"nix 0.30.1",
|
||||
"once_cell",
|
||||
"pipewire-sys",
|
||||
"thiserror 2.0.18",
|
||||
@@ -2654,6 +2819,20 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -2729,7 +2908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.5.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -2743,12 +2922,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.5.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"gtk4",
|
||||
"khronos-egl",
|
||||
"libadwaita",
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
@@ -2765,7 +2945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.5.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2788,7 +2968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.5.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -2796,6 +2976,7 @@ dependencies = [
|
||||
"criterion",
|
||||
"fec-rs",
|
||||
"hmac",
|
||||
"if-addrs 0.13.4",
|
||||
"libc",
|
||||
"opus",
|
||||
"proptest",
|
||||
@@ -2818,7 +2999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.5.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -2836,10 +3017,12 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"if-addrs 0.13.4",
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"log",
|
||||
"mac_address",
|
||||
"mdns-sd",
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
@@ -2881,13 +3064,14 @@ dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"windows-service",
|
||||
"winreg",
|
||||
"winresource",
|
||||
"x509-parser",
|
||||
"xkbcommon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.5.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -2899,6 +3083,23 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
"libc",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ureq",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"windows-service",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
@@ -4601,6 +4802,22 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -4610,6 +4827,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
@@ -5221,8 +5444,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ members = [
|
||||
"crates/punktfunk-core",
|
||||
"crates/punktfunk-host",
|
||||
"crates/punktfunk-host/vendor/usbip-sim",
|
||||
"crates/punktfunk-tray",
|
||||
"crates/pf-driver-proto",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
@@ -16,7 +17,7 @@ members = [
|
||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.1"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -15,6 +15,9 @@ your local network.
|
||||
💬 **Community: [Discord](https://discord.gg/kaPNvzMuGU)** — chat, support, and **Android beta
|
||||
access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
|
||||
|
||||
🔒 **Security:** found a vulnerability? Report it privately to **security@punktfunk.com** — see
|
||||
[SECURITY.md](SECURITY.md). Please don't open a public issue.
|
||||
|
||||
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
||||
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
|
||||
@@ -26,6 +29,11 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
||||
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display
|
||||
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.
|
||||
- **Displays you configure, not just create.** Keep a game's display (and the game) alive across
|
||||
disconnects so a reconnect drops straight back in; make the stream your sole desktop or extend
|
||||
alongside your monitors; let several devices become monitors of one desktop; keep each client's
|
||||
scaling. One-click presets in the console — a dedicated couch box, a shared desktop, a multi-monitor
|
||||
workstation. See [Virtual displays](docs-site/content/docs/virtual-displays.md).
|
||||
- **A real virtual display on Windows, too.** On Linux the host uses per-compositor virtual outputs;
|
||||
on Windows you get the same on-the-fly virtual display — at the client's exact mode, no physical
|
||||
monitor or dummy HDMI plug, even on the secure desktop (UAC / lock screen). It also has **its own
|
||||
@@ -49,7 +57,7 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
||||
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
||||
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
||||
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
||||
| **Windows host** (x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||
| **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
|
||||
| **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, AAudio audio, controllers, discovery, pairing |
|
||||
@@ -61,7 +69,7 @@ The **GameStream host works with a stock Moonlight client** — validated live o
|
||||
and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin,
|
||||
gamescope, Mutter, and Sway/wlroots backends), encoded with GPU **zero-copy** (dmabuf → CUDA/Vulkan →
|
||||
NVENC) up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a
|
||||
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→reassembled at 720p120), with
|
||||
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→received at 720p120), with
|
||||
mid-stream mode renegotiation and a wall-clock skew handshake so latency stays valid across machines.
|
||||
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
|
||||
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
|
||||
@@ -80,9 +88,10 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
| Platform | Install | Guide |
|
||||
|--------|---------|-------|
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
|
||||
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Windows** (x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
|
||||
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||
@@ -138,7 +147,6 @@ clients/
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
|
||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||
design/ design notes & deep-dive plans (index: design/README.md)
|
||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||
tools/ latency-probe · loss-harness (measurement)
|
||||
```
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
# Security Policy
|
||||
|
||||
punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
|
||||
machine, so we take security reports seriously and appreciate responsible disclosure.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please report security issues privately by email to security@punktfunk.com.**
|
||||
|
||||
Do **not** open a public issue, pull request, or chat/forum post for a suspected vulnerability — that
|
||||
exposes other users before a fix exists.
|
||||
|
||||
### What to include
|
||||
|
||||
The more of this you can give us, the faster we can act:
|
||||
|
||||
- The component and version (e.g. `punktfunk-host 0.6.0`, Windows or Linux, which client).
|
||||
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
|
||||
admin, a paired client, …).
|
||||
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
|
||||
- Any suggested fix or mitigation (optional).
|
||||
|
||||
## What to expect
|
||||
|
||||
We're a small team, so timelines are best-effort, but we commit to:
|
||||
|
||||
- **Acknowledge** your report within **3 business days**.
|
||||
- Give an **initial assessment** (severity + whether we can reproduce) within about **7 days**.
|
||||
- Keep you updated, and tell you when a fix ships.
|
||||
- **Credit** you in the advisory / release notes when the fix is public — unless you'd rather stay
|
||||
anonymous.
|
||||
|
||||
We practice **coordinated disclosure**: please give us reasonable time to release a fix before
|
||||
publishing details. We aim to resolve valid issues within **90 days** and will agree a disclosure
|
||||
date with you.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope — the code in this repository:
|
||||
|
||||
- The host (`punktfunk-host`), its Windows drivers, and the protocol/crypto core (`punktfunk-core`).
|
||||
- The native clients (Apple, Linux, Windows, Android), the web management console, and the management
|
||||
API.
|
||||
|
||||
Known limits — documented behavior, not vulnerabilities (see
|
||||
https://docs.punktfunk.unom.io/docs/security):
|
||||
|
||||
- **Admin/SYSTEM already on the host = out of scope.** An attacker who is already administrator or
|
||||
SYSTEM on the host owns the machine regardless of punktfunk.
|
||||
- **The virtual display is a real monitor** — any process already in the interactive desktop session
|
||||
can capture it via the normal OS screen-capture APIs, exactly as it could a physical monitor.
|
||||
- **GameStream/Moonlight compatibility** (`--gamestream`) uses legacy encryption and is documented as
|
||||
opt-in, trusted-LAN-only.
|
||||
- **Public-internet exposure is unsupported** — issues that only arise from exposing the host to the
|
||||
WAN are expected; keep the host on a trusted LAN or a VPN.
|
||||
|
||||
If you're unsure whether something is in scope, report it anyway — we'd rather hear about it.
|
||||
|
||||
## Safe harbor
|
||||
|
||||
We consider good-faith security research that follows this policy to be authorized, and we won't
|
||||
pursue legal action against researchers who:
|
||||
|
||||
- make a good-faith effort to avoid privacy violations, data loss, and service disruption,
|
||||
- only test systems they own or have explicit permission to test,
|
||||
- give us reasonable time to remediate before public disclosure,
|
||||
- don't exfiltrate more data than needed to demonstrate the issue.
|
||||
|
||||
Thank you for helping keep punktfunk and its users safe.
|
||||
+752
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.5.1"
|
||||
"version": "0.7.4"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -138,6 +138,224 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/display/layout": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"display"
|
||||
],
|
||||
"summary": "Arrange virtual displays",
|
||||
"description": "Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor\ngroup (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block\nand switched to manual mode; applied from the next connect (a live group re-applies on its next\nacquire). Locks in the current effective behavior as explicit fields, so arranging displays never\nsilently changes keep-alive/topology/conflict/identity. See `design/display-management.md` §6.2.",
|
||||
"operationId": "setDisplayLayout",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DisplayLayoutRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Layout stored; the new settings state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DisplaySettingsState"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Layout could not be persisted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/display/release": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"display"
|
||||
],
|
||||
"summary": "Release kept virtual displays",
|
||||
"description": "Tear down lingering/pinned displays now — so a physical-screen user gets their screen back\nwithout waiting out the linger. `slot` releases one; omit it to release all kept displays.\nActive (streaming) displays are never torn down here (that is session control).",
|
||||
"operationId": "releaseDisplay",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReleaseDisplayRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The number of kept displays released",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReleaseDisplayResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/display/settings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"display"
|
||||
],
|
||||
"summary": "Display-management policy",
|
||||
"description": "The stored virtual-display policy (lifecycle, topology, conflict handling, identity, layout),\nevery preset's expansion, and which options this build enforces yet. See\n`design/display-management.md`.",
|
||||
"operationId": "getDisplaySettings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Stored policy + preset expansions + enforced options",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DisplaySettingsState"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"display"
|
||||
],
|
||||
"summary": "Set the display-management policy",
|
||||
"description": "Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a\nrunning session keeps the display it opened on. `keep_alive: forever` (the gaming-rig preset) is\nhonored (the display is Pinned; free it via `POST /display/release`).",
|
||||
"operationId": "setDisplaySettings",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DisplayPolicy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Policy stored; the new state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DisplaySettingsState"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Malformed policy body",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Policy could not be persisted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/display/state": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"display"
|
||||
],
|
||||
"summary": "Live virtual displays",
|
||||
"description": "The host's managed virtual displays right now — active (streaming), lingering (kept after\ndisconnect, counting down to teardown), or pinned (kept indefinitely). See\n`design/display-management.md`.",
|
||||
"operationId": "getDisplayState",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The live/kept virtual displays",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DisplayStateResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/gpus": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -578,6 +796,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/local/summary": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"host"
|
||||
],
|
||||
"summary": "Local status summary for the tray icon",
|
||||
"description": "Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device\nnames). Unauthenticated, but served to loopback peers only.",
|
||||
"operationId": "getLocalSummary",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Non-sensitive local host status (loopback peers only)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LocalSummary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Non-loopback peer",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/logs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1566,6 +1819,99 @@
|
||||
"av1"
|
||||
]
|
||||
},
|
||||
"ApiDisplayInfo": {
|
||||
"type": "object",
|
||||
"description": "One live or kept virtual display.",
|
||||
"required": [
|
||||
"slot",
|
||||
"backend",
|
||||
"mode",
|
||||
"state",
|
||||
"sessions",
|
||||
"group",
|
||||
"display_index",
|
||||
"x",
|
||||
"y",
|
||||
"topology"
|
||||
],
|
||||
"properties": {
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"description": "Backend name (`pf-vdisplay`, `kwin`, …)."
|
||||
},
|
||||
"client": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Short client label, when the owner tracks it."
|
||||
},
|
||||
"display_index": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "This display's ordinal within its group, in acquire order (0-based).",
|
||||
"minimum": 0
|
||||
},
|
||||
"expires_in_ms": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Milliseconds until a lingering display is torn down (absent when active/pinned).",
|
||||
"minimum": 0
|
||||
},
|
||||
"group": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Display group (shared desktop) id — several displays with the same group form one desktop (§6A).",
|
||||
"minimum": 0
|
||||
},
|
||||
"identity_slot": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "Stable per-client identity slot keying persistent config + manual layout (absent = shared/anonymous).",
|
||||
"minimum": 0
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"description": "`WIDTHxHEIGHT@HZ`."
|
||||
},
|
||||
"sessions": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Live sessions holding the display.",
|
||||
"minimum": 0
|
||||
},
|
||||
"slot": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Stable-enough id for the `/display/release` `slot` argument.",
|
||||
"minimum": 0
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "`active` | `lingering` | `pinned`."
|
||||
},
|
||||
"topology": {
|
||||
"type": "string",
|
||||
"description": "Effective topology for this display's group (`extend` | `primary` | `exclusive`)."
|
||||
},
|
||||
"x": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Desktop-space top-left `x` (auto-row or the console's manual arrangement, §6.2)."
|
||||
},
|
||||
"y": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Desktop-space top-left `y`."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiError": {
|
||||
"type": "object",
|
||||
"description": "Error envelope for every non-2xx response.",
|
||||
@@ -1874,6 +2220,146 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DisplayLayoutRequest": {
|
||||
"type": "object",
|
||||
"description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`).",
|
||||
"properties": {
|
||||
"positions": {
|
||||
"type": "object",
|
||||
"description": "`{\"<identity_slot>\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits.",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Position"
|
||||
},
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DisplayPolicy": {
|
||||
"type": "object",
|
||||
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
|
||||
"properties": {
|
||||
"identity": {
|
||||
"$ref": "#/components/schemas/Identity"
|
||||
},
|
||||
"keep_alive": {
|
||||
"$ref": "#/components/schemas/KeepAlive"
|
||||
},
|
||||
"layout": {
|
||||
"$ref": "#/components/schemas/Layout"
|
||||
},
|
||||
"max_displays": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).",
|
||||
"minimum": 0
|
||||
},
|
||||
"mode_conflict": {
|
||||
"$ref": "#/components/schemas/ModeConflict"
|
||||
},
|
||||
"preset": {
|
||||
"$ref": "#/components/schemas/Preset"
|
||||
},
|
||||
"topology": {
|
||||
"$ref": "#/components/schemas/Topology"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"DisplaySettingsState": {
|
||||
"type": "object",
|
||||
"description": "Full display-management state for the console: the stored policy, every preset's expansion, the\nresolved effective policy, and which options this build actually enforces yet (Stage 0 wires\nkeep-alive linger + topology; the rest are stored but not yet acted on).",
|
||||
"required": [
|
||||
"settings",
|
||||
"configured",
|
||||
"effective",
|
||||
"presets",
|
||||
"enforced"
|
||||
],
|
||||
"properties": {
|
||||
"configured": {
|
||||
"type": "boolean",
|
||||
"description": "True once a `display-settings.json` exists (the console has configured this host)."
|
||||
},
|
||||
"effective": {
|
||||
"$ref": "#/components/schemas/EffectivePolicy",
|
||||
"description": "The effective (preset-expanded) policy currently in force."
|
||||
},
|
||||
"enforced": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Option names this build enforces right now. All five axes are now acted on (keep_alive +\ntopology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console\nreads this to know which controls are live vs. \"coming soon\" (per-backend nuance, e.g. layout\nposition apply being KWin-only, is reported per display in `/display/state`)."
|
||||
},
|
||||
"presets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PresetInfo"
|
||||
},
|
||||
"description": "Every named preset and what it expands to (for the picker's preview)."
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/DisplayPolicy",
|
||||
"description": "The stored policy (preset + custom fields), or the built-in default when unconfigured."
|
||||
}
|
||||
}
|
||||
},
|
||||
"DisplayStateResponse": {
|
||||
"type": "object",
|
||||
"description": "The host's managed virtual displays right now.",
|
||||
"required": [
|
||||
"displays"
|
||||
],
|
||||
"properties": {
|
||||
"displays": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ApiDisplayInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"EffectivePolicy": {
|
||||
"type": "object",
|
||||
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
|
||||
"required": [
|
||||
"keep_alive",
|
||||
"topology",
|
||||
"mode_conflict",
|
||||
"identity",
|
||||
"layout",
|
||||
"max_displays"
|
||||
],
|
||||
"properties": {
|
||||
"identity": {
|
||||
"$ref": "#/components/schemas/Identity"
|
||||
},
|
||||
"keep_alive": {
|
||||
"$ref": "#/components/schemas/KeepAlive"
|
||||
},
|
||||
"layout": {
|
||||
"$ref": "#/components/schemas/Layout"
|
||||
},
|
||||
"max_displays": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
},
|
||||
"mode_conflict": {
|
||||
"$ref": "#/components/schemas/ModeConflict"
|
||||
},
|
||||
"topology": {
|
||||
"$ref": "#/components/schemas/Topology"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GameEntry": {
|
||||
"type": "object",
|
||||
"description": "One title in the unified library, regardless of which store it came from.",
|
||||
@@ -2064,6 +2550,72 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Identity": {
|
||||
"type": "string",
|
||||
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.",
|
||||
"enum": [
|
||||
"shared",
|
||||
"per-client",
|
||||
"per-client-mode"
|
||||
]
|
||||
},
|
||||
"KeepAlive": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Tear the display down at session end (today's default on every backend but Windows, which\nlingers 10 s).",
|
||||
"required": [
|
||||
"mode"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"off"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.",
|
||||
"required": [
|
||||
"seconds",
|
||||
"mode"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"duration"
|
||||
]
|
||||
},
|
||||
"seconds": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Linger window in seconds.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.",
|
||||
"required": [
|
||||
"mode"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"forever"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)\nsurvives after the last client session detaches. Serialized as an object tagged on `mode`\n(`{\"mode\":\"off\"}` / `{\"mode\":\"duration\",\"seconds\":300}` / `{\"mode\":\"forever\"}`) so the web form\nand the OpenAPI schema stay simple."
|
||||
},
|
||||
"LaunchSpec": {
|
||||
"type": "object",
|
||||
"description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;\n`command` → run `<value>` nested in a gamescope session.",
|
||||
@@ -2083,6 +2635,99 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Layout": {
|
||||
"type": "object",
|
||||
"description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"$ref": "#/components/schemas/LayoutMode"
|
||||
},
|
||||
"positions": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Position"
|
||||
},
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"LayoutMode": {
|
||||
"type": "string",
|
||||
"description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.",
|
||||
"enum": [
|
||||
"auto-row",
|
||||
"manual"
|
||||
]
|
||||
},
|
||||
"LocalSummary": {
|
||||
"type": "object",
|
||||
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
|
||||
"required": [
|
||||
"version",
|
||||
"video_streaming",
|
||||
"audio_streaming",
|
||||
"paired_clients",
|
||||
"native_paired_clients",
|
||||
"pin_pending",
|
||||
"pending_approvals",
|
||||
"kept_displays"
|
||||
],
|
||||
"properties": {
|
||||
"audio_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while the audio stream thread is running."
|
||||
},
|
||||
"kept_displays": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.",
|
||||
"minimum": 0
|
||||
},
|
||||
"native_paired_clients": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Number of paired native (punktfunk/1) devices.",
|
||||
"minimum": 0
|
||||
},
|
||||
"paired_clients": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Number of pinned (paired) GameStream client certificates.",
|
||||
"minimum": 0
|
||||
},
|
||||
"pending_approvals": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Native pairing knocks awaiting the operator's approval (count only).",
|
||||
"minimum": 0
|
||||
},
|
||||
"pin_pending": {
|
||||
"type": "boolean",
|
||||
"description": "True while a GameStream pairing handshake is parked waiting for the user's PIN."
|
||||
},
|
||||
"session": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionInfo",
|
||||
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Host version (mirrors `/health`)."
|
||||
},
|
||||
"video_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while the video stream thread is running."
|
||||
}
|
||||
}
|
||||
},
|
||||
"LogEntry": {
|
||||
"type": "object",
|
||||
"description": "One captured log event.",
|
||||
@@ -2147,6 +2792,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ModeConflict": {
|
||||
"type": "string",
|
||||
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.",
|
||||
"enum": [
|
||||
"separate",
|
||||
"steal",
|
||||
"join",
|
||||
"reject"
|
||||
]
|
||||
},
|
||||
"NativeClient": {
|
||||
"type": "object",
|
||||
"description": "A paired native (punktfunk/1) client.",
|
||||
@@ -2344,6 +2999,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Position": {
|
||||
"type": "object",
|
||||
"description": "A desktop-space offset for a display (top-left origin).",
|
||||
"required": [
|
||||
"x",
|
||||
"y"
|
||||
],
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"y": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Preset": {
|
||||
"type": "string",
|
||||
"description": "A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any\nother preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).",
|
||||
"enum": [
|
||||
"custom",
|
||||
"default",
|
||||
"gaming-rig",
|
||||
"shared-desktop",
|
||||
"hotdesk",
|
||||
"workstation"
|
||||
]
|
||||
},
|
||||
"PresetInfo": {
|
||||
"type": "object",
|
||||
"description": "One preset's human-facing description + the fields it expands to, so the console can render a\npreset picker with an accurate \"what this does\" preview without hardcoding the expansion.",
|
||||
"required": [
|
||||
"id",
|
||||
"summary",
|
||||
"fields"
|
||||
],
|
||||
"properties": {
|
||||
"fields": {
|
||||
"$ref": "#/components/schemas/EffectivePolicy",
|
||||
"description": "The effective policy this preset expands to (the same fields a `custom` policy carries)."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`)."
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "One-line story shown next to the option."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReleaseDisplayRequest": {
|
||||
"type": "object",
|
||||
"description": "Request body for `releaseDisplay`.",
|
||||
"properties": {
|
||||
"slot": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Slot to release (see `state`); omit to release **all** kept displays.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReleaseDisplayResult": {
|
||||
"type": "object",
|
||||
"description": "Result of a `/display/release`.",
|
||||
"required": [
|
||||
"released"
|
||||
],
|
||||
"properties": {
|
||||
"released": {
|
||||
"type": "integer",
|
||||
"description": "Number of kept displays torn down.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuntimeStatus": {
|
||||
"type": "object",
|
||||
"description": "Live host status (changes as clients launch/end sessions).",
|
||||
@@ -2645,6 +3382,16 @@
|
||||
"example": "1234"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Topology": {
|
||||
"type": "string",
|
||||
"description": "What the host does to the box's display topology while managed virtual displays are up.",
|
||||
"enum": [
|
||||
"auto",
|
||||
"extend",
|
||||
"primary",
|
||||
"exclusive"
|
||||
]
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
@@ -2668,6 +3415,10 @@
|
||||
"name": "gpu",
|
||||
"description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"
|
||||
},
|
||||
{
|
||||
"name": "display",
|
||||
"description": "Virtual-display management policy: lifecycle (keep-alive), topology (primary/exclusive), conflict handling, identity, and layout"
|
||||
},
|
||||
{
|
||||
"name": "clients",
|
||||
"description": "Paired Moonlight client management"
|
||||
|
||||
@@ -16,7 +16,9 @@ couch (D-pad / gamepad focus navigation).
|
||||
pairing** (or TOFU on trusted LANs), then reconnects on a Keystore-wrapped, pinned identity.
|
||||
- **Compose UI** — Connect / Settings / Stream screens with Material You theming.
|
||||
|
||||
Built for `arm64-v8a` + `x86_64`.
|
||||
Built for `arm64-v8a` + `armeabi-v7a` + `x86_64` — the 32-bit `armeabi-v7a` slice is what keeps the
|
||||
app installable on the many 32-bit Google TV / Android TV streamers (Walmart onn. 4K, Chromecast with
|
||||
Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "not compatible".
|
||||
|
||||
## Get it
|
||||
|
||||
@@ -54,7 +56,7 @@ kit/ :kit — NativeBridge · native mDNS discovery · Gamepad · K
|
||||
|
||||
**Prerequisites:** Android SDK + **NDK r30** (`30.0.14904198`), `platforms;android-37.0`,
|
||||
`build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 17–21, not
|
||||
a newer default); Rust with `rustup target add aarch64-linux-android x86_64-linux-android` and
|
||||
a newer default); Rust with `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` and
|
||||
`cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM
|
||||
2026.05.01 · compileSdk 37 · minSdk 31).
|
||||
|
||||
|
||||
@@ -22,14 +22,34 @@ android {
|
||||
}
|
||||
|
||||
applicationId = "io.unom.punktfunk"
|
||||
minSdk = 31
|
||||
// Android 9. Reaches older Android TV boxes (e.g. Amlogic streamers still on Android 9–11);
|
||||
// the handful of API 31+ APIs we use are runtime-gated (Material You → brand palette, rumble
|
||||
// → legacy Vibrator, NEARBY_WIFI/lights/ADPF already gated), so nothing is lost above 28.
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
||||
versionCode = vCode?.toInt() ?: 1
|
||||
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
|
||||
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
|
||||
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2"
|
||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
||||
// Local dev (no VERSION_NAME) falls back to the workspace version from the root Cargo.toml —
|
||||
// the single source of truth — so an on-device build shows the real current version, not a
|
||||
// stale placeholder.
|
||||
val workspaceVersion = runCatching {
|
||||
project.rootProject.file("../../Cargo.toml").readLines()
|
||||
.dropWhile { !it.trim().startsWith("[workspace.package]") }
|
||||
.firstOrNull { it.trim().startsWith("version") }
|
||||
?.substringAfter('=')?.trim()?.trim('"')
|
||||
}.getOrNull()
|
||||
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME"))
|
||||
?: workspaceVersion ?: "0.0.0"
|
||||
// Ship 32-bit armeabi-v7a alongside 64-bit arm64-v8a: many Google TV / Android TV streamers
|
||||
// (Walmart onn. 4K, Chromecast with Google TV, budget Amlogic boxes) run a 32-bit Android
|
||||
// userspace, and because this app carries native code, Google Play (and a sideload installer)
|
||||
// filters it as "not compatible" on those devices unless an armeabi-v7a variant is present.
|
||||
// x86_64 stays for the emulator. Google keeps delivering to 32-bit TV devices (see the Aug
|
||||
// 2025 "64-bit app compatibility for Google TV and Android TV" post) — the 64-bit lib is the
|
||||
// required half; the 32-bit lib is what actually reaches the boxes people report failing.
|
||||
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -97,9 +117,18 @@ dependencies {
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.foundation:foundation")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-core") // bottom-bar tab icons
|
||||
implementation("androidx.compose.material:material-icons-core") // bottom-bar / rail tab icons
|
||||
implementation("androidx.compose.material:material-icons-extended") // settings-category icons
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
// Cover-art loading for the game-library coverflow. Coil 2.x uses OkHttp under the hood, so we
|
||||
// feed it the same mTLS OkHttpClient the library fetch uses (reaching the host's own art proxy).
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
|
||||
// Real backdrop blur for the floating console legends (RenderEffect on API 31+, a translucent
|
||||
// scrim below). The gamepad UI's frosted pills sample + blur whatever scrolls behind them.
|
||||
implementation("dev.chrisbanes.haze:haze:1.6.0")
|
||||
|
||||
// Android TV components (we target phone + TV) land in the TV-UI milestone:
|
||||
// implementation("androidx.tv:tv-material:1.1.0")
|
||||
// The manifest already declares leanback so the scaffold installs on TV.
|
||||
|
||||
@@ -27,8 +27,16 @@
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
|
||||
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
|
||||
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
|
||||
eligible for the vendor's game performance profile — the aggressive CPU/GPU governor and
|
||||
scheduler treatment games get — which, together with the ADPF hints in the native decode
|
||||
path, is what keeps clocks up for low, consistent decode latency. Also groups it correctly
|
||||
under Games in battery/data usage. Advisory: devices without Game Mode ignore it. -->
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:appCategory="game"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -2,40 +2,62 @@ package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
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.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.models.Tab
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
fun App(forceGamepadUi: Boolean = false) {
|
||||
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) }
|
||||
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
|
||||
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val controllerConnected by rememberControllerConnected()
|
||||
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = streamHandle != 0L,
|
||||
transitionSpec = {
|
||||
@@ -46,46 +68,154 @@ fun App() {
|
||||
if (isStreaming) {
|
||||
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
|
||||
} else if (gamepadUi) {
|
||||
GamepadShell(
|
||||
settings = settings,
|
||||
onSettingsChange = { settings = it; settingsStore.save(it) },
|
||||
onConnected = { streamHandle = it },
|
||||
)
|
||||
} 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) {
|
||||
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||
// with its items centred vertically (the common Android tablet idiom, mirroring iPad's
|
||||
// side navigation). A short landscape phone keeps the bottom bar (rail needs height too).
|
||||
// Tabs slide along the axis the nav sits on: horizontally with the bottom bar (phone),
|
||||
// vertically with the side rail (tablet), so the motion tracks the direction you moved.
|
||||
val tabContent: @Composable (vertical: Boolean) -> Unit = { vertical ->
|
||||
AnimatedContent(
|
||||
targetState = tab,
|
||||
transitionSpec = {
|
||||
val forward = targetState.ordinal > initialState.ordinal
|
||||
when {
|
||||
vertical && forward ->
|
||||
slideInVertically { it } + fadeIn() togetherWith
|
||||
slideOutVertically { -it } + fadeOut()
|
||||
vertical ->
|
||||
slideInVertically { -it } + fadeIn() togetherWith
|
||||
slideOutVertically { it } + fadeOut()
|
||||
forward ->
|
||||
slideInHorizontally { it } + fadeIn() togetherWith
|
||||
slideOutHorizontally { -it } + fadeOut()
|
||||
} else {
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
if (maxWidth >= 600.dp && maxHeight >= 480.dp) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
NavigationRail(Modifier.fillMaxHeight()) {
|
||||
Spacer(Modifier.weight(1f)) // centre the rail items vertically
|
||||
Tab.entries.forEach { t ->
|
||||
NavigationRailItem(
|
||||
selected = tab == t,
|
||||
onClick = { tab = t },
|
||||
icon = { Icon(t.icon, contentDescription = t.label) },
|
||||
label = { Text(t.label) },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
// The rail handles its own insets; the content pane insets itself (the screens
|
||||
// don't, since they used to rely on the Scaffold's padding).
|
||||
Box(Modifier.weight(1f).fillMaxHeight().systemBarsPadding()) { tabContent(true) }
|
||||
}
|
||||
} else {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
Tab.entries.forEach { t ->
|
||||
NavigationBarItem(
|
||||
selected = tab == t,
|
||||
onClick = { tab = t },
|
||||
icon = { Icon(t.icon, contentDescription = t.label) },
|
||||
label = { Text(t.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
label = "TabTransition"
|
||||
) { 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 },
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(Modifier.fillMaxSize().padding(innerPadding)) { tabContent(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Which console screen the gamepad shell is showing. */
|
||||
private enum class GamepadScreen { Home, Settings, Library }
|
||||
|
||||
/**
|
||||
* The console (gamepad) shell — the Android mirror of the Apple client's ContentView gamepad branch:
|
||||
* a full-screen host carousel with X → Settings and Y → a saved host's library, all sharing
|
||||
* [ConnectScreen]'s connect logic. No bottom bar; navigation is button-driven.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadShell(
|
||||
settings: Settings,
|
||||
onSettingsChange: (Settings) -> Unit,
|
||||
onConnected: (Long) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
|
||||
|
||||
// On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the
|
||||
// effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the
|
||||
// panel reports fewer dp than that; a low-density TV that's already spacious, and every phone /
|
||||
// tablet, keep their real density unchanged. This is the "based on pixel density" scale the layout
|
||||
// wanted — one uniform factor across text, cards, spacing, and insets.
|
||||
val isTv = remember { isTvDevice(context) }
|
||||
val baseDensity = LocalDensity.current
|
||||
val screenWidthPx = LocalConfiguration.current.screenWidthDp * baseDensity.density
|
||||
val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP
|
||||
val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density
|
||||
|
||||
CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) {
|
||||
// Cross-fade between console screens so switches are smooth. Each slot's controller nav is gated
|
||||
// on being the CURRENT target (`s == screen`), so during the fade only the incoming screen drives
|
||||
// the pad. All screens pin their legend at the same ConsoleLegendInset, so it reads as fixed while
|
||||
// the content behind it fades.
|
||||
Crossfade(targetState = screen, animationSpec = tween(240), label = "consoleScreen") { s ->
|
||||
when (s) {
|
||||
GamepadScreen.Home -> ConnectScreen(
|
||||
settings = settings,
|
||||
onConnected = onConnected,
|
||||
gamepadUi = true,
|
||||
onOpenSettings = { screen = GamepadScreen.Settings },
|
||||
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
|
||||
navGate = s == screen,
|
||||
)
|
||||
GamepadScreen.Settings -> GamepadSettingsScreen(
|
||||
initial = settings,
|
||||
onChange = onSettingsChange,
|
||||
onBack = { screen = GamepadScreen.Home },
|
||||
navActive = s == screen,
|
||||
)
|
||||
GamepadScreen.Library -> libraryHost?.let { host ->
|
||||
LibraryScreen(
|
||||
host = host,
|
||||
onBack = { screen = GamepadScreen.Home; libraryHost = null },
|
||||
navActive = s == screen,
|
||||
)
|
||||
} ?: run { screen = GamepadScreen.Home }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */
|
||||
private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f
|
||||
|
||||
@@ -9,7 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -33,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -320,32 +323,75 @@ internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
||||
* friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
||||
* Edit a saved host: name, address, port, and the Wake-on-LAN MAC. The MAC is auto-learned from the
|
||||
* host's mDNS advert while it's online, but this is where you can enter or correct it (e.g. to wake a
|
||||
* host you've only ever reached by address). [suggestedMacs] prefills the field from the live advert
|
||||
* when nothing's been learned yet. Keyed by the host so reopening resets the fields. Mirrors the
|
||||
* Apple client's edit form.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RenameHostDialog(
|
||||
internal fun EditHostDialog(
|
||||
target: KnownHost,
|
||||
onRename: (String) -> Unit,
|
||||
suggestedMacs: List<String>,
|
||||
onSave: (KnownHost) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var newName by remember(target) { mutableStateOf(target.name) }
|
||||
var name by remember(target) { mutableStateOf(target.name) }
|
||||
var address by remember(target) { mutableStateOf(target.address) }
|
||||
var port by remember(target) { mutableStateOf(target.port.toString()) }
|
||||
var mac by remember(target) {
|
||||
mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", "))
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename host") },
|
||||
title = { Text("Edit host") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text(target.address) },
|
||||
singleLine = true,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text(target.address) },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = address,
|
||||
onValueChange = { address = it },
|
||||
label = { Text("Address") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
||||
label = { Text("Port") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = mac,
|
||||
onValueChange = { mac = it },
|
||||
label = { Text("Wake-on-LAN MAC") },
|
||||
placeholder = { Text("auto-filled when the host is seen") },
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = newName.isNotBlank(),
|
||||
onClick = { onRename(newName.trim()) },
|
||||
enabled = address.isNotBlank(),
|
||||
onClick = {
|
||||
onSave(
|
||||
target.copy(
|
||||
name = name.trim().ifEmpty { target.address },
|
||||
address = address.trim(),
|
||||
port = port.toIntOrNull() ?: target.port,
|
||||
mac = KnownHostStore.parseMacs(mac),
|
||||
),
|
||||
)
|
||||
},
|
||||
) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
|
||||
@@ -84,7 +84,17 @@ private class RequestAccessState(val target: PendingTrust) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
fun ConnectScreen(
|
||||
settings: Settings,
|
||||
onConnected: (Long) -> Unit,
|
||||
// Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this
|
||||
// screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the
|
||||
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
|
||||
gamepadUi: Boolean = false,
|
||||
onOpenSettings: () -> Unit = {},
|
||||
onOpenLibrary: (KnownHost) -> Unit = {},
|
||||
navGate: Boolean = true, // false while the console home is cross-fading out
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var host by remember { mutableStateOf("") }
|
||||
@@ -124,6 +134,29 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
val identityStore = remember { IdentityStore(context) }
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||
// Wakes a sleeping saved host and waits for it to reappear on mDNS before dialing (its overlay
|
||||
// rides over both the touch and console home). Fire-and-forget WoL isn't enough — a cold boot can
|
||||
// take a minute-plus to advertise again.
|
||||
val waker = remember { WakeController(scope) }
|
||||
// Learn wake MAC(s) from live adverts for hosts we've saved (parity with the desktop clients),
|
||||
// so we can Wake-on-LAN them once they sleep. Runs only when the discovered set changes; the
|
||||
// prefs write is guarded (no-op when unchanged), and we refresh the saved list only if a MAC
|
||||
// was actually newly learned.
|
||||
LaunchedEffect(discovered) {
|
||||
val learned = withContext(Dispatchers.IO) {
|
||||
var any = false
|
||||
discovered.forEach { dh ->
|
||||
if (dh.mac.isNotEmpty() &&
|
||||
knownHostStore.get(dh.host, dh.port)?.let { it.mac != dh.mac } == true
|
||||
) {
|
||||
knownHostStore.learnMac(dh.host, dh.port, dh.mac)
|
||||
any = true
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
if (learned) savedHosts = knownHostStore.all()
|
||||
}
|
||||
// Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and
|
||||
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
|
||||
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
|
||||
@@ -137,8 +170,11 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
||||
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
||||
// A saved host whose label is being edited (the Rename dialog).
|
||||
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||
// A saved host being edited (name / address / port / MAC).
|
||||
var editTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||
// A saved host whose console options menu (Wake / Edit / Forget) is open — reached with Up on the
|
||||
// carousel (the console counterpart of the touch host card's overflow menu).
|
||||
var optionsTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||
|
||||
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
|
||||
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
|
||||
@@ -165,12 +201,11 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||
// appears in the saved-hosts list.
|
||||
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
val id = identity ?: run {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
@@ -195,6 +230,47 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// Wake-aware connect. If the target is a saved host with a learned MAC that ISN'T currently
|
||||
// advertising (asleep/off), wake it and WAIT for it to reappear on mDNS (WakeController shows the
|
||||
// "Waking…" overlay) before dialing — discovery stays running meanwhile so we can see it come
|
||||
// back. A fire-and-forget packet + the connect timeout wasn't enough for a cold boot. Otherwise
|
||||
// dial straight through.
|
||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
if (identity == null) {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
val kh = knownHostStore.get(targetHost, targetPort)
|
||||
val macs = kh?.mac ?: emptyList()
|
||||
// "Up" = a live advert that is THIS host — matched by fingerprint first (so it survives a DHCP
|
||||
// address change on a cold boot), else by address:port. Returns the CURRENT advert so we can
|
||||
// dial its live address rather than the stale saved one.
|
||||
fun liveAdvert(): DiscoveredHost? =
|
||||
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
||||
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
||||
if (macs.isNotEmpty() && liveAdvert() == null) {
|
||||
waker.start(
|
||||
hostName = name,
|
||||
connectsAfter = true,
|
||||
macs = macs,
|
||||
lastIp = targetHost,
|
||||
isOnline = { liveAdvert() != null },
|
||||
onOnline = {
|
||||
val live = liveAdvert()
|
||||
// Woke back on a new address? Re-key the saved record so it (and future connects)
|
||||
// point at the live one, then dial there.
|
||||
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
|
||||
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex)
|
||||
}
|
||||
}
|
||||
|
||||
// The no-PIN "request access" path (delegated approval): open a normal identified connect that
|
||||
// the host PARKS until the operator clicks Approve in its console/web UI, showing a cancelable
|
||||
// "Waiting for approval…" dialog meanwhile. The SAME connection is admitted on approval (no
|
||||
@@ -277,7 +353,62 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
|
||||
var showManualSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (gamepadUi) {
|
||||
// Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares
|
||||
// every action above; the trailing Add Host tile opens the same manual-entry sheet.
|
||||
val tiles = buildList {
|
||||
savedHosts.forEach { kh ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "saved-${kh.address}:${kh.port}",
|
||||
title = kh.name,
|
||||
subtitle = "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = discovered.any { it.host == kh.address && it.port == kh.port },
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
activate = { connect(kh.address, kh.port) },
|
||||
),
|
||||
)
|
||||
}
|
||||
discoveredUnsaved.forEach { dh ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "disc-${dh.host}:${dh.port}",
|
||||
title = dh.name,
|
||||
subtitle = "${dh.host}:${dh.port}",
|
||||
online = true,
|
||||
activate = { connect(dh.host, dh.port, dh) },
|
||||
),
|
||||
)
|
||||
}
|
||||
add(
|
||||
HomeTile(
|
||||
id = "add",
|
||||
title = "Add Host",
|
||||
subtitle = "Register a host by address",
|
||||
isAdd = true,
|
||||
activate = { showManualSheet = true },
|
||||
),
|
||||
)
|
||||
}
|
||||
GamepadHome(
|
||||
tiles = tiles,
|
||||
libraryEnabled = settings.libraryEnabled,
|
||||
controllerName = io.unom.punktfunk.kit.Gamepad.firstPad()?.name,
|
||||
// Stop the carousel from consuming the pad while a sheet/dialog/overlay owns the screen,
|
||||
// while a connect is in flight (else a second A launches a concurrent connect that leaks a
|
||||
// handle — the touch grid guards the same way with enabled=!connecting), or while the whole
|
||||
// console home is cross-fading out.
|
||||
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
|
||||
awaiting == null && editTarget == null && optionsTarget == null && waker.waking == null,
|
||||
onActivate = { it.activate() },
|
||||
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOptions = { it.knownHost?.let { kh -> optionsTarget = kh } },
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -358,7 +489,25 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
knownHostStore.remove(kh.address, kh.port)
|
||||
savedHosts = knownHostStore.all()
|
||||
},
|
||||
onRename = { renameTarget = kh },
|
||||
onEdit = { editTarget = kh },
|
||||
// Explicit wake-only: offered when the host is offline and we have a MAC. Runs
|
||||
// through the WakeController so it shows the "Waking…" overlay and waits for
|
||||
// the host to come online (matched by fingerprint, so a new DHCP address on a
|
||||
// cold boot still counts as "up") rather than firing a single silent packet.
|
||||
onWake = if (kh.mac.isNotEmpty() && discovered.none { kh.matches(it) }) {
|
||||
{
|
||||
waker.start(
|
||||
hostName = kh.name,
|
||||
connectsAfter = false,
|
||||
macs = kh.mac,
|
||||
lastIp = kh.address,
|
||||
isOnline = { discovered.any { kh.matches(it) } },
|
||||
onOnline = {},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -415,80 +564,134 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showManualSheet) {
|
||||
AddHostSheet(
|
||||
hostName = hostName,
|
||||
onHostNameChange = { hostName = it },
|
||||
host = host,
|
||||
onHostChange = { host = it },
|
||||
port = port,
|
||||
onPortChange = { port = it },
|
||||
connecting = connecting,
|
||||
modeLabel = "$w×$h@$hz",
|
||||
onDismiss = { showManualSheet = false },
|
||||
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
|
||||
)
|
||||
}
|
||||
|
||||
pendingTrust?.let { pt ->
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW -> TrustNewHostDialog(
|
||||
pt = pt,
|
||||
onTrust = { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) },
|
||||
onPairInstead = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
||||
onDismiss = { pendingTrust = null },
|
||||
)
|
||||
PendingTrust.Kind.FP_CHANGED -> FingerprintChangedDialog(
|
||||
pt = pt,
|
||||
onRepair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
||||
onDismiss = { pendingTrust = null },
|
||||
)
|
||||
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessDialog(
|
||||
pt = pt,
|
||||
onRequestAccess = { pendingTrust = null; requestAccess(pt) },
|
||||
onUsePin = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
||||
onDismiss = { pendingTrust = null },
|
||||
)
|
||||
PendingTrust.Kind.PAIR -> PairPinDialog(
|
||||
pt = pt,
|
||||
identity = identity,
|
||||
onPaired = { fp ->
|
||||
// Verified host fp — save as a paired known host, then connect pinned.
|
||||
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
|
||||
savedHosts = knownHostStore.all()
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, fp)
|
||||
if (gamepadUi) {
|
||||
// Console add-host: field list + on-screen controller keyboard. "Add" connects (which
|
||||
// saves the host on TOFU/pair), exactly like the touch sheet's Connect.
|
||||
GamepadAddHostScreen(
|
||||
onAdd = { n, addr, p ->
|
||||
showManualSheet = false
|
||||
connect(addr, p, manualName = n)
|
||||
},
|
||||
onDismiss = { pendingTrust = null },
|
||||
onDismiss = { showManualSheet = false },
|
||||
)
|
||||
} else {
|
||||
AddHostSheet(
|
||||
hostName = hostName,
|
||||
onHostNameChange = { hostName = it },
|
||||
host = host,
|
||||
onHostChange = { host = it },
|
||||
port = port,
|
||||
onPortChange = { port = it },
|
||||
connecting = connecting,
|
||||
modeLabel = "$w×$h@$hz",
|
||||
onDismiss = { showManualSheet = false },
|
||||
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pendingTrust?.let { pt ->
|
||||
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
|
||||
val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
|
||||
val onSavePaired = { fp: String ->
|
||||
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
|
||||
savedHosts = knownHostStore.all()
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, fp)
|
||||
}
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW ->
|
||||
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
|
||||
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
|
||||
PendingTrust.Kind.FP_CHANGED ->
|
||||
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||
PendingTrust.Kind.REQUEST_ACCESS ->
|
||||
if (gamepadUi) GamepadRequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
|
||||
else RequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
|
||||
PendingTrust.Kind.PAIR ->
|
||||
if (gamepadUi) GamepadPairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
|
||||
else PairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
|
||||
}
|
||||
}
|
||||
|
||||
awaiting?.let { req ->
|
||||
AwaitingApprovalDialog(
|
||||
hostLabel = req.target.name,
|
||||
onCancel = {
|
||||
req.cancelled.set(true)
|
||||
awaiting = null
|
||||
connecting = false
|
||||
discovery.start() // the request may still be pending on the host; keep scanning
|
||||
val onCancel = {
|
||||
req.cancelled.set(true)
|
||||
awaiting = null
|
||||
connecting = false
|
||||
discovery.start() // the request may still be pending on the host; keep scanning
|
||||
}
|
||||
if (gamepadUi) GamepadAwaitingApprovalDialog(req.target.name, onCancel)
|
||||
else AwaitingApprovalDialog(hostLabel = req.target.name, onCancel = onCancel)
|
||||
}
|
||||
|
||||
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
|
||||
optionsTarget?.let { kh ->
|
||||
val offline = discovered.none { kh.matches(it) }
|
||||
GamepadHostOptionsDialog(
|
||||
hostName = kh.name,
|
||||
canWake = kh.mac.isNotEmpty() && offline,
|
||||
onWake = {
|
||||
optionsTarget = null
|
||||
waker.start(
|
||||
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
|
||||
isOnline = { discovered.any { kh.matches(it) } },
|
||||
onOnline = {},
|
||||
)
|
||||
},
|
||||
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
|
||||
// so a TV remote reaches the library here instead of via the Y face button.
|
||||
onLibrary = if (settings.libraryEnabled) {
|
||||
{ optionsTarget = null; onOpenLibrary(kh) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onEdit = { optionsTarget = null; editTarget = kh },
|
||||
onForget = {
|
||||
knownHostStore.remove(kh.address, kh.port)
|
||||
savedHosts = knownHostStore.all()
|
||||
optionsTarget = null
|
||||
},
|
||||
onDismiss = { optionsTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
renameTarget?.let { kh ->
|
||||
RenameHostDialog(
|
||||
target = kh,
|
||||
onRename = { newName ->
|
||||
knownHostStore.rename(kh.address, kh.port, newName)
|
||||
savedHosts = knownHostStore.all()
|
||||
renameTarget = null
|
||||
},
|
||||
onDismiss = { renameTarget = null },
|
||||
)
|
||||
editTarget?.let { kh ->
|
||||
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
|
||||
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
|
||||
val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
|
||||
val onSaveHost: (KnownHost) -> Unit = { updated ->
|
||||
knownHostStore.update(kh.address, kh.port, updated)
|
||||
savedHosts = knownHostStore.all()
|
||||
editTarget = null
|
||||
}
|
||||
if (gamepadUi) {
|
||||
// Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the
|
||||
// host with an extra MAC row; the action SAVES instead of connecting.
|
||||
GamepadAddHostScreen(
|
||||
onAdd = { _, _, _ -> },
|
||||
onDismiss = { editTarget = null },
|
||||
editHost = kh,
|
||||
suggestedMacs = suggested,
|
||||
onSave = onSaveHost,
|
||||
)
|
||||
} else {
|
||||
EditHostDialog(
|
||||
target = kh,
|
||||
suggestedMacs = suggested,
|
||||
onSave = onSaveHost,
|
||||
onDismiss = { editTarget = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
|
||||
WakeOverlay(waker, gamepadUi)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -244,7 +245,7 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val canRumble = dev.vibratorManager.vibratorIds.isNotEmpty()
|
||||
val canRumble = deviceHasVibrator(dev)
|
||||
if (canRumble) {
|
||||
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
|
||||
} else {
|
||||
@@ -318,11 +319,27 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
||||
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
dev.vibratorManager.vibratorIds.isNotEmpty()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
dev.vibrator.hasVibrator()
|
||||
}
|
||||
|
||||
private fun testRumble(dev: InputDevice) {
|
||||
val vm = dev.vibratorManager
|
||||
if (vm.vibratorIds.isEmpty()) return
|
||||
runCatching {
|
||||
vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200)))
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val vm = dev.vibratorManager
|
||||
if (vm.vibratorIds.isEmpty()) return
|
||||
vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200)))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val v = dev.vibrator
|
||||
if (!v.hasVibrator()) return
|
||||
v.vibrate(VibrationEffect.createOneShot(300, 200))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// The gamepad-driven "Add Host" screen — the Android mirror of the Apple client's GamepadAddHostView
|
||||
// + GamepadKeyboard: three field rows (name / address / port) plus an Add action, navigated with the
|
||||
// vertical focus list; A on a field opens the on-screen keyboard so a host can be registered end to
|
||||
// end from the couch. One GamepadNavEffect2D owns BOTH modes (list vs keyboard) so they never fight
|
||||
// over the shared input probes. B peels one layer: close the keyboard, then cancel the screen.
|
||||
|
||||
// Keyboard grid: digits, qwerty letters, hostname/address punctuation, then space / delete / done.
|
||||
private val KB_CHAR_ROWS = listOf("1234567890", "qwertyuiop", "asdfghjkl-", "zxcvbnm._:")
|
||||
private const val KB_ACTIONS_ROW = 4 // index of the [space, delete, done] row
|
||||
private const val KB_ROWS = 5
|
||||
|
||||
private class Field(val id: String, val label: String, val value: String, val placeholder: String)
|
||||
|
||||
@Composable
|
||||
fun GamepadAddHostScreen(
|
||||
onAdd: (name: String, address: String, port: Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
// Non-null → EDIT mode: fields seed from this host, a MAC row is added, and the action SAVES the
|
||||
// edited record via [onSave] instead of connecting. [suggestedMacs] prefills a not-yet-learned MAC.
|
||||
editHost: KnownHost? = null,
|
||||
suggestedMacs: List<String> = emptyList(),
|
||||
onSave: ((KnownHost) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isTv = remember { isTvDevice(context) }
|
||||
val isEdit = editHost != null
|
||||
val title = if (isEdit) "Edit Host" else "Add Host"
|
||||
val actionLabel = if (isEdit) "Save" else "Add Host"
|
||||
var name by remember { mutableStateOf(editHost?.name ?: "") }
|
||||
var address by remember { mutableStateOf(editHost?.address ?: "") }
|
||||
var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") }
|
||||
var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") }
|
||||
val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0
|
||||
fun commit() {
|
||||
if (isEdit && editHost != null && onSave != null) {
|
||||
onSave(
|
||||
editHost.copy(
|
||||
name = name.trim().ifEmpty { editHost.address },
|
||||
address = address.trim(),
|
||||
port = port.toIntOrNull() ?: editHost.port,
|
||||
mac = KnownHostStore.parseMacs(mac),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
onAdd(name.trim(), address.trim(), port.toIntOrNull() ?: 9777)
|
||||
}
|
||||
}
|
||||
|
||||
// On a TV the OS provides a leanback on-screen keyboard for text fields, so use real (focusable)
|
||||
// text fields + the system IME there. Our controller keyboard is for a phone-with-controller,
|
||||
// where the phone's own soft keyboard needs a touch a pad can't provide.
|
||||
if (isTv) {
|
||||
TvAddHostForm(
|
||||
title = title, actionLabel = actionLabel,
|
||||
name = name, onName = { name = it },
|
||||
address = address, onAddress = { address = it },
|
||||
port = port, onPort = { port = it.filter(Char::isDigit).take(5) },
|
||||
mac = if (isEdit) mac else null, onMac = { mac = it },
|
||||
canAdd = canAdd,
|
||||
onAdd = { commit() },
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var focus by remember { mutableIntStateOf(1) } // start on Address
|
||||
var editing by remember { mutableStateOf<String?>(null) } // field id being typed, or null
|
||||
var kbRow by remember { mutableIntStateOf(1) }
|
||||
var kbCol by remember { mutableIntStateOf(0) }
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
val fields = buildList {
|
||||
add(Field("name", "Name", name, "Optional — e.g. Living Room"))
|
||||
add(Field("address", "Address", address, "IP or hostname"))
|
||||
add(Field("port", "Port", port, "9777"))
|
||||
if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen"))
|
||||
}
|
||||
val actionIndex = fields.size // the Save/Add action sits just after the last field
|
||||
|
||||
fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 }
|
||||
fun closeKeyboard() { editing = null }
|
||||
fun editField(id: String, transform: (String) -> String) {
|
||||
when (id) {
|
||||
"name" -> name = transform(name)
|
||||
"address" -> address = transform(address)
|
||||
"port" -> port = transform(port).take(5)
|
||||
"mac" -> mac = transform(mac)
|
||||
}
|
||||
}
|
||||
fun allowed(id: String, c: Char): Boolean = when (id) {
|
||||
"port" -> c.isDigit()
|
||||
"address" -> c != ' '
|
||||
else -> true
|
||||
}
|
||||
fun activateField() {
|
||||
if (focus == actionIndex) {
|
||||
if (canAdd) commit() else { focus = 1; openKeyboard("address") }
|
||||
} else {
|
||||
openKeyboard(fields[focus].id)
|
||||
}
|
||||
}
|
||||
fun pressKey() {
|
||||
val id = editing ?: return
|
||||
if (kbRow < KB_ACTIONS_ROW) {
|
||||
val c = KB_CHAR_ROWS[kbRow][kbCol.coerceIn(0, KB_CHAR_ROWS[kbRow].lastIndex)]
|
||||
if (allowed(id, c)) editField(id) { it + c }
|
||||
} else when (kbCol) {
|
||||
0 -> if (allowed(id, ' ')) editField(id) { "$it " }
|
||||
1 -> editField(id) { it.dropLast(1) }
|
||||
else -> closeKeyboard()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler { if (editing != null) closeKeyboard() else onDismiss() }
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = { dir ->
|
||||
if (editing == null) {
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < actionIndex) focus++
|
||||
else -> {}
|
||||
}
|
||||
} else {
|
||||
when (dir) {
|
||||
NavDir.UP -> if (kbRow > 0) { kbRow--; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
|
||||
NavDir.DOWN -> if (kbRow < KB_ROWS - 1) { kbRow++; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
|
||||
NavDir.LEFT -> if (kbCol > 0) kbCol--
|
||||
NavDir.RIGHT -> if (kbCol < rowCols(kbRow) - 1) kbCol++
|
||||
}
|
||||
}
|
||||
},
|
||||
onActivate = { if (editing == null) activateField() else pressKey() },
|
||||
onTertiary = { if (editing != null) editField(editing!!) { it.dropLast(1) } },
|
||||
onSecondary = { if (editing != null) closeKeyboard() },
|
||||
)
|
||||
|
||||
val onFieldClick: (Int) -> Unit = { i -> if (focus == i) activateField() else focus = i }
|
||||
val onAddClick: () -> Unit = { if (focus == actionIndex) activateField() else focus = actionIndex }
|
||||
// Tappable (touch escape hatch): the legend doubles as buttons when there's no working controller.
|
||||
val typeHints = listOf(
|
||||
PadGlyph.hint('A', "Type") { pressKey() },
|
||||
PadGlyph.hint('X', "Delete") { editing?.let { id -> editField(id) { it.dropLast(1) } } },
|
||||
PadGlyph.hint('B', "Done") { closeKeyboard() },
|
||||
)
|
||||
val sideBySide = landscape && editing != null
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
|
||||
if (sideBySide) {
|
||||
// Landscape + typing: fields and keyboard SIDE BY SIDE so the field being edited stays
|
||||
// visible (stacked, the keyboard covered the whole short screen). The legend is NOT put
|
||||
// under the keyboard here — it floats at the same fixed bottom-left spot as everywhere.
|
||||
Row(
|
||||
Modifier.fillMaxSize().systemBarsPadding().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ConsoleHeader(title, horizontalInset = false)
|
||||
fields.forEachIndexed { i, f -> FieldRow(f, focused = false, editing = editing == f.id) { onFieldClick(i) } }
|
||||
AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() }
|
||||
Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left
|
||||
}
|
||||
Column(
|
||||
Modifier.weight(1.15f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
KeyboardGrid(kbRow, kbCol, compact = true) { r, c -> kbRow = r; kbCol = c; pressKey() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Portrait (or landscape not typing): the FORM SCROLLS so the Add button is never
|
||||
// compressed by the keyboard; the keyboard sits below it; the legend floats (fixed).
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) {
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ConsoleHeader(title, horizontalInset = false)
|
||||
if (editing == null && !landscape) {
|
||||
Text(
|
||||
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } }
|
||||
AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() }
|
||||
Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled
|
||||
}
|
||||
if (editing != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// The keyboard fills to the bottom; its bottom frame is padded so the fixed
|
||||
// legend sits OVER that frame (bottom-left corner) rather than in a gap below.
|
||||
KeyboardGrid(kbRow, kbCol, compact = false, bottomInset = 52.dp) { r, c -> kbRow = r; kbCol = c; pressKey() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floating legend — ALWAYS at the same fixed bottom-start spot (portrait or landscape, keyboard
|
||||
// open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred.
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
if (editing != null) {
|
||||
typeHints
|
||||
} else {
|
||||
listOf(
|
||||
PadGlyph.hint('A', "Select") { activateField() },
|
||||
PadGlyph.hint('B', "Cancel", onClick = onDismiss),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add-Host on a TV: real focusable text fields + the system (leanback) IME, driven by the OS. No
|
||||
* custom keyboard or input probes — the native focus engine moves between fields and the Add button,
|
||||
* and focusing a field pops the OS keyboard. B backs out.
|
||||
*/
|
||||
@Composable
|
||||
private fun TvAddHostForm(
|
||||
title: String,
|
||||
actionLabel: String,
|
||||
name: String,
|
||||
onName: (String) -> Unit,
|
||||
address: String,
|
||||
onAddress: (String) -> Unit,
|
||||
port: String,
|
||||
onPort: (String) -> Unit,
|
||||
mac: String?, // non-null only in edit mode
|
||||
onMac: (String) -> Unit,
|
||||
canAdd: Boolean,
|
||||
onAdd: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = onDismiss)
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.padding(horizontal = 56.dp, vertical = 36.dp)
|
||||
.widthIn(max = 720.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(
|
||||
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name, onValueChange = onName, singleLine = true,
|
||||
label = { Text("Name (optional)") },
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(firstFocus),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = address, onValueChange = onAddress, singleLine = true,
|
||||
label = { Text("Address") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = port, onValueChange = onPort, singleLine = true,
|
||||
label = { Text("Port") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (mac != null) {
|
||||
OutlinedTextField(
|
||||
value = mac, onValueChange = onMac, singleLine = true,
|
||||
label = { Text("Wake-on-LAN MAC") },
|
||||
placeholder = { Text("auto-filled when the host is seen") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Button(onClick = onAdd, enabled = canAdd, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
|
||||
}
|
||||
|
||||
private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row].length else 3
|
||||
|
||||
@Composable
|
||||
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
|
||||
val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale")
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape)
|
||||
.background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||
.border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
f.value.ifEmpty { f.placeholder },
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (editing) Text(" |", color = Color(0xFF8678F5))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale")
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape)
|
||||
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||
.padding(vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyboardGrid(
|
||||
cursorRow: Int,
|
||||
cursorCol: Int,
|
||||
compact: Boolean,
|
||||
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
|
||||
onKey: (Int, Int) -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val gap = if (compact) 5.dp else 7.dp
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 640.dp)
|
||||
.clip(shape)
|
||||
.background(Color(0x1FFFFFFF))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), shape)
|
||||
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
|
||||
verticalArrangement = Arrangement.spacedBy(gap),
|
||||
) {
|
||||
KB_CHAR_ROWS.forEachIndexed { r, chars ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
|
||||
chars.forEachIndexed { c, ch ->
|
||||
Keycap(ch.toString(), focused = cursorRow == r && cursorCol == c, compact = compact, modifier = Modifier.weight(1f)) { onKey(r, c) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
|
||||
Keycap("space", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 0, compact = compact, modifier = Modifier.weight(2f)) { onKey(KB_ACTIONS_ROW, 0) }
|
||||
Keycap("⌫", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 1, compact = compact, modifier = Modifier.weight(1f)) { onKey(KB_ACTIONS_ROW, 1) }
|
||||
Keycap("Done", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 2, compact = compact, modifier = Modifier.weight(1.5f)) { onKey(KB_ACTIONS_ROW, 2) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(if (compact) 34.dp else 44.dp)
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF))
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (focused) Color.Black else Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
|
||||
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
|
||||
// GamepadChrome.swift: a slow-drifting violet aurora backdrop, a bottom button-glyph hint bar, and a
|
||||
// connected-controller status chip. One look across every screen is what makes the console UI read
|
||||
// as a coherent mode rather than a set of themed pages.
|
||||
|
||||
/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */
|
||||
private class AuroraBlob(
|
||||
val color: Color,
|
||||
val baseX: Float,
|
||||
val baseY: Float,
|
||||
val driftX: Float,
|
||||
val driftY: Float,
|
||||
val sx: Int,
|
||||
val sy: Int,
|
||||
val phase: Float,
|
||||
val radiusFrac: Float,
|
||||
val alpha: Float,
|
||||
)
|
||||
|
||||
private val auroraBlobs = listOf(
|
||||
AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet
|
||||
AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo
|
||||
AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum
|
||||
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
|
||||
)
|
||||
|
||||
/**
|
||||
* The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops,
|
||||
* finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation
|
||||
* of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
val transition = rememberInfiniteTransition(label = "aurora")
|
||||
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap
|
||||
// so the field never visibly jumps when the animation restarts.
|
||||
val angle by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = (2 * PI).toFloat(),
|
||||
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "angle",
|
||||
)
|
||||
Canvas(modifier) {
|
||||
drawRect(Color.Black)
|
||||
val span = max(size.width, size.height)
|
||||
for (b in auroraBlobs) {
|
||||
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
|
||||
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
|
||||
val r = span * b.radiusFrac
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
blendMode = BlendMode.Plus,
|
||||
)
|
||||
}
|
||||
// Cinematic vignette: pool light centre, sink the corners.
|
||||
drawRect(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
|
||||
center = Offset(size.width / 2, size.height / 2),
|
||||
radius = span * 0.92f,
|
||||
),
|
||||
)
|
||||
// Top/bottom legibility scrim for the pinned title + hint bar.
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Black.copy(alpha = 0.40f),
|
||||
0.30f to Color.Black.copy(alpha = 0.05f),
|
||||
0.70f to Color.Black.copy(alpha = 0.06f),
|
||||
1.0f to Color.Black.copy(alpha = 0.42f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet
|
||||
* (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass
|
||||
* rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||
Canvas(modifier) {
|
||||
val span = max(size.width, size.height)
|
||||
drawRect(Color(0xFF131126))
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xE6635AAE), Color.Transparent),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xBF343E96), Color.Transparent),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact inset every console screen places its floating legend at (bottom-start), so the legend
|
||||
* sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind
|
||||
* it cross-fades between screens.
|
||||
*/
|
||||
val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp)
|
||||
|
||||
/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */
|
||||
val ConsoleEdgeInset = 24.dp
|
||||
|
||||
/**
|
||||
* The heading every console screen uses — one style, one inset, so titles line up across Home /
|
||||
* Settings / Add-Host / Library. Callers place it at the top of their content (or float it, on Home).
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) {
|
||||
// `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a
|
||||
// LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way.
|
||||
val h = if (horizontalInset) ConsoleEdgeInset else 0.dp
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue.
|
||||
* [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working
|
||||
* controller can still drive the console UI (and reach Settings to switch it off).
|
||||
*/
|
||||
class GamepadHint(
|
||||
val glyph: Char,
|
||||
val color: Color,
|
||||
val text: String,
|
||||
val onClick: (() -> Unit)? = null,
|
||||
// Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc —
|
||||
// for a TV remote, which has no A/B/X/Y.
|
||||
val select: Boolean = false,
|
||||
// Render as the gamepad Select/View button (a small capsule).
|
||||
val viewButton: Boolean = false,
|
||||
)
|
||||
|
||||
/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */
|
||||
object PadGlyph {
|
||||
val A = Color(0xFF6BBE45)
|
||||
val B = Color(0xFFD14B4B)
|
||||
val X = Color(0xFF4B7BD1)
|
||||
val Y = Color(0xFFE0B23C)
|
||||
fun hint(glyph: Char, text: String, onClick: (() -> Unit)? = null) = GamepadHint(
|
||||
glyph, when (glyph) { 'A' -> A; 'B' -> B; 'X' -> X; 'Y' -> Y; else -> Color(0xFF9A93C7) }, text, onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
|
||||
@Composable
|
||||
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
glyph.toString(),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = (size.value * 0.52f).sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */
|
||||
@Composable
|
||||
private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
Box(
|
||||
modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape))
|
||||
}
|
||||
}
|
||||
|
||||
/** The remote's "Back" button — a back-arrow disc; the TV-remote glyph for B (back / cancel / done). */
|
||||
@Composable
|
||||
private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
GamepadButtonGlyph('↩', PadGlyph.B, size)
|
||||
}
|
||||
|
||||
/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */
|
||||
@Composable
|
||||
private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
Box(Modifier.size(size), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = size * 0.74f, height = size * 0.46f)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned controls legend every gamepad screen shows along the bottom — worn as a self-contained
|
||||
* translucent pill so it floats over the aurora rather than dissolving into it.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
|
||||
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
|
||||
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
|
||||
// home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests).
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
val shape = RoundedCornerShape(50)
|
||||
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
|
||||
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
|
||||
val frosted = if (hazeState != null) {
|
||||
modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A))
|
||||
} else {
|
||||
modifier.clip(shape).background(Color(0x8C14122A))
|
||||
}
|
||||
Row(
|
||||
modifier = frosted
|
||||
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
for (h in hints) {
|
||||
val cb = h.onClick
|
||||
val cell = if (cb != null) {
|
||||
Modifier.clip(RoundedCornerShape(50)).clickable(onClick = cb).padding(horizontal = 4.dp, vertical = 5.dp)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
|
||||
when {
|
||||
h.viewButton -> ViewButtonGlyph()
|
||||
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
|
||||
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
|
||||
else -> GamepadButtonGlyph(h.glyph, h.color)
|
||||
}
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
h.text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
maxLines = 1,
|
||||
softWrap = false, // never char-wrap a label when several hints crowd a narrow pill
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */
|
||||
@Composable
|
||||
fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.White.copy(alpha = 0.08f))
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.SportsEsports,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.75f),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(7.dp))
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// Console-styled trust/pairing dialogs — the controller-navigable counterparts of the touch
|
||||
// AlertDialogs in ConnectDialogs.kt, shown while the gamepad UI is active. A dark glass card over a
|
||||
// scrim with focusable action buttons: D-pad left/right moves the focus, A activates it, B dismisses.
|
||||
|
||||
/** One dialog action button. */
|
||||
class DialogAction(
|
||||
val label: String,
|
||||
val primary: Boolean = false,
|
||||
val enabled: Boolean = true,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The shared console-dialog scaffold: scrim + glass card with a title, [body], and a row of focusable
|
||||
* [actions]. Owns its own controller nav (the presenting carousel drops its probes while a dialog is
|
||||
* up, via ConnectScreen's `navActive`). B → [onDismiss].
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadDialog(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
actions: List<DialogAction>,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
|
||||
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
|
||||
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
|
||||
BackHandler(onBack = onDismiss)
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < actions.lastIndex) focus++
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
|
||||
)
|
||||
// Cap the card to most of the screen and let the BODY scroll — in a short landscape window the
|
||||
// title + body + buttons would otherwise overflow and compress/clip the bottom button.
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 520.dp)
|
||||
.heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
// The body scrolls; the title above and the buttons below stay pinned + always visible.
|
||||
Column(
|
||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
body()
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
actions.forEachIndexed { i, a ->
|
||||
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
||||
val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale")
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val bg = when {
|
||||
focused -> Color(0xFF6656F2)
|
||||
primary -> Color(0x336656F2)
|
||||
else -> Color(0x14FFFFFF)
|
||||
}
|
||||
val fg = when {
|
||||
!enabled -> Color.White.copy(alpha = 0.35f)
|
||||
focused -> Color.White
|
||||
primary -> Color(0xFF8678F5)
|
||||
else -> Color.White.copy(alpha = 0.85f)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape)
|
||||
.background(bg)
|
||||
.border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(horizontal = 20.dp, vertical = 13.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Body text helper — a dimmed paragraph. */
|
||||
@Composable
|
||||
private fun DialogText(text: String) {
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Edit,
|
||||
* Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of
|
||||
* the touch host card's overflow menu.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadHostOptionsDialog(
|
||||
hostName: String,
|
||||
canWake: Boolean,
|
||||
onWake: () -> Unit,
|
||||
onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y
|
||||
onEdit: () -> Unit,
|
||||
onForget: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
GamepadDialog(
|
||||
title = hostName,
|
||||
onDismiss = onDismiss,
|
||||
actions = buildList {
|
||||
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
|
||||
if (canWake) add(DialogAction("Wake host", onClick = onWake))
|
||||
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
|
||||
add(DialogAction("Forget", onClick = onForget))
|
||||
add(DialogAction("Cancel", onClick = onDismiss))
|
||||
},
|
||||
) {
|
||||
DialogText("Manage this saved host.")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
|
||||
GamepadDialog(
|
||||
title = "Trust this host?",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
DialogAction("Pair with PIN", onClick = onPairInstead),
|
||||
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
|
||||
),
|
||||
) {
|
||||
DialogText("First connection to ${pt.host}:${pt.port}.")
|
||||
pt.advertisedFp?.let { DialogText("Fingerprint ${it.take(16)}…") }
|
||||
DialogText(
|
||||
"This host allows trust-on-first-use, but that can't tell an impostor from the real host. " +
|
||||
"Pairing with a PIN is stronger — it proves both sides.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GamepadFingerprintChangedDialog(pt: PendingTrust, onRepair: () -> Unit, onDismiss: () -> Unit) {
|
||||
GamepadDialog(
|
||||
title = "Host identity changed",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
DialogAction("Re-pair", primary = true, onClick = onRepair),
|
||||
),
|
||||
) {
|
||||
DialogText(
|
||||
"The pinned fingerprint for ${pt.host} no longer matches what it now advertises. This can " +
|
||||
"mean a host reinstall — or an impostor. Re-pair with the host's PIN to continue.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, onUsePin: () -> Unit, onDismiss: () -> Unit) {
|
||||
GamepadDialog(
|
||||
title = "Pairing required",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
DialogAction("Use a PIN", onClick = onUsePin),
|
||||
DialogAction("Request access", primary = true, onClick = onRequestAccess),
|
||||
),
|
||||
) {
|
||||
DialogText("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||
DialogText(
|
||||
"Request access and approve this device in the host's console (or web UI) — no PIN needed. " +
|
||||
"Or pair with the 4-digit PIN the host displays.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
GamepadDialog(
|
||||
title = "Waiting for approval",
|
||||
onDismiss = onCancel,
|
||||
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
|
||||
) {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
|
||||
Text("Approve this device on $hostLabel.", color = Color.White)
|
||||
}
|
||||
DialogText(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
|
||||
"once you approve — no PIN needed.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console PIN pairing: four digit slots set with the D-pad (left/right selects a slot, up/down changes
|
||||
* 0–9), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified
|
||||
* fingerprint to [onPaired]. No text keyboard needed — a PIN is four digits.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
|
||||
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
|
||||
var pairing by remember(pt) { mutableStateOf(false) }
|
||||
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||
val name = remember { Build.MODEL ?: "Android" }
|
||||
|
||||
fun pair() {
|
||||
val id = identity ?: return
|
||||
pairing = true
|
||||
err = null
|
||||
val pin = digits.joinToString("")
|
||||
scope.launch {
|
||||
val fp = withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
|
||||
}
|
||||
pairing = false
|
||||
if (fp.isNotEmpty()) onPaired(fp) else err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(onBack = { if (!pairing) onDismiss() })
|
||||
GamepadNavEffect2D(
|
||||
active = !pairing,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.LEFT -> if (slot > 0) slot--
|
||||
NavDir.RIGHT -> if (slot < 4) slot++
|
||||
NavDir.UP -> if (slot < 4) digits[slot] = (digits[slot] + 1) % 10
|
||||
NavDir.DOWN -> if (slot < 4) digits[slot] = (digits[slot] + 9) % 10
|
||||
}
|
||||
},
|
||||
onActivate = { if (slot == 4 && identity != null) pair() },
|
||||
)
|
||||
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(
|
||||
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
|
||||
style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
|
||||
}
|
||||
err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) }
|
||||
DialogButton(
|
||||
label = if (pairing) "Pairing…" else "Pair",
|
||||
focused = slot == 4 && !pairing,
|
||||
primary = true,
|
||||
enabled = !pairing && identity != null,
|
||||
onClick = { if (identity != null) pair() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinSlot(value: Int, focused: Boolean) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Box(
|
||||
Modifier.size(54.dp, 66.dp).clip(shape)
|
||||
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PageSize
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct,
|
||||
// "10-foot" console-style host launcher shown INSTEAD of the touch grid while the console UI is
|
||||
// active. A center-snapping carousel of hosts (saved first, then discovered, then a trailing Add
|
||||
// Host tile), driven from the couch: A connects, X opens Settings, Y opens a saved host's library.
|
||||
|
||||
/** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */
|
||||
class HomeTile(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val filled: Boolean = false, // saved (solid monogram) vs discovered / action (tinted outline)
|
||||
val online: Boolean = false, // advertising on the LAN right now
|
||||
val paired: Boolean = false, // pinned identity (shows a lock)
|
||||
val connecting: Boolean = false,
|
||||
val isAdd: Boolean = false, // the trailing Add Host tile (plus icon, not a monogram)
|
||||
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
|
||||
val activate: () -> Unit,
|
||||
) {
|
||||
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
|
||||
// first" message if the host hasn't authorized this device for its management API.
|
||||
val hasLibrary: Boolean get() = knownHost != null
|
||||
}
|
||||
|
||||
/**
|
||||
* The console home. [tiles] is rebuilt by the caller from the live host stores; [onActivate] runs a
|
||||
* tile's action, [onOpenLibrary]/[onOpenSettings] are the Y/X actions. Fully driven by D-pad / stick
|
||||
* / face buttons (MainActivity already maps a pad's A→center, B→back, sticks→D-pad) and by touch.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadHome(
|
||||
tiles: List<HomeTile>,
|
||||
libraryEnabled: Boolean,
|
||||
controllerName: String?,
|
||||
// False while a sheet/dialog is on top → the carousel stops consuming the pad so the overlay
|
||||
// can be driven instead.
|
||||
navActive: Boolean,
|
||||
onActivate: (HomeTile) -> Unit,
|
||||
onOpenLibrary: (HomeTile) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
// Up on a saved host opens its options (Wake / Edit / Forget). Only saved tiles carry a knownHost.
|
||||
onOptions: (HomeTile) -> Unit = {},
|
||||
) {
|
||||
// Equal inset for the pinned title + hint bar, measured from the safe-area edges (so the legend
|
||||
// sits the same distance from the left and the bottom).
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
val pagerState = rememberPagerState(pageCount = { tiles.size })
|
||||
val scope = rememberCoroutineScope()
|
||||
// navTarget is the navigation authority — a controller move steps THIS, and the pager is pointed
|
||||
// at it, so a fast repeat coalesces to the latest target instead of reading a lagging currentPage
|
||||
// mid-animation (which is what let a flick overshoot by two).
|
||||
var navTarget by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||
val current = tiles.getOrNull(navTarget)
|
||||
|
||||
GamepadNavEffect(
|
||||
active = navActive && tiles.isNotEmpty(),
|
||||
onMove = { dir ->
|
||||
val target = (navTarget + dir).coerceIn(0, tiles.lastIndex)
|
||||
if (target != navTarget) {
|
||||
navTarget = target
|
||||
scope.launch { pagerState.animateScrollToPage(target) }
|
||||
}
|
||||
},
|
||||
onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect
|
||||
onSecondary = { // Y (gamepad) → Library
|
||||
tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary)
|
||||
},
|
||||
onTertiary = onOpenSettings, // X (gamepad) → Settings
|
||||
// A TV remote has no A/B/X/Y: Up → Settings, Down → a saved host's Options (Wake / Library /
|
||||
// Edit / Forget). A gamepad instead opens Options on its Select/View button.
|
||||
onUp = onOpenSettings,
|
||||
onDown = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
|
||||
onOptions = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
|
||||
)
|
||||
|
||||
// The legend follows the LAST-USED input: a real gamepad shows its A/X/Y face buttons + the
|
||||
// Select/View button for Options; a TV D-pad remote (no face buttons) shows a select ring + Up
|
||||
// (Settings) / Down (Options) arrows, with Library folded into Options. Input is universal either
|
||||
// way. Each hint is also TAPPABLE (touch hatch).
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: false
|
||||
val connectLabel = if (current?.isAdd == true) "Add Host" else "Connect"
|
||||
val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) }
|
||||
val optionsAction: () -> Unit = { current?.let(onOptions) }
|
||||
val arrowTint = Color(0xFF9A93C7)
|
||||
val hints = buildList {
|
||||
if (padIsGamepad) {
|
||||
add(PadGlyph.hint('A', connectLabel, onClick = connectAction))
|
||||
if (libraryEnabled && current?.hasLibrary == true) add(PadGlyph.hint('Y', "Library") {
|
||||
tiles.getOrNull(navTarget)?.takeIf { it.hasLibrary }?.let(onOpenLibrary)
|
||||
})
|
||||
add(PadGlyph.hint('X', "Settings", onClick = onOpenSettings))
|
||||
// The pad's Select/View button (drawn as its capsule glyph) opens host options.
|
||||
if (current?.knownHost != null) add(GamepadHint(' ', arrowTint, "Options", onClick = optionsAction, viewButton = true))
|
||||
} else {
|
||||
add(GamepadHint(' ', PadGlyph.A, connectLabel, onClick = connectAction, select = true))
|
||||
add(GamepadHint('↑', arrowTint, "Settings", onClick = { onOpenSettings() }))
|
||||
if (current?.knownHost != null) add(GamepadHint('↓', arrowTint, "Options", onClick = optionsAction))
|
||||
}
|
||||
}
|
||||
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// The whole backdrop (aurora + carousel) is the haze source, so the floating legend can blur
|
||||
// whatever scrolls under it.
|
||||
BoxWithConstraints(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
|
||||
// Carousel centred on the FULL screen — the title + legend FLOAT over it (below), so they
|
||||
// no longer push the cards below the true centre.
|
||||
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
|
||||
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
|
||||
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
|
||||
Box(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(cardWidth),
|
||||
contentPadding = PaddingValues(horizontal = sidePad),
|
||||
pageSpacing = 22.dp,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) { page ->
|
||||
val tile = tiles[page]
|
||||
// Real distance-from-centered (page + fractional drag), so the pop tracks the
|
||||
// live scroll: centered tile at full scale/brightness, neighbours recede + blur.
|
||||
val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
|
||||
.absoluteValue.coerceIn(0f, 1f)
|
||||
GamepadHostTile(
|
||||
tile = tile,
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
val s = lerp(1f, 0.86f, offset)
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
alpha = lerp(1f, 0.5f, offset)
|
||||
}
|
||||
// Unbounded so the depth blur isn't hard-clipped at the card's rectangle
|
||||
// (the cut-off edge). No-op below API 31; a soft blur above.
|
||||
.blur(radius = (offset * 12f).dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
|
||||
.height(cardHeight)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
if (page == navTarget) {
|
||||
onActivate(tile)
|
||||
} else {
|
||||
navTarget = page
|
||||
scope.launch { pagerState.animateScrollToPage(page) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Title floats over the top (out of the carousel's layout, so the cards stay centred). Uses
|
||||
// the shared ConsoleHeader so it lines up with every other screen's heading.
|
||||
Row(
|
||||
Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding()
|
||||
.padding(end = ConsoleEdgeInset),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ConsoleHeader("Select a Host", modifier = Modifier.weight(1f))
|
||||
if (controllerName != null) ControllerStatusChip(controllerName)
|
||||
}
|
||||
|
||||
// Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE
|
||||
// it ignores the safe area (the nav-bar inset made the bottom gap look oversized).
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(hints, hazeState = hazeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
|
||||
@Composable
|
||||
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
val wash = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(wash)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
|
||||
.padding(22.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||
MonogramBadge(tile)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (tile.paired) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = "Paired",
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(end = 6.dp).size(15.dp),
|
||||
)
|
||||
}
|
||||
if (tile.online) {
|
||||
Box(
|
||||
Modifier.size(10.dp).clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(Color(0xFF3CD070)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
tile.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
tile.subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonogramBadge(tile: HomeTile) {
|
||||
val shape = RoundedCornerShape(15.dp)
|
||||
val fill = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(52.dp).clip(shape).background(fill),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
tile.connecting -> CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White,
|
||||
)
|
||||
tile.isAdd -> Icon(
|
||||
Icons.Filled.Add,
|
||||
contentDescription = null,
|
||||
tint = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||
)
|
||||
else -> Text(
|
||||
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "•",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
// Controller navigation for the console carousels (host launcher + library coverflow). It taps the
|
||||
// SAME MainActivity input probes the Controllers debug screen uses (padMotionProbe / padKeyProbe) so
|
||||
// it sees the raw analog stick and consumes it BEFORE MainActivity's stick→D-pad focus synthesis —
|
||||
// which is what made carousel scrolling feel wrong: that path is edge-only (no hold-to-repeat, so a
|
||||
// held stick did nothing) and a flick could cross the threshold twice (double-move). Here the left
|
||||
// stick drives discrete moves with hysteresis (fire once when it crosses HIGH; re-arm only after it
|
||||
// falls back under LOW → a flick is exactly one move) and auto-repeat while held. The caller coalesces
|
||||
// the moves against a target index so a fast repeat walks smoothly instead of overshooting.
|
||||
|
||||
private const val STICK_HIGH = 0.6f // cross this to commit a move
|
||||
private const val STICK_LOW = 0.3f // fall back under this to re-arm (hysteresis)
|
||||
private const val INITIAL_DELAY_MS = 420L // hold this long before the first auto-repeat
|
||||
private const val REPEAT_MS = 150L // then repeat this often while held
|
||||
|
||||
private class NavInputState {
|
||||
@Volatile var stickX = 0f
|
||||
@Volatile var stickY = 0f
|
||||
@Volatile var hatX = 0f
|
||||
@Volatile var hatY = 0f
|
||||
@Volatile var dpadX = 0
|
||||
@Volatile var dpadY = 0
|
||||
fun reset() { stickX = 0f; stickY = 0f; hatX = 0f; hatY = 0f; dpadX = 0; dpadY = 0 }
|
||||
}
|
||||
|
||||
/** A committed navigation direction from the stick / D-pad / HAT. */
|
||||
enum class NavDir { UP, DOWN, LEFT, RIGHT }
|
||||
|
||||
/**
|
||||
* Installs controller navigation for a console screen while [active]. [onMove] gets -1 (left) / +1
|
||||
* (right) for each committed step; [onActivate] is A / D-pad-center / Enter, [onTertiary] is X,
|
||||
* [onSecondary] is Y. B and the shoulders fall through to MainActivity (B → its BACK remap → the
|
||||
* screen's BackHandler). [active] is set false while a sheet/dialog is on top so the carousel stops
|
||||
* consuming the pad and the overlay can be navigated.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadNavEffect(
|
||||
active: Boolean,
|
||||
onMove: (Int) -> Unit,
|
||||
onActivate: () -> Unit,
|
||||
onSecondary: () -> Unit = {},
|
||||
onTertiary: () -> Unit = {},
|
||||
// D-pad Up (the carousel is horizontal) → e.g. Settings, since a TV remote has no X face button.
|
||||
onUp: () -> Unit = {},
|
||||
onDown: () -> Unit = {},
|
||||
// Context/options menu — fired by the gamepad Select/View button OR a long-press of the select/OK
|
||||
// button (the Android-TV context-menu convention). A short OK press is [onActivate].
|
||||
onOptions: () -> Unit = {},
|
||||
) {
|
||||
val activity = LocalContext.current as? MainActivity ?: return
|
||||
val state = remember { NavInputState() }
|
||||
// The effects below are keyed on `active` only (they must NOT restart on every recomposition), so
|
||||
// they'd otherwise capture the FIRST callbacks — closing over a stale `tiles` (fewer hosts than are
|
||||
// discovered later, which clamped navigation to that old count). rememberUpdatedState keeps the
|
||||
// long-lived coroutine/probes pointed at the CURRENT callbacks.
|
||||
val currentOnMove by rememberUpdatedState(onMove)
|
||||
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||
val currentOnUp by rememberUpdatedState(onUp)
|
||||
val currentOnDown by rememberUpdatedState(onDown)
|
||||
val currentOnOptions by rememberUpdatedState(onOptions)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs (see GamepadNavEffect2D) so onDispose only releases the slot if we still
|
||||
// own it — a cross-fading-out screen mustn't null the incoming screen's probes.
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||
state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
return@probe true // consume → MainActivity's stick→D-pad synthesis stays out of it
|
||||
}
|
||||
false
|
||||
}
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
|
||||
KeyEvent.KEYCODE_DPAD_UP -> { if (edge) currentOnUp(); true }
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> { if (edge) currentOnDown(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||
// The gamepad Select / View / Share button → context options (a remote uses Down).
|
||||
KeyEvent.KEYCODE_BUTTON_SELECT -> { if (edge) currentOnOptions(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||
else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
activity.padMotionProbe = motionProbe
|
||||
activity.padKeyProbe = keyProbe
|
||||
}
|
||||
onDispose {
|
||||
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(active) {
|
||||
if (!active) return@LaunchedEffect
|
||||
var committed = 0 // the direction currently held (hysteresis + repeat authority)
|
||||
var fireAt = 0L // uptime at/after which the next auto-repeat may fire
|
||||
while (isActive) {
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val hat = if (state.hatX <= -0.5f) -1 else if (state.hatX >= 0.5f) 1 else 0
|
||||
val dir = when {
|
||||
state.dpadX != 0 -> state.dpadX
|
||||
hat != 0 -> hat
|
||||
else -> {
|
||||
val x = state.stickX
|
||||
when {
|
||||
x >= STICK_HIGH -> 1
|
||||
x <= -STICK_HIGH -> -1
|
||||
abs(x) < STICK_LOW -> 0
|
||||
else -> committed // inside the hysteresis band → hold the committed value
|
||||
}
|
||||
}
|
||||
}
|
||||
when {
|
||||
dir == 0 -> committed = 0
|
||||
dir != committed -> { currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS }
|
||||
now >= fireAt -> { currentOnMove(dir); fireAt = now + REPEAT_MS }
|
||||
}
|
||||
delay(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2-D controller navigation for the console form screens (settings focus list, add-host, on-screen
|
||||
* keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant
|
||||
* stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick
|
||||
* returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X,
|
||||
* [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels
|
||||
* one layer": close the keyboard, then the screen).
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadNavEffect2D(
|
||||
active: Boolean,
|
||||
onDirection: (NavDir) -> Unit,
|
||||
onActivate: () -> Unit,
|
||||
onTertiary: () -> Unit = {},
|
||||
onSecondary: () -> Unit = {},
|
||||
) {
|
||||
val activity = LocalContext.current as? MainActivity ?: return
|
||||
val state = remember { NavInputState() }
|
||||
val currentOnDirection by rememberUpdatedState(onDirection)
|
||||
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
|
||||
// cross-fade both the outgoing and incoming screen are briefly composed, and the outgoing's
|
||||
// teardown must not null out the incoming screen's just-installed probes.
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||
state.stickY = ev.getAxisValue(MotionEvent.AXIS_Y)
|
||||
state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
state.hatY = ev.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
return@probe true
|
||||
}
|
||||
false
|
||||
}
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> { state.dpadY = if (down) 1 else 0; true }
|
||||
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
activity.padMotionProbe = motionProbe
|
||||
activity.padKeyProbe = keyProbe
|
||||
}
|
||||
onDispose {
|
||||
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(active) {
|
||||
if (!active) return@LaunchedEffect
|
||||
var committed: NavDir? = null
|
||||
var fireAt = 0L
|
||||
while (isActive) {
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val raw = resolveDir(state)
|
||||
val nearCentre = state.dpadX == 0 && state.dpadY == 0 &&
|
||||
abs(state.hatX) < 0.5f && abs(state.hatY) < 0.5f &&
|
||||
abs(state.stickX) < STICK_LOW && abs(state.stickY) < STICK_LOW
|
||||
when {
|
||||
raw == null && nearCentre -> committed = null
|
||||
raw == null -> { /* in the hysteresis band → hold, don't fire */ }
|
||||
raw != committed -> { currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS }
|
||||
now >= fireAt -> { currentOnDirection(raw); fireAt = now + REPEAT_MS }
|
||||
}
|
||||
delay(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The direction currently past the commit threshold (D-pad/HAT first, then the dominant stick axis). */
|
||||
private fun resolveDir(s: NavInputState): NavDir? {
|
||||
if (s.dpadY < 0) return NavDir.UP
|
||||
if (s.dpadY > 0) return NavDir.DOWN
|
||||
if (s.dpadX < 0) return NavDir.LEFT
|
||||
if (s.dpadX > 0) return NavDir.RIGHT
|
||||
if (s.hatY <= -0.5f) return NavDir.UP
|
||||
if (s.hatY >= 0.5f) return NavDir.DOWN
|
||||
if (s.hatX <= -0.5f) return NavDir.LEFT
|
||||
if (s.hatX >= 0.5f) return NavDir.RIGHT
|
||||
return if (abs(s.stickY) >= abs(s.stickX)) {
|
||||
when {
|
||||
s.stickY <= -STICK_HIGH -> NavDir.UP
|
||||
s.stickY >= STICK_HIGH -> NavDir.DOWN
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
when {
|
||||
s.stickX <= -STICK_HIGH -> NavDir.LEFT
|
||||
s.stickX >= STICK_HIGH -> NavDir.RIGHT
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
|
||||
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
|
||||
|
||||
private class GpRow(
|
||||
val id: String,
|
||||
val header: String?,
|
||||
val label: String,
|
||||
val value: String,
|
||||
val detail: String,
|
||||
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GamepadSettingsScreen(
|
||||
initial: Settings,
|
||||
onChange: (Settings) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true, // false while this screen is cross-fading out, so it drops the pad
|
||||
) {
|
||||
var s by remember { mutableStateOf(initial) }
|
||||
fun update(next: Settings) { s = next; onChange(next) }
|
||||
|
||||
val rows = buildSettingsRows(s, ::update)
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
GamepadNavEffect2D(
|
||||
active = navActive,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||
NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1)
|
||||
NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1)
|
||||
}
|
||||
},
|
||||
onActivate = { rows.getOrNull(focus)?.activate() },
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||
// +1 accounts for the heading being item 0.
|
||||
LaunchedEffect(focus) {
|
||||
runCatching {
|
||||
val itemIndex = focus + 1
|
||||
val info = listState.layoutInfo
|
||||
val item = info.visibleItemsInfo.firstOrNull { it.index == itemIndex }
|
||||
val offScreen = item == null ||
|
||||
item.offset < info.viewportStartOffset ||
|
||||
item.offset + item.size > info.viewportEndOffset - 96 // keep clear of the floating legend
|
||||
if (offScreen) listState.animateScrollToItem(itemIndex)
|
||||
}
|
||||
}
|
||||
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// Everything scrolls — including the heading — so nothing is pinned. Vital in landscape,
|
||||
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().systemBarsPadding(),
|
||||
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
item(key = "__title") {
|
||||
ConsoleHeader("Settings", horizontalInset = false)
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(row, focused = index == focus, onClick = {
|
||||
if (focus == index) row.activate() else focus = index
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floating frosted legend — a real backdrop blur of the rows scrolling behind it (no dedicated
|
||||
// strip). In landscape it ignores the safe area so it hugs the corner instead of the nav-bar inset.
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
),
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
|
||||
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale")
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Column {
|
||||
if (row.header != null) {
|
||||
Text(
|
||||
row.header.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
letterSpacing = 1.4.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape)
|
||||
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (focused) Text("‹ ", color = Color.White.copy(alpha = 0.6f))
|
||||
Text(
|
||||
row.value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (focused) Color.White else Color.White.copy(alpha = 0.6f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (focused) Text(" ›", color = Color.White.copy(alpha = 0.6f))
|
||||
}
|
||||
// The focused row carries its own one-line description — no dedicated (space-eating)
|
||||
// detail strip. It appears right where you're looking, and the row grows to fit.
|
||||
if (focused && row.detail.isNotBlank()) {
|
||||
Text(
|
||||
row.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update]. */
|
||||
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
|
||||
fun <T> choice(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||
): GpRow {
|
||||
val idx = options.indexOfFirst { it.first == current }
|
||||
return GpRow(
|
||||
id, header, label,
|
||||
value = options.getOrNull(idx)?.second ?: "—",
|
||||
detail = detail,
|
||||
adjust = { delta ->
|
||||
if (idx < 0) {
|
||||
options.firstOrNull()?.let { write(it.first) } != null
|
||||
} else {
|
||||
val t = idx + delta
|
||||
if (t in options.indices) { write(options[t].first); true } else false
|
||||
}
|
||||
},
|
||||
activate = {
|
||||
val i = if (idx < 0) 0 else (idx + 1) % options.size
|
||||
options.getOrNull(i)?.let { write(it.first) }
|
||||
},
|
||||
)
|
||||
}
|
||||
fun toggle(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
value: Boolean, write: (Boolean) -> Unit,
|
||||
): GpRow = GpRow(
|
||||
id, header, label,
|
||||
value = if (value) "On" else "Off",
|
||||
detail = detail,
|
||||
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
||||
activate = { write(!value) },
|
||||
)
|
||||
|
||||
return listOf(
|
||||
choice(
|
||||
"resolution", "Stream", "Resolution",
|
||||
"The host creates a virtual display at exactly this size — no scaling.",
|
||||
RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to lbl }, s.width to s.height,
|
||||
) { (w, h) -> update(s.copy(width = w, height = h)) },
|
||||
choice(
|
||||
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
|
||||
REFRESH_OPTIONS, s.hz,
|
||||
) { update(s.copy(hz = it)) },
|
||||
choice(
|
||||
"bitrate", null, "Bitrate",
|
||||
"Automatic uses the host's default. Run a speed test from the touch UI for an informed value.",
|
||||
BITRATE_OPTIONS, s.bitrateKbps,
|
||||
) { update(s.copy(bitrateKbps = it)) },
|
||||
choice(
|
||||
"compositor", null, "Compositor",
|
||||
"Which compositor drives the virtual output — honored only if available on the host.",
|
||||
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
|
||||
) { update(s.copy(compositor = it)) },
|
||||
|
||||
choice(
|
||||
"codec", "Video", "Video codec",
|
||||
"A preference — the host falls back if it can't encode this one.",
|
||||
CODEC_OPTIONS, s.codec,
|
||||
) { update(s.copy(codec = it)) },
|
||||
toggle(
|
||||
"hdr", null, "10-bit HDR",
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||
s.hdrEnabled,
|
||||
) { update(s.copy(hdrEnabled = it)) },
|
||||
|
||||
choice(
|
||||
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
|
||||
) { update(s.copy(audioChannels = it)) },
|
||||
toggle(
|
||||
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
|
||||
s.micEnabled,
|
||||
) { update(s.copy(micEnabled = it)) },
|
||||
|
||||
choice(
|
||||
"padType", "Controller", "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
|
||||
toggle(
|
||||
"hud", "Interface", "Statistics overlay",
|
||||
"Show FPS, throughput and latency while streaming.",
|
||||
s.statsHudEnabled,
|
||||
) { update(s.copy(statsHudEnabled = it)) },
|
||||
toggle(
|
||||
"library", null, "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", null, "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
|
||||
/**
|
||||
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
|
||||
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
|
||||
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
|
||||
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
|
||||
* console UI (as long as the setting is on).
|
||||
*/
|
||||
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
|
||||
enabled && (controllerConnected || tv || forced)
|
||||
|
||||
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
|
||||
fun isTvDevice(context: Context): Boolean {
|
||||
val pm = context.packageManager
|
||||
if (pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) ||
|
||||
pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
val uiMode = context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager
|
||||
return uiMode?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
||||
}
|
||||
|
||||
/**
|
||||
* Live "is a game controller attached" state, updated as pads connect/disconnect via
|
||||
* [InputManager]'s device listener — so the home screen flips to the console UI the instant a pad is
|
||||
* plugged in or paired, and back to touch when it's removed. Mirrors the reactivity the Apple client
|
||||
* gets from observing `GamepadManager.shared`.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberControllerConnected(): State<Boolean> {
|
||||
val context = LocalContext.current
|
||||
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
|
||||
DisposableEffect(Unit) {
|
||||
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||
val listener = object : InputManager.InputDeviceListener {
|
||||
private fun refresh() { connected.value = Gamepad.firstPad() != null }
|
||||
override fun onInputDeviceAdded(deviceId: Int) = refresh()
|
||||
override fun onInputDeviceRemoved(deviceId: Int) = refresh()
|
||||
override fun onInputDeviceChanged(deviceId: Int) = refresh()
|
||||
}
|
||||
im.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
|
||||
connected.value = Gamepad.firstPad() != null
|
||||
onDispose { im.unregisterInputDeviceListener(listener) }
|
||||
}
|
||||
return connected
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PageSize
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.zIndex
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.library.LibraryClient
|
||||
import io.unom.punktfunk.kit.library.LibraryResult
|
||||
import io.unom.punktfunk.kit.library.mtlsHttpClient
|
||||
import io.unom.punktfunk.kit.security.IdentityStore
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sign
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// The host game-library browser — the Android mirror of the Apple client's LibraryCoverflowView:
|
||||
// a gamepad-driven poster coverflow (centered cover flat + prominent, neighbours receding on a 3D
|
||||
// Y-tilt) fetched from the host's management API over mTLS. Reached with Y from a saved host.
|
||||
|
||||
private sealed class LibState {
|
||||
object Loading : LibState()
|
||||
data class Ready(val games: List<GameEntry>, val loader: ImageLoader) : LibState()
|
||||
data class Message(val text: String) : LibState() // unauthorized / empty / error
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true) {
|
||||
BackHandler(onBack = onBack)
|
||||
val context = LocalContext.current
|
||||
val hazeState = remember { HazeState() }
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
|
||||
|
||||
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||
state = LibState.Loading
|
||||
state = withContext(Dispatchers.IO) {
|
||||
val id = runCatching { obtainIdentity(IdentityStore(context)) }.getOrNull()
|
||||
?: return@withContext LibState.Message("Identity unavailable — re-pair may be required.")
|
||||
when (val res = LibraryClient.fetch(
|
||||
address = host.address,
|
||||
mgmtPort = DEFAULT_MGMT_PORT,
|
||||
certPem = id.certPem,
|
||||
keyPem = id.privateKeyPem,
|
||||
fpHex = host.fpHex,
|
||||
)) {
|
||||
is LibraryResult.Ok -> if (res.games.isEmpty()) {
|
||||
LibState.Message("No games found on this host.")
|
||||
} else {
|
||||
val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex)
|
||||
LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build())
|
||||
}
|
||||
is LibraryResult.Unauthorized -> LibState.Message(res.message)
|
||||
is LibraryResult.Error -> LibState.Message(res.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
ConsoleHeader("${host.name} — Library")
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
when (val s = state) {
|
||||
is LibState.Loading -> LoadingState()
|
||||
is LibState.Message -> MessageState(s.text)
|
||||
is LibState.Ready -> Coverflow(s.games, s.loader, navActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Floating legend at the shared spot — same landscape-aware inset as every other console
|
||||
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(listOf(PadGlyph.hint('B', "Close", onClick = onBack)), hazeState = hazeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingState() {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageState(text: String) {
|
||||
Text(
|
||||
text,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Boolean) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
|
||||
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
|
||||
val coverWidth = coverHeight * 2f / 3f
|
||||
val sidePad = ((maxWidth - coverWidth) / 2).coerceAtLeast(0.dp)
|
||||
val pagerState = rememberPagerState(pageCount = { games.size })
|
||||
val scope = rememberCoroutineScope()
|
||||
var navTarget by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||
val current = games.getOrNull(navTarget)
|
||||
|
||||
// Controller nav: the pad drives the coverflow (it wasn't captured before). Left/right steps a
|
||||
// coalesced target the pager chases; A is reserved for launch (browse-only for now); B closes
|
||||
// via the screen's BackHandler.
|
||||
GamepadNavEffect(
|
||||
active = navActive && games.isNotEmpty(),
|
||||
onMove = { dir ->
|
||||
val t = (navTarget + dir).coerceIn(0, games.lastIndex)
|
||||
if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } }
|
||||
},
|
||||
onActivate = { /* launch a title — browse-only for now */ },
|
||||
)
|
||||
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(coverWidth),
|
||||
contentPadding = PaddingValues(horizontal = sidePad),
|
||||
pageSpacing = 0.dp, // translationX (below) does the spacing so covers sit closer
|
||||
beyondViewportPageCount = 3, // render more neighbours so a denser fan is visible
|
||||
modifier = Modifier.fillMaxWidth().height(coverHeight + 24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) { page ->
|
||||
val signed = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
|
||||
val d = signed.absoluteValue
|
||||
Poster(
|
||||
game = games[page],
|
||||
loader = loader,
|
||||
modifier = Modifier
|
||||
.zIndex(-d) // centred cover on top, neighbours stacked behind
|
||||
.width(coverWidth)
|
||||
.height(coverHeight)
|
||||
.graphicsLayer {
|
||||
// Centre at full size; EVERY neighbour settles to one size, so an even pitch
|
||||
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
|
||||
// the "edges spread apart while the centre gets crowded" look.)
|
||||
val scale = 1f - 0.28f * d.coerceAtMost(1f)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
alpha = (1f - 0.26f * d).coerceAtLeast(0.15f) // depth via fade, not size
|
||||
val rotDeg = signed.coerceIn(-2.5f, 2.5f) * 26f // tilt inward
|
||||
rotationY = rotDeg
|
||||
// Even neighbour pitch (0.8·cover) + a little extra outward push (ramped over
|
||||
// the first step so scrolling stays smooth) so the CENTRE card breathes.
|
||||
val base = signed * size.width * 0.2f - signed.coerceIn(-1f, 1f) * size.width * 0.14f
|
||||
// Counter-balance: a rotated card projects narrower (≈cos θ), which opens its
|
||||
// inner gap — pull it back toward centre by the half-width it loses so the
|
||||
// gaps stay even no matter the tilt.
|
||||
val halfW = size.width * scale * 0.5f
|
||||
val counter = sign(signed) * halfW * (1f - cos(rotDeg * (PI.toFloat() / 180f)))
|
||||
translationX = base + counter
|
||||
// Lower cameraDistance = stronger perspective (CSS `perspective`); the flat
|
||||
// 22 washed the tilt out. 9 makes the same angle read as real depth.
|
||||
cameraDistance = 9f * density
|
||||
transformOrigin = TransformOrigin(0.5f, 0.5f)
|
||||
},
|
||||
)
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(top = 14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
current?.title ?: " ",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (current != null) {
|
||||
Text(
|
||||
if (current.isCustom) "CUSTOM" else "STEAM",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
letterSpacing = 2.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */
|
||||
@Composable
|
||||
private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) {
|
||||
val candidates = game.art.posterCandidates
|
||||
var idx by remember(game.id) { mutableStateOf(0) }
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(Color(0xFF241F3D))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (idx < candidates.size) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current).data(candidates[idx]).build(),
|
||||
imageLoader = loader,
|
||||
contentDescription = game.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Store badge, top-start.
|
||||
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||
Text(
|
||||
if (game.isCustom) "Custom" else "Steam",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,21 @@ package io.unom.punktfunk
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -31,6 +38,13 @@ fun LicensesScreen(onBack: () -> Unit) {
|
||||
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
|
||||
}.getOrDefault("Third-party notices unavailable.")
|
||||
}
|
||||
// The bundled brand typeface (Geist Sans) ships under the SIL Open Font License 1.1. The OFL
|
||||
// requires the license travel with the font, so surface it here (mirrors the Apple client).
|
||||
val fontLicense = remember {
|
||||
runCatching {
|
||||
context.assets.open("GEIST-OFL.txt").bufferedReader().use { it.readText() }
|
||||
}.getOrNull()
|
||||
}
|
||||
val version = remember {
|
||||
runCatching {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -38,29 +52,52 @@ fun LicensesScreen(onBack: () -> Unit) {
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text("Open-source licenses", style = MaterialTheme.typography.headlineMedium)
|
||||
if (version != null) {
|
||||
Text(
|
||||
"punktfunk $version",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler).
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (version != null) {
|
||||
Text(
|
||||
"Punktfunk $version",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
||||
"components below, each under its own license.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
notices,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
if (fontLicense != null) {
|
||||
Text("Bundled font", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
fontLicense,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
||||
"components below, each under its own license.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
notices,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
@@ -10,6 +11,9 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
@@ -34,8 +38,30 @@ class MainActivity : ComponentActivity() {
|
||||
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
||||
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
||||
|
||||
/**
|
||||
* Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a
|
||||
* couch user with no keyboard/Back can always leave a stream.
|
||||
*/
|
||||
var requestStreamExit: (() -> Unit)? = null
|
||||
|
||||
/** Currently-held forwarded pad buttons (bitmask of `Gamepad.BTN_*`), for chord detection. */
|
||||
private var heldPadButtons = 0
|
||||
|
||||
/**
|
||||
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
|
||||
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
|
||||
* face buttons, or a select glyph + arrows for a remote. Compose observes it (a snapshot state).
|
||||
*/
|
||||
var lastPadIsGamepad by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||
private var highRefreshModeId = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
resolveHighRefreshMode()
|
||||
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
|
||||
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
||||
// the status/nav bars blend with our surface and get light icons. (The no-arg edge-to-edge
|
||||
// picks the *system* light/dark, which left a black status bar over our dark background.)
|
||||
@@ -43,13 +69,39 @@ class MainActivity : ComponentActivity() {
|
||||
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||
)
|
||||
// Dev escape hatch (mirrors the Apple client's PUNKTFUNK_FORCE_GAMEPAD_UI): force the console
|
||||
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
|
||||
// pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV.
|
||||
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
|
||||
setContent {
|
||||
PunktfunkTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) { App() }
|
||||
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
||||
private fun resolveHighRefreshMode() {
|
||||
@Suppress("DEPRECATION")
|
||||
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||
highRefreshModeId = disp?.supportedModes?.maxWithOrNull(
|
||||
compareBy({ it.refreshRate }, { it.physicalWidth * it.physicalHeight }),
|
||||
)?.modeId ?: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
|
||||
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
|
||||
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so
|
||||
* its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead.
|
||||
*/
|
||||
fun setConsoleHighRefreshRate(high: Boolean) {
|
||||
if (highRefreshModeId == 0) return
|
||||
window.attributes = window.attributes.apply {
|
||||
preferredDisplayModeId = if (high) highRefreshModeId else 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val handle = streamHandle
|
||||
if (handle != 0L) {
|
||||
@@ -60,9 +112,20 @@ class MainActivity : ComponentActivity() {
|
||||
if (bit != 0) {
|
||||
when (event.action) {
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
KeyEvent.ACTION_DOWN ->
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
|
||||
KeyEvent.ACTION_UP -> NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
||||
heldPadButtons = heldPadButtons or bit
|
||||
// Emergency exit: Select + Start + L1 + R1 held together leaves the stream
|
||||
// (a couch user has no keyboard/Back). Fired once per full chord.
|
||||
if (heldPadButtons and STREAM_EXIT_CHORD == STREAM_EXIT_CHORD) {
|
||||
heldPadButtons = 0
|
||||
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
|
||||
}
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
||||
heldPadButtons = heldPadButtons and bit.inv()
|
||||
}
|
||||
}
|
||||
return true // consumed
|
||||
}
|
||||
@@ -90,18 +153,29 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's
|
||||
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are).
|
||||
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
||||
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
}
|
||||
// The Controllers debug screen sees pad events before the navigation remap below.
|
||||
padKeyProbe?.let { if (it(event)) return true }
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
||||
// buttons to the navigation 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
|
||||
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
|
||||
// already move focus on their own, so they fall through to super untouched.
|
||||
when (event.keyCode) {
|
||||
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
|
||||
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
|
||||
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
|
||||
KeyEvent.KEYCODE_BUTTON_B -> {
|
||||
if (event.action == KeyEvent.ACTION_UP) onBackPressedDispatcher.onBackPressed()
|
||||
return true
|
||||
}
|
||||
// A → activate the focused element (the focus system understands DPAD_CENTER).
|
||||
KeyEvent.KEYCODE_BUTTON_A ->
|
||||
return super.dispatchKeyEvent(KeyEvent(event.action, KeyEvent.KEYCODE_DPAD_CENTER))
|
||||
}
|
||||
if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped))
|
||||
}
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
@@ -137,6 +211,7 @@ class MainActivity : ComponentActivity() {
|
||||
if (dir != lastNavDir) {
|
||||
lastNavDir = dir
|
||||
if (dir != 0) {
|
||||
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
||||
return true
|
||||
@@ -147,4 +222,17 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
/** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */
|
||||
private fun isConsoleNavKey(kc: Int): Boolean = when (kc) {
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
|
||||
-> true
|
||||
else -> KeyEvent.isGamepadButton(kc)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together. */
|
||||
val STREAM_EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,19 @@ data class Settings(
|
||||
* understand touch. Mirrors the Apple client's TouchInputMode.
|
||||
*/
|
||||
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
||||
/**
|
||||
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
|
||||
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
|
||||
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
|
||||
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||
*/
|
||||
val gamepadUiEnabled: Boolean = true,
|
||||
/**
|
||||
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
|
||||
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
|
||||
* client's `libraryEnabled`.
|
||||
*/
|
||||
val libraryEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
/** [Settings.touchMode] values; persisted by name. */
|
||||
@@ -67,6 +80,8 @@ class SettingsStore(context: Context) {
|
||||
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -83,6 +98,8 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -99,6 +116,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_MIC = "mic_enabled"
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
|
||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
|
||||
@@ -5,44 +5,79 @@ import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Tv
|
||||
import androidx.compose.material.icons.filled.VolumeUp
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted
|
||||
* immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native"
|
||||
* resolve from the device display at connect time.
|
||||
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||
* subpages. On a phone the category list pushes to a full-screen detail; on a tablet / large screen
|
||||
* it becomes a two-pane list-detail (the list stays on the left, the detail on the right). Edits
|
||||
* persist immediately via [onChange]; [onBack] returns to the connect screen.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
|
||||
fun SettingsScreen(
|
||||
initial: Settings,
|
||||
onChange: (Settings) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var s by remember { mutableStateOf(initial) }
|
||||
val context = LocalContext.current
|
||||
var showLicenses by remember { mutableStateOf(false) }
|
||||
@@ -52,13 +87,20 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
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)) }
|
||||
val onMicChange: (Boolean) -> Unit = { on ->
|
||||
when {
|
||||
!on -> update(s.copy(micEnabled = false))
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true))
|
||||
else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
|
||||
// Deep sub-screens replace the whole settings surface (they carry their own back).
|
||||
if (showLicenses) {
|
||||
LicensesScreen(onBack = { showLicenses = false })
|
||||
return
|
||||
@@ -68,160 +110,314 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
||||
// Selected category persists across rotation (stored by name — null = the bare list on a phone).
|
||||
var selectedName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val selected = selectedName?.let { n -> SettingsCategory.entries.firstOrNull { it.name == n } }
|
||||
|
||||
val (nw, nh, nhz) = nativeDisplayMode(context)
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val twoPane = maxWidth >= 640.dp
|
||||
// A two-column layout must never show an empty detail — land on the first category.
|
||||
LaunchedEffect(twoPane) {
|
||||
if (twoPane && selected == null) selectedName = SettingsCategory.Display.name
|
||||
}
|
||||
|
||||
SettingsGroup("Display") {
|
||||
SettingDropdown(
|
||||
label = "Resolution",
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
|
||||
(w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl)
|
||||
},
|
||||
selected = s.width to s.height,
|
||||
) { (w, h) -> update(s.copy(width = w, height = h)) }
|
||||
|
||||
SettingDropdown(
|
||||
label = "Refresh rate",
|
||||
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
|
||||
selected = s.hz,
|
||||
) { hz -> update(s.copy(hz = hz)) }
|
||||
|
||||
SettingDropdown(
|
||||
label = "Bitrate",
|
||||
options = BITRATE_OPTIONS,
|
||||
selected = s.bitrateKbps,
|
||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
||||
|
||||
SettingDropdown(
|
||||
label = "Video codec",
|
||||
options = CODEC_OPTIONS,
|
||||
selected = s.codec,
|
||||
) { c -> update(s.copy(codec = c)) }
|
||||
|
||||
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle
|
||||
// is disabled (and HDR is never advertised regardless) so the host doesn't send PQ the
|
||||
// panel would mis-tone-map. The capability is fixed for the device, so read it once.
|
||||
val hdrCapable = remember { displaySupportsHdr(context) }
|
||||
ToggleRow(
|
||||
title = "HDR",
|
||||
subtitle = if (hdrCapable) {
|
||||
"Stream 10-bit HDR (BT.2020 PQ) when the host supports it"
|
||||
} else {
|
||||
"This display can't present HDR10 — streams stay SDR"
|
||||
},
|
||||
checked = s.hdrEnabled && hdrCapable,
|
||||
enabled = hdrCapable,
|
||||
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
|
||||
val detail: @Composable (SettingsCategory, (() -> Unit)?) -> Unit = { cat, back ->
|
||||
CategoryDetail(
|
||||
category = cat,
|
||||
settings = s,
|
||||
onChange = ::update,
|
||||
context = context,
|
||||
onMicChange = onMicChange,
|
||||
onOpenControllers = { showControllers = true },
|
||||
onOpenLicenses = { showLicenses = true },
|
||||
onBack = back,
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Host") {
|
||||
SettingDropdown(
|
||||
label = "Compositor",
|
||||
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.compositor,
|
||||
) { c -> update(s.copy(compositor = c)) }
|
||||
|
||||
SettingDropdown(
|
||||
label = "Controller type",
|
||||
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.gamepad,
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
subtitle = "What the app detects, with a live input test",
|
||||
onClick = { showControllers = true },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Audio") {
|
||||
SettingDropdown(
|
||||
label = "Audio channels",
|
||||
options = AUDIO_CHANNEL_OPTIONS,
|
||||
selected = s.audioChannels,
|
||||
) { ch -> update(s.copy(audioChannels = ch)) }
|
||||
|
||||
ToggleRow(
|
||||
title = "Microphone",
|
||||
subtitle = "Send your mic to the host's virtual microphone",
|
||||
checked = s.micEnabled,
|
||||
onCheckedChange = { on ->
|
||||
when {
|
||||
!on -> update(s.copy(micEnabled = false))
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true))
|
||||
else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
if (twoPane) {
|
||||
BackHandler(onBack = onBack)
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
CategoryList(
|
||||
selected = selected,
|
||||
twoPane = true,
|
||||
onSelect = { selectedName = it.name },
|
||||
modifier = Modifier.width(300.dp).fillMaxHeight(),
|
||||
)
|
||||
VerticalDivider()
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||
// Cross-fade the detail pane as the selected category changes.
|
||||
AnimatedContent(
|
||||
targetState = selected ?: SettingsCategory.Display,
|
||||
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||
label = "SettingsPane",
|
||||
) { cat -> detail(cat, null) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Compact: the category list pushes to a full-screen detail and back, like the iOS /
|
||||
// Android system settings — a horizontal slide that tracks the drill-in direction.
|
||||
BackHandler { if (selected != null) selectedName = null else onBack() }
|
||||
AnimatedContent(
|
||||
targetState = selected,
|
||||
transitionSpec = {
|
||||
if (targetState != null) {
|
||||
slideInHorizontally { it } + fadeIn() togetherWith
|
||||
slideOutHorizontally { -it } + fadeOut()
|
||||
} else {
|
||||
slideInHorizontally { -it } + fadeIn() togetherWith
|
||||
slideOutHorizontally { it } + fadeOut()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Touch input") {
|
||||
SettingDropdown(
|
||||
label = "Touch input",
|
||||
options = TOUCH_MODE_OPTIONS,
|
||||
selected = s.touchMode,
|
||||
onSelect = { mode -> update(s.copy(touchMode = mode)) },
|
||||
)
|
||||
Text(
|
||||
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger " +
|
||||
"tap right-clicks, two fingers scroll, tap-then-drag holds the button. " +
|
||||
"Direct pointer: the cursor jumps to your finger. Touch passthrough: real " +
|
||||
"multi-touch reaches the host, for apps that understand touch.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
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)) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("About") {
|
||||
ClickableRow(
|
||||
title = "Open-source licenses",
|
||||
subtitle = "Third-party notices and credits",
|
||||
onClick = { showLicenses = true },
|
||||
)
|
||||
label = "SettingsPush",
|
||||
) { sel ->
|
||||
if (sel == null) {
|
||||
CategoryList(
|
||||
selected = null,
|
||||
twoPane = false,
|
||||
onSelect = { selectedName = it.name },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
detail(sel) { selectedName = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A titled group of settings rendered inside an outlined card. */
|
||||
/** The top-level settings groups — each opens its own subpage (list on phone, split on tablet). */
|
||||
enum class SettingsCategory(val title: String, val icon: ImageVector) {
|
||||
Display("Display", Icons.Filled.Tv),
|
||||
Audio("Audio", Icons.Filled.VolumeUp),
|
||||
Controls("Controls", Icons.Filled.SportsEsports),
|
||||
Interface("Interface", Icons.Filled.Tune),
|
||||
About("About", Icons.Filled.Info),
|
||||
}
|
||||
|
||||
/** The category list — the settings root. Highlights the [selected] row when it drives a detail pane. */
|
||||
@Composable
|
||||
private fun SettingsGroup(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
private fun CategoryList(
|
||||
selected: SettingsCategory?,
|
||||
twoPane: Boolean,
|
||||
onSelect: (SettingsCategory) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
"Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(start = 8.dp, bottom = 12.dp),
|
||||
)
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
content = content,
|
||||
)
|
||||
SettingsCategory.entries.forEach { cat ->
|
||||
val highlighted = twoPane && selected == cat
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent)
|
||||
.clickable { onSelect(cat) }
|
||||
.padding(horizontal = 14.dp, vertical = 15.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
cat.icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
)
|
||||
Text(cat.title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
if (!twoPane) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One category's controls. [onBack] non-null (phone push) shows a back arrow; null (tablet pane) hides it. */
|
||||
@Composable
|
||||
private fun CategoryDetail(
|
||||
category: SettingsCategory,
|
||||
settings: Settings,
|
||||
onChange: (Settings) -> Unit,
|
||||
context: android.content.Context,
|
||||
onMicChange: (Boolean) -> Unit,
|
||||
onOpenControllers: () -> Unit,
|
||||
onOpenLicenses: () -> Unit,
|
||||
onBack: (() -> Unit)?,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.padding(end = 4.dp)) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
Text(category.title, style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
when (category) {
|
||||
SettingsCategory.Display -> DisplaySettings(settings, onChange, context)
|
||||
SettingsCategory.Audio -> AudioSettings(settings, onChange, onMicChange)
|
||||
SettingsCategory.Controls -> ControlsSettings(settings, onChange, onOpenControllers)
|
||||
SettingsCategory.Interface -> InterfaceSettings(settings, onChange)
|
||||
SettingsCategory.About -> AboutSettings(onOpenLicenses)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
|
||||
val (nw, nh, nhz) = nativeDisplayMode(context)
|
||||
SettingsCard {
|
||||
SettingDropdown(
|
||||
label = "Resolution",
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) },
|
||||
selected = s.width to s.height,
|
||||
) { (w, h) -> update(s.copy(width = w, height = h)) }
|
||||
|
||||
SettingDropdown(
|
||||
label = "Refresh rate",
|
||||
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
|
||||
selected = s.hz,
|
||||
) { hz -> update(s.copy(hz = hz)) }
|
||||
|
||||
SettingDropdown(label = "Bitrate", options = BITRATE_OPTIONS, selected = s.bitrateKbps) { kbps ->
|
||||
update(s.copy(bitrateKbps = kbps))
|
||||
}
|
||||
|
||||
SettingDropdown(label = "Video codec", options = CODEC_OPTIONS, selected = s.codec) { c ->
|
||||
update(s.copy(codec = c))
|
||||
}
|
||||
|
||||
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle is
|
||||
// disabled (and HDR is never advertised) so the host doesn't send PQ the panel mis-tone-maps.
|
||||
val hdrCapable = remember { displaySupportsHdr(context) }
|
||||
ToggleRow(
|
||||
title = "HDR",
|
||||
subtitle = if (hdrCapable) {
|
||||
"Stream 10-bit HDR (BT.2020 PQ) when the host supports it"
|
||||
} else {
|
||||
"This display can't present HDR10 — streams stay SDR"
|
||||
},
|
||||
checked = s.hdrEnabled && hdrCapable,
|
||||
enabled = hdrCapable,
|
||||
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
|
||||
)
|
||||
|
||||
SettingDropdown(
|
||||
label = "Compositor",
|
||||
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.compositor,
|
||||
) { c -> update(s.copy(compositor = c)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange: (Boolean) -> Unit) {
|
||||
SettingsCard {
|
||||
SettingDropdown(label = "Audio channels", options = AUDIO_CHANNEL_OPTIONS, selected = s.audioChannels) { ch ->
|
||||
update(s.copy(audioChannels = ch))
|
||||
}
|
||||
ToggleRow(
|
||||
title = "Microphone",
|
||||
subtitle = "Send your mic to the host's virtual microphone",
|
||||
checked = s.micEnabled,
|
||||
onCheckedChange = onMicChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
|
||||
SettingsCard {
|
||||
SettingDropdown(label = "Touch input", options = TOUCH_MODE_OPTIONS, selected = s.touchMode) { mode ->
|
||||
update(s.copy(touchMode = mode))
|
||||
}
|
||||
Text(
|
||||
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger tap " +
|
||||
"right-clicks, two fingers scroll, tap-then-drag holds the button. Direct pointer: " +
|
||||
"the cursor jumps to your finger. Touch passthrough: real multi-touch reaches the " +
|
||||
"host, for apps that understand touch.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
SettingsCard {
|
||||
SettingDropdown(
|
||||
label = "Controller type",
|
||||
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.gamepad,
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
subtitle = "What the app detects, with a live input test",
|
||||
onClick = onOpenControllers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
SettingsCard {
|
||||
ToggleRow(
|
||||
title = "Controller-optimized UI",
|
||||
subtitle = "Switch to the console home (host carousel) when a controller is connected",
|
||||
checked = s.gamepadUiEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Game library",
|
||||
subtitle = "Browse a paired host's game library (press Y on a saved host)",
|
||||
checked = s.libraryEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Stats overlay",
|
||||
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
||||
checked = s.statsHudEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutSettings(onOpenLicenses: () -> Unit) {
|
||||
SettingsCard {
|
||||
ClickableRow(
|
||||
title = "Open-source licenses",
|
||||
subtitle = "Third-party notices and credits",
|
||||
onClick = onOpenLicenses,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A group of settings rendered inside an outlined card. */
|
||||
@Composable
|
||||
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */
|
||||
@Composable
|
||||
private fun ToggleRow(
|
||||
@@ -265,6 +461,12 @@ private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,16 @@ import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The live stats overlay — mirrors the Apple client's HUD. Reads the 14-double layout from
|
||||
* The live stats overlay — the unified HUD (`design/stats-unification.md`, Android v1: headline is
|
||||
* `capture→decoded`, tiled by `host+network` + `decode`). Reads the 18-double layout from
|
||||
* [NativeBridge.nativeVideoStats]:
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc]`. The trailing four (present on a current native lib) describe the
|
||||
* negotiated video feed and render as a codec/depth/colour/chroma line; older layouts just omit it.
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms]`. Indexes 10–13
|
||||
* (present on a current native lib) describe the negotiated video feed and render as a
|
||||
* codec/depth/colour/chroma line; 14/15 render as the stage equation — split into
|
||||
* `host + network + decode` when the Phase-2 terms at 16/17 are nonzero (a current host sends
|
||||
* per-AU 0xCF timings; an old host leaves them 0 and the combined `host+network` term stands);
|
||||
* older layouts just omit those lines.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
@@ -29,7 +34,7 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
val hz = s[8].toInt()
|
||||
val latValid = s[4] != 0.0
|
||||
val skew = s[5] != 0.0
|
||||
val dropped = s[9].toLong()
|
||||
val lost = s[9].toLong()
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||
@@ -50,17 +55,33 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
if (latValid) {
|
||||
val tag = if (skew) "" else " (same-host)"
|
||||
val tag = if (skew) "" else " (same-host clock)"
|
||||
Text(
|
||||
"capture→client ${"%.1f".format(s[2])}/${"%.1f".format(s[3])} ms p50/p95$tag",
|
||||
"end-to-end ${"%.1f".format(s[2])} ms p50 · ${"%.1f".format(s[3])} p95 · capture→decoded$tag",
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
if (s.size >= 16) {
|
||||
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
|
||||
// reported its share this window; otherwise the combined term (old host / no
|
||||
// matched 0xCF timing).
|
||||
val equation = if (s.size >= 18 && s[16] > 0) {
|
||||
"= host ${"%.1f".format(s[16])} + network ${"%.1f".format(s[17])} + decode ${"%.1f".format(s[15])}"
|
||||
} else {
|
||||
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
|
||||
}
|
||||
Text(
|
||||
equation,
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (dropped > 0) {
|
||||
if (lost > 0) {
|
||||
Text(
|
||||
"dropped $dropped",
|
||||
"lost $lost",
|
||||
color = Color(0xFFFFB0B0),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
|
||||
@@ -91,6 +91,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
activity?.streamHandle = handle // route hardware keys to this session
|
||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
||||
activity?.requestStreamExit = onDisconnect // Select+Start+L1+R1 chord leaves the stream
|
||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||
onDispose {
|
||||
@@ -99,6 +101,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
||||
activity?.axisMapper = null
|
||||
activity?.streamHandle = 0L
|
||||
activity?.requestStreamExit = null
|
||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
// Release the landscape lock so the rest of the app follows the device/system again.
|
||||
|
||||
@@ -41,5 +41,7 @@ fun PunktfunkTheme(content: @Composable () -> Unit) {
|
||||
} else {
|
||||
BrandDark
|
||||
}
|
||||
MaterialTheme(colorScheme = scheme, content = content)
|
||||
// Geist Sans across the whole type scale — the brand typeface the website and the Apple client
|
||||
// already ship (see Type.kt).
|
||||
MaterialTheme(colorScheme = scheme, typography = PunktfunkTypography, content = content)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
// Geist — the punktfunk brand typeface (the same family the website and the Apple client ship).
|
||||
// Bundled as static OTF weights in res/font and applied to every Material 3 text style below, so the
|
||||
// Android UI carries the brand type identically to the other clients. Geist Sans only — Geist Mono
|
||||
// is intentionally not shipped (the licenses screen's technical block uses the platform monospace).
|
||||
//
|
||||
// Licensed under the SIL Open Font License 1.1 (see the Geist OFL entry in THIRD-PARTY-NOTICES.txt).
|
||||
val Geist = FontFamily(
|
||||
Font(R.font.geist_regular, FontWeight.Normal),
|
||||
Font(R.font.geist_medium, FontWeight.Medium),
|
||||
Font(R.font.geist_semibold, FontWeight.SemiBold),
|
||||
Font(R.font.geist_bold, FontWeight.Bold),
|
||||
)
|
||||
|
||||
/**
|
||||
* The default Material 3 type scale re-based on [Geist]. Material 3's [Typography] has no
|
||||
* `defaultFontFamily` shortcut (that was Material 2), so each of the 15 roles is re-emitted with the
|
||||
* Geist family while keeping Material's sizes, line heights, letter spacing and per-role weights.
|
||||
*/
|
||||
val PunktfunkTypography: Typography = Typography().run {
|
||||
Typography(
|
||||
displayLarge = displayLarge.copy(fontFamily = Geist),
|
||||
displayMedium = displayMedium.copy(fontFamily = Geist),
|
||||
displaySmall = displaySmall.copy(fontFamily = Geist),
|
||||
headlineLarge = headlineLarge.copy(fontFamily = Geist),
|
||||
headlineMedium = headlineMedium.copy(fontFamily = Geist),
|
||||
headlineSmall = headlineSmall.copy(fontFamily = Geist),
|
||||
titleLarge = titleLarge.copy(fontFamily = Geist),
|
||||
titleMedium = titleMedium.copy(fontFamily = Geist),
|
||||
titleSmall = titleSmall.copy(fontFamily = Geist),
|
||||
bodyLarge = bodyLarge.copy(fontFamily = Geist),
|
||||
bodyMedium = bodyMedium.copy(fontFamily = Geist),
|
||||
bodySmall = bodySmall.copy(fontFamily = Geist),
|
||||
labelLarge = labelLarge.copy(fontFamily = Geist),
|
||||
labelMedium = labelMedium.copy(fontFamily = Geist),
|
||||
labelSmall = labelSmall.copy(fontFamily = Geist),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Wake a sleeping host and WAIT for it to come back before proceeding — the Android mirror of the
|
||||
* Apple client's `HostWaker`.
|
||||
*
|
||||
* A magic packet is fire-and-forget, and a cold box can take 20–60 s to POST, boot, and start
|
||||
* advertising on mDNS again — far longer than a connect attempt will sit. So instead of firing one
|
||||
* packet and immediately dialing (which just fails on a genuinely-asleep host), this drives a visible
|
||||
* "Waking…" state: it (re-)sends the packet, polls the host's mDNS presence once a second via
|
||||
* [isOnline], and on success runs [onOnline] (the real connect for a Wake-&-Connect, or nothing for
|
||||
* a wake-only); on timeout it parks in a retry/cancel state. One wake at a time.
|
||||
*
|
||||
* [scope] is the composition's coroutine scope (main-dispatched), so [waking] mutations and the
|
||||
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
|
||||
*/
|
||||
class WakeController(private val scope: CoroutineScope) {
|
||||
/** null = idle; non-null drives [WakeOverlay]. */
|
||||
data class Waking(
|
||||
val hostName: String,
|
||||
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
|
||||
val connectsAfter: Boolean,
|
||||
val seconds: Int = 0,
|
||||
val timedOut: Boolean = false,
|
||||
)
|
||||
|
||||
var waking by mutableStateOf<Waking?>(null)
|
||||
private set
|
||||
|
||||
private var loop: Job? = null
|
||||
|
||||
/** Captured so "Try Again" replays the exact same wait. */
|
||||
private var replay: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Wake the host and wait for [isOnline] to go true, then run [onOnline]. [macs]/[lastIp] target
|
||||
* the magic packet. No-ops straight to [onOnline] when there's nothing to wake with or the host
|
||||
* is already up (a race between the caller's check and here).
|
||||
*/
|
||||
fun start(
|
||||
hostName: String,
|
||||
connectsAfter: Boolean,
|
||||
macs: List<String>,
|
||||
lastIp: String,
|
||||
isOnline: () -> Boolean,
|
||||
onOnline: () -> Unit,
|
||||
) {
|
||||
if (macs.isEmpty() || isOnline()) {
|
||||
cancel()
|
||||
onOnline()
|
||||
return
|
||||
}
|
||||
replay = { run(hostName, connectsAfter, macs, lastIp, isOnline, onOnline) }
|
||||
replay?.invoke()
|
||||
}
|
||||
|
||||
/** Stop waiting and dismiss the overlay (B / Cancel). */
|
||||
fun cancel() {
|
||||
loop?.cancel()
|
||||
loop = null
|
||||
replay = null
|
||||
waking = null
|
||||
}
|
||||
|
||||
/** Restart the wait after a timeout (A / Try Again). */
|
||||
fun retry() {
|
||||
replay?.invoke()
|
||||
}
|
||||
|
||||
private fun run(
|
||||
hostName: String,
|
||||
connectsAfter: Boolean,
|
||||
macs: List<String>,
|
||||
lastIp: String,
|
||||
isOnline: () -> Boolean,
|
||||
onOnline: () -> Unit,
|
||||
) {
|
||||
loop?.cancel()
|
||||
waking = Waking(hostName = hostName, connectsAfter = connectsAfter)
|
||||
loop = scope.launch {
|
||||
var elapsed = 0
|
||||
while (isActive) {
|
||||
// Re-send periodically: a single packet can be missed, and some NICs only wake on a
|
||||
// fresh packet after dropping into a deeper sleep state.
|
||||
if (elapsed % RESEND_EVERY_S == 0) {
|
||||
val csv = macs.joinToString(",")
|
||||
launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(csv, lastIp) }
|
||||
}
|
||||
if (isOnline()) {
|
||||
waking = null
|
||||
loop = null
|
||||
onOnline()
|
||||
return@launch
|
||||
}
|
||||
if (elapsed >= TIMEOUT_S) {
|
||||
waking = waking?.copy(timedOut = true)
|
||||
loop = null
|
||||
return@launch
|
||||
}
|
||||
delay(1000)
|
||||
elapsed++
|
||||
waking = waking?.copy(seconds = elapsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** How long to wait for the host to reappear before giving up (a cold boot can be a minute+). */
|
||||
const val TIMEOUT_S = 90
|
||||
|
||||
/** Re-send the magic packet this often. */
|
||||
const val RESEND_EVERY_S = 6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The "Waking <host>…" modal shown while [WakeController] brings a sleeping host back — a spinner + a
|
||||
* live elapsed counter, escalating to a retry/cancel prompt on timeout. The Android mirror of the
|
||||
* Apple client's `WakeOverlay`. Rendered over BOTH the touch grid and the console home; it swallows
|
||||
* input to the screen behind it, and in console mode the pad drives it (B cancels, A retries once
|
||||
* timed out) while the touch buttons work for a pointer.
|
||||
*/
|
||||
@Composable
|
||||
fun WakeOverlay(waker: WakeController, gamepadUi: Boolean) {
|
||||
val w = waker.waking ?: return
|
||||
|
||||
BackHandler { waker.cancel() } // system Back / pad B (remapped) cancels the wait
|
||||
if (gamepadUi) {
|
||||
// A retries once timed out; B falls through to the BackHandler above.
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = {},
|
||||
onActivate = { if (w.timedOut) waker.retry() },
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
// Swallow taps so the home behind can't be touched while waking.
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(40.dp)
|
||||
.widthIn(max = 380.dp)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp))
|
||||
.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
if (w.timedOut) {
|
||||
Icon(
|
||||
Icons.Filled.Bedtime,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.85f),
|
||||
modifier = Modifier.size(34.dp),
|
||||
)
|
||||
Text(
|
||||
"${w.hostName} didn't wake",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 19.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"It may still be booting, or it's powered off / off this network.",
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
) {
|
||||
OutlinedButton(onClick = { waker.cancel() }) { Text("Cancel") }
|
||||
Button(onClick = { waker.retry() }) { Text("Try Again") }
|
||||
}
|
||||
} else {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
Text(
|
||||
"Waking ${w.hostName}…",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 19.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"Waiting for it to come online · ${w.seconds}s",
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
OutlinedButton(onClick = { waker.cancel() }, modifier = Modifier.padding(top = 6.dp)) {
|
||||
Text(if (w.connectsAfter) "Cancel" else "Stop Waiting")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,8 @@ fun HostCard(
|
||||
enabled: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
onForget: (() -> Unit)?,
|
||||
onRename: (() -> Unit)? = null,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
onWake: (() -> Unit)? = null,
|
||||
) {
|
||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
||||
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
|
||||
@@ -107,7 +108,7 @@ fun HostCard(
|
||||
StatusPill(status)
|
||||
}
|
||||
|
||||
if (onForget != null || onRename != null) {
|
||||
if (onForget != null || onEdit != null || onWake != null) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||
@@ -119,12 +120,21 @@ fun HostCard(
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
if (onRename != null) {
|
||||
if (onWake != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
text = { Text("Wake host") },
|
||||
onClick = {
|
||||
menu = false
|
||||
onRename()
|
||||
onWake()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (onEdit != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit…") },
|
||||
onClick = {
|
||||
menu = false
|
||||
onEdit()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -83,7 +83,7 @@ internal fun HostsScene() {
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
|
||||
items(SAVED) { h ->
|
||||
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onRename = {})
|
||||
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onEdit = {})
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
@@ -37,13 +37,30 @@ def call(method, url, token=None, data=None, content_type=None, want_json=True):
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
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
|
||||
# Transient-fault retries: googleapis.com occasionally drops the TLS session ("EOF
|
||||
# occurred in violation of protocol" — failed two release uploads on 2026-07-02) or
|
||||
# answers 5xx. Retry those with backoff; 4xx raises immediately (a real API error).
|
||||
# The edits API is transactional until commit, so re-sending any of these is safe.
|
||||
last = None
|
||||
for attempt in range(4):
|
||||
if attempt:
|
||||
delay = 3**attempt
|
||||
print(f"transient Play API failure ({last}); retry {attempt}/3 in {delay}s")
|
||||
time.sleep(delay)
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300) as r:
|
||||
body = r.read()
|
||||
return json.loads(body) if (want_json and body) else body
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code >= 500:
|
||||
last = f"HTTP {e.code}"
|
||||
continue
|
||||
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
|
||||
except urllib.error.URLError as e:
|
||||
last = str(getattr(e, "reason", e))
|
||||
continue
|
||||
sys.exit(f"ERROR: {method} {url} still failing after retries: {last}")
|
||||
|
||||
|
||||
def load_sa():
|
||||
|
||||
@@ -15,8 +15,10 @@ android {
|
||||
ndkVersion = ndkVer
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 31
|
||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
||||
minSdk = 28 // Android 9 — reaches older TV boxes; API 31+ features are runtime-gated.
|
||||
// Keep in lockstep with :app — 32-bit armeabi-v7a for the many 32-bit Google TV / Android TV
|
||||
// boxes, 64-bit arm64-v8a for phones + modern TV, x86_64 for the emulator.
|
||||
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
@@ -28,6 +30,9 @@ android {
|
||||
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
|
||||
|
||||
dependencies {
|
||||
// mTLS HTTPS client for the host's management API (the game-library fetch + cover-art loads).
|
||||
// OkHttp lets us present the paired client cert and pin the host's self-signed cert by SHA-256.
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser
|
||||
}
|
||||
|
||||
@@ -85,9 +90,11 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
// find their subtools.
|
||||
val cmd = mutableListOf(
|
||||
"$cargoBin/cargo", "ndk",
|
||||
"-t", "arm64-v8a", "-t", "x86_64",
|
||||
// Link against the minSdk-31 sysroot so libaaudio (API 26+) is found.
|
||||
"--platform", "31",
|
||||
"-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64",
|
||||
// Link against the minSdk-28 sysroot: libaaudio (API 26) is present, and building at the
|
||||
// floor makes the linker reject any accidental >28 hard import (the one API-30 call we
|
||||
// make, ANativeWindow_setFrameRate, is dlsym-resolved — see decode::try_set_frame_rate).
|
||||
"--platform", "28",
|
||||
"-o", file("src/main/jniLibs").absolutePath,
|
||||
"build", "-p", "punktfunk-client-android",
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.hardware.lights.LightsRequest
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
@@ -16,7 +17,8 @@ import java.nio.ByteBuffer
|
||||
/**
|
||||
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
||||
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
||||
* `VibratorManager`; HID-output → lightbar / player-LED via `LightsManager` (API 33+); adaptive
|
||||
* `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output → lightbar /
|
||||
* player-LED via `LightsManager` (API 33+); adaptive
|
||||
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
||||
*
|
||||
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
||||
@@ -40,6 +42,9 @@ class GamepadFeedback(private val handle: Long) {
|
||||
private var hidoutThread: Thread? = null
|
||||
|
||||
private var vm: VibratorManager? = null
|
||||
// API 28–30 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager
|
||||
// until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor.
|
||||
private var legacy: Vibrator? = null
|
||||
private var vibratorIds: IntArray = IntArray(0)
|
||||
private var amplitudeControlled = false
|
||||
|
||||
@@ -81,6 +86,7 @@ class GamepadFeedback(private val handle: Long) {
|
||||
rumbleThread?.interrupt()
|
||||
hidoutThread?.interrupt()
|
||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||
runCatching { legacy?.cancel() }
|
||||
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
||||
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
||||
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
||||
@@ -98,6 +104,7 @@ class GamepadFeedback(private val handle: Long) {
|
||||
rgbLight = null
|
||||
playerLight = null
|
||||
vm = null
|
||||
legacy = null
|
||||
vibratorIds = IntArray(0)
|
||||
}
|
||||
|
||||
@@ -111,39 +118,65 @@ class GamepadFeedback(private val handle: Long) {
|
||||
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
||||
return
|
||||
}
|
||||
val m = dev.vibratorManager
|
||||
val ids = m.vibratorIds
|
||||
if (ids.isEmpty()) {
|
||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
||||
return
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val m = dev.vibratorManager
|
||||
val ids = m.vibratorIds
|
||||
if (ids.isEmpty()) {
|
||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
||||
return
|
||||
}
|
||||
vm = m
|
||||
vibratorIds = ids
|
||||
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
||||
} else {
|
||||
// API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator.
|
||||
@Suppress("DEPRECATION")
|
||||
val v = dev.vibrator
|
||||
if (!v.hasVibrator()) {
|
||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
|
||||
return
|
||||
}
|
||||
legacy = v
|
||||
amplitudeControlled = v.hasAmplitudeControl()
|
||||
Log.i(TAG, "rumble: bound legacy vibrator amplitudeControl=$amplitudeControlled")
|
||||
}
|
||||
vm = m
|
||||
vibratorIds = ids
|
||||
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
||||
}
|
||||
|
||||
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
|
||||
private fun renderRumble(low: Int, high: Int) {
|
||||
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
|
||||
val m = vm ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
val m = vm
|
||||
if (m != null) {
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
if (amplitudeControlled && vibratorIds.size >= 2) {
|
||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
||||
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
|
||||
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
|
||||
} else {
|
||||
// Single motor or no amplitude control: blend both into one effect.
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
||||
}
|
||||
runCatching { m.vibrate(combo.combine()) }
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
if (amplitudeControlled && vibratorIds.size >= 2) {
|
||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
||||
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
|
||||
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
|
||||
} else {
|
||||
// Single motor or no amplitude control: blend both into one effect.
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
||||
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||
val lv = legacy ?: return
|
||||
if (lo == 0 && hi == 0) {
|
||||
lv.cancel() // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
runCatching {
|
||||
lv.vibrate(if (amplitudeControlled) oneShot(a) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
}
|
||||
runCatching { m.vibrate(combo.combine()) }
|
||||
}
|
||||
|
||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||
|
||||
@@ -86,7 +86,7 @@ object NativeBridge {
|
||||
|
||||
/**
|
||||
* The current resolved-host snapshot for [handle]: newline-joined records, each
|
||||
* `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
|
||||
* `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
|
||||
* cheap (a lock + string build), safe to call on the main thread.
|
||||
*/
|
||||
external fun nativeDiscoveryPoll(handle: Long): String
|
||||
@@ -94,6 +94,15 @@ object NativeBridge {
|
||||
/** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */
|
||||
external fun nativeDiscoveryStop(handle: Long)
|
||||
|
||||
/**
|
||||
* Send a Wake-on-LAN magic packet to wake a sleeping host. [macsCsv] is comma-separated MAC
|
||||
* addresses (`aa:bb:..,cc:dd:..`), learned from the host's mDNS `mac` TXT while it was online;
|
||||
* [lastIp] is the host's last-known IPv4 (or empty). Returns true if at least one datagram was
|
||||
* sent. No handle — callable without a live session. Do NOT call on the main thread (it does
|
||||
* blocking socket sends); run it on a background dispatcher.
|
||||
*/
|
||||
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
|
||||
|
||||
/**
|
||||
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
||||
@@ -105,12 +114,17 @@ object NativeBridge {
|
||||
|
||||
/**
|
||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||
* Returns 14 doubles:
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped,
|
||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`
|
||||
* (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — bit depth
|
||||
* 8/10, CICP primaries/transfer, and the HEVC chroma_format_idc 1=4:2:0 / 3=4:4:4). Poll ~1 Hz;
|
||||
* each call resets the measurement window.
|
||||
* Returns 18 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
* netP50Ms]`
|
||||
* (the two flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
||||
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
||||
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
||||
* `host+network` = capture→received, `decode` = received→decoded; 16/17 split the
|
||||
* `host+network` term via the host's per-AU 0xCF timings — `host` = the host's capture→sent,
|
||||
* `network` = the remainder — both 0.0 when no timing matched this window, i.e. an old host).
|
||||
* Poll ~1 Hz; each call resets the measurement window.
|
||||
*/
|
||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
||||
|
||||
|
||||
+7
-3
@@ -17,15 +17,17 @@ data class DiscoveredHost(
|
||||
val port: Int,
|
||||
val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies)
|
||||
val pairingRequired: Boolean = false,
|
||||
val mac: List<String> = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN)
|
||||
)
|
||||
|
||||
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||
private const val FIELD_SEP = '\u001F'
|
||||
|
||||
/**
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
|
||||
* if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side
|
||||
* already applied the protocol gate and address selection, so this is just field marshaling.
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac`), or
|
||||
* null if it's malformed. `mac` (7th field) is optional — an older host omits it. Pure —
|
||||
* unit-tested without Android (see ParseRecordTest). The native side already applied the protocol
|
||||
* gate and address selection, so this is just field marshaling.
|
||||
*/
|
||||
fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
val f = record.split(FIELD_SEP)
|
||||
@@ -40,6 +42,8 @@ fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
port = port,
|
||||
fingerprint = f[4].ifBlank { null },
|
||||
pairingRequired = f[5] == "required",
|
||||
mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
else emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package io.unom.punktfunk.kit.library
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyStore
|
||||
import java.security.MessageDigest
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.KeyManagerFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
// Android game-library client — the mirror of the Apple client's LibraryClient.swift. Fetches a
|
||||
// host's unified game library from its management REST API (`GET /api/v1/library`) over **mTLS**: the
|
||||
// paired client presents its persistent cert/key (the same identity the host paired over QUIC), and
|
||||
// the host's self-signed cert is pinned by SHA-256(DER). Read-only. Mirrors the GameEntry/Artwork
|
||||
// schema in crates/punktfunk-host/src/library.rs.
|
||||
|
||||
/** The management API's default port — matches `mgmt::DEFAULT_PORT` on the host and the Apple client. */
|
||||
const val DEFAULT_MGMT_PORT = 47990
|
||||
|
||||
/** Cover-art URLs. Steam art arrives as host-relative proxy paths, resolved to absolute by [LibraryClient]. */
|
||||
data class Artwork(val portrait: String?, val header: String?, val hero: String?) {
|
||||
/** Poster preference for a 2:3 tile: portrait capsule → header → hero (near-universal fallbacks). */
|
||||
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
|
||||
}
|
||||
|
||||
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
|
||||
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
}
|
||||
|
||||
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
|
||||
sealed class LibraryResult {
|
||||
data class Ok(val games: List<GameEntry>) : LibraryResult()
|
||||
data class Unauthorized(val message: String) : LibraryResult()
|
||||
data class Error(val message: String) : LibraryResult()
|
||||
}
|
||||
|
||||
object LibraryClient {
|
||||
/**
|
||||
* `GET https://<address>:<mgmtPort>/api/v1/library`, authenticated by mTLS. [fpHex] is the pinned
|
||||
* host-cert SHA-256 (64 hex, from the paired [io.unom.punktfunk.kit.security.KnownHost]); a blank
|
||||
* value means the host was never connected/paired, so there's nothing authorized to browse.
|
||||
* BLOCKING — call from a background dispatcher.
|
||||
*/
|
||||
fun fetch(
|
||||
address: String,
|
||||
mgmtPort: Int = DEFAULT_MGMT_PORT,
|
||||
certPem: String,
|
||||
keyPem: String,
|
||||
fpHex: String,
|
||||
): LibraryResult {
|
||||
if (fpHex.isBlank()) {
|
||||
return LibraryResult.Unauthorized(
|
||||
"Connect to this host once first — the library uses the identity created on pairing to authenticate.",
|
||||
)
|
||||
}
|
||||
val client = try {
|
||||
mtlsHttpClient(certPem, keyPem, address, fpHex)
|
||||
} catch (e: Exception) {
|
||||
return LibraryResult.Error("Couldn't set up the secure connection: ${e.message}")
|
||||
}
|
||||
val base = "https://$address:$mgmtPort"
|
||||
val req = Request.Builder().url("$base/api/v1/library").build()
|
||||
return try {
|
||||
client.newCall(req).execute().use { resp ->
|
||||
when (resp.code) {
|
||||
200 -> LibraryResult.Ok(parse(resp.body?.string().orEmpty(), base))
|
||||
401 -> LibraryResult.Unauthorized(
|
||||
"The host didn't recognize this device. Pair with the host first — it authorizes paired clients by their certificate.",
|
||||
)
|
||||
else -> LibraryResult.Error("The management API returned HTTP ${resp.code}.")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LibraryResult.Error(
|
||||
"Couldn't reach the host's management API: ${e.message}. It binds the LAN by default, so check the host is updated and reachable.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(json: String, base: String): List<GameEntry> {
|
||||
val arr = JSONArray(json)
|
||||
val out = ArrayList<GameEntry>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
val art = o.optJSONObject("art") ?: JSONObject()
|
||||
out.add(
|
||||
GameEntry(
|
||||
id = o.optString("id"),
|
||||
store = o.optString("store"),
|
||||
title = o.optString("title"),
|
||||
art = Artwork(
|
||||
portrait = resolveArt(str(art, "portrait"), base),
|
||||
header = resolveArt(str(art, "header"), base),
|
||||
hero = resolveArt(str(art, "hero"), base),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** A present, non-null, non-blank JSON string field, else null. */
|
||||
private fun str(o: JSONObject, key: String): String? =
|
||||
if (o.has(key) && !o.isNull(key)) o.optString(key).ifBlank { null } else null
|
||||
|
||||
/** Host-relative art path (`/api/v1/library/art/...`) → absolute against the host; else unchanged. */
|
||||
private fun resolveArt(s: String?, base: String): String? =
|
||||
if (s != null && s.startsWith("/")) base + s else s
|
||||
}
|
||||
|
||||
/**
|
||||
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
|
||||
* SHA-256(DER) — reused for BOTH the library fetch and the cover-art loads (so a paired client
|
||||
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
|
||||
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
|
||||
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
|
||||
*/
|
||||
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
|
||||
val clientCert = CertificateFactory.getInstance("X.509")
|
||||
.generateCertificate(ByteArrayInputStream(certPem.toByteArray())) as X509Certificate
|
||||
val privateKey = parsePrivateKey(keyPem)
|
||||
|
||||
val keyStore = KeyStore.getInstance("PKCS12").apply {
|
||||
load(null, null)
|
||||
setKeyEntry("client", privateKey, CharArray(0), arrayOf(clientCert))
|
||||
}
|
||||
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||
kmf.init(keyStore, CharArray(0))
|
||||
|
||||
// System default trust manager, for non-host (external CDN) origins.
|
||||
val sysTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
sysTmf.init(null as KeyStore?)
|
||||
val sysTm = sysTmf.trustManagers.filterIsInstance<X509TrustManager>().first()
|
||||
|
||||
val pinned = fpHex.lowercase()
|
||||
val trustManager = object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
if (sha256Hex(chain[0].encoded) == pinned) return // the pinned host
|
||||
sysTm.checkServerTrusted(chain, authType) // external CDN — normal public trust
|
||||
}
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = sysTm.acceptedIssuers
|
||||
}
|
||||
|
||||
val ssl = SSLContext.getInstance("TLS")
|
||||
ssl.init(kmf.keyManagers, arrayOf<TrustManager>(trustManager), null)
|
||||
|
||||
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
val verifier = HostnameVerifier { hostname, session ->
|
||||
hostname == host || defaultVerifier.verify(hostname, session)
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.sslSocketFactory(ssl.socketFactory, trustManager)
|
||||
.hostnameVerifier(verifier)
|
||||
.connectTimeout(8, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** Parse a PKCS#8 PEM private key (rcgen emits `-----BEGIN PRIVATE KEY-----`), trying EC then RSA/Ed25519. */
|
||||
private fun parsePrivateKey(pem: String): PrivateKey {
|
||||
val body = pem
|
||||
.replace(Regex("-----BEGIN [A-Z ]*PRIVATE KEY-----"), "")
|
||||
.replace(Regex("-----END [A-Z ]*PRIVATE KEY-----"), "")
|
||||
.replace(Regex("\\s"), "")
|
||||
val der = Base64.getDecoder().decode(body)
|
||||
val spec = PKCS8EncodedKeySpec(der)
|
||||
for (alg in listOf("EC", "RSA", "Ed25519")) {
|
||||
try {
|
||||
return KeyFactory.getInstance(alg).generatePrivate(spec)
|
||||
} catch (_: Exception) {
|
||||
// try the next algorithm
|
||||
}
|
||||
}
|
||||
throw IllegalArgumentException("unsupported private-key format (not EC/RSA/Ed25519 PKCS#8)")
|
||||
}
|
||||
|
||||
private fun sha256Hex(der: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256").digest(der).joinToString("") { "%02x".format(it) }
|
||||
@@ -13,6 +13,11 @@ data class KnownHost(
|
||||
val name: String,
|
||||
val fpHex: String,
|
||||
val paired: Boolean,
|
||||
/**
|
||||
* Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it was
|
||||
* online, so the client can wake it once it sleeps. Empty until first learned.
|
||||
*/
|
||||
val mac: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -42,9 +47,22 @@ class KnownHostStore(context: Context) {
|
||||
.put("name", host.name)
|
||||
.put("fp", host.fpHex.lowercase())
|
||||
.put("paired", host.paired)
|
||||
.put("mac", host.mac.joinToString(","))
|
||||
prefs.edit().putString(key(host.address, host.port), json.toString()).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while online).
|
||||
* No-op when the host isn't saved, the list is empty, or it's unchanged — so it doesn't churn
|
||||
* prefs on every discovery tick.
|
||||
*/
|
||||
fun learnMac(address: String, port: Int, mac: List<String>) {
|
||||
if (mac.isEmpty()) return
|
||||
val h = get(address, port) ?: return
|
||||
if (h.mac == mac) return
|
||||
save(h.copy(mac = mac))
|
||||
}
|
||||
|
||||
/** Forget [address]:[port] (the next connect re-pairs / re-TOFUs). */
|
||||
fun remove(address: String, port: Int) {
|
||||
prefs.edit().remove(key(address, port)).apply()
|
||||
@@ -56,6 +74,16 @@ class KnownHostStore(context: Context) {
|
||||
save(h.copy(name = newName))
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a saved host, RE-KEYING if the address or port changed (the pref key IS `address:port`, so
|
||||
* a plain [save] would otherwise leave a stale record under the old key). The caller passes an
|
||||
* [updated] copy that preserves `fpHex`/`paired` (and sets `mac` from the edit form).
|
||||
*/
|
||||
fun update(oldAddress: String, oldPort: Int, updated: KnownHost) {
|
||||
if (oldAddress != updated.address || oldPort != updated.port) remove(oldAddress, oldPort)
|
||||
save(updated)
|
||||
}
|
||||
|
||||
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
||||
fun all(): List<KnownHost> =
|
||||
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
||||
@@ -68,6 +96,25 @@ class KnownHostStore(context: Context) {
|
||||
name = j.getString("name"),
|
||||
fpHex = j.getString("fp"),
|
||||
paired = j.optBoolean("paired", false),
|
||||
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parse a free-typed Wake-on-LAN field into normalized `aa:bb:cc:dd:ee:ff` entries (comma /
|
||||
* space / newline separated). Anything that isn't six colon-separated hex octets is dropped;
|
||||
* an empty result clears the host's MAC. Mirrors the Apple client's `AddHostSheet.parseMacs`.
|
||||
*/
|
||||
fun parseMacs(s: String): List<String> = s
|
||||
.split(',', ';', ' ', '\n', '\t')
|
||||
.map { it.trim().lowercase() }
|
||||
.filter { m ->
|
||||
// Exactly six octets, each two literal hex digits. (Not toIntOrNull(16) — that accepts
|
||||
// a leading +/- sign, so "aa:bb:cc:dd:ee:-1" would wrongly pass.)
|
||||
m.split(":").let { o ->
|
||||
o.size == 6 && o.all { it.length == 2 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package io.unom.punktfunk.kit.security
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/** Unit tests for the pure MAC-parsing helper backing the host edit form. */
|
||||
class KnownHostStoreTest {
|
||||
@Test
|
||||
fun parsesAndNormalizesSingleMac() {
|
||||
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("AA:BB:CC:DD:EE:FF"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesMultipleSeparators() {
|
||||
val expected = listOf("aa:bb:cc:dd:ee:ff", "11:22:33:44:55:66")
|
||||
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff, 11:22:33:44:55:66"))
|
||||
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff 11:22:33:44:55:66"))
|
||||
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff\n11:22:33:44:55:66"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dropsMalformedEntries() {
|
||||
// Not six octets / bad hex / wrong width are all dropped; an empty field clears the MAC.
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs(""))
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("not-a-mac"))
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee")) // 5 octets
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("gg:bb:cc:dd:ee:ff")) // non-hex
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aaa:bb:cc:dd:ee:ff")) // wrong width
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee:-1")) // signed octet
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("+a:-b:+c:-d:+e:-f")) // signed octets
|
||||
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("junk, aa:bb:cc:dd:ee:ff"))
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,11 @@ android_logger = "0.14"
|
||||
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
|
||||
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
||||
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
||||
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-31"] }
|
||||
# api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and
|
||||
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; the one API-30 call we make
|
||||
# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not
|
||||
# linked, so the .so still loads on API 28/29.
|
||||
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
|
||||
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
|
||||
libc = "0.2"
|
||||
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Android Adaptive Performance Framework (ADPF) — CPU performance hints for the decode thread.
|
||||
//!
|
||||
//! ADPF lets a latency-critical app tell the platform "these threads run a repeating workload with
|
||||
//! this per-cycle deadline, and here's how long they *actually* took." The kernel's CPU governor
|
||||
//! (on Qualcomm Snapdragon in particular — its ADPF backend is among the most responsive) then keeps
|
||||
//! those threads on the fast cores at high clocks instead of migrating them to a little core or
|
||||
//! down-clocking between frames. For a stream client the win is on the in-process hot path we
|
||||
//! control — the `pf-decode` feed/drain/present loop — *not* the hardware codec itself (that decodes
|
||||
//! in the mediacodec service, a separate process we can't hint); keeping our loop from being
|
||||
//! scheduled late directly trims the jitter between "AU received" and "buffer released to the
|
||||
//! Surface." It complements the codec-side `operating-rate`/`priority` hints, which push the codec's
|
||||
//! own clocks.
|
||||
//!
|
||||
//! The `APerformanceHint_*` API arrived in NDK **API level 33**. minSdk is 31, so we CANNOT link the
|
||||
//! symbols directly: a `libpunktfunk_android.so` carrying an unresolved
|
||||
//! `APerformanceHint_createSession` import fails to load on API 31/32 devices
|
||||
//! (`System.loadLibrary` throws) even if the code path is never taken. Instead we resolve the
|
||||
//! entry points from `libandroid.so` with `dlsym` at runtime — absent on < 33 ⇒
|
||||
//! [`HintSession::create`] returns `None` and the decode loop simply runs without hints.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::os::raw::c_int;
|
||||
|
||||
// `APerformanceHint_*` function-pointer types. The manager/session handles are opaque, so we treat
|
||||
// them as `*mut c_void`.
|
||||
type GetManagerFn = unsafe extern "C" fn() -> *mut c_void;
|
||||
type CreateSessionFn = unsafe extern "C" fn(*mut c_void, *const i32, usize, i64) -> *mut c_void;
|
||||
type ReportFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
|
||||
type UpdateTargetFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
|
||||
type CloseFn = unsafe extern "C" fn(*mut c_void);
|
||||
|
||||
/// The entry points we use, resolved once from `libandroid.so`, plus the process-wide manager.
|
||||
struct Api {
|
||||
create_session: CreateSessionFn,
|
||||
report: ReportFn,
|
||||
update_target: UpdateTargetFn,
|
||||
close: CloseFn,
|
||||
manager: *mut c_void,
|
||||
}
|
||||
|
||||
/// Resolve the ADPF entry points + the process manager, or `None` on API < 33 (symbols absent) or if
|
||||
/// the manager is unavailable.
|
||||
fn resolve_api() -> Option<Api> {
|
||||
// SAFETY: `dlopen` of an always-present system library with a NUL-terminated name; it returns
|
||||
// null on failure (checked below). `libandroid.so` is already mapped into every app process, so
|
||||
// this only bumps its refcount — we intentionally never `dlclose` (process-lifetime handle).
|
||||
let lib = unsafe { libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW) };
|
||||
if lib.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `dlsym` on the valid handle above with NUL-terminated symbol names; each returns null
|
||||
// when the symbol is absent (device API < 33), which we check before transmuting the non-null
|
||||
// pointer to its fn-pointer type (layout-compatible; a resolved symbol is a valid code address).
|
||||
unsafe {
|
||||
let get_manager = libc::dlsym(lib, c"APerformanceHint_getManager".as_ptr());
|
||||
let create_session = libc::dlsym(lib, c"APerformanceHint_createSession".as_ptr());
|
||||
let report = libc::dlsym(lib, c"APerformanceHint_reportActualWorkDuration".as_ptr());
|
||||
let update_target = libc::dlsym(lib, c"APerformanceHint_updateTargetWorkDuration".as_ptr());
|
||||
let close = libc::dlsym(lib, c"APerformanceHint_closeSession".as_ptr());
|
||||
if get_manager.is_null()
|
||||
|| create_session.is_null()
|
||||
|| report.is_null()
|
||||
|| update_target.is_null()
|
||||
|| close.is_null()
|
||||
{
|
||||
return None; // device API < 33 — no ADPF
|
||||
}
|
||||
let get_manager = std::mem::transmute::<*mut c_void, GetManagerFn>(get_manager);
|
||||
let manager = get_manager();
|
||||
if manager.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(Api {
|
||||
create_session: std::mem::transmute::<*mut c_void, CreateSessionFn>(create_session),
|
||||
report: std::mem::transmute::<*mut c_void, ReportFn>(report),
|
||||
update_target: std::mem::transmute::<*mut c_void, UpdateTargetFn>(update_target),
|
||||
close: std::mem::transmute::<*mut c_void, CloseFn>(close),
|
||||
manager,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A live ADPF hint session bound to a set of thread ids. Dropping it closes the session. Holds raw
|
||||
/// handles, so it is `!Send`/`!Sync` — created and used only on the `pf-decode` thread.
|
||||
pub struct HintSession {
|
||||
api: Api,
|
||||
session: *mut c_void,
|
||||
}
|
||||
|
||||
impl HintSession {
|
||||
/// Open a session hinting `tids` with an initial per-frame target of `target_ns` nanoseconds.
|
||||
/// `None` when ADPF is unavailable (device API < 33) or the platform declines — the caller then
|
||||
/// runs unhinted (a no-op, not an error).
|
||||
pub fn create(target_ns: i64, tids: &[i32]) -> Option<Self> {
|
||||
if target_ns <= 0 || tids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let api = resolve_api()?;
|
||||
// SAFETY: `api.manager` is the live process manager returned above; `tids` is a valid slice
|
||||
// of `len` i32s that `createSession` copies; it returns null on failure (checked).
|
||||
let session =
|
||||
unsafe { (api.create_session)(api.manager, tids.as_ptr(), tids.len(), target_ns) };
|
||||
if session.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(Self { api, session })
|
||||
}
|
||||
|
||||
/// Report the wall-clock time the hinted thread spent producing the last displayed frame. When
|
||||
/// it exceeds the session target the governor boosts the cores running the thread; when it
|
||||
/// stays under, clocks may relax. No-op on a non-positive duration (the API rejects it).
|
||||
pub fn report_actual(&self, actual_ns: i64) {
|
||||
if actual_ns <= 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `self.session` is a live session for `self`'s lifetime.
|
||||
unsafe { (self.api.report)(self.session, actual_ns) };
|
||||
}
|
||||
|
||||
/// Update the per-frame target (e.g. after a mid-session refresh-rate change). Unused today —
|
||||
/// the decode thread restarts on renegotiation — but kept for that path.
|
||||
#[allow(dead_code)]
|
||||
pub fn update_target(&self, target_ns: i64) {
|
||||
if target_ns <= 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `self.session` is a live session for `self`'s lifetime.
|
||||
unsafe { (self.api.update_target)(self.session, target_ns) };
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HintSession {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.session` was created by `createSession` and is closed exactly once, here.
|
||||
unsafe { (self.api.close)(self.session) };
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,10 @@ fn decode_loop(
|
||||
counters: Arc<Counters>,
|
||||
channels: usize,
|
||||
) {
|
||||
// Fold this Opus→AAudio thread into the client's hot-thread set so the ADPF session the decode
|
||||
// thread opens also keeps audio decode on a fast core (registered before the video pump's first
|
||||
// frame arrives, so it's captured when that session is created). No-op below API 33.
|
||||
client.register_hot_thread();
|
||||
// Interleaved f32 samples per millisecond at this layout — the ring's 5 ms reserve check below.
|
||||
let ms = (SAMPLE_RATE as usize / 1000) * channels;
|
||||
// Opus decode scratch: worst-case 120 ms frame (5760 samples/ch) × channels.
|
||||
|
||||
@@ -9,16 +9,28 @@
|
||||
use ndk::data_space::DataSpace;
|
||||
use ndk::media::media_codec::{
|
||||
DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection,
|
||||
OutputBuffer,
|
||||
};
|
||||
use ndk::media::media_format::MediaFormat;
|
||||
use ndk::native_window::{FrameRateCompatibility, NativeWindow};
|
||||
use ndk::native_window::NativeWindow;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::error::PunktfunkError;
|
||||
use punktfunk_core::session::Frame;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Cap on the pts→received-timestamp map below: MediaCodec holds only a handful of frames in
|
||||
/// flight, so anything beyond this is stale (codec flushed / HUD toggled) and gets evicted.
|
||||
const IN_FLIGHT_CAP: usize = 64;
|
||||
|
||||
/// Cap on received AUs awaiting their 0xCF host timing (Phase 2 host/network split): the timing
|
||||
/// datagram trails its AU by at most the wire, so a match lands within a frame or two — anything
|
||||
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
|
||||
const PENDING_SPLIT_CAP: usize = 256;
|
||||
|
||||
/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes.
|
||||
pub fn run(
|
||||
client: Arc<NativeClient>,
|
||||
@@ -61,7 +73,14 @@ pub fn run(
|
||||
// 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);
|
||||
// Operating rate = the codec's clock hint. Setting it to the display rate merely asks the
|
||||
// decoder to *sustain* that cadence — a Qualcomm decoder can meet 60/120 fps at a power-saving
|
||||
// clock that adds a millisecond-plus of decode latency per frame. Setting it to the AOSP
|
||||
// "unbounded" sentinel (Short.MAX) instead asks the decoder to run each frame at max clocks and
|
||||
// finish ASAP, minimising per-frame decode latency — the right trade for a real-time stream
|
||||
// (costs power/heat; the dial to lower if a device thermally throttles over a long session).
|
||||
// Ignored where unsupported.
|
||||
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
||||
|
||||
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
|
||||
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
|
||||
@@ -95,15 +114,36 @@ pub fn run(
|
||||
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}",
|
||||
// vsync (no 60-in-120 judder on high-refresh panels). `ANativeWindow_setFrameRate` is NDK API 30,
|
||||
// above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link
|
||||
// it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent
|
||||
// there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it).
|
||||
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32) {
|
||||
log::debug!(
|
||||
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
||||
mode.refresh_hz
|
||||
);
|
||||
}
|
||||
|
||||
// ADPF: hint the platform that the whole video pipeline — this pf-decode feed/drain/present
|
||||
// loop, the core's data-plane pump (UDP receive + FEC reassembly), and the audio thread — runs a
|
||||
// per-frame real-time workload, so the CPU governor keeps those threads on fast cores at high
|
||||
// clocks instead of down-clocking between frames or parking them on a little core. Snapdragon's
|
||||
// ADPF backend responds well to this. We register this thread now but create the session lazily
|
||||
// on the first presented frame: by then the pump + audio threads have registered their ids too,
|
||||
// and ADPF `createSession` rejects a set with any not-yet-live/dead tid. No-op below API 33.
|
||||
let frame_period_ns = if mode.refresh_hz > 0 {
|
||||
1_000_000_000i64 / mode.refresh_hz as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
client.register_hot_thread(); // this decode thread → the pipeline's hot-thread set
|
||||
let mut hint: Option<crate::adpf::HintSession> = None;
|
||||
let mut hint_tried = false;
|
||||
// Accumulates the loop's productive (feed+drain) time between displayed frames; reported to ADPF
|
||||
// once per rendered frame against the frame-period target.
|
||||
let mut work_accum_ns: i64 = 0;
|
||||
|
||||
let mut fed: u64 = 0;
|
||||
let mut rendered: u64 = 0;
|
||||
let mut discarded: u64 = 0;
|
||||
@@ -115,9 +155,19 @@ pub fn run(
|
||||
// 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").
|
||||
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
||||
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
||||
// HUD flags it "(same-host clock)").
|
||||
let clock_offset = client.clock_offset_ns;
|
||||
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
|
||||
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
||||
// to its receipt for the `decode` stage. Only fed while the HUD is visible.
|
||||
let mut in_flight: VecDeque<(u64, i128)> = VecDeque::new();
|
||||
// Phase-2 host/network split (design/stats-unification.md): received AUs awaiting their 0xCF
|
||||
// host timing, as (pts_ns, capture→received µs). The timings are drained non-blockingly right
|
||||
// where receipts are recorded and matched by pts; `network = hostnet − host` (saturating).
|
||||
// Only fed while the HUD is visible; an old host never sends a 0xCF, so entries just age out.
|
||||
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
|
||||
// The dataspace we've signalled on the Surface so far (None = default/SDR). Set reactively once
|
||||
// the decoder reports an HDR stream (see `drain`); avoids re-applying every format event.
|
||||
let mut applied_ds: Option<DataSpace> = None;
|
||||
@@ -138,15 +188,41 @@ pub fn run(
|
||||
&p[..p.len().min(6)]
|
||||
);
|
||||
}
|
||||
// HUD stat: capture→client-receipt latency = client_now + (host−client) −
|
||||
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
||||
// capture_pts. Gated on the HUD being visible — `enabled` first so the hidden
|
||||
// steady state skips the wall-clock read and the lock entirely.
|
||||
// steady state skips the wall-clock read and the lock entirely. The receipt
|
||||
// stamp is also parked in `in_flight` (keyed by the pts the codec will echo on
|
||||
// the output buffer) for the decoded-point pairing in `drain`.
|
||||
if stats.enabled() {
|
||||
let lat_ns =
|
||||
now_realtime_ns() + clock_offset as i128 - frame.pts_ns as i128;
|
||||
let received_ns = now_realtime_ns();
|
||||
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
stats.note(frame.data.len(), lat_us, clock_offset != 0);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
||||
if in_flight.len() > IN_FLIGHT_CAP {
|
||||
in_flight.pop_front(); // stale — codec never echoed it back
|
||||
}
|
||||
// Phase-2 split: park this AU's capture→received sample, then match any
|
||||
// 0xCF host timings that have arrived — host = the host's own
|
||||
// capture→sent, network = our capture→received minus it (per-frame
|
||||
// tiling; saturating in case of clock jitter).
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front(); // 0xCF lost / old host — evict
|
||||
}
|
||||
}
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pending = Some(frame);
|
||||
}
|
||||
@@ -154,6 +230,9 @@ pub fn run(
|
||||
Err(_) => break, // session closed
|
||||
}
|
||||
}
|
||||
// Time the productive work (feed + drain) only — the `next_frame` poll wait above is idle
|
||||
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
|
||||
let work_t0 = Instant::now();
|
||||
if let Some(frame) = pending.take() {
|
||||
if feed(&codec, &frame.data, frame.pts_ns / 1000) {
|
||||
fed += 1;
|
||||
@@ -173,10 +252,48 @@ pub fn run(
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
let (r, d) = drain(&codec, &window, &mut applied_ds, wait);
|
||||
let (r, d) = drain(
|
||||
&codec,
|
||||
&window,
|
||||
&mut applied_ds,
|
||||
wait,
|
||||
&stats,
|
||||
&mut in_flight,
|
||||
clock_offset,
|
||||
);
|
||||
rendered += r;
|
||||
discarded += d;
|
||||
|
||||
// ADPF: attribute this iteration's feed+drain time to the frame being produced, and report
|
||||
// the accumulated per-frame work once one is actually presented (r > 0). Under back-pressure
|
||||
// the short output-dequeue wait is included in the tally — for a latency-first client,
|
||||
// biasing the governor toward "boost" is the desired behaviour. Cheap when `hint` is None
|
||||
// (one `Instant` diff, no report).
|
||||
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||
if r > 0 {
|
||||
if !hint_tried {
|
||||
// First presented frame: the pump + audio threads have registered their ids by now.
|
||||
// Build one ADPF session over the whole pipeline's thread set (empty below API 33,
|
||||
// or where the platform declines → `None`, and the loop runs unhinted).
|
||||
hint_tried = true;
|
||||
let tids = client.hot_thread_ids();
|
||||
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
|
||||
log::info!(
|
||||
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
||||
if hint.is_some() {
|
||||
"active"
|
||||
} else {
|
||||
"unavailable"
|
||||
},
|
||||
tids.len(),
|
||||
);
|
||||
}
|
||||
if let Some(h) = &hint {
|
||||
h.report_actual(work_accum_ns);
|
||||
}
|
||||
work_accum_ns = 0;
|
||||
}
|
||||
|
||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
||||
// 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
|
||||
@@ -226,6 +343,32 @@ fn boost_thread_priority() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ANativeWindow_setFrameRate` (NDK **API 30**) resolved from `libandroid.so` at runtime, so the lib
|
||||
/// still loads on our API-28 floor — a hard import of a >floor symbol makes `dlopen`/`System.load`
|
||||
/// fail on every API-28/29 device, even where this path is never hit. Mirrors the dlsym approach in
|
||||
/// [`crate::adpf`]. Returns `true` when the platform accepted the hint; `false` on API < 30 (symbol
|
||||
/// absent) or when the platform declined. `compatibility` is fixed to the DEFAULT (0) policy.
|
||||
fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32) -> bool {
|
||||
// int32_t ANativeWindow_setFrameRate(ANativeWindow*, float frameRate, int8_t compatibility)
|
||||
type SetFrameRateFn = unsafe extern "C" fn(*mut c_void, f32, i8) -> i32;
|
||||
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never closed —
|
||||
// process-lifetime handle). `dlsym` returns null when the symbol is absent (device API < 30),
|
||||
// checked before transmuting the non-null pointer to its fn-pointer type. `window.ptr()` is the
|
||||
// live `ANativeWindow` this `NativeWindow` owns for the call's duration.
|
||||
unsafe {
|
||||
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
|
||||
if lib.is_null() {
|
||||
return false;
|
||||
}
|
||||
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
|
||||
if sym.is_null() {
|
||||
return false; // device API < 30 — no per-surface frame-rate hint
|
||||
}
|
||||
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
|
||||
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
|
||||
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
|
||||
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
|
||||
@@ -271,11 +414,19 @@ fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) -> bool {
|
||||
/// the caller's input is blocked so the loop waits on decoder progress instead of busy-spinning.
|
||||
/// Returns `(rendered, discarded)`. Also reacts to `OutputFormatChanged` (which can interleave
|
||||
/// between buffers — handled without losing the held buffer) to signal HDR on the Surface.
|
||||
///
|
||||
/// Each dequeued buffer is also the HUD's `decoded` measurement point (rendered or not — the frame
|
||||
/// finished decoding either way): end-to-end = decoded + clock_offset − capture pts, and the
|
||||
/// `decode` stage pairs the buffer's echoed presentationTimeUs back to the receipt stamp in
|
||||
/// `in_flight` (single-clock local difference, no skew involved).
|
||||
fn drain(
|
||||
codec: &MediaCodec,
|
||||
window: &NativeWindow,
|
||||
applied_ds: &mut Option<DataSpace>,
|
||||
first_wait: Duration,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
) -> (u64, u64) {
|
||||
let mut held = None; // newest ready buffer so far, presented after the loop
|
||||
let mut discarded: u64 = 0;
|
||||
@@ -284,6 +435,9 @@ fn drain(
|
||||
match codec.dequeue_output_buffer(wait) {
|
||||
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
||||
wait = Duration::ZERO; // only the first dequeue may block
|
||||
if stats.enabled() {
|
||||
note_decoded(stats, in_flight, clock_offset, &buf);
|
||||
}
|
||||
if let Some(stale) = held.replace(buf) {
|
||||
// A newer frame is ready — drop the held one without rendering.
|
||||
if let Err(e) = codec.release_output_buffer(stale, false) {
|
||||
@@ -333,6 +487,40 @@ fn drain(
|
||||
(rendered, discarded)
|
||||
}
|
||||
|
||||
/// HUD `decoded` point for one dequeued output buffer: build the end-to-end (capture→decoded,
|
||||
/// skew-corrected, clamped to (0, 10 s)) and `decode` (received→decoded, single-clock local, ≥ 0)
|
||||
/// samples and hand them to [`crate::stats::VideoStats::note_decoded`]. The codec echoes the input
|
||||
/// `presentationTimeUs` on the output buffer, which keys the receipt stamp in `in_flight`; entries
|
||||
/// older than the echoed pts are evicted (decode order == input order here — low-latency, no
|
||||
/// B-frames — so anything before it was dropped inside the codec or stamped before a flush).
|
||||
fn note_decoded(
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
buf: &OutputBuffer<'_>,
|
||||
) {
|
||||
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
||||
let decoded_ns = now_realtime_ns();
|
||||
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
||||
let mut received_ns = None;
|
||||
while let Some(&(p, r)) = in_flight.front() {
|
||||
if p > pts_us {
|
||||
break; // future frame — leave it for its own output buffer
|
||||
}
|
||||
in_flight.pop_front();
|
||||
if p == pts_us {
|
||||
received_ns = Some(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// pts_us is the truncated frame.pts_ns/1000 we queued, so ×1000 re-approximates capture time
|
||||
// to < 1 µs — negligible against the ms-scale figures shown.
|
||||
let e2e_ns = decoded_ns + clock_offset as i128 - pts_us as i128 * 1000;
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
let decode_us = received_ns.map(|r| ((decoded_ns - r).max(0) / 1000) as u64);
|
||||
stats.note_decoded(e2e_us, decode_us);
|
||||
}
|
||||
|
||||
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
||||
/// 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).
|
||||
|
||||
@@ -31,7 +31,7 @@ const PROTO: &str = "punktfunk/1";
|
||||
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||
const FIELD_SEP: char = '\u{1f}';
|
||||
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]).
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = [`FIELD_SEP`]).
|
||||
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
||||
/// every field so no value can break it.
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -42,6 +42,8 @@ struct Host {
|
||||
port: u16,
|
||||
fp: String,
|
||||
pair: String,
|
||||
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated), for later wake. Empty if absent.
|
||||
mac: String,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
@@ -54,13 +56,14 @@ impl Host {
|
||||
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||
}
|
||||
format!(
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
clean(&self.key),
|
||||
clean(&self.name),
|
||||
clean(&self.addr),
|
||||
self.port,
|
||||
clean(&self.fp),
|
||||
clean(&self.pair),
|
||||
clean(&self.mac),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -182,6 +185,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
port: info.get_port(),
|
||||
fp: val("fp"),
|
||||
pair: val("pair"),
|
||||
mac: val("mac"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -202,7 +206,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||
@@ -263,16 +267,18 @@ mod tests {
|
||||
port: 9777,
|
||||
fp: "ab".repeat(32),
|
||||
pair: "required".into(),
|
||||
mac: "aa:bb:cc:dd:ee:ff".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields.len(), 6);
|
||||
assert_eq!(fields.len(), 7);
|
||||
assert_eq!(fields[0], "host-123");
|
||||
assert_eq!(fields[1], "home-worker-2");
|
||||
assert_eq!(fields[2], "192.168.1.70");
|
||||
assert_eq!(fields[3], "9777");
|
||||
assert_eq!(fields[4], "ab".repeat(32));
|
||||
assert_eq!(fields[5], "required");
|
||||
assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff");
|
||||
assert!(
|
||||
!encoded.contains('\n'),
|
||||
"a record must never contain the record separator"
|
||||
@@ -282,7 +288,7 @@ mod tests {
|
||||
#[test]
|
||||
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
||||
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
|
||||
// them so the snapshot stays exactly one record of exactly six fields.
|
||||
// them so the snapshot stays exactly one record of exactly seven fields.
|
||||
let h = Host {
|
||||
key: "k\u{1f}injected".into(),
|
||||
name: "evil\nhost\r".into(),
|
||||
@@ -290,9 +296,14 @@ mod tests {
|
||||
port: 9777,
|
||||
fp: "ab\u{1f}cd".into(),
|
||||
pair: "required\n".into(),
|
||||
mac: "aa:bb\u{1f}cc".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields");
|
||||
assert_eq!(
|
||||
encoded.matches(FIELD_SEP).count(),
|
||||
6,
|
||||
"exactly seven fields"
|
||||
);
|
||||
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields[0], "kinjected");
|
||||
|
||||
@@ -25,6 +25,8 @@ use jni::objects::JObject;
|
||||
use jni::sys::jint;
|
||||
use jni::JNIEnv;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod adpf;
|
||||
#[cfg(target_os = "android")]
|
||||
mod audio;
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -37,6 +39,9 @@ mod feedback;
|
||||
mod mic;
|
||||
mod session;
|
||||
mod stats;
|
||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||
// into the host workspace build too. Kotlin only ever calls it on device.
|
||||
mod wol;
|
||||
|
||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
||||
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
|
||||
|
||||
@@ -72,14 +72,19 @@ 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 14 doubles
|
||||
/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`
|
||||
/// (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — see below), or
|
||||
/// `null` when no decode thread is running. Poll ~1 Hz from the UI; each call resets the measurement
|
||||
/// window. Not android-gated — pure `jni` + connector reads, so it links on the host build too
|
||||
/// (Kotlin only ever calls it on device).
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 18 doubles
|
||||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
/// netP50Ms]`
|
||||
/// (the two flags are 1.0/0.0; indexes 0–15 match the previous 16-double layout — 0–13 the original
|
||||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
|
||||
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
|
||||
/// window, i.e. an old host), or `null` when no decode thread is running. Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
env: JNIEnv,
|
||||
@@ -98,11 +103,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
let snap = h.stats.drain();
|
||||
let mode = h.client.mode();
|
||||
let color = h.client.color;
|
||||
let buf: [f64; 14] = [
|
||||
let buf: [f64; 18] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.lat_p50_ms,
|
||||
snap.lat_p95_ms,
|
||||
snap.e2e_p50_ms,
|
||||
snap.e2e_p95_ms,
|
||||
if snap.lat_valid { 1.0 } else { 0.0 },
|
||||
if snap.skew_corrected { 1.0 } else { 0.0 },
|
||||
mode.width as f64,
|
||||
@@ -117,6 +122,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
color.primaries as f64,
|
||||
color.transfer as f64,
|
||||
h.client.chroma_format as f64,
|
||||
// Stage p50s tiling the end-to-end headline (appended to keep 0–13 index-compatible).
|
||||
snap.hostnet_p50_ms,
|
||||
snap.decode_p50_ms,
|
||||
// Phase-2 host/network split of the `host+network` stage (0xCF host timings): 0.0
|
||||
// when no timing matched this window (old host) — the HUD keeps the combined term.
|
||||
snap.host_p50_ms,
|
||||
snap.net_p50_ms,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
//! 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. Sampling is gated on the HUD actually being visible (`set_enabled`, driven by
|
||||
//! `nativeSetVideoStatsEnabled`) so the hidden steady state costs one relaxed atomic load per frame.
|
||||
//! Live decode stats for the on-stream HUD, following the unified stats spec
|
||||
//! (`design/stats-unification.md`): FPS, receive throughput, and the Android v1 stage split —
|
||||
//! headline `end-to-end` = capture→decoded (p50/p95) tiled by `host+network` = capture→received
|
||||
//! and `decode` = received→decoded (stage p50s). When the host emits per-AU 0xCF host timings, the
|
||||
//! `host+network` term further splits into `host` + `network` (Phase 2, `note_host_split`); an old
|
||||
//! host emits none and the combined term stands. The decode thread is the sole writer
|
||||
//! (`note_received` per access unit at receipt, `note_decoded` per decoder output buffer); the JNI
|
||||
//! accessor `nativeVideoStats` drains a snapshot ~1 Hz and resets the window. Sampling is gated on
|
||||
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
|
||||
//! hidden steady state costs one relaxed atomic load per frame.
|
||||
//! Pure `std` so it compiles on the host build too (the decode thread is android-only, but
|
||||
//! `SessionHandle` holds the shared handle unconditionally).
|
||||
|
||||
@@ -13,9 +18,9 @@ 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 {
|
||||
/// HUD gate: `note` runs on the per-frame decode path, so while the overlay is hidden it (and
|
||||
/// the caller's latency computation — see `enabled`) early-outs on this flag alone. Off until
|
||||
/// Kotlin shows the HUD.
|
||||
/// HUD gate: the samplers run on the per-frame decode path, so while the overlay is hidden
|
||||
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
|
||||
/// Off until Kotlin shows the HUD.
|
||||
enabled: AtomicBool,
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
@@ -24,23 +29,52 @@ struct Inner {
|
||||
window_start: Instant,
|
||||
frames: u64,
|
||||
bytes: u64,
|
||||
/// capture→client-receipt latency samples for this window, in microseconds.
|
||||
lat_us: Vec<u64>,
|
||||
/// `end-to-end` = capture→decoded latency samples for this window, in microseconds
|
||||
/// (skew-corrected clock base).
|
||||
e2e_us: Vec<u64>,
|
||||
/// `host+network` stage = capture→received samples, in microseconds (skew-corrected).
|
||||
hostnet_us: Vec<u64>,
|
||||
/// Phase-2 split of `host+network` (design/stats-unification.md Phase 2), fed only when the
|
||||
/// host emits per-AU 0xCF timings: `host` = the host's own capture→sent duration, µs.
|
||||
host_us: Vec<u64>,
|
||||
/// The matching `network` term, µs: capture→received minus the host's capture→sent
|
||||
/// (wire + reassembly). Always pushed in lockstep with `host_us`.
|
||||
net_us: Vec<u64>,
|
||||
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
|
||||
decode_us: Vec<u64>,
|
||||
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
|
||||
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).
|
||||
/// A drained, computed view of one window. `lat_valid` is false when no in-range end-to-end sample
|
||||
/// landed (then the latency figures are 0 and the HUD hides the latency lines, exactly like the
|
||||
/// Apple client).
|
||||
pub struct Snapshot {
|
||||
pub fps: f64,
|
||||
pub mbps: f64,
|
||||
pub lat_p50_ms: f64,
|
||||
pub lat_p95_ms: f64,
|
||||
/// Headline `end-to-end` (capture→decoded) percentiles, ms.
|
||||
pub e2e_p50_ms: f64,
|
||||
pub e2e_p95_ms: f64,
|
||||
/// Stage p50s (ms): `host+network` (capture→received) and `decode` (received→decoded).
|
||||
pub hostnet_p50_ms: f64,
|
||||
pub decode_p50_ms: f64,
|
||||
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
|
||||
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
|
||||
pub host_p50_ms: f64,
|
||||
pub net_p50_ms: f64,
|
||||
pub lat_valid: bool,
|
||||
pub skew_corrected: bool,
|
||||
}
|
||||
|
||||
/// Percentile over a sorted-in-place µs sample vec, in ms. 0.0 when empty.
|
||||
fn pctl_ms(sorted_us: &[u64], p: f64) -> f64 {
|
||||
if sorted_us.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let n = sorted_us.len();
|
||||
sorted_us[((n as f64 * p) as usize).min(n - 1)] as f64 / 1000.0
|
||||
}
|
||||
|
||||
impl VideoStats {
|
||||
pub fn new() -> VideoStats {
|
||||
VideoStats {
|
||||
@@ -49,14 +83,18 @@ impl VideoStats {
|
||||
window_start: Instant::now(),
|
||||
frames: 0,
|
||||
bytes: 0,
|
||||
lat_us: Vec::with_capacity(256),
|
||||
e2e_us: Vec::with_capacity(256),
|
||||
hostnet_us: Vec::with_capacity(256),
|
||||
host_us: Vec::with_capacity(256),
|
||||
net_us: Vec::with_capacity(256),
|
||||
decode_us: Vec::with_capacity(256),
|
||||
skew_corrected: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the HUD wants samples. The decode thread checks this BEFORE building a latency
|
||||
/// sample, so the per-frame wall-clock read is skipped too while hidden.
|
||||
/// sample, so the per-frame wall-clock reads are skipped too while hidden.
|
||||
// Read only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn enabled(&self) -> bool {
|
||||
@@ -75,18 +113,23 @@ impl VideoStats {
|
||||
g.window_start = Instant::now();
|
||||
g.frames = 0;
|
||||
g.bytes = 0;
|
||||
g.lat_us.clear();
|
||||
g.e2e_us.clear();
|
||||
g.hostnet_us.clear();
|
||||
g.host_us.clear();
|
||||
g.net_us.clear();
|
||||
g.decode_us.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one decoded access unit: its wire size and (if in range) its capture→client latency.
|
||||
/// Record one received access unit: its wire size and (if in range) its capture→received
|
||||
/// `host+network` stage sample. Receipt is the fps/goodput counting point per the spec.
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note(&self, bytes: usize, lat_us: Option<u64>, skew_corrected: bool) {
|
||||
pub fn note_received(&self, bytes: usize, hostnet_us: Option<u64>, skew_corrected: bool) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock (the caller already skipped the clock read)
|
||||
}
|
||||
// Poison-proof: `note` runs per-frame on the decode thread, which has no catch_unwind —
|
||||
// Poison-proof: this runs per-frame on the decode thread, which has no catch_unwind —
|
||||
// a panic elsewhere must not turn every later lock into a second panic (the counters
|
||||
// stay consistent regardless).
|
||||
let mut g = self
|
||||
@@ -96,14 +139,56 @@ impl VideoStats {
|
||||
g.frames += 1;
|
||||
g.bytes += bytes as u64;
|
||||
g.skew_corrected = skew_corrected;
|
||||
if let Some(l) = lat_us {
|
||||
g.lat_us.push(l);
|
||||
if let Some(l) = hostnet_us {
|
||||
g.hostnet_us.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one matched host/network split sample (Phase 2): the host's reported capture→sent
|
||||
/// duration and our capture→received minus it, both µs — one pair per AU whose 0xCF host
|
||||
/// timing arrived and matched by pts. An old host emits none, leaving the vecs empty and the
|
||||
/// snapshot p50s at 0 (HUD keeps the combined `host+network` term).
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_host_split(&self, host_us: u64, net_us: u64) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock
|
||||
}
|
||||
// Poison-proof for the same reason as `note_received`.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.host_us.push(host_us);
|
||||
g.net_us.push(net_us);
|
||||
}
|
||||
|
||||
/// Record one decoded output frame: its capture→decoded `end-to-end` sample and its
|
||||
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
|
||||
/// this pts predates the HUD being shown).
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_decoded(&self, e2e_us: Option<u64>, decode_us: Option<u64>) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock (the caller already skipped the clock read)
|
||||
}
|
||||
// Poison-proof for the same reason as `note_received`.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(l) = e2e_us {
|
||||
g.e2e_us.push(l);
|
||||
}
|
||||
if let Some(l) = decode_us {
|
||||
g.decode_us.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
||||
pub fn drain(&self) -> Snapshot {
|
||||
// Poison-proof for the same reason as `note` — a poisoned window still drains fine.
|
||||
// Poison-proof for the same reason as `note_received` — a poisoned window still drains
|
||||
// fine.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -111,26 +196,31 @@ impl VideoStats {
|
||||
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)
|
||||
g.e2e_us.sort_unstable();
|
||||
g.hostnet_us.sort_unstable();
|
||||
g.host_us.sort_unstable();
|
||||
g.net_us.sort_unstable();
|
||||
g.decode_us.sort_unstable();
|
||||
let snap = Snapshot {
|
||||
fps,
|
||||
mbps,
|
||||
e2e_p50_ms: pctl_ms(&g.e2e_us, 0.50),
|
||||
e2e_p95_ms: pctl_ms(&g.e2e_us, 0.95),
|
||||
hostnet_p50_ms: pctl_ms(&g.hostnet_us, 0.50),
|
||||
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
|
||||
host_p50_ms: pctl_ms(&g.host_us, 0.50),
|
||||
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
||||
lat_valid: !g.e2e_us.is_empty(),
|
||||
skew_corrected: g.skew_corrected,
|
||||
};
|
||||
let skew = g.skew_corrected;
|
||||
g.window_start = Instant::now();
|
||||
g.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,
|
||||
}
|
||||
g.e2e_us.clear();
|
||||
g.hostnet_us.clear();
|
||||
g.host_us.clear();
|
||||
g.net_us.clear();
|
||||
g.decode_us.clear();
|
||||
snap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! JNI seam for Wake-on-LAN: parse the stored MAC strings and hand them to the shared core sender
|
||||
//! (`punktfunk_core::wol`). Like [`crate::discovery`], this takes no session handle — a sleeping
|
||||
//! host has no ARP entry, so the broadcast the core sends is what wakes it, and Kotlin calls this
|
||||
//! just before connecting to an offline saved host.
|
||||
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::JNIEnv;
|
||||
|
||||
/// `NativeBridge.nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean` — send a Wake-on-LAN
|
||||
/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's
|
||||
/// mDNS `mac` TXT while it was online); `lastIp` is the host's last-known IPv4 (or empty).
|
||||
/// Returns true if at least one datagram went out.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
macs_csv: JString<'local>,
|
||||
last_ip: JString<'local>,
|
||||
) -> jni::sys::jboolean {
|
||||
let macs_csv: String = match env.get_string(&macs_csv) {
|
||||
Ok(s) => s.into(),
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let last_ip: String = env
|
||||
.get_string(&last_ip)
|
||||
.map(Into::<String>::into)
|
||||
.unwrap_or_default();
|
||||
let macs: Vec<[u8; 6]> = macs_csv
|
||||
.split(',')
|
||||
.filter_map(|s| punktfunk_core::wol::parse_mac(s.trim()))
|
||||
.collect();
|
||||
if macs.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let ip = last_ip.trim().parse::<std::net::Ipv4Addr>().ok();
|
||||
match punktfunk_core::wol::send_magic_packet(&macs, ip) {
|
||||
Ok(()) => 1,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
@@ -48,21 +48,21 @@
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
|
||||
<!-- Controller rumble via CoreHaptics: GCDeviceHaptics.createEngine → CHHapticEngine
|
||||
(GamepadFeedback's RumbleRenderer), and AVAudioEngine playback, reach the system
|
||||
audio-analytics daemon `com.apple.audioanalyticsd` over Mach. The sandbox denies that
|
||||
global-name lookup unless it's whitelisted here, and the framework's own precondition
|
||||
turns the denial into a HARD CRASH ("Process is sandboxed but
|
||||
com.apple.security.exception.mach-lookup.global-name doesn't contain
|
||||
com.apple.audioanalyticsd") the moment a controller's haptics engine starts. This
|
||||
temporary exception is the documented, App-Store-acceptable way to permit exactly that
|
||||
lookup — and ONLY that service (the key takes exact names, no wildcards). App Store:
|
||||
declare it in App Store Connect → App Sandbox Entitlement Usage Information ("CoreHaptics
|
||||
gamepad rumble contacts the system audio-analytics daemon"). -->
|
||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||
<array>
|
||||
<string>com.apple.audioanalyticsd</string>
|
||||
</array>
|
||||
<!-- NO mach-lookup temporary exception here — and none is needed. Build 0.4.2 (3384) shipped a
|
||||
`com.apple.security.temporary-exception.mach-lookup.global-name` = com.apple.audioanalyticsd
|
||||
exception on the THEORY that CoreHaptics controller rumble (CHHapticEngine — the session
|
||||
RumbleRenderer + MenuHaptics) hard-crashes under the App Sandbox without it, because the
|
||||
framework reaches the audio-analytics daemon over Mach and the sandbox denies that lookup.
|
||||
App Review REJECTED the exception under guideline 2.4.5(i) (review 2026-07-04). We then
|
||||
tested the premise directly on macOS: a CHHapticEngine start + full-intensity rumble on a
|
||||
real Xbox pad, in a genuinely ENFORCED sandbox (NSHomeDirectory redirected into the app
|
||||
container) with NO exception on the codesigned binary — and it ran WITHOUT crashing, rumble
|
||||
and all, even with a live AVAudioEngine stream running concurrently. CoreHaptics simply
|
||||
tolerates the denied audioanalyticsd lookup (it's telemetry, not a hard precondition). So
|
||||
controller rumble works fully sandboxed with none of these exceptions. Do NOT re-add one —
|
||||
it will be rejected again AND it buys nothing. (DualSense rumble separately goes over raw
|
||||
HID via device.usb/device.bluetooth — CoreHaptics genuinely doesn't drive Sony motors on
|
||||
macOS — but that path needs no exception either; see DualSenseHID.) -->
|
||||
|
||||
<!-- Keychain Sharing (unchanged from the shared file): a team-scoped access group so the
|
||||
punktfunk/1 client identity in the data-protection keychain is gated by the app's
|
||||
|
||||
@@ -11,5 +11,22 @@
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
||||
</array>
|
||||
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
|
||||
entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to
|
||||
broadcast/multicast addresses unless the app carries this managed entitlement — it must
|
||||
be requested from and approved by Apple for the App ID, then enabled in the provisioning
|
||||
profile. macOS is not gated by this (its App Sandbox network.client/server cover it).
|
||||
|
||||
GATED pending Apple's approval of the request (form filed) — an unauthorized managed
|
||||
entitlement breaks iOS/tvOS signing, so it's commented out to keep those apps releasable.
|
||||
ON APPROVAL: (1) uncomment the two lines below, and (2) flip
|
||||
PunktfunkConnection.wakeOnLANAvailable (PunktfunkConnection.swift) to enable the iOS/tvOS
|
||||
wake path + UI. Until then iOS/tvOS Wake-on-LAN is a clean no-op — MACs are still learned
|
||||
from mDNS so it works immediately once ungated. macOS is unaffected (separate entitlements
|
||||
file, no multicast entitlement needed). -->
|
||||
<!--
|
||||
<key>com.apple.developer.networking.multicast</key>
|
||||
<true/>
|
||||
-->
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -365,6 +365,7 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
@@ -399,6 +400,7 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
|
||||
@@ -52,6 +52,9 @@ struct ContentView: View {
|
||||
@State private var awaitingApproval: ApprovalRequest?
|
||||
@State private var speedTestTarget: StoredHost?
|
||||
@State private var libraryTarget: StoredHost?
|
||||
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
||||
/// "Waking…" overlay). macOS-only in practice — WoL is gated off on iOS/tvOS.
|
||||
@StateObject private var waker = HostWaker()
|
||||
#if !os(macOS)
|
||||
@State private var showSettings = false
|
||||
#endif
|
||||
@@ -212,12 +215,18 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var home: some View {
|
||||
// The "Waking…" overlay rides over BOTH home UIs (and the pre-connect window is still
|
||||
// `home`, so it covers the whole wake→online→connect sequence).
|
||||
homeBase.overlay { WakeOverlay(waker: waker) }
|
||||
}
|
||||
|
||||
@ViewBuilder private var homeBase: some View {
|
||||
#if os(macOS)
|
||||
Group {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||
} else {
|
||||
HomeView(
|
||||
@@ -225,7 +234,7 @@ struct ContentView: View {
|
||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
@@ -233,7 +242,7 @@ struct ContentView: View {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||
} else {
|
||||
HomeView(
|
||||
@@ -242,7 +251,7 @@ struct ContentView: View {
|
||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||
showSettings: $showSettings,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||
}
|
||||
}
|
||||
#else
|
||||
@@ -252,7 +261,7 @@ struct ContentView: View {
|
||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||
showSettings: $showSettings,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -326,15 +335,21 @@ struct ContentView: View {
|
||||
onCaptureChange: { [weak model] captured in
|
||||
model?.mouseCaptured = captured
|
||||
},
|
||||
onFrame: { [meter = model.meter, latency = model.latency, offset = conn.clockOffsetNs] au in
|
||||
onFrame: { [meter = model.meter, latency = model.latency,
|
||||
split = model.latencySplit, offset = conn.clockOffsetNs] au in
|
||||
meter.note(byteCount: au.data.count)
|
||||
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
||||
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
||||
// host/network split — drained by the 1 s stats tick).
|
||||
split.recordReceipt(
|
||||
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
|
||||
},
|
||||
onSessionEnd: { [weak model] in
|
||||
Task { @MainActor in model?.sessionEnded() }
|
||||
},
|
||||
presentMeter: model.presentLatency,
|
||||
presentTailMeter: model.presentTail
|
||||
endToEndMeter: model.endToEnd,
|
||||
decodeMeter: model.decodeStage,
|
||||
displayMeter: model.displayStage
|
||||
)
|
||||
.overlay(alignment: placement.alignment) {
|
||||
if captureEnabled && hudEnabled {
|
||||
@@ -400,8 +415,37 @@ struct ContentView: View {
|
||||
/// delegated-approval connect (host parks it until the operator approves).
|
||||
private func startSession(
|
||||
_ host: StoredHost, launchID: String? = nil,
|
||||
allowTofu: Bool, requestAccess: Bool = false
|
||||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
|
||||
) {
|
||||
let go = {
|
||||
startSessionDirect(
|
||||
host, launchID: launchID, allowTofu: allowTofu,
|
||||
requestAccess: requestAccess, approvalReq: approvalReq)
|
||||
}
|
||||
// Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come
|
||||
// back online — a cold box takes far longer to boot than a connect will sit — showing the
|
||||
// "Waking…" overlay meanwhile. Then connect. Otherwise dial straight away.
|
||||
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||
discovery.start() // so we can observe it reappear
|
||||
waker.start(
|
||||
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
||||
isOnline: { discovery.advertises(host) }, onOnline: go)
|
||||
} else {
|
||||
go()
|
||||
}
|
||||
}
|
||||
|
||||
/// The actual dial — reached directly when the host is awake, or from the waker once a woken
|
||||
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
|
||||
/// is advertising (and is a harmless no-op otherwise).
|
||||
private func startSessionDirect(
|
||||
_ host: StoredHost, launchID: String? = nil,
|
||||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
|
||||
) {
|
||||
prepareWake(for: host)
|
||||
// The delegated-approval wait prompt only makes sense once we're actually dialing — set it
|
||||
// here (after any wake), not before, so it never stacks under the "Waking…" overlay.
|
||||
if let approvalReq { awaitingApproval = approvalReq }
|
||||
model.connect(
|
||||
to: host,
|
||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||
@@ -420,18 +464,49 @@ struct ContentView: View {
|
||||
requestAccess: requestAccess)
|
||||
}
|
||||
|
||||
/// Learn-while-awake, wake-while-asleep — run just before every connect:
|
||||
/// • host currently advertising (awake) → refresh its stored Wake-on-LAN MAC(s) from the live
|
||||
/// advert, so a later wake has an up-to-date target;
|
||||
/// • host NOT advertising (likely asleep/off) and we have MAC(s) → fire a magic packet first.
|
||||
/// The connect that follows already retries/times out long enough for a woken host to come
|
||||
/// up; if it's genuinely off/unreachable the connect fails as before. Best-effort and
|
||||
/// non-blocking (the send runs off the main thread).
|
||||
private func prepareWake(for host: StoredHost) {
|
||||
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
||||
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
||||
} else if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
||||
let macs = host.wakeMacs
|
||||
let ip = host.address
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The no-PIN delegated-approval flow: open an identified connect the host parks until the
|
||||
/// operator approves it in the console, showing the cancelable "Waiting for approval" prompt
|
||||
/// meanwhile. On success the SAME connection is admitted (no reconnect) and the host is pinned
|
||||
/// as paired (see the `.streaming` branch of `onChange`).
|
||||
private func requestAccess(_ req: ApprovalRequest) {
|
||||
guard !model.isBusy else { return }
|
||||
awaitingApproval = req
|
||||
// Pin the advertised certificate for a discovered host (impostor defence during the long
|
||||
// wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||
var host = req.host
|
||||
host.pinnedSHA256 = req.advertisedFingerprint
|
||||
startSession(host, allowTofu: false, requestAccess: true)
|
||||
// `awaitingApproval` is set inside startSessionDirect (after any wake), so it never stacks
|
||||
// under the "Waking…" overlay.
|
||||
startSession(host, allowTofu: false, requestAccess: true, approvalReq: req)
|
||||
}
|
||||
|
||||
/// Explicit wake-only (the touch card's "Wake Host" menu item / a future gamepad action): fire
|
||||
/// the packet and wait for the host to come online, but don't connect — the user then sees it
|
||||
/// go online and can connect.
|
||||
private func wakeOnly(_ host: StoredHost) {
|
||||
guard PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty else { return }
|
||||
discovery.start()
|
||||
waker.start(
|
||||
host: host, connectsAfter: false, macs: host.wakeMacs, lastIP: host.address,
|
||||
isOnline: { discovery.advertises(host) }, onOnline: {})
|
||||
}
|
||||
|
||||
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
||||
@@ -449,7 +524,9 @@ struct ContentView: View {
|
||||
/// inside `connect`.)
|
||||
private func connectDiscovered(_ d: DiscoveredHost) {
|
||||
guard !model.isBusy else { return }
|
||||
let host = StoredHost(name: d.name, address: d.host, port: d.port)
|
||||
let host = StoredHost(
|
||||
name: d.name, address: d.host, port: d.port,
|
||||
macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses)
|
||||
store.add(host)
|
||||
if d.allowsTofu {
|
||||
connect(host, allowTofu: true)
|
||||
|
||||
@@ -1,67 +1,87 @@
|
||||
// "+" sheet: name (optional) + address + port → a card in the hosts grid. The first
|
||||
// actual connection runs the trust-on-first-use fingerprint prompt.
|
||||
// Add / edit a host: name (optional) + address + port + Wake-on-LAN MAC → a card in the grid.
|
||||
// The MAC prefills from what we already know — the host's stored MAC, or the live mDNS advert's if
|
||||
// it hasn't been learned yet — so it's usually already correct; type/paste it for a host we've
|
||||
// never seen advertise. The first actual connection still runs the trust-on-first-use prompt.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct AddHostSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var address = ""
|
||||
@State private var port = 9777
|
||||
|
||||
/// nil = add a new host; non-nil = edit this one (fields prefilled, identity/pin preserved).
|
||||
let existing: StoredHost?
|
||||
/// MAC(s) to offer when the host has none stored yet — the live advert's, so the field is
|
||||
/// prefilled the moment the host is on the network, even before a connect has learned it.
|
||||
let suggestedMacs: [String]
|
||||
let onSave: (StoredHost) -> Void
|
||||
|
||||
@State private var name: String
|
||||
@State private var address: String
|
||||
@State private var port: Int
|
||||
@State private var mac: String
|
||||
#if os(tvOS)
|
||||
private enum EditField: String, Identifiable {
|
||||
case name, address, port
|
||||
case name, address, port, mac
|
||||
var id: String { rawValue }
|
||||
}
|
||||
@State private var editing: EditField?
|
||||
@State private var editingField: EditField?
|
||||
#endif
|
||||
|
||||
let onAdd: (StoredHost) -> Void
|
||||
private var isEditing: Bool { existing != nil }
|
||||
private var actionTitle: String { isEditing ? "Save" : "Add Host" }
|
||||
private var canSave: Bool { !address.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
|
||||
init(existing: StoredHost? = nil, suggestedMacs: [String] = [], onSave: @escaping (StoredHost) -> Void) {
|
||||
self.existing = existing
|
||||
self.suggestedMacs = suggestedMacs
|
||||
self.onSave = onSave
|
||||
_name = State(initialValue: existing?.name ?? "")
|
||||
_address = State(initialValue: existing?.address ?? "")
|
||||
_port = State(initialValue: Int(existing?.port ?? 9777))
|
||||
let stored = existing?.macAddresses ?? []
|
||||
_mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", "))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
// No inline text editing on tvOS — Settings-style value rows; pressing one
|
||||
// raises the SYSTEM fullscreen keyboard (TVTextEntry).
|
||||
VStack(spacing: 24) {
|
||||
TVFieldRow(
|
||||
label: "Name", value: name, placeholder: "Optional"
|
||||
) { editing = .name }
|
||||
TVFieldRow(
|
||||
label: "Address", value: address, placeholder: "IP or hostname"
|
||||
) { editing = .address }
|
||||
TVFieldRow(
|
||||
label: "Port", value: String(port), placeholder: ""
|
||||
) { editing = .port }
|
||||
TVFieldRow(label: "Name", value: name, placeholder: "Optional") { editingField = .name }
|
||||
TVFieldRow(label: "Address", value: address, placeholder: "IP or hostname") { editingField = .address }
|
||||
TVFieldRow(label: "Port", value: String(port), placeholder: "") { editingField = .port }
|
||||
TVFieldRow(label: "MAC", value: mac, placeholder: "Wake-on-LAN — auto-filled when known") { editingField = .mac }
|
||||
HStack(spacing: 32) {
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
Button("Add Host") { add() }
|
||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
Button(actionTitle) { save() }.disabled(!canSave)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
.padding(60)
|
||||
.navigationTitle("Add Host")
|
||||
.fullScreenCover(item: $editing) { field in
|
||||
.navigationTitle(isEditing ? "Edit Host" : "Add Host")
|
||||
.fullScreenCover(item: $editingField) { field in
|
||||
switch field {
|
||||
case .name:
|
||||
TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) {
|
||||
name = $0
|
||||
editing = nil
|
||||
editingField = nil
|
||||
}
|
||||
case .address:
|
||||
TVTextEntry(title: "IP or hostname", text: address) {
|
||||
address = $0.trimmingCharacters(in: .whitespaces)
|
||||
editing = nil
|
||||
editingField = nil
|
||||
}
|
||||
case .port:
|
||||
TVTextEntry(
|
||||
title: "Port", text: String(port), keyboardType: .numberPad
|
||||
) {
|
||||
if let value = Int($0), (1...65535).contains(value) {
|
||||
port = value
|
||||
}
|
||||
editing = nil
|
||||
TVTextEntry(title: "Port", text: String(port), keyboardType: .numberPad) {
|
||||
if let value = Int($0), (1...65535).contains(value) { port = value }
|
||||
editingField = nil
|
||||
}
|
||||
case .mac:
|
||||
TVTextEntry(title: "MAC address(es), comma-separated — aa:bb:cc:dd:ee:ff", text: mac) {
|
||||
mac = $0.trimmingCharacters(in: .whitespaces)
|
||||
editingField = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,77 +91,77 @@ struct AddHostSheet: View {
|
||||
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
|
||||
TextField("Address", text: $address, prompt: Text("IP or hostname"))
|
||||
TextField("Port", value: $port, format: .number.grouping(.never))
|
||||
#if os(tvOS)
|
||||
// tvOS floats the label above a non-empty field INSIDE the pill,
|
||||
// shoving the value off-center — the field is always prefilled
|
||||
// here, so drop the label there.
|
||||
.labelsHidden()
|
||||
TextField("MAC", text: $mac, prompt: Text("Wake-on-LAN — auto-filled when known"))
|
||||
.autocorrectionDisabled()
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
.formStyle(.grouped)
|
||||
// The grouped form's default system text is oversized next to the app's Geist
|
||||
// typography — bring it down and on-brand so the panel doesn't read out of place.
|
||||
.font(.geist(12, relativeTo: .callout))
|
||||
.controlSize(.small)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The detent below is sized to fit all 3 rows + the action button exactly, so the
|
||||
// Form must NOT scroll/bounce inside it — lock it. (iOS 16+; safe at iOS 17.)
|
||||
.scrollDisabled(true)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// macOS: UNCHANGED — Cancel + Spacer + Add in an HStack, both wired to the
|
||||
// window's default/cancel keyboard actions. The 380-wide .fixedSize panel below
|
||||
// keeps this compact and centered.
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Add Host") { add() }
|
||||
Button(actionTitle) { save() }
|
||||
.glassProminentButtonStyle()
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.disabled(!canSave)
|
||||
}
|
||||
.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)
|
||||
Button { save() } label: {
|
||||
Text(actionTitle).frame(maxWidth: .infinity)
|
||||
}
|
||||
.glassProminentButtonStyle()
|
||||
.controlSize(.large)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.disabled(!canSave)
|
||||
.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). The Form itself is .scrollDisabled (above) so it can't
|
||||
// bounce/scroll inside this fixed detent. (.height(_:) is iOS 16+, safe at iOS 17.)
|
||||
.presentationDetents([.height(320)])
|
||||
// Four fields + the action row — a touch taller than the 3-field add sheet used to be.
|
||||
.presentationDetents([.height(392)])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.frame(width: 380)
|
||||
.frame(width: 400)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
private func add() {
|
||||
onAdd(StoredHost(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
address: address.trimmingCharacters(in: .whitespaces),
|
||||
port: UInt16(clamping: port)))
|
||||
private func save() {
|
||||
var host = existing ?? StoredHost(name: "", address: "")
|
||||
host.name = name.trimmingCharacters(in: .whitespaces)
|
||||
host.address = address.trimmingCharacters(in: .whitespaces)
|
||||
host.port = UInt16(clamping: port)
|
||||
host.macAddresses = Self.parseMacs(mac)
|
||||
onSave(host)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
/// Split comma/space/newline-separated MACs, keep only well-formed `aa:bb:cc:dd:ee:ff` (six hex
|
||||
/// octets, normalized lower-case); nil when none are valid, so clearing the field clears the
|
||||
/// stored MAC.
|
||||
static func parseMacs(_ s: String) -> [String]? {
|
||||
let macs = s
|
||||
.split(whereSeparator: { ",; \n\t".contains($0) })
|
||||
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
||||
.filter { m in
|
||||
let parts = m.split(separator: ":")
|
||||
return parts.count == 6 && parts.allSatisfy { $0.count == 2 && UInt8($0, radix: 16) != nil }
|
||||
}
|
||||
return macs.isEmpty ? nil : macs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,16 +58,19 @@ struct GamepadAddHostView: View {
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .topTrailing) { closeButton.padding(.trailing, 20) }
|
||||
.overlay(alignment: .topTrailing) { closeButton.padding(.top, 20).padding(.trailing, 20) }
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
bottomTray
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||
.padding(.horizontal, compact ? 12 : 18)
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
.background { GamepadTrayScrim(edge: .bottom) }
|
||||
}
|
||||
.background { GamepadScreenBackground() }
|
||||
// No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
|
||||
.background { GamepadFormBackground() }
|
||||
// A port can't exceed 5 digits — cap while typing so the row can't grow absurd.
|
||||
.onChange(of: port) { _, value in
|
||||
if value.count > 5 { port = String(value.prefix(5)) }
|
||||
@@ -165,14 +168,16 @@ struct GamepadAddHostView: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 13)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.white.opacity(focused || editing == row.id ? 0.1 : 0))
|
||||
}
|
||||
// Liquid Glass rows, matching the settings screen; the focused (or actively edited) row
|
||||
// takes the brand wash, and the edited row keeps its brand caret border.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
||||
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.22 : 0),
|
||||
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
|
||||
lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(focused ? 1.0 : 0.98)
|
||||
|
||||
@@ -39,7 +39,9 @@ struct GamepadHint: Identifiable {
|
||||
}
|
||||
|
||||
/// The pinned controls legend every gamepad screen shows bottom-leading (via `.safeAreaInset`).
|
||||
/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration.
|
||||
/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration —
|
||||
/// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over
|
||||
/// the backdrop instead of dissolving into it.
|
||||
struct GamepadHintBar: View {
|
||||
let hints: [GamepadHint]
|
||||
|
||||
@@ -57,39 +59,141 @@ struct GamepadHintBar: View {
|
||||
}
|
||||
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.padding(13)
|
||||
.consoleGlass(Capsule())
|
||||
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// The console backdrop: a living "aurora" field in the brand's violet family — soft color blobs
|
||||
/// drifting on slow Lissajous paths over black, in the direction of Apple Music's animated player
|
||||
/// background but calmer (long 30–90 s periods, muted opacities, a legibility scrim on top, so it
|
||||
/// reads as ambience behind the cards, never as content). Deliberately pure SwiftUI rather than a
|
||||
/// .metal shader: these sources are built both by SwiftPM (`swift run`/tests) and by the Xcode
|
||||
/// project's synchronized folders, and a compiled metallib is only reliably bundled in one of the
|
||||
/// two — radial gradients driven by a TimelineView give the same look with none of that risk.
|
||||
/// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black
|
||||
/// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an
|
||||
/// animated `MeshGradient` — a continuous silk of colour whose control points wander on slow,
|
||||
/// out-of-phase sinusoids — finished with an elliptical vignette (pools light in the centre, sinks
|
||||
/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting
|
||||
/// radial-blob field, unchanged, so nothing regresses.
|
||||
///
|
||||
/// Applied via `.background { }` — NOT as a ZStack sibling — so the `.ignoresSafeArea()` here
|
||||
/// can't inflate the caller's layout past the safe area (see the layout discipline note in
|
||||
/// GamepadHomeView's header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||
/// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/
|
||||
/// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably
|
||||
/// bundled in one of the two. MeshGradient + TimelineView give the silky look with none of that
|
||||
/// risk. Applied via `.background { }` — NOT a ZStack sibling — so the `.ignoresSafeArea()` here
|
||||
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
|
||||
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||
struct GamepadScreenBackground: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular
|
||||
/// speeds (rad/s — periods of 30–90 s), and a radius that slowly breathes.
|
||||
var body: some View {
|
||||
Group {
|
||||
if reduceMotion {
|
||||
composite(at: 0)
|
||||
} else {
|
||||
// 30 Hz is plenty for a field that drifts centimetres per minute, and halves the
|
||||
// redraw cost of a battery-fed couch device vs. the display's native rate.
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||||
composite(at: context.date.timeIntervalSinceReferenceDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
/// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the
|
||||
/// title/hints legibility scrim.
|
||||
private func composite(at t: TimeInterval) -> some View {
|
||||
ZStack {
|
||||
Color.black
|
||||
colorField(at: t)
|
||||
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
||||
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
||||
// Cinematic vignette: darker toward the edges so the cards sit in the pooled light.
|
||||
// Soft (extends past the frame) so the corners deepen rather than crush to black.
|
||||
EllipticalGradient(
|
||||
colors: [.clear, .black.opacity(0.42)],
|
||||
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
|
||||
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
|
||||
// darkens the aurora itself (it's the backdrop's bottom layer — nothing behind it to
|
||||
// blur), so it stays a gradient, just a light one now.
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black.opacity(0.38), location: 0),
|
||||
.init(color: .black.opacity(0.06), location: 0.32),
|
||||
.init(color: .black.opacity(0.08), location: 0.68),
|
||||
.init(color: .black.opacity(0.40), location: 1),
|
||||
],
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func colorField(at t: TimeInterval) -> some View {
|
||||
if #available(iOS 18, macOS 15, tvOS 18, *) {
|
||||
MeshGradient(
|
||||
width: 4, height: 4,
|
||||
points: Self.meshPoints(at: t),
|
||||
colors: Self.meshColors,
|
||||
smoothsColors: true)
|
||||
} else {
|
||||
LegacyBlobField(t: t)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MeshGradient aurora (iOS 18 / macOS 15+)
|
||||
|
||||
/// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry
|
||||
/// mid-tone violets, and the four interior points hold the bright brand family — a violet and a
|
||||
/// blue-violet up top, a magenta-violet and a violet below — so warm pools on the left, cool on
|
||||
/// the right, and the silk shifts temperature as those interior points drift.
|
||||
private static let meshColors: [Color] = {
|
||||
let corner = Color(red: 0.075, green: 0.060, blue: 0.160)
|
||||
return [
|
||||
corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner,
|
||||
Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64),
|
||||
Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70),
|
||||
corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner,
|
||||
]
|
||||
}()
|
||||
|
||||
/// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh
|
||||
/// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black
|
||||
/// behind it), while only the four interior points wander on slow, out-of-phase sinusoids
|
||||
/// (periods ~90–130 s) so the bright colour pools breathe without ever looking like they loop.
|
||||
private static func meshPoints(at t: TimeInterval) -> [SIMD2<Float>] {
|
||||
func wob(_ bx: Float, _ by: Float, _ a: Float,
|
||||
_ sx: Double, _ sy: Double, _ ph: Double) -> SIMD2<Float> {
|
||||
SIMD2(bx + a * Float(sin(t * sx + ph)), by + a * Float(cos(t * sy + ph * 1.3)))
|
||||
}
|
||||
return [
|
||||
SIMD2(0, 0), SIMD2(0.333, 0), SIMD2(0.667, 0), SIMD2(1, 0),
|
||||
SIMD2(0, 0.333),
|
||||
wob(0.333, 0.333, 0.11, 0.049, 0.063, 0.4),
|
||||
wob(0.667, 0.333, 0.10, 0.055, 0.052, 2.1),
|
||||
SIMD2(1, 0.333),
|
||||
SIMD2(0, 0.667),
|
||||
wob(0.333, 0.667, 0.10, 0.058, 0.049, 3.6),
|
||||
wob(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
|
||||
SIMD2(1, 0.667),
|
||||
SIMD2(0, 1), SIMD2(0.333, 1), SIMD2(0.667, 1), SIMD2(1, 1),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field — four
|
||||
/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see
|
||||
/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+).
|
||||
private struct LegacyBlobField: View {
|
||||
let t: TimeInterval
|
||||
|
||||
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds
|
||||
/// (rad/s — periods of 30–90 s), and a radius that slowly breathes.
|
||||
private struct Blob {
|
||||
let color: Color
|
||||
let center: CGPoint
|
||||
let drift: CGSize
|
||||
let speed: (x: Double, y: Double)
|
||||
let phase: (x: Double, y: Double)
|
||||
/// Radius as a fraction of the view's larger dimension (+ breathing amplitude/speed).
|
||||
let radius: CGFloat
|
||||
let breathe: (amount: CGFloat, speed: Double)
|
||||
let opacity: Double
|
||||
}
|
||||
|
||||
/// The brand violet, a deeper indigo, a warmer plum, and a cool blue — related hues so the
|
||||
/// field shifts within one temperature instead of strobing through the rainbow.
|
||||
private static let blobs: [Blob] = [
|
||||
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
|
||||
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
|
||||
@@ -110,49 +214,18 @@ struct GamepadScreenBackground: View {
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if reduceMotion {
|
||||
field(at: 0)
|
||||
} else {
|
||||
// 30 Hz is plenty for centimeters-per-minute drift, and halves the redraw cost
|
||||
// of a battery-fed couch device vs. the default display rate.
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||||
field(at: context.date.timeIntervalSinceReferenceDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
private func field(at t: TimeInterval) -> some View {
|
||||
GeometryReader { geo in
|
||||
let side = max(geo.size.width, geo.size.height)
|
||||
ZStack {
|
||||
Color.black
|
||||
ZStack {
|
||||
ForEach(Self.blobs.indices, id: \.self) { i in
|
||||
blobView(Self.blobs[i], at: t, in: geo.size, side: side)
|
||||
}
|
||||
ForEach(Self.blobs.indices, id: \.self) { i in
|
||||
blobView(Self.blobs[i], in: geo.size, side: side)
|
||||
}
|
||||
// ±10° over ~5 min — the whole field very slowly warms and cools.
|
||||
.hueRotation(.degrees(sin(t * 0.021) * 10))
|
||||
// Composite the additive blobs offscreen once instead of per-layer.
|
||||
.drawingGroup()
|
||||
// Legibility scrim: the title (top) and detail/hints (bottom) always sit on
|
||||
// near-black, whatever the blobs are doing behind them.
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black.opacity(0.55), location: 0),
|
||||
.init(color: .black.opacity(0.15), location: 0.35),
|
||||
.init(color: .black.opacity(0.20), location: 0.65),
|
||||
.init(color: .black.opacity(0.60), location: 1),
|
||||
],
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
.drawingGroup()
|
||||
}
|
||||
}
|
||||
|
||||
private func blobView(_ blob: Blob, at t: TimeInterval, in size: CGSize, side: CGFloat) -> some View {
|
||||
private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View {
|
||||
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
|
||||
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
|
||||
let r = side * blob.radius
|
||||
@@ -168,28 +241,62 @@ struct GamepadScreenBackground: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A darkening scrim behind a pinned tray (a screen title, the hints/detail bar, the keyboard
|
||||
/// tray): scrollable rows pass beneath those insets, so without this the tray text and the row
|
||||
/// underneath render interleaved. Fades toward the content so it reads as depth, not a bar.
|
||||
/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray):
|
||||
/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath
|
||||
/// render interleaved. Pure blur — a dark material faded out by a gradient mask, no dark tint — so
|
||||
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
|
||||
struct GamepadTrayScrim: View {
|
||||
let edge: VerticalEdge
|
||||
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black.opacity(0.92), location: 0),
|
||||
.init(color: .black.opacity(0.85), location: 0.55),
|
||||
.init(color: .black.opacity(0), location: 1),
|
||||
],
|
||||
startPoint: edge == .top ? .top : .bottom,
|
||||
endPoint: edge == .top ? .bottom : .top)
|
||||
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
|
||||
let toContent: UnitPoint = edge == .top ? .bottom : .top
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
// These trays always sit on the dark console UI; force dark so the material frosts dark
|
||||
// (white text stays legible) regardless of the system appearance.
|
||||
.environment(\.colorScheme, .dark)
|
||||
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
|
||||
.mask {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black, location: 0),
|
||||
.init(color: .black.opacity(0.9), location: 0.5),
|
||||
.init(color: .clear, location: 1),
|
||||
],
|
||||
startPoint: fromEdge, endPoint: toContent)
|
||||
}
|
||||
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own
|
||||
// text always sits on the near-opaque part, rows dim before they reach it.
|
||||
// text always sits on the strong part, rows blur out before they reach it.
|
||||
.padding(edge == .top ? .bottom : .top, -32)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
/// The calm backdrop for the gamepad UI's form screens (settings, add-host) — NOT the launcher's
|
||||
/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid
|
||||
/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo
|
||||
/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens,
|
||||
/// so the rows read as glass while the screen stays restful.
|
||||
struct GamepadFormBackground: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(red: 0.075, green: 0.062, blue: 0.150)
|
||||
// Violet lift top-leading, cooler indigo bottom-trailing — resolution-independent
|
||||
// (fraction radii) so the glow scale tracks the window on any screen.
|
||||
EllipticalGradient(
|
||||
colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear],
|
||||
center: UnitPoint(x: 0.26, y: 0.14),
|
||||
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||||
EllipticalGradient(
|
||||
colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear],
|
||||
center: UnitPoint(x: 0.82, y: 0.9),
|
||||
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet
|
||||
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
||||
/// when the pad or its battery state changes.
|
||||
|
||||
@@ -44,8 +44,8 @@ private struct HomeTile: Identifiable {
|
||||
var hasLibrary = false
|
||||
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
|
||||
var icon: String?
|
||||
/// Whether the detail panel shows the online/paired pill (hosts yes, actions no).
|
||||
var showsStatus = true
|
||||
/// Offline saved host we hold a MAC for (and WoL is available) — activating it wakes first.
|
||||
var canWake = false
|
||||
let activate: () -> Void
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ struct GamepadHomeView: View {
|
||||
@ObservedObject var model: SessionModel
|
||||
@ObservedObject var discovery: HostDiscovery
|
||||
@Binding var libraryTarget: StoredHost?
|
||||
/// Wake-and-wait driver — gates the carousel while its overlay is up, and the carousel's
|
||||
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
|
||||
@ObservedObject var waker: HostWaker
|
||||
let connect: (StoredHost) -> Void
|
||||
let connectDiscovered: (DiscoveredHost) -> Void
|
||||
|
||||
@@ -84,8 +87,11 @@ struct GamepadHomeView: View {
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
GamepadHintBar(hints: hints)
|
||||
.padding(.leading, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
// Equal distance from the left and bottom edges — the pill's corner inset was the
|
||||
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 4 : 8)
|
||||
}
|
||||
.background { GamepadScreenBackground() }
|
||||
.onAppear { discovery.start() }
|
||||
@@ -115,13 +121,13 @@ struct GamepadHomeView: View {
|
||||
|
||||
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
||||
let cardWidth = min(340, size.width * 0.84)
|
||||
// 96 ≈ the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so
|
||||
// the strip + detail always fit the region the safe-area insets leave.
|
||||
let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96))
|
||||
// 48 ≈ the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
|
||||
// always fits the region the pinned title / hints safe-area insets leave. (The old detail
|
||||
// line below the strip is gone — it only re-printed what the centered card already shows.)
|
||||
let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
|
||||
VStack(spacing: compact ? 8 : 10) {
|
||||
Spacer(minLength: 0)
|
||||
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
||||
detailPanel
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -155,9 +161,9 @@ struct GamepadHomeView: View {
|
||||
onActivate: { $0.activate() },
|
||||
onSecondary: { openLibraryForSelected() },
|
||||
onTertiary: { showSettings = true },
|
||||
// Stop consuming the controller while another screen is presented on top — otherwise
|
||||
// the launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet).
|
||||
isActive: libraryTarget == nil && !showSettings && !showAddHost
|
||||
// Stop consuming the controller while another screen (or the wake overlay) is on top —
|
||||
// otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad).
|
||||
isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil
|
||||
) { tile in
|
||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
||||
}
|
||||
@@ -186,49 +192,14 @@ struct GamepadHomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The "now focused" host, spelled out below the strip — empty (not hidden) so the layout
|
||||
/// doesn't jump as the selection changes.
|
||||
@ViewBuilder private var detailPanel: some View {
|
||||
let tile = tiles.first { $0.id == selection }
|
||||
VStack(spacing: 6) {
|
||||
Text(tile?.title ?? " ")
|
||||
.font(.geist(22, .bold, relativeTo: .title2))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 10) {
|
||||
Text(tile?.subtitle ?? " ")
|
||||
.font(.geist(13, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
if let tile, tile.showsStatus {
|
||||
statusPill(online: tile.isOnline, paired: tile.isPaired)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 24)
|
||||
.animation(.smooth(duration: 0.25), value: selection)
|
||||
}
|
||||
|
||||
private func statusPill(online: Bool, paired: Bool) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(online ? Color.green : Color.white.opacity(0.35))
|
||||
.frame(width: 6, height: 6)
|
||||
Text(online ? "ONLINE" : "OFFLINE")
|
||||
if paired { Text("· PAIRED") }
|
||||
}
|
||||
.font(.geist(11, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
}
|
||||
|
||||
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
let selected = tiles.first { $0.id == selection }
|
||||
var hints = [GamepadHint(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
||||
text: selected?.id == .addHost ? "Add Host" : "Connect")]
|
||||
text: selected?.id == .addHost ? "Add Host"
|
||||
: (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
||||
if libraryEnabled, selected?.hasLibrary == true {
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
||||
}
|
||||
@@ -252,6 +223,8 @@ struct GamepadHomeView: View {
|
||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||
filled: true,
|
||||
hasLibrary: true,
|
||||
canWake: PunktfunkConnection.wakeOnLANAvailable
|
||||
&& !discovery.advertises(host) && !host.wakeMacs.isEmpty,
|
||||
activate: { connect(host) })
|
||||
}
|
||||
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
||||
@@ -267,7 +240,6 @@ struct GamepadHomeView: View {
|
||||
title: "Add Host",
|
||||
subtitle: "Register a host by address",
|
||||
icon: "plus",
|
||||
showsStatus: false,
|
||||
activate: { showAddHost = true })
|
||||
return saved + discovered + [add]
|
||||
}
|
||||
@@ -291,14 +263,23 @@ private struct GamepadHostTile: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
monogramBadge
|
||||
Spacer(minLength: 0)
|
||||
if tile.isOnline {
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 9, height: 9)
|
||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||
// The status the removed detail panel used to spell out, now on the card itself: a
|
||||
// lock for a paired (pinned-identity) host + a green pip when it's live on the LAN.
|
||||
HStack(spacing: 7) {
|
||||
if tile.isPaired {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
if tile.isOnline {
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 9, height: 9)
|
||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
@@ -315,11 +296,11 @@ private struct GamepadHostTile: View {
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
|
||||
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous),
|
||||
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.strokeBorder(
|
||||
@@ -328,7 +309,6 @@ private struct GamepadHostTile: View {
|
||||
startPoint: .top, endPoint: .bottom),
|
||||
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,13 @@ struct HomeView: View {
|
||||
let onPaired: (StoredHost, Data) -> Void
|
||||
/// Picked a title in the (experimental) library — start a session that launches it.
|
||||
let onLaunchTitle: (StoredHost, String) -> Void
|
||||
/// Explicit Wake-on-LAN of an offline host — fires the packet and waits for it to come online
|
||||
/// (the "Waking…" overlay), without connecting. Routed through ContentView's HostWaker.
|
||||
let wake: (StoredHost) -> Void
|
||||
/// Experimental game-library browser (gated) — the host-card "Browse Library…" action.
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
|
||||
@State private var editTarget: StoredHost?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -126,6 +131,13 @@ struct HomeView: View {
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
.sheet(item: $editTarget) { host in
|
||||
// Prefill the MAC from the live advert when the host hasn't stored one yet.
|
||||
AddHostSheet(
|
||||
existing: host,
|
||||
suggestedMacs: discovery.hosts.first { host.matches($0) }?.macAddresses ?? [],
|
||||
onSave: { store.update($0) })
|
||||
}
|
||||
#if os(iOS)
|
||||
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
|
||||
// is presented directly — wrapping it in a NavigationStack here would nest a split view in
|
||||
@@ -154,7 +166,9 @@ struct HomeView: View {
|
||||
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
|
||||
onForget: { store.forgetIdentity(host) },
|
||||
onRemove: { store.remove(host) },
|
||||
onBrowseLibrary: onBrowseLibrary)
|
||||
onBrowseLibrary: onBrowseLibrary,
|
||||
onWake: { wake(host) },
|
||||
onEdit: { editTarget = host })
|
||||
}
|
||||
|
||||
private var discoveredSection: some View {
|
||||
|
||||
@@ -86,6 +86,11 @@ struct HostCardView: View {
|
||||
let onRemove: () -> Void
|
||||
/// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
|
||||
var onBrowseLibrary: (() -> Void)? = nil
|
||||
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
|
||||
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
|
||||
var onWake: (() -> Void)? = nil
|
||||
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
|
||||
var onEdit: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
@@ -133,11 +138,17 @@ struct HostCardView: View {
|
||||
#endif
|
||||
.disabled(isBusy)
|
||||
.contextMenu {
|
||||
if let onEdit {
|
||||
Button("Edit…", systemImage: "pencil", action: onEdit)
|
||||
}
|
||||
Button("Pair with PIN…", action: onPair)
|
||||
Button("Test Network Speed…", action: onSpeedTest)
|
||||
if let onBrowseLibrary {
|
||||
Button("Browse Library…", action: onBrowseLibrary)
|
||||
}
|
||||
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
|
||||
Button("Wake Host", systemImage: "power", action: onWake)
|
||||
}
|
||||
if host.pinnedSHA256 != nil {
|
||||
// Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via
|
||||
// PIN (unless the host advertises pair=optional). Wording reflects that.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// The "Waking <host>…" modal shown while HostWaker brings a sleeping host back — a spinner + a
|
||||
// live elapsed counter, escalating to a retry/cancel prompt on timeout. Presented over BOTH the
|
||||
// touch and gamepad home (a wake only ever starts on macOS today, where WoL is ungated), and it
|
||||
// drives from either a pointer (the buttons) or a controller (B cancels, A retries once timed out).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct WakeOverlay: View {
|
||||
@ObservedObject var waker: HostWaker
|
||||
|
||||
var body: some View {
|
||||
if let w = waker.waking {
|
||||
ZStack {
|
||||
// Dim + swallow input to the home behind it.
|
||||
Rectangle().fill(.black.opacity(0.6)).ignoresSafeArea()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {}
|
||||
card(w)
|
||||
.frame(maxWidth: 380)
|
||||
.padding(28)
|
||||
.consoleGlass(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
.padding(40)
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
.transition(.opacity)
|
||||
#if os(iOS) || os(macOS)
|
||||
.background { WakeControllerInput(waker: waker) }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func card(_ w: HostWaker.Waking) -> some View {
|
||||
VStack(spacing: 14) {
|
||||
if w.timedOut {
|
||||
Image(systemName: "moon.zzz.fill")
|
||||
.font(.system(size: 34)).foregroundStyle(.white.opacity(0.85))
|
||||
Text("\(w.hostName) didn't wake")
|
||||
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
Text("It may still be booting, or it's powered off / off this network.")
|
||||
.font(.geist(13, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||
.multilineTextAlignment(.center)
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
|
||||
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
|
||||
}
|
||||
.padding(.top, 6)
|
||||
} else {
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
Text("Waking \(w.hostName)…")
|
||||
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
Text("Waiting for it to come online · \(w.seconds)s")
|
||||
.font(.geistFixed(13)).foregroundStyle(.white.opacity(0.6))
|
||||
.monospacedDigit()
|
||||
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// Controller binding for the overlay: B cancels; A retries once it has timed out. A zero-size
|
||||
/// backing view owning a `GamepadMenuInput` for the overlay's lifetime (the home carousel/list is
|
||||
/// gated inactive while a wake is up, so nothing else is consuming the pad).
|
||||
private struct WakeControllerInput: View {
|
||||
@ObservedObject var waker: HostWaker
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.onAppear {
|
||||
input.onBack = { waker.cancel() }
|
||||
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
|
||||
input.start()
|
||||
}
|
||||
.onDisappear { input.stop() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -18,23 +18,47 @@ struct ShotScene {
|
||||
|
||||
@MainActor
|
||||
enum ShotScenes {
|
||||
static let all: [ShotScene] = [
|
||||
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotStreamHero())
|
||||
},
|
||||
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotHome())
|
||||
},
|
||||
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotPair())
|
||||
},
|
||||
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotTrust())
|
||||
},
|
||||
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotSettings())
|
||||
},
|
||||
]
|
||||
static var all: [ShotScene] {
|
||||
var scenes: [ShotScene] = [
|
||||
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotStreamHero())
|
||||
},
|
||||
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotHome())
|
||||
},
|
||||
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotPair())
|
||||
},
|
||||
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotTrust())
|
||||
},
|
||||
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotSettings())
|
||||
},
|
||||
]
|
||||
#if os(iOS) || os(macOS)
|
||||
// The gamepad-mode console screens (no tvOS — native focus engine there). Dev-only shots
|
||||
// for eyeballing the Liquid Glass host tiles + settings rows.
|
||||
scenes += [
|
||||
ShotScene(name: "06-gamepad-home", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotGamepadHome())
|
||||
},
|
||||
ShotScene(name: "07-gamepad-settings", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotGamepadSettings())
|
||||
},
|
||||
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotGamepadAddHost())
|
||||
},
|
||||
ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotWaking())
|
||||
},
|
||||
]
|
||||
#endif
|
||||
scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotEditHost())
|
||||
})
|
||||
return scenes
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock data
|
||||
@@ -75,7 +99,7 @@ private struct ShotHome: View {
|
||||
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||
connect: { _ in }, connectDiscovered: { _ in },
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
|
||||
#else
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
@@ -83,11 +107,77 @@ private struct ShotHome: View {
|
||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||
showSettings: .constant(false),
|
||||
connect: { _ in }, connectDiscovered: { _ in },
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gamepad-mode console screens (dev-only glass preview)
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
private struct ShotGamepadHome: View {
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var waker = HostWaker()
|
||||
|
||||
var body: some View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _ in }, connectDiscovered: { _ in })
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShotGamepadSettings: View {
|
||||
var body: some View { GamepadSettingsView() }
|
||||
}
|
||||
|
||||
private struct ShotGamepadAddHost: View {
|
||||
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
|
||||
}
|
||||
|
||||
private struct ShotWaking: View {
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var waker = HostWaker()
|
||||
|
||||
var body: some View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _ in }, connectDiscovered: { _ in }
|
||||
)
|
||||
.overlay { WakeOverlay(waker: waker) }
|
||||
.onAppear {
|
||||
waker.debugSet(.init(
|
||||
hostID: store.hosts.first?.id ?? UUID(),
|
||||
hostName: "Battlestation", connectsAfter: true, seconds: 14))
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Edit host (add/edit sheet with the Wake-on-LAN MAC field)
|
||||
|
||||
private struct ShotEditHost: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
|
||||
AddHostSheet(
|
||||
existing: StoredHost(
|
||||
name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: ShotMock.fingerprint, macAddresses: ["a4:b1:c2:d3:e4:f5"]),
|
||||
onSave: { _ in })
|
||||
#if os(macOS)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(radius: 40, y: 16)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
private struct ShotSettings: View {
|
||||
@@ -170,7 +260,10 @@ private struct ShotHUD: View {
|
||||
Text("5120×1440@240 240 fps 812.4 Mb/s")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
Text("capture→client 1.3/2.1 ms p50/p95")
|
||||
Text("end-to-end 2.9 ms p50 · 3.8 p95 · capture→on-glass")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("= host+network 1.3 + decode 0.7 + display 0.9")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
#if os(macOS)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// Wake a sleeping host and WAIT for it to come back before proceeding.
|
||||
//
|
||||
// A magic packet is fire-and-forget, and a cold box can take 20–60 s to POST, boot, and start
|
||||
// advertising on mDNS again — far longer than a connect attempt will sit. The old path fired a
|
||||
// packet and immediately dialed, so a genuinely-asleep host just failed. This drives a visible
|
||||
// "Waking…" state instead: it (re-)sends the packet, polls the host's mDNS presence once a second,
|
||||
// and on success runs `onOnline` (the real connect for a Wake-&-Connect, or nothing for an explicit
|
||||
// wake-only); on timeout it parks in a retry/cancel state. One wake at a time.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class HostWaker: ObservableObject {
|
||||
struct Waking: Equatable {
|
||||
let hostID: UUID
|
||||
let hostName: String
|
||||
/// Whether coming online chains into a connect (Wake & Connect) vs. just stopping.
|
||||
let connectsAfter: Bool
|
||||
var seconds = 0
|
||||
var timedOut = false
|
||||
}
|
||||
|
||||
/// nil = idle; non-nil drives `WakeOverlay`.
|
||||
@Published private(set) var waking: Waking?
|
||||
|
||||
/// How long to wait for the host to reappear before giving up. Generous — a cold boot + service
|
||||
/// start can be a minute-plus.
|
||||
private let timeoutSeconds = 90
|
||||
/// Re-send the packet this often: a single one can be missed, and some NICs only wake on a fresh
|
||||
/// packet after dropping into a deeper sleep state.
|
||||
private let resendEverySeconds = 6
|
||||
|
||||
private var loop: Task<Void, Never>?
|
||||
/// Captured so "Try Again" replays the exact same wait.
|
||||
private var replay: (() -> Void)?
|
||||
|
||||
/// Wake `host` and wait for `isOnline()` to go true, then run `onOnline`. `macs`/`lastIP` target
|
||||
/// the magic packet. No-ops straight to `onOnline` when there's nothing to wake with or the host
|
||||
/// is already up (a race between the caller's check and here).
|
||||
func start(
|
||||
host: StoredHost, connectsAfter: Bool,
|
||||
macs: [String], lastIP: String?,
|
||||
isOnline: @escaping () -> Bool, onOnline: @escaping () -> Void
|
||||
) {
|
||||
guard !macs.isEmpty, !isOnline() else {
|
||||
cancel()
|
||||
onOnline()
|
||||
return
|
||||
}
|
||||
replay = { [weak self] in
|
||||
self?.run(host: host, connectsAfter: connectsAfter, macs: macs, lastIP: lastIP,
|
||||
isOnline: isOnline, onOnline: onOnline)
|
||||
}
|
||||
replay?()
|
||||
}
|
||||
|
||||
/// Stop waiting and dismiss the overlay (B / Cancel).
|
||||
func cancel() {
|
||||
loop?.cancel()
|
||||
loop = nil
|
||||
replay = nil
|
||||
waking = nil
|
||||
}
|
||||
|
||||
/// Restart the wait after a timeout (A / Try Again).
|
||||
func retry() { replay?() }
|
||||
|
||||
private func run(
|
||||
host: StoredHost, connectsAfter: Bool, macs: [String], lastIP: String?,
|
||||
isOnline: @escaping () -> Bool, onOnline: @escaping () -> Void
|
||||
) {
|
||||
loop?.cancel()
|
||||
waking = Waking(hostID: host.id, hostName: host.displayName, connectsAfter: connectsAfter)
|
||||
let timeout = timeoutSeconds
|
||||
let resend = resendEverySeconds
|
||||
loop = Task { [weak self] in
|
||||
var elapsed = 0
|
||||
while !Task.isCancelled {
|
||||
if elapsed % resend == 0 { Self.sendPacket(macs: macs, lastIP: lastIP) }
|
||||
if isOnline() {
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.waking = nil
|
||||
self.loop = nil
|
||||
onOnline()
|
||||
return
|
||||
}
|
||||
if elapsed >= timeout {
|
||||
self?.waking?.timedOut = true
|
||||
self?.loop = nil
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
elapsed += 1
|
||||
self?.waking?.seconds = elapsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocking sends (see PunktfunkConnection.wakeOnLAN) — off the main thread.
|
||||
private static func sendPacket(macs: [String], lastIP: String?) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: lastIP)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Force a static waking state for the screenshot harness (no timers, no packets).
|
||||
func debugSet(_ w: Waking) { waking = w }
|
||||
#endif
|
||||
}
|
||||
@@ -59,36 +59,62 @@ final class SessionModel: ObservableObject {
|
||||
@Published var fps = 0
|
||||
@Published var mbps = 0.0
|
||||
@Published var totalFrames = 0
|
||||
/// Capture→client-receipt latency (ms), skew-corrected across machines via the connect-time
|
||||
/// clock offset — p50/p95 for the HUD. `latencyValid` is false until the first sample drains
|
||||
/// (and whenever no host frames arrived in the last interval). `latencySkewCorrected` = the host
|
||||
/// The unified latency stages (design/stats-unification.md), ms per 1 s window. `host+network`
|
||||
/// = capture→received, skew-corrected across machines via the connect-time clock offset: the
|
||||
/// stage-2 HUD shows its p50 in the equation line; the stage-1 fallback shows p50/p95 as its
|
||||
/// `capture→received` headline. `hostNetworkValid` is false until the first sample drains (and
|
||||
/// whenever no host frames arrived in the last interval). `hostNetworkSkewCorrected` = the host
|
||||
/// answered the skew handshake (the number is cross-machine valid, not just same-host).
|
||||
@Published var latencyP50Ms = 0.0
|
||||
@Published var latencyP95Ms = 0.0
|
||||
@Published var latencyValid = false
|
||||
@Published var latencySkewCorrected = false
|
||||
/// Capture→present (glass-to-glass, modulo the host render→capture term) — only the stage-2
|
||||
/// presenter can stamp this (it owns decode + a CAMetalLayer/display-link present). Stays
|
||||
/// invalid under stage-1, where the layer presents internally with no per-frame callback.
|
||||
@Published var presentLatencyP50Ms = 0.0
|
||||
@Published var presentLatencyP95Ms = 0.0
|
||||
@Published var presentLatencyValid = false
|
||||
@Published var presentLatencySkewCorrected = false
|
||||
/// Decode-completion→present (the "present tail": ring wait + render + vsync) — the term the
|
||||
/// stage-2 presenter exists to shorten. Both instants are client-side, so no skew applies.
|
||||
@Published var presentTailP50Ms = 0.0
|
||||
@Published var presentTailP95Ms = 0.0
|
||||
@Published var presentTailValid = false
|
||||
@Published var hostNetworkP50Ms = 0.0
|
||||
@Published var hostNetworkP95Ms = 0.0
|
||||
@Published var hostNetworkValid = false
|
||||
@Published var hostNetworkSkewCorrected = false
|
||||
/// Phase 2 of the same stage: `host+network` split into its two terms via the host's per-AU
|
||||
/// 0xCF timing reports (host = capture→fully-sent as the host measured it, network = the
|
||||
/// remainder), matched to receipts by pts in `latencySplit`. `splitValid` is false whenever
|
||||
/// no timing matched in the window — an old host that never emits the plane, or heavy 0xCF
|
||||
/// loss — and the HUD then falls back to the combined `host+network` term.
|
||||
@Published var hostP50Ms = 0.0
|
||||
@Published var networkP50Ms = 0.0
|
||||
@Published var splitValid = false
|
||||
/// End-to-end = capture→on-glass, measured directly per frame (never summed from the stages) —
|
||||
/// the HUD headline. Only the stage-2 presenter can stamp it (it owns decode + a
|
||||
/// CAMetalLayer/display-link present); stays invalid under stage-1, where the layer presents
|
||||
/// internally with no per-frame callback.
|
||||
@Published var endToEndP50Ms = 0.0
|
||||
@Published var endToEndP95Ms = 0.0
|
||||
@Published var endToEndValid = false
|
||||
@Published var endToEndSkewCorrected = false
|
||||
/// The client-local stage terms of the HUD's equation line (single clock, no skew; p50 only):
|
||||
/// decode = received→decoded, display = decoded→on-glass (ring wait + render + vsync — the
|
||||
/// term the stage-2 presenter exists to shorten).
|
||||
@Published var decodeP50Ms = 0.0
|
||||
@Published var decodeValid = false
|
||||
@Published var displayP50Ms = 0.0
|
||||
@Published var displayValid = false
|
||||
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
|
||||
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
|
||||
@Published var lostFrames = 0
|
||||
@Published var lostPct = 0.0
|
||||
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
||||
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
||||
@Published var mouseCaptured = false
|
||||
|
||||
let meter = FrameMeter()
|
||||
/// Capture→received (the host+network stage), fed per AU at receipt by the stream view's
|
||||
/// onFrame — under both presenters.
|
||||
let latency = LatencyMeter()
|
||||
/// Fed by the stage-2 presenter's display link (capture→present). Passed to StreamView.
|
||||
let presentLatency = LatencyMeter()
|
||||
/// Fed by the same present stamp (decode-completion→present). Passed to StreamView.
|
||||
let presentTail = LatencyMeter()
|
||||
/// The host/network split of that same stage: onFrame also records (pts, interval) receipts
|
||||
/// here, and the 1 s stats tick drains the connection's 0xCF host timings into it — under
|
||||
/// both presenters (the receipt path is presenter-independent).
|
||||
let latencySplit = HostNetworkSplitter()
|
||||
/// The stage-2 meters, passed to StreamView: end-to-end (capture→on-glass, stamped at
|
||||
/// present), decode (received→decoded), display (decoded→on-glass).
|
||||
let endToEnd = LatencyMeter()
|
||||
let decodeStage = LatencyMeter()
|
||||
let displayStage = LatencyMeter()
|
||||
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
|
||||
private var lastFramesDropped: UInt64 = 0
|
||||
private var statsTimer: Timer?
|
||||
private var audio: SessionAudio?
|
||||
private var gamepadCapture: GamepadCapture?
|
||||
@@ -281,7 +307,13 @@ final class SessionModel: ObservableObject {
|
||||
phase = .idle
|
||||
fps = 0
|
||||
mbps = 0
|
||||
latencyValid = false
|
||||
hostNetworkValid = false
|
||||
splitValid = false
|
||||
endToEndValid = false
|
||||
decodeValid = false
|
||||
displayValid = false
|
||||
lostFrames = 0
|
||||
lostPct = 0
|
||||
mouseCaptured = false
|
||||
}
|
||||
|
||||
@@ -306,6 +338,7 @@ final class SessionModel: ObservableObject {
|
||||
audio.start(
|
||||
speakerUID: defaults.string(forKey: DefaultsKey.speakerUID) ?? "",
|
||||
micUID: defaults.string(forKey: DefaultsKey.micUID) ?? "",
|
||||
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
|
||||
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
|
||||
self.audio = audio
|
||||
// Gamepads: forward GamepadManager's active controller as pad 0 and render the
|
||||
@@ -321,6 +354,8 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func startStatsTimer() {
|
||||
lastFramesDropped = 0 // a fresh connection's cumulative drop counter starts at 0
|
||||
latencySplit.reset() // no stale receipts/samples from a previous session
|
||||
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
@@ -328,28 +363,60 @@ final class SessionModel: ObservableObject {
|
||||
self.fps = frames
|
||||
self.mbps = Double(bytes) * 8 / 1_000_000
|
||||
self.totalFrames = total
|
||||
// Per-window `lost` = the delta of the connector's cumulative reassembler-drop
|
||||
// counter (0 after close — treat a rewind as no loss rather than underflowing).
|
||||
let dropped = self.connection?.framesDropped() ?? 0
|
||||
let lost = dropped >= self.lastFramesDropped
|
||||
? Int(dropped - self.lastFramesDropped) : 0
|
||||
self.lastFramesDropped = dropped
|
||||
self.lostFrames = lost
|
||||
self.lostPct = lost > 0 ? Double(lost) / Double(frames + lost) * 100 : 0
|
||||
if let lat = self.latency.drain() {
|
||||
self.latencyP50Ms = lat.p50Ms
|
||||
self.latencyP95Ms = lat.p95Ms
|
||||
self.latencySkewCorrected = lat.skewCorrected
|
||||
self.latencyValid = true
|
||||
self.hostNetworkP50Ms = lat.p50Ms
|
||||
self.hostNetworkP95Ms = lat.p95Ms
|
||||
self.hostNetworkSkewCorrected = lat.skewCorrected
|
||||
self.hostNetworkValid = true
|
||||
} else {
|
||||
self.latencyValid = false
|
||||
self.hostNetworkValid = false
|
||||
}
|
||||
if let p = self.presentLatency.drain() {
|
||||
self.presentLatencyP50Ms = p.p50Ms
|
||||
self.presentLatencyP95Ms = p.p95Ms
|
||||
self.presentLatencySkewCorrected = p.skewCorrected
|
||||
self.presentLatencyValid = true
|
||||
} else {
|
||||
self.presentLatencyValid = false
|
||||
// Phase 2: drain the window's per-AU host timings (0xCF) into the splitter —
|
||||
// non-blocking, bounded (a 240 fps window is ~240 reports; the cap only guards
|
||||
// a pathological burst). `try?` flattens (SE-0230); a throw (.closed during
|
||||
// teardown) just ends the drain. An old host never emits any → splitValid stays
|
||||
// false and the HUD keeps the combined host+network term.
|
||||
if let conn = self.connection {
|
||||
var burst = 0
|
||||
while burst < 1024, let t = try? conn.nextHostTiming(timeoutMs: 0) {
|
||||
self.latencySplit.noteHostTiming(ptsNs: t.ptsNs, hostUs: t.hostUs)
|
||||
burst += 1
|
||||
}
|
||||
}
|
||||
if let t = self.presentTail.drain() {
|
||||
self.presentTailP50Ms = t.p50Ms
|
||||
self.presentTailP95Ms = t.p95Ms
|
||||
self.presentTailValid = true
|
||||
if let s = self.latencySplit.drain() {
|
||||
self.hostP50Ms = s.hostP50Ms
|
||||
self.networkP50Ms = s.networkP50Ms
|
||||
self.splitValid = true
|
||||
} else {
|
||||
self.presentTailValid = false
|
||||
self.splitValid = false
|
||||
}
|
||||
if let e = self.endToEnd.drain() {
|
||||
self.endToEndP50Ms = e.p50Ms
|
||||
self.endToEndP95Ms = e.p95Ms
|
||||
self.endToEndSkewCorrected = e.skewCorrected
|
||||
self.endToEndValid = true
|
||||
} else {
|
||||
self.endToEndValid = false
|
||||
}
|
||||
if let d = self.decodeStage.drain() {
|
||||
self.decodeP50Ms = d.p50Ms
|
||||
self.decodeValid = true
|
||||
} else {
|
||||
self.decodeValid = false
|
||||
}
|
||||
if let d = self.displayStage.drain() {
|
||||
self.displayP50Ms = d.p50Ms
|
||||
self.displayValid = true
|
||||
} else {
|
||||
self.displayValid = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// The streaming overlay HUD: mode + fps/throughput, the capture→client (and, under the stage-2
|
||||
// presenter, capture→present) latency lines, the platform input hint, and disconnect.
|
||||
// The streaming overlay HUD: mode + fps/throughput, the unified latency lines
|
||||
// (design/stats-unification.md — end-to-end headline + the stage equation under stage-2, the
|
||||
// capture→received headline under the stage-1 fallback), the loss counter, the platform input
|
||||
// hint, and disconnect.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
@@ -18,24 +20,46 @@ struct StreamHUDView: View {
|
||||
Text("\(connection.width)×\(connection.height)@\(connection.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
if model.latencyValid {
|
||||
// Capture→client-receipt (skew-corrected); excludes the layer's decode+present —
|
||||
// see LatencyMeter. "(same-host)" when the host didn't answer the skew handshake.
|
||||
Text("capture→client \(model.latencyP50Ms, specifier: "%.1f")/\(model.latencyP95Ms, specifier: "%.1f") ms p50/p95\(model.latencySkewCorrected ? "" : " (same-host)")")
|
||||
if model.endToEndValid {
|
||||
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
||||
// corrected) — "(same-host clock)" when the host didn't answer the skew handshake.
|
||||
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if model.presentLatencyValid {
|
||||
// Capture→present (glass-to-glass, modulo host render→capture) — stage-2 presenter
|
||||
// only; stage-1's layer presents internally with no per-frame stamp.
|
||||
Text("capture→present \(model.presentLatencyP50Ms, specifier: "%.1f")/\(model.presentLatencyP95Ms, specifier: "%.1f") ms p50/p95\(model.presentLatencySkewCorrected ? "" : " (same-host)")")
|
||||
// The equation: the stages tiling the headline interval (per-window p50s —
|
||||
// they only approximately sum to the directly-measured total). With a host
|
||||
// that reports per-AU timings (0xCF) the first term splits into host + network
|
||||
// (phase 2); an old host keeps the combined term.
|
||||
if model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||
if model.splitValid {
|
||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} else if model.hostNetworkValid {
|
||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||||
// per-frame stamp, so the honest headline ends at receipt. The host/network
|
||||
// split still applies there (receipt is presenter-independent) — it becomes the
|
||||
// only equation line; without it, host+network IS the whole measured interval.
|
||||
Text("capture→received \(model.hostNetworkP50Ms, specifier: "%.1f") ms p50 · \(model.hostNetworkP95Ms, specifier: "%.1f") p95\(model.hostNetworkSkewCorrected ? "" : " (same-host clock)")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
if model.splitValid {
|
||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if model.presentTailValid {
|
||||
// Decode→present (the client-local "present tail": ring wait + render + vsync) —
|
||||
// the term the stage-2 presenter shortens; no skew applies (one clock).
|
||||
Text("decode→present \(model.presentTailP50Ms, specifier: "%.1f")/\(model.presentTailP95Ms, specifier: "%.1f") ms p50/p95")
|
||||
if model.lostFrames > 0 {
|
||||
// Unrecoverable network drops this window; hidden while the link is clean.
|
||||
// String(format:) rather than specifier interpolation: the literal % would
|
||||
// otherwise land in the LocalizedStringKey's format string as a bogus conversion.
|
||||
Text(String(format: "lost %d (%.1f%%)", model.lostFrames, model.lostPct))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -81,13 +81,17 @@ struct GamepadSettingsView: View {
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
])
|
||||
}
|
||||
.padding(.leading, 22)
|
||||
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.trailing, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayScrim(edge: .bottom) }
|
||||
}
|
||||
.background { GamepadScreenBackground() }
|
||||
// No aurora here — the settings read as clean Liquid Glass over a quiet dark base, so the
|
||||
// glass rows are the only material on the screen.
|
||||
.background { GamepadFormBackground() }
|
||||
.onAppear {
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
@@ -148,13 +152,14 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 13)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.white.opacity(focused ? 0.1 : 0))
|
||||
}
|
||||
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
||||
tint: focused ? Color.brand.opacity(0.30) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(.white.opacity(focused ? 0.22 : 0), lineWidth: 1)
|
||||
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(focused ? 1.0 : 0.98)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
|
||||
@@ -7,63 +7,15 @@ import SwiftUI
|
||||
extension SettingsView {
|
||||
// MARK: - Sections (shared)
|
||||
|
||||
// NOTE: the Section content is deliberately split into the small named builders below — as one
|
||||
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
|
||||
// type-checker budget ("unable to type-check this expression in reasonable time"), which
|
||||
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
||||
@ViewBuilder var streamModeSection: some View {
|
||||
Section {
|
||||
#if os(iOS)
|
||||
// Touch-first: a rotating wheel of common resolutions (this device's own mode first) and
|
||||
// a segmented refresh-rate control — the same family as the Clock/Timer pickers. The host
|
||||
// renders a virtual output at exactly the chosen mode, so these are real pixel sizes. The
|
||||
// last wheel row, "Custom…", reveals width/height/refresh fields for an arbitrary mode.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Resolution")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Resolution", selection: resolutionSelection) {
|
||||
ForEach(resolutionChoices, id: \.tag) { choice in
|
||||
Text(choice.label).tag(choice.tag)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxHeight: 140)
|
||||
}
|
||||
if isCustomResolution {
|
||||
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
|
||||
HStack {
|
||||
TextField("Width", value: $width, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
Text("×")
|
||||
TextField("Height", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
// A row built from an HStack of TextFields otherwise insets its bottom separator to
|
||||
// the inner content, clipping the hairline under "Width"; pin it to the cell edge.
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
|
||||
LabeledContent("Refresh rate") {
|
||||
TextField("Hz", value: $hz, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if refreshChoices.count > 1 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Refresh rate")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Refresh rate", selection: $hz) {
|
||||
ForEach(refreshChoices, id: \.self) { rate in
|
||||
Text("\(rate) Hz").tag(rate)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
} else {
|
||||
// A device with a single supported rate (e.g. 60 Hz) has nothing to pick.
|
||||
LabeledContent("Refresh rate") {
|
||||
Text("\(hz) Hz").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
iosResolutionWheel
|
||||
iosRefreshRows
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
#elseif os(macOS)
|
||||
HStack {
|
||||
@@ -78,23 +30,7 @@ extension SettingsView {
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
if bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
Text("Bitrate")
|
||||
}
|
||||
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 76, alignment: .trailing)
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
bitrateRows
|
||||
#endif
|
||||
} header: {
|
||||
Text("Stream mode")
|
||||
@@ -109,6 +45,67 @@ extension SettingsView {
|
||||
#if os(iOS)
|
||||
// MARK: - Stream mode (iOS wheel)
|
||||
|
||||
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) — the
|
||||
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
|
||||
/// chosen mode, so these are real pixel sizes. The last wheel row, "Custom…", reveals
|
||||
/// width/height/refresh fields for an arbitrary mode (see `iosRefreshRows`).
|
||||
@ViewBuilder private var iosResolutionWheel: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Resolution")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Resolution", selection: resolutionSelection) {
|
||||
ForEach(resolutionChoices, id: \.tag) { choice in
|
||||
Text(choice.label).tag(choice.tag)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxHeight: 140)
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom W×H(+Hz) fields, a segmented refresh picker, or a static single-rate row.
|
||||
@ViewBuilder private var iosRefreshRows: some View {
|
||||
if isCustomResolution {
|
||||
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
|
||||
HStack {
|
||||
TextField("Width", value: $width, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
Text("×")
|
||||
TextField("Height", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
// A row built from an HStack of TextFields otherwise insets its bottom separator to
|
||||
// the inner content, clipping the hairline under "Width"; pin it to the cell edge.
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
|
||||
LabeledContent("Refresh rate") {
|
||||
TextField("Hz", value: $hz, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if refreshChoices.count > 1 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Refresh rate")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Refresh rate", selection: $hz) {
|
||||
ForEach(refreshChoices, id: \.self) { rate in
|
||||
Text("\(rate) Hz").tag(rate)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
} else {
|
||||
// A device with a single supported rate (e.g. 60 Hz) has nothing to pick.
|
||||
LabeledContent("Refresh rate") {
|
||||
Text("\(hz) Hz").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentinel wheel tag for the "Custom…" row. Real tags are "WxH" (digits + "x"), so this can't
|
||||
/// collide with a resolution.
|
||||
private static let customResolutionTag = "custom"
|
||||
@@ -156,6 +153,29 @@ extension SettingsView {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
||||
@ViewBuilder private var bitrateRows: some View {
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
if bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
Text("Bitrate")
|
||||
}
|
||||
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 76, alignment: .trailing)
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder var audioSection: some View {
|
||||
Section {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
@@ -188,6 +208,17 @@ extension SettingsView {
|
||||
}
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
|
||||
// pick it. Auto sums every channel (a lone hot mic still passes at full level).
|
||||
if micChannelCount > 1 {
|
||||
Picker("Microphone channel", selection: $micChannel) {
|
||||
Text("Auto (all channels)").tag(0)
|
||||
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||
Text("Channel \(ch)").tag(ch)
|
||||
}
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
}
|
||||
#endif
|
||||
} header: {
|
||||
Text("Audio")
|
||||
@@ -204,35 +235,42 @@ extension SettingsView {
|
||||
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
|
||||
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
|
||||
@ViewBuilder var pointerSection: some View {
|
||||
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||
Section {
|
||||
Picker("Touch input", selection: $touchMode) {
|
||||
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
|
||||
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
|
||||
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
|
||||
}
|
||||
if isPad {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||
}
|
||||
} header: {
|
||||
Text("Touch & pointer")
|
||||
} footer: {
|
||||
Text("Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
|
||||
+ "click, two-finger tap for a right click, two-finger drag to scroll, "
|
||||
+ "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
|
||||
+ "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
|
||||
+ "multi-touch reaches the host, for apps that understand touch. Applies from "
|
||||
+ "the next touch."
|
||||
+ (isPad
|
||||
? " Pointer capture locks a hardware mouse/trackpad for relative movement "
|
||||
+ "(mouse-look); off keeps the pointer free and sends absolute positions. "
|
||||
+ "The lock needs the stream full-screen and frontmost, and falls back "
|
||||
+ "automatically (Stage Manager, Slide Over)."
|
||||
: ""))
|
||||
Text(pointerFooterText)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Footer copy for `pointerSection`, built in plain `+=` statements. Deliberately NOT one big
|
||||
/// `+` chain (with a ternary) inside the ViewBuilder — that single expression blew Swift's
|
||||
/// type-checker budget and was what actually broke the iOS archive.
|
||||
private var pointerFooterText: String {
|
||||
var text = "Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
|
||||
text += "click, two-finger tap for a right click, two-finger drag to scroll, "
|
||||
text += "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
|
||||
text += "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
|
||||
text += "multi-touch reaches the host, for apps that understand touch. Applies from "
|
||||
text += "the next touch."
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
text += " Pointer capture locks a hardware mouse/trackpad for relative movement "
|
||||
text += "(mouse-look); off keeps the pointer free and sends absolute positions. "
|
||||
text += "The lock needs the stream full-screen and frontmost, and falls back "
|
||||
text += "automatically (Stage Manager, Slide Over)."
|
||||
}
|
||||
return text
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder var compositorSection: some View {
|
||||
@@ -283,10 +321,11 @@ extension SettingsView {
|
||||
Text("Video presenter · debug")
|
||||
} footer: {
|
||||
Text("Stage 2 (default) decodes explicitly and presents through Metal with a display "
|
||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD and "
|
||||
+ "self-recovers from decode stalls. Stage 1 feeds compressed video straight to the "
|
||||
+ "system display layer; it freezes on a lost HEVC reference frame, so it's a debug "
|
||||
+ "fallback only. Applies from the next session.")
|
||||
+ "link — it gives the HUD the end-to-end (capture→on-glass) headline with the "
|
||||
+ "host+network/decode/display stage equation and self-recovers from decode "
|
||||
+ "stalls. Stage 1 feeds compressed video straight to the system display layer; "
|
||||
+ "it freezes on a lost HEVC reference frame, so it's a debug fallback only. "
|
||||
+ "Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -61,8 +61,12 @@ struct SettingsView: View {
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||||
@State var outputDevices: [AudioDevice] = []
|
||||
@State var inputDevices: [AudioDevice] = []
|
||||
// Input channels of the selected mic — drives the "Microphone channel" picker, which only
|
||||
// appears for a multi-channel interface (>1). 0 until the Audio tab loads it.
|
||||
@State var micChannelCount = 0
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
@@ -115,6 +119,12 @@ struct SettingsView: View {
|
||||
.onAppear {
|
||||
outputDevices = AudioDevices.outputs()
|
||||
inputDevices = AudioDevices.inputs()
|
||||
micChannelCount = AudioDevices.inputChannelCount(forUID: micUID)
|
||||
}
|
||||
.onChange(of: micUID) { _, newUID in
|
||||
// A different mic → different channel count; drop a now-out-of-range pin to Auto.
|
||||
micChannelCount = AudioDevices.inputChannelCount(forUID: newUID)
|
||||
if micChannel > micChannelCount { micChannel = 0 }
|
||||
}
|
||||
.tabItem { Label("Audio", systemImage: "speaker.wave.2") }
|
||||
|
||||
|
||||
@@ -26,9 +26,16 @@ struct StoredHost: Identifiable, Codable, Hashable {
|
||||
/// decode: synthesized Decodable ignores property defaults but treats a missing Optional as
|
||||
/// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.)
|
||||
var mgmtPort: UInt16?
|
||||
/// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`.
|
||||
/// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the
|
||||
/// client can send a magic packet to wake the host later (when it's asleep and no longer
|
||||
/// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned.
|
||||
var macAddresses: [String]?
|
||||
|
||||
var displayName: String { name.isEmpty ? address : name }
|
||||
var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
|
||||
/// Wake-capable, in a form the wake helper accepts (empty when none learned yet).
|
||||
var wakeMacs: [String] { macAddresses ?? [] }
|
||||
}
|
||||
|
||||
extension StoredHost {
|
||||
@@ -91,6 +98,13 @@ final class HostStore: ObservableObject {
|
||||
hosts.removeAll { $0.id == host.id }
|
||||
}
|
||||
|
||||
/// Replace a saved host in place (the edit sheet) — matched by id, so identity/pin/last-connected
|
||||
/// carried on the passed value are preserved.
|
||||
func update(_ host: StoredHost) {
|
||||
guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return }
|
||||
hosts[i] = host
|
||||
}
|
||||
|
||||
func markConnected(_ hostID: UUID) {
|
||||
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
||||
hosts[i].lastConnected = Date()
|
||||
@@ -101,6 +115,16 @@ final class HostStore: ObservableObject {
|
||||
hosts[i].pinnedSHA256 = fingerprint
|
||||
}
|
||||
|
||||
/// Learn/refresh this host's Wake-on-LAN MAC(s) from its live advert (called while the host is
|
||||
/// awake, so the client can wake it once it sleeps). No-op when unchanged, so it doesn't churn
|
||||
/// UserDefaults on every discovery tick.
|
||||
func updateMacs(_ hostID: UUID, macs: [String]) {
|
||||
guard !macs.isEmpty,
|
||||
let i = hosts.firstIndex(where: { $0.id == hostID }),
|
||||
hosts[i].macAddresses != macs else { return }
|
||||
hosts[i].macAddresses = macs
|
||||
}
|
||||
|
||||
/// Drop the pinned identity (e.g. after a legitimate host reinstall). This does NOT downgrade
|
||||
/// to TOFU: the next connect re-pairs via the PIN ceremony, unless the host advertises
|
||||
/// `pair=optional` (the only case the connect path still offers the trust prompt).
|
||||
|
||||
@@ -67,3 +67,41 @@ extension View {
|
||||
modifier(GlassProminentButton())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Console glass (gamepad host tiles + settings rows)
|
||||
|
||||
/// Liquid Glass tuned for the gamepad UI's dark "console" surfaces — the host-carousel tiles and
|
||||
/// the settings rows. Unlike `glassBackground` (floating-overlay only, per HIG), this deliberately
|
||||
/// clads content tiles / dense rows: a chosen part of the 10-foot console look. `tint` washes the
|
||||
/// glass toward a color (the brand violet on the focused / primary surface); `interactive` makes
|
||||
/// it flex on press. The pre-26 fallback is `.ultraThinMaterial` forced dark — these surfaces
|
||||
/// always sit on the near-black backdrop, so the material must stay dark even in a light appearance.
|
||||
private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
let shape: S
|
||||
var tint: Color?
|
||||
var interactive = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26, macOS 26, tvOS 26, *) {
|
||||
content.glassEffect(glass, in: shape)
|
||||
} else {
|
||||
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) }
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 26, macOS 26, tvOS 26, *)
|
||||
private var glass: Glass {
|
||||
var g: Glass = .regular
|
||||
if let tint { g = g.tint(tint) }
|
||||
if interactive { g = g.interactive() }
|
||||
return g
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Liquid Glass for a dark console surface (a host tile / settings row), or `.ultraThinMaterial`
|
||||
/// (forced dark) pre-26. Pass the surface's shape explicitly — glass defaults to a Capsule.
|
||||
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
|
||||
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ struct PairSheet: View {
|
||||
TextField(
|
||||
"PIN", text: $pin,
|
||||
prompt: Text("Shown in the host's web console"))
|
||||
.font(.system(.title3, design: .monospaced))
|
||||
.font(.geistFixed(16)) // prominent, but on-brand mono (not oversized title3)
|
||||
#if os(iOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
@@ -134,6 +134,11 @@ struct PairSheet: View {
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.formStyle(.grouped)
|
||||
// Bring the grouped form's default system text down to the app's Geist scale so the sheet
|
||||
// doesn't read oversized / out of place (matches AddHostSheet). The PIN field keeps its own
|
||||
// explicit Geist Mono font.
|
||||
.font(.geist(12, relativeTo: .callout))
|
||||
.controlSize(.small)
|
||||
#endif
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel) {
|
||||
|
||||
@@ -33,6 +33,49 @@ public enum AudioDevices {
|
||||
}
|
||||
}
|
||||
|
||||
/// Input channel count of the mic the picker would use — the device with this UID, or the
|
||||
/// system default input when `uid` is empty. 0 when it can't be resolved. Drives the
|
||||
/// "Microphone channel" picker (only shown for multi-channel interfaces).
|
||||
public static func inputChannelCount(forUID uid: String) -> Int {
|
||||
let id = uid.isEmpty ? defaultInputDevice() : deviceID(forUID: uid)
|
||||
guard let id else { return 0 }
|
||||
return channelCount(id, scope: kAudioObjectPropertyScopeInput)
|
||||
}
|
||||
|
||||
private static func defaultInputDevice() -> AudioDeviceID? {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var dev = AudioDeviceID(0)
|
||||
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||
guard AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &dev) == noErr,
|
||||
dev != 0
|
||||
else { return nil }
|
||||
return dev
|
||||
}
|
||||
|
||||
/// Sum of channels across the device's streams in `scope` (its total input/output channels).
|
||||
private static func channelCount(
|
||||
_ id: AudioDeviceID, scope: AudioObjectPropertyScope
|
||||
) -> Int {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||
mScope: scope,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var size: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr, size > 0
|
||||
else { return 0 }
|
||||
let raw = UnsafeMutableRawPointer.allocate(
|
||||
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||
defer { raw.deallocate() }
|
||||
guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, raw) == noErr else { return 0 }
|
||||
let abl = UnsafeMutableAudioBufferListPointer(
|
||||
raw.assumingMemoryBound(to: AudioBufferList.self))
|
||||
return abl.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||
}
|
||||
|
||||
private static func all() -> [AudioDeviceID] {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
@@ -62,7 +105,8 @@ public enum AudioDevices {
|
||||
return AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr && size > 0
|
||||
}
|
||||
|
||||
private static func describe(_ id: AudioDeviceID) -> AudioDevice? {
|
||||
/// UID + human name for a live AudioDeviceID (nil if either property is unreadable).
|
||||
static func describe(_ id: AudioDeviceID) -> AudioDevice? {
|
||||
guard let uid = stringProperty(id, kAudioDevicePropertyDeviceUID),
|
||||
let name = stringProperty(id, kAudioObjectPropertyName)
|
||||
else { return nil }
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
// AVAudioSourceNode pulls from the ring (silence on underrun with re-priming, so a
|
||||
// network gap costs one dip, not permanent crackle).
|
||||
//
|
||||
// mic → host: a second AVAudioEngine taps the input device, resamples to 48 kHz
|
||||
// stereo, slices 20 ms chunks, Opus-encodes, and sendMic()s each packet — the host
|
||||
// feeds them into a virtual PipeWire source.
|
||||
// mic → host: a second AVAudioEngine taps the input device, folds it to one mono bus (the
|
||||
// chosen channel of a multi-channel interface, or a sum of all channels), resamples to 48 kHz
|
||||
// stereo, slices 20 ms chunks, Opus-encodes, and sendMic()s each packet — the host feeds them
|
||||
// into a virtual PipeWire source.
|
||||
//
|
||||
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
|
||||
// concrete device and follows default-device changes). Two engines, not one — a single
|
||||
@@ -68,10 +69,11 @@ public final class SessionAudio {
|
||||
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
|
||||
/// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not
|
||||
/// on return. The mic may start later still if the permission prompt is pending.
|
||||
public func start(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||
public func start(speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool) {
|
||||
#if os(macOS)
|
||||
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||
startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
|
||||
startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel, micEnabled: micEnabled)
|
||||
#else
|
||||
// Configure + activate the session OFF the main thread (it blocks on the audio server),
|
||||
// then start the engines back on the main thread once it's active — engine routing/format
|
||||
@@ -81,7 +83,9 @@ public final class SessionAudio {
|
||||
self.activateAudioSession(micEnabled: micEnabled)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
|
||||
self.startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -115,7 +119,9 @@ public final class SessionAudio {
|
||||
|
||||
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main
|
||||
/// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
|
||||
private func startEngines(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||
private func startEngines(
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool
|
||||
) {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
#if os(tvOS)
|
||||
// No app-accessible microphone input on tvOS — playback only.
|
||||
@@ -123,12 +129,12 @@ public final class SessionAudio {
|
||||
guard micEnabled else { return }
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
startCapture(micUID: micUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self, granted, !self.flag.isStopped else { return }
|
||||
self.startCapture(micUID: micUID)
|
||||
self.startCapture(micUID: micUID, micChannel: micChannel)
|
||||
}
|
||||
}
|
||||
default:
|
||||
@@ -280,7 +286,7 @@ public final class SessionAudio {
|
||||
// MARK: - Mic (mic → host)
|
||||
|
||||
#if !os(tvOS)
|
||||
private func startCapture(micUID: String) {
|
||||
private func startCapture(micUID: String, micChannel: Int) {
|
||||
let engine = AVAudioEngine()
|
||||
let input = engine.inputNode
|
||||
#if os(macOS)
|
||||
@@ -300,8 +306,63 @@ public final class SessionAudio {
|
||||
log.error("no usable input device — mic uplink disabled")
|
||||
return
|
||||
}
|
||||
guard let encoder = try? OpusEncoder(),
|
||||
let resampler = AVAudioConverter(from: inFormat, to: encoder.pcmFormat),
|
||||
|
||||
// Multi-channel-interface handling. A pro interface exposes N discrete inputs with the mic
|
||||
// on ONE of them, but AVAudioConverter's N→stereo downmix takes channels 0/1 — dead
|
||||
// silence when the mic sits higher up (the classic "host receives zeros"). So we fold the
|
||||
// input to a single mono bus OURSELVES and resample that. micChannel: 0 = Auto (sum every
|
||||
// channel — a lone hot mic passes at full level), n≥1 pins 1-based input channel n.
|
||||
let inChannels = Int(inFormat.channelCount)
|
||||
let pinnedChannel: Int? = {
|
||||
guard micChannel >= 1 else { return nil }
|
||||
let idx = micChannel - 1
|
||||
guard idx < inChannels else {
|
||||
log.warning(
|
||||
"mic channel \(micChannel) out of range (device has \(inChannels)) — mixing all")
|
||||
return nil
|
||||
}
|
||||
return idx
|
||||
}()
|
||||
let channelPlan = pinnedChannel.map { "channel \($0 + 1)/\(inChannels)" }
|
||||
?? (inChannels > 1 ? "mix \(inChannels)ch→mono" : "mono")
|
||||
|
||||
// Name the device we're ACTUALLY recording from + its format + how we fold it, once per
|
||||
// session. This single line localizes the whole class of "host receives silence" failures
|
||||
// that otherwise need a host-side tone injection to pin down: a UID that silently fell back
|
||||
// to the default, the wrong device being live, or the wrong channel picked.
|
||||
#if os(macOS)
|
||||
if let unit = input.audioUnit, let live = Self.currentDevice(of: unit),
|
||||
let dev = AudioDevices.describe(live) {
|
||||
if !micUID.isEmpty, dev.uid != micUID {
|
||||
log.warning("""
|
||||
mic selection not honored — requested \(micUID) but capturing from \
|
||||
\(dev.name) [\(dev.uid)]; the device's UID likely changed (replug) — \
|
||||
reselect it in Settings
|
||||
""")
|
||||
}
|
||||
log.info("""
|
||||
mic capture: \(dev.name) [\(dev.uid)] — \(Int(inFormat.sampleRate)) Hz, \
|
||||
\(inChannels) ch, \(channelPlan)
|
||||
""")
|
||||
} else {
|
||||
log.info("""
|
||||
mic capture: <device unavailable> — \(Int(inFormat.sampleRate)) Hz, \
|
||||
\(inChannels) ch, \(channelPlan)
|
||||
""")
|
||||
}
|
||||
#else
|
||||
log.info(
|
||||
"mic capture: \(Int(inFormat.sampleRate)) Hz, \(inChannels) ch, \(channelPlan)")
|
||||
#endif
|
||||
|
||||
// Encode a single mono bus (folded from `inFormat` in the tap): the resampler goes
|
||||
// mono@inputSR → the encoder's 48 kHz stereo, so it handles both the rate change and the
|
||||
// mono→stereo duplication, and the wrong-channel downmix never happens.
|
||||
guard let monoFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: inFormat.sampleRate,
|
||||
channels: 1, interleaved: false),
|
||||
let encoder = try? OpusEncoder(),
|
||||
let resampler = AVAudioConverter(from: monoFormat, to: encoder.pcmFormat),
|
||||
let chunk = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: OpusEncoder.framesPerPacket)
|
||||
else {
|
||||
@@ -317,11 +378,59 @@ public final class SessionAudio {
|
||||
let connection = connection
|
||||
let flag = flag
|
||||
|
||||
// Silence tripwire (tap-confined): a "recording" app can be handed pure digital zeros —
|
||||
// a zeroed input-volume slider, a stale TCC grant, a muted device, OR the wrong channel
|
||||
// picked — and everything downstream looks alive while the host gets silence. Track the
|
||||
// peak of the EXTRACTED mono bus over the first ~10 s (not the raw device — a mic present
|
||||
// on a channel we didn't grab must still read as silence) and emit exactly ONE verdict.
|
||||
// This is the log line whose absence made the last occurrence take a host-side tone.
|
||||
let silenceWindow = Int(inFormat.sampleRate * 10)
|
||||
let deviceLabel = micUID.isEmpty ? "default input" : micUID
|
||||
var framesInspected = 0
|
||||
var inputPeak: Float = 0
|
||||
var levelReported = false
|
||||
|
||||
input.installTap(onBus: 0, bufferSize: 2048, format: inFormat) { buffer, _ in
|
||||
if flag.isStopped { return }
|
||||
let frames = Int(buffer.frameLength)
|
||||
guard frames > 0, let src = buffer.floatChannelData,
|
||||
let mono = AVAudioPCMBuffer(
|
||||
pcmFormat: monoFormat, frameCapacity: buffer.frameLength),
|
||||
let dst = mono.floatChannelData?[0]
|
||||
else { return }
|
||||
mono.frameLength = buffer.frameLength
|
||||
|
||||
// Fold the multi-channel input down to the one mono bus we encode.
|
||||
Self.foldToMono(
|
||||
input: src, frames: frames, channels: Int(buffer.format.channelCount),
|
||||
interleaved: buffer.format.isInterleaved, pinned: pinnedChannel, out: dst)
|
||||
|
||||
if !levelReported {
|
||||
var localPeak: Float = 0
|
||||
for i in 0..<frames where abs(dst[i]) > localPeak { localPeak = abs(dst[i]) }
|
||||
if localPeak > inputPeak { inputPeak = localPeak }
|
||||
framesInspected += frames
|
||||
if framesInspected >= silenceWindow {
|
||||
levelReported = true
|
||||
if inputPeak == 0 {
|
||||
log.warning("""
|
||||
mic uplink has been pure digital SILENCE for 10 s (\(deviceLabel), \
|
||||
\(channelPlan)) — check the input level (System Settings → Sound → \
|
||||
Input), Privacy & Security → Microphone, and the Microphone channel in \
|
||||
Settings; the host is receiving zeros
|
||||
""")
|
||||
} else {
|
||||
let dbfs = 20 * log10(inputPeak)
|
||||
log.info("""
|
||||
mic uplink OK — peak \(String(format: "%.1f", dbfs)) dBFS over first \
|
||||
10 s (\(deviceLabel), \(channelPlan))
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ratio = 48_000 / inFormat.sampleRate
|
||||
let outCapacity = AVAudioFrameCount(
|
||||
(Double(buffer.frameLength) * ratio).rounded(.up) + 64)
|
||||
let outCapacity = AVAudioFrameCount((Double(frames) * ratio).rounded(.up) + 64)
|
||||
guard let staging = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: outCapacity)
|
||||
else { return }
|
||||
@@ -334,7 +443,7 @@ public final class SessionAudio {
|
||||
}
|
||||
fed = true
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
return mono
|
||||
}
|
||||
guard status != .error, let p = staging.floatChannelData?[0] else { return }
|
||||
fifo.append(contentsOf: UnsafeBufferPointer(
|
||||
@@ -378,6 +487,42 @@ public final class SessionAudio {
|
||||
stateLock.unlock()
|
||||
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
|
||||
}
|
||||
|
||||
/// Fold `channels` of input (`floatChannelData` layout: `interleaved` → one buffer strided by
|
||||
/// channel count; else one buffer per channel) down to a single mono bus in `out` (`frames`
|
||||
/// long). `pinned` (0-based, must be `< channels`) copies exactly that channel — the fix for a
|
||||
/// mic on one input of a multi-channel interface; `nil` sums every channel, clamped to
|
||||
/// [-1, 1], so a lone hot channel still passes at full level instead of the silent 0/1 the
|
||||
/// default N→stereo downmix would grab. Pure + `internal` for unit testing the index math.
|
||||
static func foldToMono(
|
||||
input: UnsafePointer<UnsafeMutablePointer<Float>>, frames: Int, channels: Int,
|
||||
interleaved: Bool, pinned: Int?, out: UnsafeMutablePointer<Float>
|
||||
) {
|
||||
if let ch = pinned, ch < channels {
|
||||
if interleaved {
|
||||
let d = input[0]
|
||||
for i in 0..<frames { out[i] = d[i * channels + ch] }
|
||||
} else {
|
||||
let d = input[ch]
|
||||
for i in 0..<frames { out[i] = d[i] }
|
||||
}
|
||||
} else if interleaved {
|
||||
let d = input[0]
|
||||
for i in 0..<frames {
|
||||
var s: Float = 0
|
||||
for c in 0..<channels { s += d[i * channels + c] }
|
||||
out[i] = max(-1, min(1, s))
|
||||
}
|
||||
} else {
|
||||
let d0 = input[0]
|
||||
for i in 0..<frames { out[i] = d0[i] }
|
||||
for c in 1..<channels {
|
||||
let d = input[c]
|
||||
for i in 0..<frames { out[i] += d[i] }
|
||||
}
|
||||
if channels > 1 { for i in 0..<frames { out[i] = max(-1, min(1, out[i])) } }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
@@ -387,5 +532,18 @@ public final class SessionAudio {
|
||||
unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0,
|
||||
&dev, UInt32(MemoryLayout<AudioDeviceID>.size)) == noErr
|
||||
}
|
||||
|
||||
/// Read back the AUHAL's live device — the definitive "what are we actually capturing
|
||||
/// from", which catches a selection that succeeded on paper but silently fell back to
|
||||
/// the system default (a stale/changed UID, a device that vanished between resolve and
|
||||
/// start). 0 / an error means we couldn't tell.
|
||||
private static func currentDevice(of unit: AudioUnit) -> AudioDeviceID? {
|
||||
var dev = AudioDeviceID(0)
|
||||
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||
let status = AudioUnitGetProperty(
|
||||
unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &dev, &size)
|
||||
guard status == noErr, dev != 0 else { return nil }
|
||||
return dev
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable {
|
||||
/// reduced-security TOFU "Trust" path. A missing/unknown `pair` field is NOT optional:
|
||||
/// pairing is mandatory unless this is true (the policy authority is the host's advert).
|
||||
public let allowsTofu: Bool
|
||||
/// Wake-on-LAN MAC address(es) the host advertised (mDNS `mac` TXT, comma-separated
|
||||
/// `aa:bb:cc:dd:ee:ff`, routed NIC first). Empty when not advertised. A client persists these
|
||||
/// onto the saved host so it can wake it after it sleeps; advisory/unauthenticated (a wrong
|
||||
/// value only makes a wake fail — the magic packet is inert and the fingerprint still gates
|
||||
/// the connection).
|
||||
public let macAddresses: [String]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -111,10 +117,15 @@ public final class HostDiscovery: ObservableObject {
|
||||
var fp: String?
|
||||
var pair: String?
|
||||
var id: String?
|
||||
var macs: [String] = []
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
fp = Self.entry(txt, "fp")
|
||||
pair = Self.entry(txt, "pair")
|
||||
id = Self.entry(txt, "id")
|
||||
macs = (Self.entry(txt, "mac") ?? "")
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
let conn = NWConnection(to: result.endpoint, using: .udp)
|
||||
connections[key] = conn
|
||||
@@ -129,7 +140,7 @@ public final class HostDiscovery: ObservableObject {
|
||||
id: (id?.isEmpty == false) ? id! : name,
|
||||
name: name, host: address, port: port.rawValue,
|
||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||
allowsTofu: pair == "optional")
|
||||
allowsTofu: pair == "optional", macAddresses: macs)
|
||||
self.publish()
|
||||
}
|
||||
conn.cancel()
|
||||
|
||||
@@ -35,6 +35,10 @@ public struct AccessUnit: Sendable {
|
||||
public let ptsNs: UInt64
|
||||
public let frameIndex: UInt32
|
||||
public let flags: UInt32
|
||||
/// Client `CLOCK_REALTIME` instant the AU was handed over by the core (post-FEC, decrypted)
|
||||
/// — the **received** measurement point of design/stats-unification.md. The decode stage is
|
||||
/// `decodedNs - receivedNs`, both client-local (no skew offset applies).
|
||||
public let receivedNs: Int64
|
||||
}
|
||||
|
||||
/// One Opus audio packet (48 kHz stereo, 5 ms frames) — decode with AVAudioConverter
|
||||
@@ -63,6 +67,53 @@ func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R)
|
||||
return s.withCString { body($0) }
|
||||
}
|
||||
|
||||
public extension PunktfunkConnection {
|
||||
/// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always
|
||||
/// broadcast (its App Sandbox network entitlements cover it). iOS/tvOS need the managed
|
||||
/// `com.apple.developer.networking.multicast` entitlement, which is GATED pending Apple's
|
||||
/// approval (see `Config/Punktfunk.entitlements`) — until it's granted, sending a broadcast is
|
||||
/// blocked by the OS, so the wake path + its UI are gated off there to avoid a dead action.
|
||||
/// The MAC-learning path stays active on every platform, so flipping this on once the
|
||||
/// entitlement lands makes wake work immediately. ON APPROVAL: change `#if os(macOS)` below to
|
||||
/// `true` for iOS/tvOS too (and uncomment the entitlement).
|
||||
static var wakeOnLANAvailable: Bool {
|
||||
#if os(macOS)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s)
|
||||
/// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are
|
||||
/// skipped. `lastKnownIP`, when set, is additionally unicast. The core broadcasts to every
|
||||
/// interface's subnet-directed broadcast + 255.255.255.255 on ports 9/7, repeated.
|
||||
///
|
||||
/// Returns true if at least one datagram went out. Does blocking sends — call OFF the main
|
||||
/// thread. On iOS/tvOS this requires the `com.apple.developer.networking.multicast` entitlement
|
||||
/// (broadcast is otherwise blocked by the OS); macOS needs only the existing network entitlements.
|
||||
@discardableResult
|
||||
static func wakeOnLAN(macs: [String], lastKnownIP: String? = nil) -> Bool {
|
||||
var bytes: [UInt8] = []
|
||||
var count = 0
|
||||
for mac in macs {
|
||||
let parts = mac.split(separator: ":")
|
||||
guard parts.count == 6 else { continue }
|
||||
let octets = parts.compactMap { UInt8($0, radix: 16) }
|
||||
guard octets.count == 6 else { continue }
|
||||
bytes.append(contentsOf: octets)
|
||||
count += 1
|
||||
}
|
||||
guard count > 0 else { return false }
|
||||
let rc: Int32 = bytes.withUnsafeBufferPointer { buf in
|
||||
withOptionalCString(lastKnownIP) { ip in
|
||||
punktfunk_wake_on_lan(buf.baseAddress, UInt(count), ip)
|
||||
}
|
||||
}
|
||||
return rc == statusOK
|
||||
}
|
||||
}
|
||||
|
||||
public final class PunktfunkConnection {
|
||||
private var handle: OpaquePointer?
|
||||
/// Set by close() before it contends for the plane locks: the pullers see it at their
|
||||
@@ -79,6 +130,9 @@ public final class PunktfunkConnection {
|
||||
/// Same role for the feedback drain thread (rumble + HID-output — two core planes,
|
||||
/// drained sequentially by one thread).
|
||||
private let feedbackLock = NSLock()
|
||||
/// Same role for the host-timing (0xCF) puller — its own plane in the core, drained
|
||||
/// non-blockingly by the app's 1 s stats tick (never contends with the blocking pullers).
|
||||
private let statsLock = NSLock()
|
||||
|
||||
/// Negotiated session mode (host-confirmed).
|
||||
public private(set) var width: UInt32 = 0
|
||||
@@ -419,9 +473,13 @@ public final class PunktfunkConnection {
|
||||
case statusOK:
|
||||
guard let base = frame.data, frame.len > 0 else { return nil }
|
||||
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
|
||||
var ts = timespec()
|
||||
clock_gettime(CLOCK_REALTIME, &ts)
|
||||
let receivedNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
return AccessUnit(
|
||||
data: data, ptsNs: frame.pts_ns,
|
||||
frameIndex: frame.frame_index, flags: frame.flags)
|
||||
frameIndex: frame.frame_index, flags: frame.flags,
|
||||
receivedNs: receivedNs)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
@@ -657,6 +715,40 @@ public final class PunktfunkConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// One per-AU host-timing report (0xCF): the host's capture→fully-sent duration for the
|
||||
/// access unit whose `AccessUnit.ptsNs` equals `ptsNs` exactly. The stats consumer derives
|
||||
/// `network = (receivedNs + clockOffsetNs − ptsNs) − hostUs` — the host/network split of the
|
||||
/// HUD's `host+network` stage (design/stats-unification.md Phase 2).
|
||||
public struct HostTiming: Sendable, Equatable {
|
||||
/// The AU's capture stamp (host capture clock — matches the AU's `ptsNs`).
|
||||
public let ptsNs: UInt64
|
||||
/// Host capture→sent duration, µs.
|
||||
public let hostUs: UInt32
|
||||
}
|
||||
|
||||
/// Pull the next per-AU host timing; nil on timeout, throws `.closed` once the session
|
||||
/// ended. Best-effort plane: an older host never emits any — keep showing the combined
|
||||
/// `host+network` stage then. Drain non-blockingly (`timeoutMs: 0`) from ONE stats
|
||||
/// consumer (its own core plane, safe alongside the other pullers).
|
||||
public func nextHostTiming(timeoutMs: UInt32 = 0) throws -> HostTiming? {
|
||||
statsLock.lock()
|
||||
defer { statsLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var out = PunktfunkHostTiming()
|
||||
let rc = punktfunk_connection_next_host_timing(h, &out, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
return HostTiming(ptsNs: out.pts_ns, hostUs: out.host_us)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
|
||||
/// silently dropped after close.
|
||||
public func send(_ event: PunktfunkInputEvent) {
|
||||
@@ -676,10 +768,12 @@ public final class PunktfunkConnection {
|
||||
pumpLock.lock() // pullers exit at their next poll boundary, releasing these
|
||||
audioLock.lock()
|
||||
feedbackLock.lock()
|
||||
statsLock.lock()
|
||||
abiLock.lock()
|
||||
let h = handle
|
||||
handle = nil
|
||||
abiLock.unlock()
|
||||
statsLock.unlock()
|
||||
feedbackLock.unlock()
|
||||
audioLock.unlock()
|
||||
pumpLock.unlock()
|
||||
|
||||
@@ -24,6 +24,12 @@ public enum DefaultsKey {
|
||||
public static let micEnabled = "punktfunk.micEnabled"
|
||||
public static let speakerUID = "punktfunk.speakerUID"
|
||||
public static let micUID = "punktfunk.micUID"
|
||||
/// macOS: which input channel of the chosen mic device feeds the host. 0 = "Auto" (sum every
|
||||
/// channel to mono — a mic on a single input of a multi-channel interface passes at full
|
||||
/// level); n≥1 pins 1-based input channel n. Multi-channel interfaces expose the mic on ONE
|
||||
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
||||
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
||||
public static let micChannel = "punktfunk.micChannel"
|
||||
public static let presenter = "punktfunk.presenter"
|
||||
/// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host
|
||||
/// has HDR content AND this display supports HDR — otherwise the stream stays 8-bit SDR.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Splits the unified stats model's `host+network` stage (capture→received) into its `host`
|
||||
// (capture→fully-sent, reported per AU by the host on the 0xCF plane) and `network`
|
||||
// (the remainder) terms — design/stats-unification.md Phase 2.
|
||||
//
|
||||
// Receipt samples are recorded per frame from the pump path; host timings are matched to them
|
||||
// by exact pts (the 0xCF datagram carries the AU's own `pts_ns`). Best-effort by construction:
|
||||
// a lost 0xCF datagram, an FEC-dropped AU, or an old host that never emits the plane simply
|
||||
// contributes no split sample — the HUD then keeps the combined `host+network` line. NSLock
|
||||
// rather than an actor — the receipt writer is the non-async pump path (same pattern as
|
||||
// LatencyMeter/FrameMeter).
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Per-frame `host` / `network` sampler: `recordReceipt` at AU receipt (pts + the combined
|
||||
/// capture→received interval), `noteHostTiming` per drained 0xCF report, `drain` the window's
|
||||
/// p50s once a second. The pending ring is bounded (drop-oldest) so an old host — receipts
|
||||
/// forever, timings never — costs a fixed ~4 KB, not growth.
|
||||
public final class HostNetworkSplitter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
/// Received AUs awaiting their 0xCF host timing: (pts, combined capture→received µs).
|
||||
private var pending: [(ptsNs: UInt64, combinedUs: Int64)] = []
|
||||
private var hostUsSamples: [Int64] = []
|
||||
private var networkUsSamples: [Int64] = []
|
||||
/// ~1 s of frames at 240 fps; beyond it the oldest receipt can no longer expect a match.
|
||||
private static let pendingCap = 256
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Record one frame at receipt. `ptsNs` is the host capture clock (the AU's pts),
|
||||
/// `receivedNs` the client `CLOCK_REALTIME` receipt instant (`AccessUnit.receivedNs`),
|
||||
/// `offsetNs` the connect-time host−client clock offset (0 = uncorrected). Same
|
||||
/// absurd-value clamp as LatencyMeter — a sample it would drop must not linger here.
|
||||
public func recordReceipt(ptsNs: UInt64, receivedNs: Int64, offsetNs: Int64) {
|
||||
let combinedNs = receivedNs &+ offsetNs &- Int64(bitPattern: ptsNs)
|
||||
guard combinedNs > 0, combinedNs < 10_000_000_000 else { return }
|
||||
lock.lock()
|
||||
pending.append((ptsNs: ptsNs, combinedUs: combinedNs / 1000))
|
||||
if pending.count > Self.pendingCap {
|
||||
pending.removeFirst(pending.count - Self.pendingCap)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Match one host timing (0xCF) to its receipt: `host` = the reported capture→sent,
|
||||
/// `network` = the combined interval minus it, floored at 0 (the terms tile per frame; a
|
||||
/// slightly-off skew offset must not produce a negative wire time). Unmatched timings —
|
||||
/// the AU was FEC-dropped, or its receipt raced this drain — are simply skipped.
|
||||
public func noteHostTiming(ptsNs: UInt64, hostUs: UInt32) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let i = pending.firstIndex(where: { $0.ptsNs == ptsNs }) else { return }
|
||||
let combinedUs = pending.remove(at: i).combinedUs
|
||||
hostUsSamples.append(Int64(hostUs))
|
||||
networkUsSamples.append(max(0, combinedUs - Int64(hostUs)))
|
||||
}
|
||||
|
||||
public struct Split: Sendable {
|
||||
public let hostP50Ms: Double
|
||||
public let networkP50Ms: Double
|
||||
public let count: Int
|
||||
}
|
||||
|
||||
/// The window's p50s since the last drain, then reset (matched samples only; the pending
|
||||
/// ring survives — a receipt may still match a timing drained next tick). `nil` when no
|
||||
/// timing matched in the interval — the caller falls back to the combined stage.
|
||||
public func drain() -> Split? {
|
||||
lock.lock()
|
||||
let host = hostUsSamples.sorted()
|
||||
let network = networkUsSamples.sorted()
|
||||
hostUsSamples.removeAll(keepingCapacity: true)
|
||||
networkUsSamples.removeAll(keepingCapacity: true)
|
||||
lock.unlock()
|
||||
guard !host.isEmpty else { return nil }
|
||||
func p50(_ sorted: [Int64]) -> Double {
|
||||
Double(sorted[min(sorted.count / 2, sorted.count - 1)]) / 1000.0 // µs → ms
|
||||
}
|
||||
return Split(hostP50Ms: p50(host), networkP50Ms: p50(network), count: host.count)
|
||||
}
|
||||
|
||||
/// Forget everything (pending receipts + window) — a fresh connection starts clean.
|
||||
public func reset() {
|
||||
lock.lock()
|
||||
pending.removeAll()
|
||||
hostUsSamples.removeAll()
|
||||
networkUsSamples.removeAll()
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,25 @@
|
||||
// Per-frame latency sampler for the live HUD: records capture->client-receipt latency and drains
|
||||
// percentiles on demand. NSLock rather than an actor — the writer is the non-async pump/arrival
|
||||
// path (same pattern as the app's FrameMeter).
|
||||
// Per-frame latency-stage sampler for the live HUD: records one interval per frame (an end
|
||||
// instant minus a start instant, both CLOCK_REALTIME ns) and drains percentiles on demand.
|
||||
// NSLock rather than an actor — the writers are the non-async pump/decode/present paths (same
|
||||
// pattern as the app's FrameMeter).
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Samples the **capture->client-receipt** latency of each access unit and reports percentiles.
|
||||
/// Samples one **latency stage** per frame and reports percentiles. One instance per stage of the
|
||||
/// unified stats model (design/stats-unification.md):
|
||||
///
|
||||
/// The latency is `now - pts_ns`, where `pts_ns` is the host's capture wall clock (the AU's pts) and
|
||||
/// `now` is the client's `CLOCK_REALTIME` instant the AU was received, shifted by the connect-time
|
||||
/// **clock-skew offset** (`PunktfunkConnection.clockOffsetNs`, host minus client) so the difference
|
||||
/// is valid across machines. `offsetNs == 0` means an old host that didn't answer the skew handshake
|
||||
/// (or genuinely synced clocks) — the number is then only meaningful same-host.
|
||||
/// - `host+network` = capture→received: `record(ptsNs:offsetNs:)` at AU receipt.
|
||||
/// - `decode` = received→decoded and `display` = decoded→displayed: client-local single-clock
|
||||
/// stages — `record(ptsNs:atNs:offsetNs:)` with the start instant as `ptsNs` and `offsetNs: 0`.
|
||||
/// - `end-to-end` = capture→displayed, measured directly (never summed from the stages):
|
||||
/// `record(ptsNs:atNs:offsetNs:)` at present.
|
||||
///
|
||||
/// SCOPE (stage-1 presenter): this covers host capture -> encode -> FEC -> network -> reassembly ->
|
||||
/// decrypt -> handed to the presenter. It does **not** include the on-device VideoToolbox decode or
|
||||
/// the `AVSampleBufferDisplayLayer` present — that layer decodes and presents compressed samples
|
||||
/// internally with no per-frame callback. True decode->present (the full glass-to-glass) needs the
|
||||
/// stage-2 presenter (`VTDecompressionSession` decode-completion + `CAMetalLayer`/display-link
|
||||
/// present); this meter is the substrate it will extend.
|
||||
/// For the host-anchored intervals (capture→…) the sample is `end + offset - pts_ns`, where
|
||||
/// `pts_ns` is the host's capture wall clock (the AU's pts) and the connect-time **clock-skew
|
||||
/// offset** (`PunktfunkConnection.clockOffsetNs`, host minus client) makes the difference valid
|
||||
/// across machines. `offsetNs == 0` means an old host that didn't answer the skew handshake (or
|
||||
/// genuinely synced clocks) — the number is then only meaningful same-host, and the HUD tags the
|
||||
/// end-to-end line `(same-host clock)`.
|
||||
public final class LatencyMeter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var samplesUs: [Int64] = []
|
||||
@@ -34,12 +36,16 @@ public final class LatencyMeter: @unchecked Sendable {
|
||||
record(ptsNs: ptsNs, atNs: nowNs, offsetNs: offsetNs)
|
||||
}
|
||||
|
||||
/// Record one frame whose latency is `atNs + offsetNs - ptsNs` — an EXPLICIT client instant
|
||||
/// rather than now. The stage-2 presenter uses this to stamp capture→present at the display
|
||||
/// link's target present time (not the moment the present call ran). All in `CLOCK_REALTIME`.
|
||||
/// Record one frame whose sample is `atNs + offsetNs - ptsNs` — an EXPLICIT end instant
|
||||
/// rather than now. `ptsNs` is the stage's start point: the AU pts for the host-anchored
|
||||
/// intervals, or a client stamp (receivedNs / decodedNs, with `offsetNs: 0`) for the local
|
||||
/// decode/display stages. The stage-2 presenter stamps its present-side samples at the
|
||||
/// display link's target present time (not the moment the present call ran). All in
|
||||
/// `CLOCK_REALTIME`.
|
||||
public func record(ptsNs: UInt64, atNs: Int64, offsetNs: Int64) {
|
||||
let latNs = atNs &+ offsetNs &- Int64(bitPattern: ptsNs)
|
||||
// Drop absurd values (a clock step, a wildly wrong offset, or garbage pts).
|
||||
// Drop absurd values (a clock step, a wildly wrong offset, garbage pts, or a stage whose
|
||||
// start stamp is missing/after its end) — samples are clamped to (0, 10 s).
|
||||
guard latNs > 0, latNs < 10_000_000_000 else { return }
|
||||
lock.lock()
|
||||
samplesUs.append(latNs / 1000)
|
||||
|
||||
@@ -38,8 +38,9 @@ final class SessionPresenter {
|
||||
func start(
|
||||
connection: PunktfunkConnection,
|
||||
baseLayer: AVSampleBufferDisplayLayer,
|
||||
presentMeter: LatencyMeter?,
|
||||
presentTailMeter: LatencyMeter? = nil,
|
||||
endToEndMeter: LatencyMeter?,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?
|
||||
@@ -59,7 +60,8 @@ final class SessionPresenter {
|
||||
#endif
|
||||
if !forceStage1,
|
||||
let pipeline = Stage2Pipeline(
|
||||
presentMeter: presentMeter, presentTailMeter: presentTailMeter) {
|
||||
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter) {
|
||||
let metal = pipeline.layer
|
||||
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
||||
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user