Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6241639042 | ||
|
|
7c72899a49 |
@@ -1,18 +0,0 @@
|
|||||||
# Workspace-wide build flags.
|
|
||||||
#
|
|
||||||
# aes_armv8: RustCrypto's `aes` 0.8.x enables ARMv8-Crypto hardware AES on aarch64 only behind
|
|
||||||
# this cfg (x86_64 AES-NI is runtime-detected with no flag; the 0.9 line will make aarch64
|
|
||||||
# automatic too). Without it every aarch64 client (all Apple + virtually all Android) ran
|
|
||||||
# SOFTWARE AES on the per-packet decrypt path — measured 2026-07-14 on an M3 Ultra at
|
|
||||||
# ~240 MiB/s/core (~7 µs per 1.4 KB datagram), which single-handedly capped receive throughput
|
|
||||||
# at ~1.57 Gbps wire. The cfg still runtime-detects via `cpufeatures`, so a chip without the
|
|
||||||
# extensions falls back safely.
|
|
||||||
#
|
|
||||||
# NOTE: a RUSTFLAGS environment variable OVERRIDES config rustflags entirely — build scripts /
|
|
||||||
# CI lanes that set RUSTFLAGS for aarch64 targets (cargo-ndk, xcframework) must carry
|
|
||||||
# `--cfg aes_armv8` themselves.
|
|
||||||
# polyval_armv8: same story for GCM's other half — `polyval` 0.6.x gates its PMULL (carry-less
|
|
||||||
# multiply) GHASH path behind this cfg on aarch64. AES alone took open_in_place from 240 to
|
|
||||||
# ~790 MiB/s on the M3 Ultra; software GHASH still dominated until this flag joined it.
|
|
||||||
[target.'cfg(target_arch = "aarch64")']
|
|
||||||
rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
|
|
||||||
@@ -46,23 +46,13 @@ jobs:
|
|||||||
# SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
|
# SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
|
||||||
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
|
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
|
||||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||||
with:
|
|
||||||
# Only platform-tools — NOT the action's default legacy `tools`, whose dependency chain
|
|
||||||
# drags in the ~250 MB emulator nobody here runs (instrumentation tests are deferred).
|
|
||||||
# That download was the single flakiest piece of this job: the shared runner fleet drops
|
|
||||||
# packets under parallel-job load and sdkmanager's streamed unzip turns a truncated
|
|
||||||
# stream into "Error on ZipFile unknown archive" (observed 2026-07-22, twice).
|
|
||||||
packages: platform-tools
|
|
||||||
|
|
||||||
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
|
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
|
||||||
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
|
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
|
||||||
# kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build.
|
# kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build.
|
||||||
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
|
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
|
||||||
# auto-download it if needed during the build.
|
# auto-download it if needed during the build.
|
||||||
# retry.sh: sdkmanager is a single-shot multi-hundred-MB fetch, exactly the class the
|
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
|
||||||
# helper exists for (fleet-load packet drops truncate the stream mid-unzip); a failed
|
|
||||||
# attempt leaves no partial package behind, so a plain re-invoke is safe.
|
|
||||||
run: bash scripts/ci/retry.sh 4 sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
|
|
||||||
|
|
||||||
- name: Caches (cargo + gradle)
|
- name: Caches (cargo + gradle)
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Build the punktfunk-host / punktfunk-client / punktfunk-web / punktfunk-scripting pacman packages from
|
# 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
|
# 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).
|
# 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
|
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
|
||||||
@@ -42,12 +42,11 @@ jobs:
|
|||||||
- name: Install build + runtime-dev deps
|
- name: Install build + runtime-dev deps
|
||||||
run: |
|
run: |
|
||||||
pacman -Syu --noconfirm --needed \
|
pacman -Syu --noconfirm --needed \
|
||||||
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
|
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
|
||||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||||
mesa libglvnd unzip libarchive
|
mesa libglvnd unzip libarchive
|
||||||
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
|
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
||||||
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so bootstrap
|
# it's AUR-only on Arch, so bootstrap the official binary.
|
||||||
# the official binary.
|
|
||||||
command -v bun >/dev/null || {
|
command -v bun >/dev/null || {
|
||||||
curl -fsSL https://bun.sh/install | bash
|
curl -fsSL https://bun.sh/install | bash
|
||||||
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||||
@@ -106,7 +105,7 @@ jobs:
|
|||||||
sudo -u builder git config --global --add safe.directory "$PWD"
|
sudo -u builder git config --global --add safe.directory "$PWD"
|
||||||
mkdir -p dist && chown builder: dist
|
mkdir -p dist && chown builder: dist
|
||||||
cd packaging/arch
|
cd packaging/arch
|
||||||
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \
|
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \
|
||||||
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
|
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
|
||||||
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
|
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
|
||||||
makepkg -f -d --holdver
|
makepkg -f -d --holdver
|
||||||
|
|||||||
+18
-121
@@ -1,18 +1,8 @@
|
|||||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
# Build the punktfunk-host and punktfunk-client .debs and publish them to Gitea's Debian
|
||||||
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
|
# package registry, so Ubuntu boxes get new builds via `apt update && apt upgrade`. Runs
|
||||||
# apt distribution/component:
|
# inside the same Ubuntu 26.04 rust-ci builder image as ci.yml, so dpkg-shlibdeps pins the
|
||||||
#
|
# runtime lib package names (libavcodec62, libpipewire-0.3-0t64, …) to exactly what the
|
||||||
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
# target boxes run.
|
||||||
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
|
|
||||||
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
|
|
||||||
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
|
|
||||||
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
|
|
||||||
# on Ubuntu 24.04 LTS through 26.04. (A 26.04-built host .deb is uninstallable
|
|
||||||
# on 24.04 — the reason this job exists; see packaging/debian/README.md.)
|
|
||||||
#
|
|
||||||
# Both compute VERSION identically (scripts/ci/pf-version.sh is deterministic per commit), so the
|
|
||||||
# host and client packages always share a version line. The release-attach helpers are race-safe
|
|
||||||
# (ensure_release create-or-fetch; upsert_asset only conflicts on same-name), so the jobs run parallel.
|
|
||||||
#
|
#
|
||||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages
|
# Registry (public, unom org): https://git.unom.io/unom/-/packages
|
||||||
# Box setup (once): see packaging/debian/README.md
|
# Box setup (once): see packaging/debian/README.md
|
||||||
@@ -93,15 +83,22 @@ jobs:
|
|||||||
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||||
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||||
|
|
||||||
- name: Build release clients
|
- name: Build release host + client
|
||||||
env:
|
env:
|
||||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
||||||
run: |
|
run: |
|
||||||
git config --global --add safe.directory "$PWD"
|
git config --global --add safe.directory "$PWD"
|
||||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
||||||
# both client binaries must ship (build-client-deb.sh installs both). The HOST is built
|
# both client binaries must ship (build-client-deb.sh installs both).
|
||||||
# separately in the build-publish-host job (Ubuntu 24.04 image + bundled FFmpeg 8).
|
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on
|
||||||
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session
|
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at
|
||||||
|
# runtime (no link-time dep; identical DT_NEEDED to a plain build), and the encoder is only
|
||||||
|
# constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT, never on VAAPI hosts.
|
||||||
|
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — raw VK_KHR_video_encode_h265
|
||||||
|
# with real RFI (design/linux-vulkan-video-encode.md). Pure Rust ash (no new lib / link dep);
|
||||||
|
# default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 → libav VAAPI), failed open falls back to VAAPI.
|
||||||
|
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||||
|
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||||
|
|
||||||
- name: Build + smoke-boot web console (bun preset)
|
- name: Build + smoke-boot web console (bun preset)
|
||||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||||
@@ -131,12 +128,10 @@ jobs:
|
|||||||
- name: Build .debs
|
- name: Build .debs
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.bun/bin:$PATH"
|
export PATH="$HOME/.bun/bin:$PATH"
|
||||||
# host .deb is built in build-publish-host (Ubuntu 24.04 image); this job ships the rest.
|
VERSION="$VERSION" bash packaging/debian/build-deb.sh
|
||||||
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
|
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
|
||||||
# Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading.
|
# Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading.
|
||||||
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
|
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
|
||||||
# The plugin/script runner (bun-bundled Effect SDK) — same vendored-bun mechanics.
|
|
||||||
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-scripting-deb.sh
|
|
||||||
|
|
||||||
- name: Publish to the Gitea apt registry
|
- name: Publish to the Gitea apt registry
|
||||||
env:
|
env:
|
||||||
@@ -171,101 +166,3 @@ jobs:
|
|||||||
for DEB in dist/*.deb; do
|
for DEB in dist/*.deb; do
|
||||||
upsert_asset "$RID" "$DEB"
|
upsert_asset "$RID" "$DEB"
|
||||||
done
|
done
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------------------------
|
|
||||||
# The HOST .deb — built on Ubuntu 24.04 (noble) with a from-source FFmpeg 8 BUNDLED, so it installs
|
|
||||||
# on Ubuntu 24.04 LTS through 26.04 (see the file header + packaging/debian/README.md). Runs in
|
|
||||||
# parallel with build-publish and publishes to the SAME distribution/component; the version step is
|
|
||||||
# byte-identical so host and clients share a version line.
|
|
||||||
build-publish-host:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
container:
|
|
||||||
image: git.unom.io/unom/punktfunk-rust-ci-noble:latest
|
|
||||||
timeout-minutes: 90
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Version + channel
|
|
||||||
run: |
|
|
||||||
git config --global --add safe.directory "$PWD"
|
|
||||||
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="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
|
||||||
esac
|
|
||||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
|
||||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
|
||||||
echo "host package version $V -> apt distribution '$DIST'"
|
|
||||||
|
|
||||||
# dpkg-shlibdeps/dpkg-deb + patchelf are baked into rust-ci-noble; python3 is for the
|
|
||||||
# release-attach helper. Re-install defensively so the job stays green against the PREVIOUS
|
|
||||||
# image on the same push (docker.yml bootstrap lag) — a no-op once the image ships them.
|
|
||||||
- name: dpkg-dev + patchelf + python3
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --no-install-recommends dpkg-dev patchelf python3
|
|
||||||
|
|
||||||
- name: Cache keys
|
|
||||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
/usr/local/cargo/registry
|
|
||||||
/usr/local/cargo/git
|
|
||||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
|
||||||
restore-keys: cargo-home-
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: target
|
|
||||||
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
|
|
||||||
# ci.yml's 26.04 target cache (mixing would poison both).
|
|
||||||
key: cargo-target-noble-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
|
||||||
restore-keys: cargo-target-noble-v1-${{ env.rustc }}-
|
|
||||||
|
|
||||||
- name: Build release host
|
|
||||||
env:
|
|
||||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
|
||||||
run: |
|
|
||||||
git config --global --add safe.directory "$PWD"
|
|
||||||
# Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA;
|
|
||||||
# NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode
|
|
||||||
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). punktfunk-tray also ships in the host
|
|
||||||
# .deb (build-deb.sh builds+installs it). ffmpeg-sys-next links the image's bundled FFmpeg 8
|
|
||||||
# via PKG_CONFIG_PATH (set in rust-ci-noble).
|
|
||||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
|
||||||
-p punktfunk-host -p punktfunk-tray
|
|
||||||
|
|
||||||
- name: Build host .deb (FFmpeg bundled)
|
|
||||||
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
|
|
||||||
# binary's rpath, so there is no `Depends: libavcodec62` to block install on 24.04. FFMPEG_PREFIX
|
|
||||||
# defaults to /opt/ffmpeg (the image's ENV also sets it).
|
|
||||||
run: |
|
|
||||||
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
|
||||||
|
|
||||||
- name: Publish to the Gitea apt registry
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
run: |
|
|
||||||
for DEB in dist/*.deb; do
|
|
||||||
echo "uploading $DEB"
|
|
||||||
NAME=$(dpkg-deb -f "$DEB" Package)
|
|
||||||
VER=$(dpkg-deb -f "$DEB" Version)
|
|
||||||
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
|
||||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
|
||||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
|
||||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
|
||||||
done
|
|
||||||
echo "published host to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
|
||||||
|
|
||||||
- name: Attach the host .deb to the Gitea release (stable tags only)
|
|
||||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
run: |
|
|
||||||
. scripts/ci/gitea-release.sh
|
|
||||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
|
||||||
for DEB in dist/*.deb; do
|
|
||||||
upsert_asset "$RID" "$DEB"
|
|
||||||
done
|
|
||||||
|
|||||||
+30
-24
@@ -6,12 +6,17 @@
|
|||||||
#
|
#
|
||||||
# The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT
|
# The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT
|
||||||
# need the Decky CLI (which requires Docker + rust-nightly only to compile native backends).
|
# need the Decky CLI (which requires Docker + rust-nightly only to compile native backends).
|
||||||
# We build the frontend with pnpm and stage the store-layout tree with the SAME script local
|
# We build the frontend with pnpm and assemble the store-layout zip by hand:
|
||||||
# builds use (clients/decky/scripts/package.sh) — the plugin's file list lives in exactly ONE
|
#
|
||||||
# place, so a file added there (bin/, assets/, controller_config/, …) can never be silently
|
# punktfunk.zip
|
||||||
# missing from the published build. (Hand-assembling the zip here is how the shipped plugin
|
# punktfunk/ <- single top-level dir == plugin.json "name"
|
||||||
# lost the shortcut artwork + Steam Input layout for a while.) CI only adds `update.json` on
|
# plugin.json [required]
|
||||||
# top: the {channel, manifest} pointer the plugin's self-update check polls.
|
# package.json [required; CI stamps "version" — Decky reads the installed version here]
|
||||||
|
# main.py [required: python backend]
|
||||||
|
# dist/index.js [required: rollup output]
|
||||||
|
# update.json [CI-baked {channel, manifest}: where the plugin's self-update check polls]
|
||||||
|
# README.md (recommended)
|
||||||
|
# LICENSE [required by the plugin store]
|
||||||
#
|
#
|
||||||
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
|
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
|
||||||
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
|
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
|
||||||
@@ -85,27 +90,28 @@ jobs:
|
|||||||
- name: Assemble store-layout zip
|
- name: Assemble store-layout zip
|
||||||
working-directory: ${{ gitea.workspace }}
|
working-directory: ${{ gitea.workspace }}
|
||||||
run: |
|
run: |
|
||||||
# node:22-bookworm ships python3 (a package.sh dep) but not zip; install both anyway
|
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
|
||||||
# so an image change can't silently break the build.
|
STAGE="$RUNNER_TEMP/decky"
|
||||||
apt-get update && apt-get install -y --no-install-recommends zip python3 >/dev/null
|
DEST="$STAGE/$PLUGIN"
|
||||||
# Stage the canonical plugin tree (dist/, main.py, bin/, assets/, controller_config/,
|
rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin"
|
||||||
# LICENSE, …) with the same script local/sideload builds use — see the header comment.
|
cp clients/decky/plugin.json "$DEST/"
|
||||||
# Runs AFTER the version stamp, so the staged package.json carries $VERSION.
|
cp clients/decky/package.json "$DEST/"
|
||||||
bash clients/decky/scripts/package.sh
|
cp clients/decky/main.py "$DEST/"
|
||||||
DEST="clients/decky/out/$PLUGIN"
|
cp clients/decky/dist/index.js "$DEST/dist/"
|
||||||
# CI-only addition: the self-update channel pointer the backend reads (main.py
|
cp clients/decky/README.md "$DEST/"
|
||||||
# check_update). It points at THIS channel's manifest.json (published below); that
|
# The stream-launch wrapper (target of the Steam shortcut); keep it executable
|
||||||
# manifest in turn points at the immutable per-version zip, so its sha256 stays valid
|
# (runner_info() also re-chmods at runtime in case the zip/extract drops the bit).
|
||||||
# across future alias re-uploads.
|
cp clients/decky/bin/punktfunkrun.sh "$DEST/bin/"
|
||||||
|
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||||
|
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
|
||||||
|
cp LICENSE-MIT "$DEST/LICENSE"
|
||||||
|
# Self-update channel pointer the backend reads (main.py check_update). It points at
|
||||||
|
# THIS channel's manifest.json (published below); that manifest in turn points at the
|
||||||
|
# immutable per-version zip, so its sha256 stays valid across future alias re-uploads.
|
||||||
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
|
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
|
||||||
( cd clients/decky/out && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||||
ls -lh "$RUNNER_TEMP/punktfunk.zip"
|
ls -lh "$RUNNER_TEMP/punktfunk.zip"
|
||||||
unzip -l "$RUNNER_TEMP/punktfunk.zip"
|
unzip -l "$RUNNER_TEMP/punktfunk.zip"
|
||||||
# Backstop against packaging drift: the runtime-loaded pieces MUST be in the zip.
|
|
||||||
for f in main.py dist/index.js bin/punktfunkrun.sh assets/grid.png \
|
|
||||||
controller_config/punktfunk.vdf update.json; do
|
|
||||||
unzip -l "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN/$f" >/dev/null || { echo "MISSING $f" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
# The update manifest the plugin polls: the immutable per-version artifact + its
|
# The update manifest the plugin polls: the immutable per-version artifact + its
|
||||||
# sha256 (Decky's installer verifies the download against this hash, aborting on
|
# sha256 (Decky's installer verifies the download against this hash, aborting on
|
||||||
# mismatch — so it MUST be the per-version URL, never the mutable alias).
|
# mismatch — so it MUST be the per-version URL, never the mutable alias).
|
||||||
|
|||||||
@@ -39,12 +39,6 @@ jobs:
|
|||||||
- image: punktfunk-rust-ci
|
- image: punktfunk-rust-ci
|
||||||
dockerfile: ci/rust-ci.Dockerfile
|
dockerfile: ci/rust-ci.Dockerfile
|
||||||
context: ci
|
context: ci
|
||||||
# Ubuntu 24.04 LTS host builder: same purpose as rust-ci but lowers the host .deb's glibc
|
|
||||||
# floor to 2.39 and bundles a from-source FFmpeg 8, so the package installs on 24.04 LTS
|
|
||||||
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
|
|
||||||
- image: punktfunk-rust-ci-noble
|
|
||||||
dockerfile: ci/rust-ci-noble.Dockerfile
|
|
||||||
context: ci
|
|
||||||
- image: punktfunk-fedora-rpm
|
- image: punktfunk-fedora-rpm
|
||||||
dockerfile: ci/fedora-rpm.Dockerfile
|
dockerfile: ci/fedora-rpm.Dockerfile
|
||||||
context: ci
|
context: ci
|
||||||
|
|||||||
@@ -73,21 +73,8 @@ jobs:
|
|||||||
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
||||||
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
||||||
# there, right before the first `flatpak` network call.
|
# there, right before the first `flatpak` network call.
|
||||||
- name: Fix container DNS (drop nss-resolve)
|
- name: Fix container DNS (drop nss-resolve — no systemd-resolved in CI)
|
||||||
run: |
|
run: sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||||
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
|
||||||
# History: this step used to ALSO force glibc onto TCP DNS (`options use-vc`) because
|
|
||||||
# the runner fleet's Docker embedded resolver dropped UDP lookups under parallel-job
|
|
||||||
# load (investigated 2026-07-11; v0.15.0/v0.16.0 each burned retry.sh's whole budget).
|
|
||||||
# That root cause is now fixed at the infra level (2026-07-22): the runner host runs a
|
|
||||||
# local dnsmasq cache on the docker bridge and daemon.json points every job container
|
|
||||||
# at it, so lookups terminate on-box instead of crossing the saturated uplink — the
|
|
||||||
# UDP path is reliable again. The TCP path through the same chain proved FLAKY under
|
|
||||||
# fleet concurrency (flatpak remote-add failed 10/10 with instant NXDOMAIN while dnf
|
|
||||||
# in the same container resolved fine), so `use-vc` flipped from mitigation to sole
|
|
||||||
# cause of this leg's failures — removed. retry.sh (10×) stays as the backstop for
|
|
||||||
# genuine upstream blips.
|
|
||||||
cat /etc/resolv.conf || true
|
|
||||||
|
|
||||||
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
||||||
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
# Publish the plugin framework (@punktfunk/plugin-kit) to the Gitea npm registry
|
|
||||||
# (https://git.unom.io/api/packages/unom/npm/).
|
|
||||||
#
|
|
||||||
# Trigger: push a tag `plugin-kit-vX.Y.Z` (must equal plugin-kit/package.json "version"),
|
|
||||||
# or run manually. Versions independently of the app's `v*` and the SDK's `sdk-v*` tags.
|
|
||||||
#
|
|
||||||
# The kit's devDependency on @punktfunk/host is `file:../sdk`, so the SDK's dist must be
|
|
||||||
# built BEFORE the kit's `bun install` copies it.
|
|
||||||
#
|
|
||||||
# Auth: REGISTRY_TOKEN — the same repo Actions secret sdk-publish.yml uses.
|
|
||||||
name: plugin-kit-publish
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['plugin-kit-v*']
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
container:
|
|
||||||
image: oven/bun:1
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
# oven/bun's slim base ships neither git, a CA bundle, nor node — actions/checkout's HTTPS
|
|
||||||
# fetch needs git + ca-certificates, and the version-guard step below uses node.
|
|
||||||
- name: Install git + node + CA certs
|
|
||||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Build the SDK (file:../sdk dependency source)
|
|
||||||
working-directory: sdk
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile --ignore-scripts
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: bun install --frozen-lockfile --ignore-scripts
|
|
||||||
|
|
||||||
- name: Typecheck
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: bun run typecheck
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: bun test
|
|
||||||
|
|
||||||
- name: Build (dist/ JS + .d.ts + theme.css)
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Tag matches package version
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: |
|
|
||||||
TAG="${GITHUB_REF_NAME#plugin-kit-v}"
|
|
||||||
PKG="$(node -p "require('./package.json').version")"
|
|
||||||
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME does not match package version $PKG"; exit 1; }
|
|
||||||
|
|
||||||
- name: Publish to Gitea registry
|
|
||||||
working-directory: plugin-kit
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
run: |
|
|
||||||
test -n "$NODE_AUTH_TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
|
|
||||||
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$NODE_AUTH_TOKEN" >> .npmrc
|
|
||||||
bun publish
|
|
||||||
@@ -149,26 +149,6 @@ jobs:
|
|||||||
# inherits this from the env during the xcframework build).
|
# inherits this from the env during the xcframework build).
|
||||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Pin + prune Xcode DerivedData
|
|
||||||
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
|
|
||||||
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
|
|
||||||
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
|
|
||||||
# under ~/Library that nothing ever collected. 31 of them piled up in three days
|
|
||||||
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
|
|
||||||
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
|
|
||||||
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
|
|
||||||
run: |
|
|
||||||
DD="$HOME/ci/derived-data/release"
|
|
||||||
mkdir -p "$DD"
|
|
||||||
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
|
|
||||||
# Safety net for trees the pin does not own: the legacy per-path ones from before this
|
|
||||||
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
|
|
||||||
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
|
|
||||||
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
|
|
||||||
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
|
|
||||||
|
|
||||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||||
@@ -196,7 +176,6 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
CODE_SIGNING_ALLOWED=NO
|
CODE_SIGNING_ALLOWED=NO
|
||||||
@@ -279,12 +258,9 @@ jobs:
|
|||||||
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
||||||
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
||||||
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
||||||
# entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and
|
# entitlements in. No -allowProvisioningUpdates → it stays OFFLINE and never cloud-signs
|
||||||
# regenerate the managed *development* profile — needed because the App Groups capability
|
# (the App-Manager ASC key can't), so the runner must have a macOS *development* profile
|
||||||
# (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached
|
# for io.unom.punktfunk installed. DISTRIBUTION signing happens in the export step below
|
||||||
# one. This is DEVELOPMENT signing against the Apple Development cert already in the
|
|
||||||
# keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export
|
|
||||||
# step below
|
|
||||||
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
||||||
# distribution profile that export needs.
|
# distribution profile that export needs.
|
||||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
@@ -294,12 +270,7 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-allowProvisioningUpdates \
|
|
||||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
|
||||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
|
||||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
CODE_SIGN_STYLE=Automatic \
|
CODE_SIGN_STYLE=Automatic \
|
||||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
@@ -337,33 +308,19 @@ jobs:
|
|||||||
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
||||||
# this step used to set matched it and failed the archive ("does not support provisioning
|
# this step used to set matched it and failed the archive ("does not support provisioning
|
||||||
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
||||||
# the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID
|
# the macOS-host macro plugins) alone. No -allowProvisioningUpdates → OFFLINE, never
|
||||||
# capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk
|
# cloud-signs (the App-Manager ASC key can't), so the runner needs an iOS *development*
|
||||||
# AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups
|
# profile for io.unom.punktfunk installed. DISTRIBUTION signing is the export step below
|
||||||
# capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension)
|
# (manual, via the plist). A running Xcode.app prunes unrecognized profiles — quit it so the
|
||||||
# invalidated the cached managed dev profile, which had no widgets profile at all. This is
|
# manually-installed App Store distribution profile survives for export.
|
||||||
# DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert
|
|
||||||
# creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles).
|
|
||||||
# DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected.
|
|
||||||
# A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed
|
|
||||||
# App Store distribution profile survives for export.
|
|
||||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk iOS App Store Distribution"
|
PROFILE="Punktfunk iOS App Store Distribution"
|
||||||
# The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second
|
|
||||||
# distribution artifact in the .ipa, so manual signing must map its App ID to its own
|
|
||||||
# App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets").
|
|
||||||
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
|
|
||||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||||
-destination 'generic/platform=iOS' \
|
-destination 'generic/platform=iOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-allowProvisioningUpdates \
|
|
||||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
|
||||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
|
||||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
CODE_SIGN_STYLE=Automatic \
|
CODE_SIGN_STYLE=Automatic \
|
||||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
@@ -378,10 +335,7 @@ jobs:
|
|||||||
<key>signingStyle</key><string>manual</string>
|
<key>signingStyle</key><string>manual</string>
|
||||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||||
<key>provisioningProfiles</key>
|
<key>provisioningProfiles</key>
|
||||||
<dict>
|
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
|
||||||
<key>io.unom.punktfunk</key><string>$PROFILE</string>
|
|
||||||
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
EOF
|
EOF
|
||||||
@@ -405,11 +359,9 @@ jobs:
|
|||||||
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
||||||
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
||||||
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
||||||
# AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities
|
# AssociationMacro) alone. No -allowProvisioningUpdates → OFFLINE, never cloud-signs (the
|
||||||
# and regenerate the managed *development* profile — the tvOS app carries the App Groups key
|
# App-Manager ASC key can't), so the runner needs a tvOS *development* profile for
|
||||||
# (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against
|
# io.unom.punktfunk installed. DISTRIBUTION signing is the export step below (manual, plist).
|
||||||
# the Apple Development cert already in the keychain, so the App-Manager ASC key suffices.
|
|
||||||
# DISTRIBUTION signing is the export step below (manual, plist).
|
|
||||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk tvOS App Store Distribution"
|
PROFILE="Punktfunk tvOS App Store Distribution"
|
||||||
@@ -417,12 +369,7 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||||
-destination 'generic/platform=tvOS' \
|
-destination 'generic/platform=tvOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-allowProvisioningUpdates \
|
|
||||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
|
||||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
|
||||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
CODE_SIGN_STYLE=Automatic \
|
CODE_SIGN_STYLE=Automatic \
|
||||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
|
|||||||
@@ -92,10 +92,9 @@ jobs:
|
|||||||
echo "rpm $V-$R -> group '$GROUP'"
|
echo "rpm $V-$R -> group '$GROUP'"
|
||||||
|
|
||||||
- name: Build RPM
|
- name: Build RPM
|
||||||
# PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 → also build the punktfunk-web console + the
|
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below
|
||||||
# punktfunk-scripting runner subpackages (the publish loop globs them in; the host RPM
|
# globs it in; the host RPM Recommends it). Needs bun (ensured in Prep).
|
||||||
# Recommends both). Both need bun (ensured in Prep).
|
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 bash packaging/rpm/build-rpm.sh
|
||||||
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh
|
|
||||||
|
|
||||||
- name: Sign RPMs (dormant until RPM_GPG_PRIVATE_KEY is set — see packaging/rpm/README.md)
|
- name: Sign RPMs (dormant until RPM_GPG_PRIVATE_KEY is set — see packaging/rpm/README.md)
|
||||||
env:
|
env:
|
||||||
@@ -132,8 +131,7 @@ jobs:
|
|||||||
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
|
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
|
||||||
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
|
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
|
||||||
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||||
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
|
||||||
|
|
||||||
- name: Publish the sysext feed
|
- name: Publish the sysext feed
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# Publish the TypeScript SDK (@punktfunk/host) to the Gitea npm registry
|
|
||||||
# (https://git.unom.io/api/packages/unom/npm/).
|
|
||||||
#
|
|
||||||
# Trigger: push a tag `sdk-vX.Y.Z` (must equal sdk/package.json "version"), or run manually.
|
|
||||||
# The SDK versions independently of the app's `v*` tags, so bumping the host doesn't republish it.
|
|
||||||
#
|
|
||||||
# Auth: REGISTRY_TOKEN — the same repo Actions secret docker.yml uses (a Gitea PAT with
|
|
||||||
# write:package scope). No new secret needed.
|
|
||||||
name: sdk-publish
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['sdk-v*']
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
container:
|
|
||||||
image: oven/bun:1
|
|
||||||
timeout-minutes: 15
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: sdk
|
|
||||||
steps:
|
|
||||||
# oven/bun's slim base ships neither git, a CA bundle, nor node — actions/checkout's HTTPS
|
|
||||||
# fetch needs git + ca-certificates, and the version-guard step below uses node.
|
|
||||||
- name: Install git + node + CA certs
|
|
||||||
working-directory: /
|
|
||||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install --frozen-lockfile --ignore-scripts
|
|
||||||
|
|
||||||
- name: Typecheck
|
|
||||||
run: bun run typecheck
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: bun test
|
|
||||||
|
|
||||||
- name: Build (dist/ JS + .d.ts)
|
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Tag matches package version
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
run: |
|
|
||||||
TAG="${GITHUB_REF_NAME#sdk-v}"
|
|
||||||
PKG="$(node -p "require('./package.json').version")"
|
|
||||||
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME does not match package version $PKG"; exit 1; }
|
|
||||||
|
|
||||||
- name: Publish to Gitea registry
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
run: |
|
|
||||||
test -n "$NODE_AUTH_TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
|
|
||||||
# .npmrc already maps the @punktfunk scope to the registry; append the auth line.
|
|
||||||
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$NODE_AUTH_TOKEN" >> .npmrc
|
|
||||||
bun publish
|
|
||||||
@@ -153,9 +153,9 @@ jobs:
|
|||||||
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
# `// 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
|
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
||||||
# toolchain-only probe crate and is excluded.)
|
# toolchain-only probe crate and is excluded.)
|
||||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
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/mouse drivers
|
- name: cargo fmt --check the safe-layer + gamepad drivers
|
||||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --check
|
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
|
||||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||||
run: |
|
run: |
|
||||||
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
||||||
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
||||||
# pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script runner,
|
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
|
||||||
# run by scheduled tasks on a bundled bun) from one signed setup.exe. Runs on a self-hosted
|
# bun) from one signed setup.exe. Runs on a self-hosted windows-amd64 runner
|
||||||
# windows-amd64 runner
|
|
||||||
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
|
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
|
||||||
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
|
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
|
||||||
#
|
#
|
||||||
@@ -24,15 +23,10 @@
|
|||||||
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
||||||
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
||||||
#
|
#
|
||||||
# GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer.
|
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
||||||
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
|
# - 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
|
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
|
||||||
# AMD/Intel-only boxes before main).
|
# AMD/Intel-only boxes before main).
|
||||||
# - QSV native (Intel, VPL — design/native-qsv-encoder.md): the MIT dispatcher is built from the
|
|
||||||
# vendored tree (libvpl-sys, cmake+bindgen — LIBCLANG_PATH already in the runner env for
|
|
||||||
# pyrowave-sys) and statically linked; the GPU runtime comes from the Intel driver store at run
|
|
||||||
# time, so no new DLL ships and non-Intel boxes are unaffected. This is the Intel dispatch;
|
|
||||||
# the ffmpeg *_qsv path below stays as its open-failure fallback until Phase 4 deletes it.
|
|
||||||
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
|
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
|
||||||
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
||||||
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
||||||
@@ -46,14 +40,9 @@ on:
|
|||||||
- 'crates/punktfunk-host/**'
|
- 'crates/punktfunk-host/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
- 'crates/punktfunk-tray/**'
|
- 'crates/punktfunk-tray/**'
|
||||||
# The encode subsystem (split out in W6) + the vendored VPL dispatcher the `qsv` feature
|
|
||||||
# builds — without these, encoder changes only reached this workflow via Cargo.lock luck.
|
|
||||||
- 'crates/pf-encode/**'
|
|
||||||
- 'crates/libvpl-sys/**'
|
|
||||||
- 'packaging/windows/**'
|
- 'packaging/windows/**'
|
||||||
- 'scripts/windows/**'
|
- 'scripts/windows/**'
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
- 'sdk/**'
|
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- '.gitea/workflows/windows-host.yml'
|
- '.gitea/workflows/windows-host.yml'
|
||||||
@@ -100,12 +89,6 @@ jobs:
|
|||||||
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
|
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
|
||||||
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
# audiopus_sys' vendored opus declares cmake_minimum_required < 3.5, which CMake 4.x
|
|
||||||
# refuses outright. Green runs today only survive on the cached configure output — a
|
|
||||||
# target-dir purge (the runner's disk-cleanup task) would fail the fresh configure, as
|
|
||||||
# observed on a clean build on this very runner (2026-07-17). No-op for compliant
|
|
||||||
# projects (libvpl-sys pins 3.13+).
|
|
||||||
"CMAKE_POLICY_VERSION_MINIMUM=3.5" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
|
||||||
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
|
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
|
||||||
# by scripts/ci/provision-windows-punktfunk-extras.ps1). The host's AMD/Intel AMF/QSV encode backend
|
# by scripts/ci/provision-windows-punktfunk-extras.ps1). The host's AMD/Intel AMF/QSV encode backend
|
||||||
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
||||||
@@ -131,11 +114,10 @@ jobs:
|
|||||||
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
Write-Output "host version $v"
|
Write-Output "host version $v"
|
||||||
|
|
||||||
- name: Build (release, nvenc + amf-qsv + qsv)
|
- name: Build (release, nvenc + amf-qsv)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
# All-vendor host: NVENC (NVIDIA, direct SDK) + native QSV (Intel, static VPL dispatcher)
|
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
|
||||||
# + AMF/QSV (AMD + the Intel ffmpeg fallback, libavcodec via FFMPEG_DIR).
|
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
|
||||||
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv,qsv
|
|
||||||
|
|
||||||
- name: Build (release, status tray)
|
- name: Build (release, status tray)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -145,18 +127,9 @@ jobs:
|
|||||||
- name: Clippy (host + tray, Windows)
|
- name: Clippy (host + tray, Windows)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
||||||
# --release is REQUIRED, not just faster: a default (debug) clippy compiles the whole dep tree
|
|
||||||
# into a SECOND target dir (C:\t\debug), which means a second full build of openh264-sys2's
|
|
||||||
# vendored C++ (the software-H.264 fallback in pf-encode) on top of the release copy the Build
|
|
||||||
# steps above already produced. That second cc-rs `cl.exe` fan-out tips this runner over into
|
|
||||||
# `cabac_decoder.cpp: fatal error C1069 (cannot read compiler command line)` — an environmental
|
|
||||||
# disk/temp exhaustion, NOT a source error (the identical file compiles fine in the release
|
|
||||||
# build minutes earlier). Linting in release reuses those native build-script artifacts (no
|
|
||||||
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
|
||||||
# pf-vkhdr-layer's clippy below runs --release.
|
|
||||||
run: |
|
run: |
|
||||||
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||||
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||||
|
|
||||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -223,24 +196,6 @@ jobs:
|
|||||||
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
||||||
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
|
||||||
- name: Build plugin/script runner bundle (bun)
|
|
||||||
shell: pwsh
|
|
||||||
# `bun build --target=bun` bundles the SDK's runner CLI to ONE self-contained JS (effect + the
|
|
||||||
# SDK inlined; the dynamic plugin import stays a runtime import). pack-host-installer.ps1 ships
|
|
||||||
# it (+ the shared bun) and registers its scheduled task DISABLED (opt-in). The SDK's deps are
|
|
||||||
# public npm (effect), so no @unom token is needed here.
|
|
||||||
run: |
|
|
||||||
$bun = $env:BUN_EXE
|
|
||||||
Push-Location sdk
|
|
||||||
& $bun install --frozen-lockfile --ignore-scripts; if ($LASTEXITCODE) { throw "sdk bun install failed ($LASTEXITCODE)" }
|
|
||||||
New-Item -ItemType Directory -Force -Path C:\t\scripting | Out-Null
|
|
||||||
& $bun build src/runner-cli.ts --target=bun --outfile=C:\t\scripting\runner-cli.js; if ($LASTEXITCODE) { throw "runner bundle build failed ($LASTEXITCODE)" }
|
|
||||||
Pop-Location
|
|
||||||
if (-not (Select-String -Path C:\t\scripting\runner-cli.js -Pattern 'attempt=' -Quiet)) {
|
|
||||||
throw "runner bundle missing the dynamic plugin import - wrong build"
|
|
||||||
}
|
|
||||||
"SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
|
||||||
|
|
||||||
- name: Pack + sign installer
|
- name: Pack + sign installer
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -104,9 +104,8 @@ jobs:
|
|||||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
||||||
|
|
||||||
# All three client binaries — the shell spawns punktfunk-session.exe (a package
|
# Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
|
||||||
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's
|
# for every stream. --no-default-features on ARM64 is a no-op for the shell.
|
||||||
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
|
|
||||||
- name: Build (release)
|
- name: Build (release)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||||
|
|||||||
@@ -38,12 +38,3 @@ CLAUDE.md
|
|||||||
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
|
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
|
||||||
.flatpak-repo/
|
.flatpak-repo/
|
||||||
.flatpak-build/
|
.flatpak-build/
|
||||||
|
|
||||||
# Nix build outputs (flake.nix) — `nix build` result symlinks + direnv cache. flake.lock IS tracked.
|
|
||||||
/result
|
|
||||||
/result-*
|
|
||||||
.direnv/
|
|
||||||
|
|
||||||
# Gradle build output inside the vendored pyrowave Granite Android platform (regenerated, never ours to commit)
|
|
||||||
/crates/pyrowave-sys/vendor/pyrowave/Granite/application/platforms/android/**/.gradle/
|
|
||||||
/crates/pyrowave-sys/vendor/pyrowave/Granite/application/platforms/android/**/build/
|
|
||||||
|
|||||||
@@ -30,16 +30,6 @@ file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
|||||||
|
|
||||||
## Before you push
|
## Before you push
|
||||||
|
|
||||||
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
|
|
||||||
workspace + the UMDF driver workspace) on every commit and push, so a push can never fail CI
|
|
||||||
on formatting alone:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git config core.hooksPath scripts/git-hooks
|
|
||||||
```
|
|
||||||
|
|
||||||
Then the usual full pass:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|||||||
Generated
+55
-394
@@ -358,6 +358,28 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-lc-rs"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-sys",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-lc-sys"
|
||||||
|
version = "0.41.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cmake",
|
||||||
|
"dunce",
|
||||||
|
"fs_extra",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -538,12 +560,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "byteorder-lite"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -656,30 +672,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "chacha20"
|
|
||||||
version = "0.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cipher",
|
|
||||||
"cpufeatures",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "chacha20poly1305"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
|
||||||
dependencies = [
|
|
||||||
"aead",
|
|
||||||
"chacha20",
|
|
||||||
"cipher",
|
|
||||||
"poly1305",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ciborium"
|
name = "ciborium"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -715,7 +707,6 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
"inout",
|
"inout",
|
||||||
"zeroize",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -765,12 +756,6 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "color_quant"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -1037,6 +1022,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dunce"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.16.0"
|
||||||
@@ -1150,15 +1141,6 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fdeflate"
|
|
||||||
version = "0.3.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
|
||||||
dependencies = [
|
|
||||||
"simd-adler32",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fec-rs"
|
name = "fec-rs"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1291,6 +1273,12 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs_extra"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1459,16 +1447,6 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gethostname"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
|
||||||
dependencies = [
|
|
||||||
"rustix",
|
|
||||||
"windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1517,16 +1495,6 @@ dependencies = [
|
|||||||
"polyval",
|
"polyval",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gif"
|
|
||||||
version = "0.14.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
|
||||||
dependencies = [
|
|
||||||
"color_quant",
|
|
||||||
"weezl",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gio"
|
name = "gio"
|
||||||
version = "0.22.6"
|
version = "0.22.6"
|
||||||
@@ -2020,23 +1988,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "image"
|
|
||||||
version = "0.25.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
|
||||||
dependencies = [
|
|
||||||
"bytemuck",
|
|
||||||
"byteorder-lite",
|
|
||||||
"color_quant",
|
|
||||||
"gif",
|
|
||||||
"moxcms",
|
|
||||||
"num-traits",
|
|
||||||
"png",
|
|
||||||
"zune-core",
|
|
||||||
"zune-jpeg",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -2194,7 +2145,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2297,14 +2248,6 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libvpl-sys"
|
|
||||||
version = "0.18.0"
|
|
||||||
dependencies = [
|
|
||||||
"bindgen",
|
|
||||||
"cmake",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -2334,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2443,16 +2386,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "moxcms"
|
|
||||||
version = "0.8.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
|
||||||
dependencies = [
|
|
||||||
"num-traits",
|
|
||||||
"pxfm",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nasm-rs"
|
name = "nasm-rs"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2821,33 +2754,11 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pf-capture"
|
|
||||||
version = "0.18.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"ashpd",
|
|
||||||
"libc",
|
|
||||||
"pf-driver-proto",
|
|
||||||
"pf-frame",
|
|
||||||
"pf-gpu",
|
|
||||||
"pf-host-config",
|
|
||||||
"pf-win-display",
|
|
||||||
"pf-zerocopy",
|
|
||||||
"pipewire",
|
|
||||||
"punktfunk-core",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
|
||||||
"x11rb",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -2855,7 +2766,6 @@ dependencies = [
|
|||||||
"pf-ffvk",
|
"pf-ffvk",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"pyrowave-sys",
|
|
||||||
"rustls",
|
"rustls",
|
||||||
"sdl3",
|
"sdl3",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2866,27 +2776,9 @@ dependencies = [
|
|||||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pf-clipboard"
|
|
||||||
version = "0.18.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"ashpd",
|
|
||||||
"futures-util",
|
|
||||||
"image",
|
|
||||||
"libc",
|
|
||||||
"punktfunk-core",
|
|
||||||
"quinn",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"wayland-client",
|
|
||||||
"wayland-protocols",
|
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2905,107 +2797,18 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pf-encode"
|
|
||||||
version = "0.18.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"ash",
|
|
||||||
"ffmpeg-next",
|
|
||||||
"libc",
|
|
||||||
"libloading",
|
|
||||||
"libvpl-sys",
|
|
||||||
"nvidia-video-codec-sdk",
|
|
||||||
"openh264",
|
|
||||||
"pf-capture",
|
|
||||||
"pf-frame",
|
|
||||||
"pf-gpu",
|
|
||||||
"pf-host-config",
|
|
||||||
"pf-zerocopy",
|
|
||||||
"punktfunk-core",
|
|
||||||
"pyrowave-sys",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-ffvk"
|
name = "pf-ffvk"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pf-frame"
|
|
||||||
version = "0.18.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"libc",
|
|
||||||
"pf-zerocopy",
|
|
||||||
"punktfunk-core",
|
|
||||||
"tracing",
|
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pf-gpu"
|
|
||||||
version = "0.18.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"pf-host-config",
|
|
||||||
"pf-paths",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tempfile",
|
|
||||||
"tracing",
|
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pf-host-config"
|
|
||||||
version = "0.18.0"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pf-inject"
|
|
||||||
version = "0.18.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"ashpd",
|
|
||||||
"futures-util",
|
|
||||||
"libc",
|
|
||||||
"parking_lot",
|
|
||||||
"pf-capture",
|
|
||||||
"pf-driver-proto",
|
|
||||||
"pf-host-config",
|
|
||||||
"pf-paths",
|
|
||||||
"punktfunk-core",
|
|
||||||
"reis",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"usbip-sim",
|
|
||||||
"wayland-backend",
|
|
||||||
"wayland-client",
|
|
||||||
"wayland-protocols",
|
|
||||||
"wayland-protocols-misc",
|
|
||||||
"wayland-protocols-wlr",
|
|
||||||
"wayland-scanner",
|
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
|
||||||
"xkbcommon",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pf-paths"
|
|
||||||
version = "0.18.0"
|
|
||||||
dependencies = [
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3018,62 +2821,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pf-vdisplay"
|
|
||||||
version = "0.18.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"ashpd",
|
|
||||||
"bytemuck",
|
|
||||||
"futures-util",
|
|
||||||
"hex",
|
|
||||||
"libc",
|
|
||||||
"pf-driver-proto",
|
|
||||||
"pf-encode",
|
|
||||||
"pf-frame",
|
|
||||||
"pf-gpu",
|
|
||||||
"pf-host-config",
|
|
||||||
"pf-paths",
|
|
||||||
"pf-win-display",
|
|
||||||
"punktfunk-core",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sha2",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"utoipa",
|
|
||||||
"wayland-backend",
|
|
||||||
"wayland-client",
|
|
||||||
"wayland-scanner",
|
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pf-win-display"
|
|
||||||
version = "0.18.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"pf-paths",
|
|
||||||
"punktfunk-core",
|
|
||||||
"serde_json",
|
|
||||||
"tracing",
|
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pf-zerocopy"
|
|
||||||
version = "0.18.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"ash",
|
|
||||||
"khronos-egl",
|
|
||||||
"libc",
|
|
||||||
"libloading",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -3146,19 +2893,6 @@ version = "0.3.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "png"
|
|
||||||
version = "0.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"crc32fast",
|
|
||||||
"fdeflate",
|
|
||||||
"flate2",
|
|
||||||
"miniz_oxide",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polling"
|
name = "polling"
|
||||||
version = "3.11.0"
|
version = "3.11.0"
|
||||||
@@ -3173,17 +2907,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "poly1305"
|
|
||||||
version = "0.8.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
|
||||||
dependencies = [
|
|
||||||
"cpufeatures",
|
|
||||||
"opaque-debug",
|
|
||||||
"universal-hash",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polyval"
|
name = "polyval"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -3269,7 +2992,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3285,7 +3008,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3301,7 +3024,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3316,7 +3039,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
@@ -3335,12 +3058,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cbindgen",
|
"cbindgen",
|
||||||
"chacha20poly1305",
|
|
||||||
"criterion",
|
"criterion",
|
||||||
"fec-rs",
|
"fec-rs",
|
||||||
"hmac",
|
"hmac",
|
||||||
@@ -3367,7 +3089,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3379,9 +3101,9 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cbc",
|
"cbc",
|
||||||
|
"ffmpeg-next",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
@@ -3392,33 +3114,21 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"mac_address",
|
"mac_address",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
|
"nvidia-video-codec-sdk",
|
||||||
|
"openh264",
|
||||||
"opus",
|
"opus",
|
||||||
"parking_lot",
|
|
||||||
"pf-capture",
|
|
||||||
"pf-clipboard",
|
|
||||||
"pf-driver-proto",
|
"pf-driver-proto",
|
||||||
"pf-encode",
|
|
||||||
"pf-frame",
|
|
||||||
"pf-gpu",
|
|
||||||
"pf-host-config",
|
|
||||||
"pf-inject",
|
|
||||||
"pf-paths",
|
|
||||||
"pf-vdisplay",
|
|
||||||
"pf-win-display",
|
|
||||||
"pf-zerocopy",
|
|
||||||
"pipewire",
|
"pipewire",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"reis",
|
"reis",
|
||||||
"ring",
|
|
||||||
"roxmltree",
|
"roxmltree",
|
||||||
"rsa",
|
"rsa",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rusty_enet",
|
"rusty_enet",
|
||||||
"semver",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -3451,7 +3161,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3465,35 +3175,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
"libc",
|
"libc",
|
||||||
"punktfunk-core",
|
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"ureq",
|
"ureq",
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"windows-service",
|
"windows-service",
|
||||||
"winresource",
|
"winresource",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pxfm"
|
|
||||||
version = "0.1.30"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyrowave-sys"
|
|
||||||
version = "0.18.0"
|
|
||||||
dependencies = [
|
|
||||||
"bindgen",
|
|
||||||
"cmake",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -3687,6 +3383,7 @@ version = "0.13.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"pem",
|
"pem",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -3915,6 +3612,7 @@ version = "0.23.41"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -3979,6 +3677,7 @@ version = "0.103.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
@@ -5303,12 +5002,6 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "weezl"
|
|
||||||
version = "0.1.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wide"
|
name = "wide"
|
||||||
version = "0.7.33"
|
version = "0.7.33"
|
||||||
@@ -5897,23 +5590,6 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "x11rb"
|
|
||||||
version = "0.13.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
|
||||||
dependencies = [
|
|
||||||
"gethostname",
|
|
||||||
"rustix",
|
|
||||||
"x11rb-protocol",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "x11rb-protocol"
|
|
||||||
version = "0.13.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x509-parser"
|
name = "x509-parser"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -6138,21 +5814,6 @@ version = "1.0.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zune-core"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zune-jpeg"
|
|
||||||
version = "0.5.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
|
||||||
dependencies = [
|
|
||||||
"zune-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zvariant"
|
name = "zvariant"
|
||||||
version = "5.12.0"
|
version = "5.12.0"
|
||||||
|
|||||||
+1
-14
@@ -6,23 +6,10 @@ members = [
|
|||||||
"crates/punktfunk-host/vendor/usbip-sim",
|
"crates/punktfunk-host/vendor/usbip-sim",
|
||||||
"crates/punktfunk-tray",
|
"crates/punktfunk-tray",
|
||||||
"crates/pf-client-core",
|
"crates/pf-client-core",
|
||||||
"crates/pf-clipboard",
|
|
||||||
"crates/pf-presenter",
|
"crates/pf-presenter",
|
||||||
"crates/pf-console-ui",
|
"crates/pf-console-ui",
|
||||||
"crates/pf-ffvk",
|
"crates/pf-ffvk",
|
||||||
"crates/pf-driver-proto",
|
"crates/pf-driver-proto",
|
||||||
"crates/pf-paths",
|
|
||||||
"crates/pf-host-config",
|
|
||||||
"crates/pf-gpu",
|
|
||||||
"crates/pf-zerocopy",
|
|
||||||
"crates/pf-frame",
|
|
||||||
"crates/pf-win-display",
|
|
||||||
"crates/pf-encode",
|
|
||||||
"crates/pf-capture",
|
|
||||||
"crates/pf-inject",
|
|
||||||
"crates/pf-vdisplay",
|
|
||||||
"crates/pyrowave-sys",
|
|
||||||
"crates/libvpl-sys",
|
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
"clients/session",
|
"clients/session",
|
||||||
@@ -48,7 +35,7 @@ exclude = [
|
|||||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.18.0"
|
version = "0.10.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+297
-679
File diff suppressed because it is too large
Load Diff
+10
-2641
File diff suppressed because it is too large
Load Diff
@@ -1,85 +0,0 @@
|
|||||||
# LTS builder for the punktfunk HOST .deb — Ubuntu 24.04 (noble), the current Ubuntu LTS.
|
|
||||||
#
|
|
||||||
# WHY THIS EXISTS (see packaging/debian/README.md → "Ubuntu 24.04 LTS"):
|
|
||||||
# The default builder (ci/rust-ci.Dockerfile) is Ubuntu 26.04, so the host .deb it produces bakes
|
|
||||||
# in a glibc 2.41 floor and a hard `Depends: libavcodec62, …` (FFmpeg 8). Ubuntu 24.04 LTS ships
|
|
||||||
# glibc 2.39 and FFmpeg 6.1 (libavcodec60), so that .deb is uninstallable there — apt reports the
|
|
||||||
# deps as "too recent". Building the host on 24.04 instead lowers the glibc floor to 2.39 (the
|
|
||||||
# binary then runs on 24.04 → 26.04), and the ONE library 24.04 is too old for — FFmpeg — is built
|
|
||||||
# from source here and BUNDLED into the .deb (packaging/debian/build-deb.sh, BUNDLE_FFMPEG=1), so
|
|
||||||
# the package no longer depends on the distro's libav* at all. Everything else the host links
|
|
||||||
# (PipeWire, Wayland, xkbcommon, GL/EGL/GBM, Vulkan; opus is vendored via cmake) is soname-compatible
|
|
||||||
# on 24.04, so this ONE universal host .deb replaces the 26.04-built one for every Ubuntu user.
|
|
||||||
#
|
|
||||||
# libcuda is deliberately NOT provided: the host dlopen's libcuda.so.1 at runtime (pf-zerocopy /
|
|
||||||
# pf-encode) and never link-imports it, so — unlike the full-workspace rust-ci image, which builds
|
|
||||||
# tests that DO link a cuda stub — this host-only build needs no NVIDIA driver package. NVENC/EGL
|
|
||||||
# come from whatever driver the target runs, out of band.
|
|
||||||
#
|
|
||||||
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-rust-ci-noble); consumed by the
|
|
||||||
# `build-publish-host` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci, the first deb.yml
|
|
||||||
# run after this image is added uses the image from a PRIOR docker.yml push — seed it once manually
|
|
||||||
# (docker build -f ci/rust-ci-noble.Dockerfile -t … ci && docker push) before the host job can run.
|
|
||||||
FROM ubuntu:24.04
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip for the rustup installer's deps
|
|
||||||
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
|
||||||
# .deb assembly: dpkg-shlibdeps/dpkg-deb; patchelf repoints the binary's rpath at the bundled FFmpeg
|
|
||||||
dpkg-dev patchelf \
|
|
||||||
# FFmpeg 8 build deps: nasm (asm), VAAPI (libva/libdrm) so the built libav* keep the AMD/Intel
|
|
||||||
# encode backend the host auto-selects; zlib (libavformat). NVENC needs only headers (below), dlopen'd.
|
|
||||||
nasm libva-dev libdrm-dev zlib1g-dev \
|
|
||||||
# host link deps present on 24.04 with sonames compatible up to 26.04
|
|
||||||
libpipewire-0.3-dev libwayland-dev libxkbcommon-dev \
|
|
||||||
libgl-dev libegl-dev libgbm-dev libvulkan-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# --- FFmpeg 8 from source -> /opt/ffmpeg (shared libs + .pc files) ----------------------------------
|
|
||||||
# libavcodec.so.62, matching the 26.04 line's soname so the host behaves identically. This is an
|
|
||||||
# LGPL build (no --enable-gpl / --enable-nonfree) so bundling the .so's into an MIT/Apache .deb stays
|
|
||||||
# license-clean — LGPL's relink clause is satisfied by dynamic linking, and the only encoders the host
|
|
||||||
# calls (h264/hevc/av1 _nvenc + _vaapi, plus scale_vaapi/hwmap filters; software H.264 fallback is the
|
|
||||||
# BSD-2 openh264 crate, NOT FFmpeg libx264) are all LGPL-compatible.
|
|
||||||
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
|
|
||||||
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
|
|
||||||
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
|
|
||||||
ARG FFMPEG_TAG=n8.0
|
|
||||||
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
|
|
||||||
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
|
|
||||||
# nvenc.c. Pin the last SDK-12 tag (has the field FFmpeg 8.0 expects). Bump alongside FFMPEG_TAG.
|
|
||||||
ARG NVHDR_TAG=n12.2.72.0
|
|
||||||
RUN set -eux; \
|
|
||||||
# nv-codec-headers: the NVENC/NVDEC headers FFmpeg's --enable-nvenc needs (headers only, no lib —
|
|
||||||
# the driver is dlopen'd at runtime). Installs ffnvcodec.pc under /usr/local/lib/pkgconfig.
|
|
||||||
git clone --depth 1 --branch "$NVHDR_TAG" https://github.com/FFmpeg/nv-codec-headers.git /tmp/nvhdr; \
|
|
||||||
make -C /tmp/nvhdr install PREFIX=/usr/local; \
|
|
||||||
git clone --depth 1 --branch "$FFMPEG_TAG" https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg; \
|
|
||||||
cd /tmp/ffmpeg; \
|
|
||||||
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure \
|
|
||||||
--prefix=/opt/ffmpeg \
|
|
||||||
--enable-shared --disable-static \
|
|
||||||
--disable-doc --disable-programs --disable-debug \
|
|
||||||
--enable-nvenc --enable-vaapi \
|
|
||||||
--extra-cflags=-I/usr/local/include --extra-ldflags=-L/usr/local/lib; \
|
|
||||||
make -j"$(nproc)"; make install; \
|
|
||||||
cd /; rm -rf /tmp/ffmpeg /tmp/nvhdr; \
|
|
||||||
# sanity: the soname we expect to bundle (libavcodec.so.62 on FFmpeg 8)
|
|
||||||
test -e /opt/ffmpeg/lib/libavcodec.so.62
|
|
||||||
|
|
||||||
# ffmpeg-sys-next discovers FFmpeg via pkg-config; point it at the bundled build. PKG_CONFIG_PATH is
|
|
||||||
# PREPENDED to pkg-config's default dirs (not a replacement — that's PKG_CONFIG_LIBDIR), so PipeWire /
|
|
||||||
# Wayland / libva / … still resolve from the system. FFMPEG_PREFIX is read by build-deb.sh's bundler.
|
|
||||||
ENV PKG_CONFIG_PATH=/opt/ffmpeg/lib/pkgconfig \
|
|
||||||
FFMPEG_PREFIX=/opt/ffmpeg
|
|
||||||
|
|
||||||
# Toolchain shared across CI users (jobs may run as different uids).
|
|
||||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
|
||||||
CARGO_HOME=/usr/local/cargo \
|
|
||||||
PATH=/usr/local/cargo/bin:$PATH
|
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
|
||||||
| sh -s -- -y --no-modify-path --profile minimal \
|
|
||||||
--component rustfmt,clippy \
|
|
||||||
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
|
|
||||||
&& rustc --version && cargo clippy --version && cargo fmt --version
|
|
||||||
@@ -27,10 +27,6 @@
|
|||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<!-- Gamepad rumble feedback. -->
|
<!-- Gamepad rumble feedback. -->
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<!-- Steam Controller 2 over direct BLE (Sc2BleLink talks Valve's vendor GATT service to the
|
|
||||||
bonded pad). A RUNTIME permission (NEARBY_DEVICES group); the capture engages only when
|
|
||||||
already granted — USB capture (wired / Puck dongle) needs no Bluetooth at all. -->
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
||||||
|
|
||||||
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
|
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
|
||||||
devices without a gamepad. -->
|
devices without a gamepad. -->
|
||||||
@@ -44,10 +40,6 @@
|
|||||||
ethernet-only boxes declare no wifi (discovery/WifiLock are best-effort hedges there). -->
|
ethernet-only boxes declare no wifi (discovery/WifiLock are best-effort hedges there). -->
|
||||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.wifi" android:required="false" />
|
<uses-feature android:name="android.hardware.wifi" android:required="false" />
|
||||||
<!-- Steam Controller 2 capture: USB host for the wired pad / Puck dongle, Bluetooth for the
|
|
||||||
direct-BLE pad — both optional (the feature quietly disengages without them). -->
|
|
||||||
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
|
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
|
||||||
|
|
||||||
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
|
<!-- 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
|
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
|
||||||
@@ -73,16 +65,10 @@
|
|||||||
android:name="android.game_mode_config"
|
android:name="android.game_mode_config"
|
||||||
android:resource="@xml/game_mode_config" />
|
android:resource="@xml/game_mode_config" />
|
||||||
|
|
||||||
<!-- configChanges includes `keyboard` (not just keyboardHidden): claiming a Steam
|
|
||||||
Controller 2's USB HID interface removes its lizard-mode keyboard/mouse input
|
|
||||||
devices, which flips CONFIG_KEYBOARD (QWERTY→NOKEYS) — without `keyboard` declared,
|
|
||||||
Android RECREATES the activity, disposing StreamScreen and killing the stream the
|
|
||||||
moment the capture engages (tester-diagnosed on-glass, 2026-07-15). Releasing the
|
|
||||||
interfaces at session end brings the devices back — same flip, same need. -->
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|keyboard|keyboardHidden|screenLayout|density|navigation"
|
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|density|navigation"
|
||||||
android:theme="@style/Theme.PunktfunkAndroid">
|
android:theme="@style/Theme.PunktfunkAndroid">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
package io.unom.punktfunk
|
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Text clipboard sync for the active session (the desktop-client model, text-only v1):
|
|
||||||
* * **Device → host**: a local copy (the primary-clip listener, plus one probe at start) is
|
|
||||||
* announced as a lazy offer — the text crosses only when the host actually pastes (a
|
|
||||||
* `fetch:` event, answered with the clipboard's current content).
|
|
||||||
* * **Host → device**: a host copy arrives as an `offer:` event and is fetched eagerly into
|
|
||||||
* the system clipboard (Android apps can't lazily materialize a paste from the network
|
|
||||||
* without a content-provider round-trip that isn't worth it here).
|
|
||||||
*
|
|
||||||
* Loop guard: text set from a host fetch is remembered ([lastFromHost]) so the resulting
|
|
||||||
* primary-clip-changed callback doesn't bounce it straight back as a new offer. Clipboard reads
|
|
||||||
* happen while the stream is foreground (Android only allows focused-app reads). The native
|
|
||||||
* events are drained on a dedicated thread and applied on the main thread; [stop] joins it.
|
|
||||||
*/
|
|
||||||
class ClipboardSync(
|
|
||||||
private val context: Context,
|
|
||||||
private val handle: Long,
|
|
||||||
) {
|
|
||||||
private val main = Handler(Looper.getMainLooper())
|
|
||||||
private val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
|
|
||||||
@Volatile private var running = true
|
|
||||||
private var seq = 0
|
|
||||||
private var lastOffered: String? = null
|
|
||||||
private var lastFromHost: String? = null
|
|
||||||
private var pendingFetch = -1
|
|
||||||
private var thread: Thread? = null
|
|
||||||
|
|
||||||
private val clipListener = ClipboardManager.OnPrimaryClipChangedListener { offerLocal() }
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
NativeBridge.nativeClipControl(handle, true)
|
|
||||||
cm.addPrimaryClipChangedListener(clipListener)
|
|
||||||
thread = Thread({ pollLoop() }, "pf-clipboard").also { it.start() }
|
|
||||||
offerLocal() // whatever is already on the clipboard is pasteable host-side right away
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
running = false
|
|
||||||
cm.removePrimaryClipChangedListener(clipListener)
|
|
||||||
thread?.join(600) // one poll timeout (250 ms) + slack
|
|
||||||
thread = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Announce the current local text (if it's new and not an echo of a host copy). */
|
|
||||||
private fun offerLocal() {
|
|
||||||
if (!running) return
|
|
||||||
val text = currentClipText() ?: return
|
|
||||||
if (text == lastOffered || text == lastFromHost) return
|
|
||||||
lastOffered = text
|
|
||||||
seq += 1
|
|
||||||
NativeBridge.nativeClipOfferText(handle, seq)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun currentClipText(): String? = runCatching {
|
|
||||||
cm.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)
|
|
||||||
?.coerceToText(context)?.toString()?.takeIf { it.isNotEmpty() }
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
private fun pollLoop() {
|
|
||||||
while (running) {
|
|
||||||
val ev = NativeBridge.nativeNextClip(handle) ?: continue
|
|
||||||
if (ev == "closed") return
|
|
||||||
main.post { handleEvent(ev) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleEvent(ev: String) {
|
|
||||||
if (!running) return
|
|
||||||
val parts = ev.split(":", limit = 3)
|
|
||||||
when (parts[0]) {
|
|
||||||
"offer" -> {
|
|
||||||
val offerSeq = parts.getOrNull(1)?.toIntOrNull() ?: return
|
|
||||||
if (parts.getOrNull(2) == "1") {
|
|
||||||
pendingFetch = NativeBridge.nativeClipFetchText(handle, offerSeq)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"fetch" -> {
|
|
||||||
val req = parts.getOrNull(1)?.toIntOrNull() ?: return
|
|
||||||
val text = currentClipText()
|
|
||||||
if (text != null) {
|
|
||||||
NativeBridge.nativeClipServeText(handle, req, text)
|
|
||||||
} else {
|
|
||||||
NativeBridge.nativeClipCancel(handle, req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"data" -> {
|
|
||||||
val xfer = parts.getOrNull(1)?.toIntOrNull() ?: return
|
|
||||||
if (xfer != pendingFetch) return // stale/unknown transfer
|
|
||||||
pendingFetch = -1
|
|
||||||
val text = parts.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: return
|
|
||||||
lastFromHost = text
|
|
||||||
runCatching { cm.setPrimaryClip(ClipData.newPlainText("Punktfunk", text)) }
|
|
||||||
}
|
|
||||||
// "state"/"cancel"/"error": nothing to drive in the text-only v1.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -303,8 +303,7 @@ internal fun PairPinDialog(
|
|||||||
if (fp.isNotEmpty()) {
|
if (fp.isNotEmpty()) {
|
||||||
onPaired(fp) // verified host fp — caller saves + connects
|
onPaired(fp) // verified host fp — caller saves + connects
|
||||||
} else {
|
} else {
|
||||||
// Cause-specific: wrong PIN vs not-armed vs unreachable.
|
err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||||
err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
package io.unom.punktfunk
|
|
||||||
|
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cause-specific user-facing messages for failed pair/connect attempts, keyed on the stable
|
|
||||||
* machine token from [NativeBridge.nativeTakeLastError]. One vocabulary for both the PIN
|
|
||||||
* ceremony and the request-access (delegated approval) path, so a dead network path is never
|
|
||||||
* reported as "wrong PIN" and an operator denial is never reported as a timeout — the exact
|
|
||||||
* collapse behind more than one support thread.
|
|
||||||
*/
|
|
||||||
object ConnectErrors {
|
|
||||||
/** Message for a failed SPAKE2 PIN ceremony ([NativeBridge.nativePair] returned `""`). */
|
|
||||||
fun pairMessage(token: String): String = when (token) {
|
|
||||||
"crypto" -> "Wrong PIN — check the PIN on the host's Pairing page and try again."
|
|
||||||
else -> shared(token) ?: transport(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Message for a failed connect / request-access ([NativeBridge.nativeConnect] returned `0`).
|
|
||||||
* [requestAccess] tunes the fallback wording for the delegated-approval path.
|
|
||||||
*/
|
|
||||||
fun connectMessage(token: String, requestAccess: Boolean): String =
|
|
||||||
shared(token) ?: when (token) {
|
|
||||||
"crypto" ->
|
|
||||||
"The host's identity doesn't match the saved fingerprint — re-pair with this host."
|
|
||||||
"timeout", "io", "" ->
|
|
||||||
if (requestAccess) {
|
|
||||||
"The request never reached the host, or nobody approved it in time — " +
|
|
||||||
"check the network path (no VPN, no guest-Wi-Fi isolation) and the " +
|
|
||||||
"host's console."
|
|
||||||
} else {
|
|
||||||
transport(token)
|
|
||||||
}
|
|
||||||
else -> "Connection failed — check host/port and logcat."
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The host's typed rejection reasons — identical wording across every punktfunk client. */
|
|
||||||
private fun shared(token: String): String? = when (token) {
|
|
||||||
"not-armed" ->
|
|
||||||
"Pairing isn't armed on the host — arm it on the host's Pairing page, then try again."
|
|
||||||
"bound-other" ->
|
|
||||||
"The host's pairing window is armed for a different device — arm it for this one."
|
|
||||||
"rate-limited" -> "Too many pairing attempts — wait a couple of seconds and try again."
|
|
||||||
"identity-required" ->
|
|
||||||
"The host requires pairing — pair this device (PIN or request access) first."
|
|
||||||
"denied" -> "The host declined this device's request."
|
|
||||||
"approval-timeout" ->
|
|
||||||
"Nobody approved the request on the host in time — approve this device in the " +
|
|
||||||
"host's console or web UI, then request access again."
|
|
||||||
"superseded" ->
|
|
||||||
"A newer request from this device replaced this one — approve the latest request " +
|
|
||||||
"on the host."
|
|
||||||
"wire-version" -> "Client and host versions don't match — update both to the same release."
|
|
||||||
"busy" -> "The host is busy with another session."
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Transport-level causes (nothing typed arrived from the host). */
|
|
||||||
private fun transport(token: String): String = when (token) {
|
|
||||||
"timeout" ->
|
|
||||||
"The host didn't answer — check that this device and the host are on the same " +
|
|
||||||
"network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
|
|
||||||
"io" ->
|
|
||||||
"Couldn't reach the host — check that this device and the host are on the same " +
|
|
||||||
"network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
|
|
||||||
else -> "Pairing failed — the host didn't answer or closed the connection (see logcat)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -305,17 +305,13 @@ fun ConnectScreen(
|
|||||||
onConnected(handle)
|
onConnected(handle)
|
||||||
} else {
|
} else {
|
||||||
discovery.start()
|
discovery.start()
|
||||||
val token = NativeBridge.nativeTakeLastError()
|
if (onFailure != null) {
|
||||||
val unreachable = token == "timeout" || token == "io" || token.isEmpty()
|
// Hand off to the wake-and-wait flow — clearing `attempt` above and setting
|
||||||
if (onFailure != null && unreachable) {
|
// `waker.waking` here land in one recompose, so the overlay slides
|
||||||
// Unreachable — hand off to the wake-and-wait flow — clearing `attempt` above
|
|
||||||
// and setting `waker.waking` here land in one recompose, so the overlay slides
|
|
||||||
// Connecting → Waking without a blank frame.
|
// Connecting → Waking without a blank frame.
|
||||||
onFailure()
|
onFailure()
|
||||||
} else {
|
} else {
|
||||||
// A typed host rejection (busy / versions differ / pairing required) means the
|
status = "Connection failed — check host/port, PIN, and logcat"
|
||||||
// host is awake — waking it would be nonsense; show the stated reason instead.
|
|
||||||
status = ConnectErrors.connectMessage(token, requestAccess = false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -420,12 +416,7 @@ fun ConnectScreen(
|
|||||||
}
|
}
|
||||||
onConnected(handle)
|
onConnected(handle)
|
||||||
} else {
|
} else {
|
||||||
// Cause-specific: an operator denial, an approval timeout, and a request that
|
status = "Request timed out — approve this device in the host's console, then retry."
|
||||||
// never reached the host are different problems with different fixes.
|
|
||||||
status = ConnectErrors.connectMessage(
|
|
||||||
NativeBridge.nativeTakeLastError(),
|
|
||||||
requestAccess = true,
|
|
||||||
)
|
|
||||||
discovery.start()
|
discovery.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.hardware.input.InputManager
|
import android.hardware.input.InputManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.CombinedVibration
|
import android.os.CombinedVibration
|
||||||
@@ -45,7 +44,6 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
import io.unom.punktfunk.kit.Sc2Capture
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,38 +147,8 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
|
||||||
// Steam Controller 2 detection: never an InputDevice (lizard mode is kb/mouse; the
|
|
||||||
// capture claims even those away), so it's enumerated on the capture side — USB device
|
|
||||||
// list + bonded BLE — and re-checked on USB hot-plug.
|
|
||||||
var sc2Generation by remember { mutableIntStateOf(0) }
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
val receiver = object : android.content.BroadcastReceiver() {
|
|
||||||
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
|
|
||||||
}
|
|
||||||
val filter = android.content.IntentFilter().apply {
|
|
||||||
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
|
||||||
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_DETACHED)
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= 33) {
|
|
||||||
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
|
||||||
} else {
|
|
||||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
|
||||||
context.registerReceiver(receiver, filter)
|
|
||||||
}
|
|
||||||
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
|
||||||
}
|
|
||||||
val sc2Probe = remember { Sc2Capture(context) }
|
|
||||||
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
|
|
||||||
val sc2Ble = remember(sc2Generation) {
|
|
||||||
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
|
||||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
) sc2Probe.pairedBleAddress() else null
|
|
||||||
}
|
|
||||||
val sc2Present = sc2Usb != null || sc2Ble != null
|
|
||||||
|
|
||||||
Group("Gamepads") {
|
Group("Gamepads") {
|
||||||
if (sc2Present) Sc2Row(sc2Usb, activity)
|
if (pads.isEmpty()) {
|
||||||
if (pads.isEmpty() && !sc2Present) {
|
|
||||||
Text(
|
Text(
|
||||||
"No controller detected. punktfunk can only forward devices Android " +
|
"No controller detected. punktfunk can only forward devices Android " +
|
||||||
"classifies as a gamepad or joystick — a pad connected through an adapter " +
|
"classifies as a gamepad or joystick — a pad connected through an adapter " +
|
||||||
@@ -246,79 +214,6 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The Steam Controller 2 card — capture-side state, since a (claimed or lizard-mode) SC2 never
|
|
||||||
* appears as a gamepad InputDevice. Shows the transport, whether the capture is live (driving
|
|
||||||
* these menus now; streamed as-is in a session), and a grant button when USB access is missing.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivity?) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val settingOn = remember { SettingsStore(context).load().sc2Capture }
|
|
||||||
val active = activity?.sc2MenuActive == true
|
|
||||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
|
|
||||||
val permitted = usbDev != null && usbManager.hasPermission(usbDev)
|
|
||||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
"Steam Controller 2",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (active) {
|
|
||||||
Text(
|
|
||||||
"navigating this UI",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
when {
|
|
||||||
usbDev == null -> "Paired via Bluetooth"
|
|
||||||
usbDev.productId == io.unom.punktfunk.kit.Sc2Device.PID_WIRED -> "Wired (USB)"
|
|
||||||
else -> "Puck dongle (USB)"
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
when {
|
|
||||||
!settingOn -> Text(
|
|
||||||
"Passthrough is disabled in Settings — enable \"Steam Controller 2 " +
|
|
||||||
"passthrough\" to capture it.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
active -> Text(
|
|
||||||
"Captured — streams as-is: the host presents a real Steam Controller 2 " +
|
|
||||||
"that its Steam drives directly (trackpads, gyro, haptics).",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
usbDev != null && !permitted -> {
|
|
||||||
Text(
|
|
||||||
"Needs USB access to be captured.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
OutlinedButton(onClick = { activity?.startSc2MenuNav(forceAsk = true) }) {
|
|
||||||
Text("Grant USB access")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> Text(
|
|
||||||
"Detected — capture engages automatically.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||||
@@ -494,8 +389,6 @@ private fun prefLabel(pref: Int): String = when (pref) {
|
|||||||
Gamepad.PREF_STEAMDECK -> "Steam Deck"
|
Gamepad.PREF_STEAMDECK -> "Steam Deck"
|
||||||
Gamepad.PREF_DUALSENSEEDGE -> "DualSense Edge"
|
Gamepad.PREF_DUALSENSEEDGE -> "DualSense Edge"
|
||||||
Gamepad.PREF_SWITCHPRO -> "Switch Pro"
|
Gamepad.PREF_SWITCHPRO -> "Switch Pro"
|
||||||
Gamepad.PREF_STEAMCONTROLLER2 -> "Steam Controller 2"
|
|
||||||
Gamepad.PREF_STEAMCONTROLLER2_PUCK -> "Steam Controller 2 Puck"
|
|
||||||
else -> "Automatic"
|
else -> "Automatic"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -351,12 +351,7 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
|||||||
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
|
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
|
||||||
}
|
}
|
||||||
pairing = false
|
pairing = false
|
||||||
if (fp.isNotEmpty()) {
|
if (fp.isNotEmpty()) onPaired(fp) else err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||||
onPaired(fp)
|
|
||||||
} else {
|
|
||||||
// Cause-specific: wrong PIN vs not-armed vs unreachable.
|
|
||||||
err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,14 +49,12 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
|
||||||
|
|
||||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
// 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
|
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||||
@@ -84,10 +82,7 @@ fun GamepadSettingsScreen(
|
|||||||
var s by remember { mutableStateOf(initial) }
|
var s by remember { mutableStateOf(initial) }
|
||||||
fun update(next: Settings) { s = next; onChange(next) }
|
fun update(next: Settings) { s = next; onChange(next) }
|
||||||
|
|
||||||
val context = LocalContext.current
|
val rows = buildSettingsRows(s, ::update)
|
||||||
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
|
|
||||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
|
||||||
val rows = buildSettingsRows(s, hasBodyVibrator, ::update)
|
|
||||||
var focus by remember { mutableIntStateOf(0) }
|
var focus by remember { mutableIntStateOf(0) }
|
||||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||||
@@ -262,13 +257,8 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
/** Build the console settings rows from the current [Settings], writing through [update]. */
|
||||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs). */
|
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
|
||||||
private fun buildSettingsRows(
|
|
||||||
s: Settings,
|
|
||||||
hasBodyVibrator: Boolean,
|
|
||||||
update: (Settings) -> Unit,
|
|
||||||
): List<GpRow> {
|
|
||||||
fun <T> choice(
|
fun <T> choice(
|
||||||
id: String, header: String?, label: String, detail: String,
|
id: String, header: String?, label: String, detail: String,
|
||||||
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||||
@@ -364,18 +354,7 @@ private fun buildSettingsRows(
|
|||||||
"The virtual pad the host creates — Automatic matches this controller.",
|
"The virtual pad the host creates — Automatic matches this controller.",
|
||||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||||
) { update(s.copy(gamepad = it)) },
|
) { update(s.copy(gamepad = it)) },
|
||||||
) + listOfNotNull(
|
|
||||||
if (hasBodyVibrator) {
|
|
||||||
toggle(
|
|
||||||
"phoneRumble", null, "Rumble on this phone",
|
|
||||||
"Also play controller 1's rumble on this phone's own vibration motor — " +
|
|
||||||
"for clip-on pads without rumble motors.",
|
|
||||||
s.rumbleOnPhone,
|
|
||||||
) { update(s.copy(rumbleOnPhone = it)) }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
) + listOf(
|
|
||||||
choice(
|
choice(
|
||||||
"hud", "Interface", "Statistics overlay",
|
"hud", "Interface", "Statistics overlay",
|
||||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import android.os.Looper
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -47,10 +46,6 @@ fun isTvDevice(context: Context): Boolean {
|
|||||||
@Composable
|
@Composable
|
||||||
fun rememberControllerConnected(): State<Boolean> {
|
fun rememberControllerConnected(): State<Boolean> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
// A menu-captured Steam Controller 2 counts as connected: it drives the console UI through
|
|
||||||
// the capture link, but never surfaces as an Android InputDevice (lizard mode is kb/mouse,
|
|
||||||
// and the claim removes even those) — the InputManager path below can't see it.
|
|
||||||
val activity = context as? MainActivity
|
|
||||||
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
|
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||||
@@ -64,7 +59,5 @@ fun rememberControllerConnected(): State<Boolean> {
|
|||||||
connected.value = Gamepad.firstPad() != null
|
connected.value = Gamepad.firstPad() != null
|
||||||
onDispose { im.unregisterInputDeviceListener(listener) }
|
onDispose { im.unregisterInputDeviceListener(listener) }
|
||||||
}
|
}
|
||||||
return remember {
|
return connected
|
||||||
derivedStateOf { connected.value || activity?.sc2MenuActive == true }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,7 @@ suspend fun connectToHost(
|
|||||||
): Long {
|
): Long {
|
||||||
// Advertise HDR only when the user enabled it AND this device's display can present it (else the
|
// Advertise HDR only when the user enabled it AND this device's display can present it (else the
|
||||||
// host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
// host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||||
val (baseW, baseH, hz) = settings.effectiveMode(context)
|
val (w, h, hz) = settings.effectiveMode(context)
|
||||||
// Render scale: ask the host for `chosen mode × scale` (even + codec-clamped) — > 1 supersamples
|
|
||||||
// (the compositor downscales the larger decoded frame to the SurfaceView), < 1 renders under
|
|
||||||
// native. 1.0 leaves the resolved mode untouched.
|
|
||||||
val (w, h) = RenderScale.apply(
|
|
||||||
baseW, baseH, settings.renderScale, RenderScale.maxDimension(settings.codec)
|
|
||||||
)
|
|
||||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
|
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
|
||||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.hardware.usb.UsbManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
import android.view.KeyCharacterMap
|
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
@@ -28,9 +20,6 @@ import io.unom.punktfunk.kit.GamepadRouter
|
|||||||
import io.unom.punktfunk.kit.Keymap
|
import io.unom.punktfunk.kit.Keymap
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
|
||||||
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
|
|
||||||
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
/**
|
/**
|
||||||
* The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown.
|
* The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown.
|
||||||
@@ -54,21 +43,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
||||||
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
||||||
|
|
||||||
/**
|
|
||||||
* Physical-mouse forwarder for the active session (built/released by StreamScreen, like
|
|
||||||
* [gamepadRouter]): uncaptured hover/click/wheel forwards as absolute cursor input, captured
|
|
||||||
* ([android.view.View.requestPointerCapture]) raw deltas as relative mouse-look. The dispatch
|
|
||||||
* overrides below route every SOURCE_MOUSE event here while streaming. Null while not streaming.
|
|
||||||
*/
|
|
||||||
var mouseForwarder: MouseForwarder? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TV remote-as-pointer for the active session (StreamScreen builds it on TV devices only):
|
|
||||||
* hold SELECT to toggle, then the D-pad glides the host cursor. Consulted first for
|
|
||||||
* non-gamepad keys while streaming. Null while not streaming or not a TV.
|
|
||||||
*/
|
|
||||||
var remotePointer: RemotePointer? = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a
|
* 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.
|
* couch user with no keyboard/Back can always leave a stream.
|
||||||
@@ -99,30 +73,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||||
private var highRefreshModeId = 0
|
private var highRefreshModeId = 0
|
||||||
|
|
||||||
/**
|
|
||||||
* Menu-time Steam Controller 2 capture (UI mode — no router): a captured SC2 never produces
|
|
||||||
* ordinary gamepad events (lizard mode is kb/mouse; the claim removes even those), so this
|
|
||||||
* drives the console UI directly from the parsed reports via [sc2NavKey]. Runs while the app
|
|
||||||
* is foreground and NOT streaming; StreamScreen pauses it around its own stream-mode capture.
|
|
||||||
* [sc2MenuActive] is observed by the console-UI gate ([rememberControllerConnected]) and the
|
|
||||||
* Controllers screen.
|
|
||||||
*/
|
|
||||||
private var sc2Menu: io.unom.punktfunk.kit.Sc2Capture? = null
|
|
||||||
var sc2MenuActive by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
private var sc2Receiver: BroadcastReceiver? = null
|
|
||||||
private var sc2PermissionAsked = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compose focus hook for the SC2's synthetic D-pad (set by [onCreate]'s composition). A
|
|
||||||
* synthetic KeyEvent dispatched from OUTSIDE the real input pipeline never reaches
|
|
||||||
* ViewRootImpl's focus-navigation stage — the one that grants initial focus for a real
|
|
||||||
* pad's first D-pad press — so on a phone in touch mode it lands on a focus-less window
|
|
||||||
* and does nothing (first on-glass run: only B worked, since it bypasses key events
|
|
||||||
* entirely). `FocusManager.moveFocus` is the public API for exactly this.
|
|
||||||
*/
|
|
||||||
private var sc2MoveFocus: ((androidx.compose.ui.focus.FocusDirection) -> Boolean)? = null
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
lastPadIsGamepad = !isTvDevice(this)
|
lastPadIsGamepad = !isTvDevice(this)
|
||||||
@@ -140,166 +90,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
|
// 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.
|
// 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
|
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
|
||||||
// SC2 hot-plug + the menu-time USB-permission grant both (re)start the menu capture.
|
|
||||||
val receiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(c: Context?, intent: Intent?) {
|
|
||||||
when (intent?.action) {
|
|
||||||
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
|
||||||
sc2PermissionAsked = false // a fresh attach may ask once again
|
|
||||||
startSc2MenuNav()
|
|
||||||
}
|
|
||||||
SC2_MENU_PERMISSION -> {
|
|
||||||
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
|
||||||
startSc2MenuNav()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sc2Receiver = receiver
|
|
||||||
val filter = IntentFilter().apply {
|
|
||||||
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
|
||||||
addAction(SC2_MENU_PERMISSION)
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= 33) {
|
|
||||||
registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
|
||||||
} else {
|
|
||||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
|
||||||
registerReceiver(receiver, filter)
|
|
||||||
}
|
|
||||||
setContent {
|
setContent {
|
||||||
PunktfunkTheme {
|
PunktfunkTheme {
|
||||||
// Focus hook for the SC2's synthetic navigation (see [sc2MoveFocus]). `Next` is
|
|
||||||
// the bootstrap: directional moves need an already-focused node, while one-
|
|
||||||
// dimensional traversal assigns initial focus when there is none.
|
|
||||||
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
|
|
||||||
androidx.compose.runtime.DisposableEffect(Unit) {
|
|
||||||
sc2MoveFocus = { dir ->
|
|
||||||
focusManager.moveFocus(dir) ||
|
|
||||||
focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Next)
|
|
||||||
}
|
|
||||||
onDispose { sc2MoveFocus = null }
|
|
||||||
}
|
|
||||||
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
|
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
startSc2MenuNav()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
// Release the claim while backgrounded so the OS (and other apps) get the pad back.
|
|
||||||
stopSc2MenuNav()
|
|
||||||
super.onPause()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
sc2Receiver?.let { runCatching { unregisterReceiver(it) } }
|
|
||||||
sc2Receiver = null
|
|
||||||
stopSc2MenuNav()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Engage the menu-time SC2 capture if possible: setting on, not streaming, and a wired/Puck
|
|
||||||
* pad attached (asking for USB permission at most once per attach — [forceAsk] re-arms the
|
|
||||||
* dialog, for the Controllers screen's explicit grant button) — else an already-paired BLE
|
|
||||||
* controller when BLUETOOTH_CONNECT is granted. Safe to call repeatedly.
|
|
||||||
*/
|
|
||||||
fun startSc2MenuNav(forceAsk: Boolean = false) {
|
|
||||||
if (forceAsk) sc2PermissionAsked = false
|
|
||||||
if (streamHandle != 0L) return // StreamScreen owns the pad while streaming
|
|
||||||
if (sc2Menu?.isActive == true) return
|
|
||||||
if (!SettingsStore(this).load().sc2Capture) return
|
|
||||||
val cap = sc2Menu ?: io.unom.punktfunk.kit.Sc2Capture(this).also { c ->
|
|
||||||
c.onUiKey = { key, down -> runOnUiThread { sc2NavKey(key, down) } }
|
|
||||||
c.onActiveChanged = { on -> runOnUiThread { sc2MenuActive = on } }
|
|
||||||
sc2Menu = c
|
|
||||||
}
|
|
||||||
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
|
|
||||||
val dev = cap.findUsbDevice()
|
|
||||||
when {
|
|
||||||
dev != null && usbManager.hasPermission(dev) -> cap.startUsb(dev)
|
|
||||||
dev != null && !sc2PermissionAsked -> {
|
|
||||||
sc2PermissionAsked = true
|
|
||||||
usbManager.requestPermission(
|
|
||||||
dev,
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
this, 1,
|
|
||||||
Intent(SC2_MENU_PERMISSION).setPackage(packageName),
|
|
||||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
|
||||||
PendingIntent.FLAG_MUTABLE,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
dev == null && checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED -> {
|
|
||||||
cap.pairedBleAddress()?.let { cap.startBle(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Release the menu-time SC2 capture (backgrounded / stream taking over). Idempotent. */
|
|
||||||
fun stopSc2MenuNav() {
|
|
||||||
sc2Menu?.stop()
|
|
||||||
sc2MenuActive = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One SC2 navigation key transition from the menu-time capture (main thread) — routed the
|
|
||||||
* same way [dispatchKeyEvent]'s not-streaming branch routes a real pad's buttons: B backs,
|
|
||||||
* A activates the focused element, everything else (D-pad, shoulders, Start/Select) goes to
|
|
||||||
* the framework's focus navigation. Also claims the console-UI glyphs for the pad.
|
|
||||||
*/
|
|
||||||
private fun sc2NavKey(keyCode: Int, down: Boolean) {
|
|
||||||
if (streamHandle != 0L) return // raced a stream start — the wire path owns input now
|
|
||||||
lastPadIsGamepad = true
|
|
||||||
lastPadStyle = Gamepad.PadStyle.XBOX // Valve pads carry A/B/X/Y in Xbox positions
|
|
||||||
val action = if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP
|
|
||||||
// The console UI navigates through padKeyProbe (GamepadNavEffect's held-state + repeat
|
|
||||||
// machinery — A/X/Y/D-pad/Select), NOT the focus system: synthesized events must be
|
|
||||||
// offered there first, exactly like real ones in dispatchKeyEvent (tester-diagnosed:
|
|
||||||
// routing everything via super.dispatchKeyEvent bypassed the probe, so only B — which
|
|
||||||
// never rides key events — did anything). The probes gate on keycode only, so a
|
|
||||||
// synthetic KeyEvent satisfies them.
|
|
||||||
padKeyProbe?.let { if (it(KeyEvent(action, keyCode))) return }
|
|
||||||
when (keyCode) {
|
|
||||||
// B → back, on release (same edge the real-pad path uses).
|
|
||||||
KeyEvent.KEYCODE_BUTTON_B -> if (!down) onBackPressedDispatcher.onBackPressed()
|
|
||||||
// A → activate the focused element (the focus system understands DPAD_CENTER; the
|
|
||||||
// Compose node focused via the moveFocus hook receives it once the ComposeView
|
|
||||||
// holds view-focus).
|
|
||||||
KeyEvent.KEYCODE_BUTTON_A ->
|
|
||||||
super.dispatchKeyEvent(KeyEvent(action, KeyEvent.KEYCODE_DPAD_CENTER))
|
|
||||||
// D-pad → Compose's own focus API (a synthetic DPAD KeyEvent can't grant initial
|
|
||||||
// focus — see [sc2MoveFocus]); one move per press edge.
|
|
||||||
KeyEvent.KEYCODE_DPAD_UP -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Up)
|
|
||||||
KeyEvent.KEYCODE_DPAD_DOWN -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Down)
|
|
||||||
KeyEvent.KEYCODE_DPAD_LEFT -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Left)
|
|
||||||
KeyEvent.KEYCODE_DPAD_RIGHT -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Right)
|
|
||||||
else -> super.dispatchKeyEvent(KeyEvent(action, keyCode))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun moveSc2Focus(dir: androidx.compose.ui.focus.FocusDirection) {
|
|
||||||
val hook = sc2MoveFocus
|
|
||||||
if (hook == null || !hook(dir)) {
|
|
||||||
// No composition hook (shouldn't happen) — fall back to the raw key dispatch.
|
|
||||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dirToKey(dir)))
|
|
||||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dirToKey(dir)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dirToKey(dir: androidx.compose.ui.focus.FocusDirection): Int = when (dir) {
|
|
||||||
androidx.compose.ui.focus.FocusDirection.Up -> KeyEvent.KEYCODE_DPAD_UP
|
|
||||||
androidx.compose.ui.focus.FocusDirection.Down -> KeyEvent.KEYCODE_DPAD_DOWN
|
|
||||||
androidx.compose.ui.focus.FocusDirection.Left -> KeyEvent.KEYCODE_DPAD_LEFT
|
|
||||||
else -> KeyEvent.KEYCODE_DPAD_RIGHT
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
||||||
private fun resolveHighRefreshMode() {
|
private fun resolveHighRefreshMode() {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@@ -332,36 +129,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (bit != 0) {
|
if (bit != 0) {
|
||||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||||
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||||
// inside the router: holding it briefly (~1 s, with an on-screen hint) fires
|
// inside the router: holding it for ~1.5 s fires router.onExitChord (wired in
|
||||||
// router.onExitChord (wired in StreamScreen), so a couch user with no keyboard/Back
|
// StreamScreen), so a couch user with no keyboard/Back can still leave — but an
|
||||||
// can still leave — but an accidental brush of the four buttons no longer quits.
|
// accidental brush of the four buttons no longer quits instantly.
|
||||||
gamepadRouter?.onButton(event, bit)
|
gamepadRouter?.onButton(event, bit)
|
||||||
return true // consumed
|
return true // consumed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
|
|
||||||
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
|
|
||||||
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
|
||||||
remotePointer?.let { if (it.onKey(event)) return true }
|
|
||||||
}
|
|
||||||
// Ctrl+Alt+Shift+Q — the cross-client pointer-capture toggle chord. Swallow both
|
|
||||||
// edges of the Q (the modifiers already went over the wire, exactly like desktop).
|
|
||||||
if (event.keyCode == KeyEvent.KEYCODE_Q &&
|
|
||||||
event.isCtrlPressed && event.isAltPressed && event.isShiftPressed
|
|
||||||
) {
|
|
||||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
|
||||||
mouseForwarder?.toggleCapture()
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
when (event.keyCode) {
|
when (event.keyCode) {
|
||||||
// A mouse back/forward button whose BUTTON_* press went unconsumed makes the
|
|
||||||
// framework synthesize a FALLBACK BACK — the button already went over the wire
|
|
||||||
// as X1/X2, and it must never yank the user out of the stream.
|
|
||||||
KeyEvent.KEYCODE_BACK ->
|
|
||||||
if (event.flags and KeyEvent.FLAG_FALLBACK != 0) return true
|
|
||||||
// Leave these to the system even while streaming.
|
// Leave these to the system even while streaming.
|
||||||
// (BACK above → BackHandler leaves the stream.)
|
KeyEvent.KEYCODE_BACK, // → BackHandler leaves the stream
|
||||||
KeyEvent.KEYCODE_VOLUME_UP,
|
KeyEvent.KEYCODE_VOLUME_UP,
|
||||||
KeyEvent.KEYCODE_VOLUME_DOWN,
|
KeyEvent.KEYCODE_VOLUME_DOWN,
|
||||||
KeyEvent.KEYCODE_VOLUME_MUTE,
|
KeyEvent.KEYCODE_VOLUME_MUTE,
|
||||||
@@ -376,18 +153,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
||||||
val vk = Keymap.toVk(event)
|
val vk = Keymap.toVk(event)
|
||||||
if (vk != 0) {
|
if (vk != 0) {
|
||||||
// Soft-keyboard events (the IME's virtual device — the stream's
|
|
||||||
// KeyCaptureView path) carry Shift only as META state, where a real
|
|
||||||
// keyboard sends discrete Shift transitions — so mirror the meta bit as
|
|
||||||
// a VK_LSHIFT wrap or every IME capital/symbol lands unshifted on the
|
|
||||||
// host. Never applied to hardware events: their Shift already went over
|
|
||||||
// the wire, and a synthetic release here would un-hold a physical Shift
|
|
||||||
// the user is still pressing.
|
|
||||||
val imeShift = event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD &&
|
|
||||||
event.isShiftPressed && vk != 0xA0 && vk != 0xA1
|
|
||||||
if (down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, true, 0)
|
|
||||||
NativeBridge.nativeSendKey(handle, vk, down, 0)
|
NativeBridge.nativeSendKey(handle, vk, down, 0)
|
||||||
if (!down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, false, 0)
|
|
||||||
return true // consumed — don't let the system also act on it
|
return true // consumed — don't let the system also act on it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,10 +195,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||||
if (streamHandle != 0L) {
|
if (streamHandle != 0L) {
|
||||||
if (gamepadRouter?.onMotion(event) == true) return true
|
if (gamepadRouter?.onMotion(event) == true) return true
|
||||||
// Physical mouse (uncaptured): hover motion, wheel, button edges.
|
|
||||||
if (event.isFromSource(InputDevice.SOURCE_MOUSE)) {
|
|
||||||
mouseForwarder?.let { if (it.onGenericMotion(event)) return true }
|
|
||||||
}
|
|
||||||
return super.dispatchGenericMotionEvent(event)
|
return super.dispatchGenericMotionEvent(event)
|
||||||
}
|
}
|
||||||
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
||||||
@@ -470,24 +232,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
return super.dispatchGenericMotionEvent(event)
|
return super.dispatchGenericMotionEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mouse clicks/drags ride the TOUCH stream (the pointer is "down"). While streaming they
|
|
||||||
* belong to the mouse forwarder, never to the Compose touch-gesture layer — a physical
|
|
||||||
* mouse click must be a real click at the cursor, not a synthesized trackpad tap.
|
|
||||||
*/
|
|
||||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
|
||||||
if (streamHandle != 0L && ev.isFromSource(InputDevice.SOURCE_MOUSE)) {
|
|
||||||
mouseForwarder?.let { if (it.onTouchEvent(ev)) return true }
|
|
||||||
}
|
|
||||||
return super.dispatchTouchEvent(ev)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The OS is the source of truth for pointer capture (it releases on focus loss). */
|
|
||||||
override fun onPointerCaptureChanged(hasCapture: Boolean) {
|
|
||||||
super.onPointerCaptureChanged(hasCapture)
|
|
||||||
mouseForwarder?.onCaptureChanged(hasCapture)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */
|
/** 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) {
|
private fun isConsoleNavKey(kc: Int): Boolean = when (kc) {
|
||||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
package io.unom.punktfunk
|
|
||||||
|
|
||||||
import android.view.InputDevice
|
|
||||||
import android.view.MotionEvent
|
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/** True when any connected input device is a pointer (USB/BT mouse, or a touchpad driving one). */
|
|
||||||
fun hasPhysicalMouse(): Boolean = InputDevice.getDeviceIds().any { id ->
|
|
||||||
InputDevice.getDevice(id)?.supportsSource(InputDevice.SOURCE_MOUSE) == true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Physical mouse → wire, in two modes (the iPadOS/desktop model):
|
|
||||||
* * **uncaptured** (default): hover/drag positions forward as absolute cursor moves
|
|
||||||
* (`MouseMoveAbs`, host-normalized against the window size) — desktop-style pointing. The
|
|
||||||
* local cursor is hidden over the stream (StreamScreen sets a TYPE_NULL pointer icon); the
|
|
||||||
* host's own cursor, composited into the video, is the one you see.
|
|
||||||
* * **captured**: the OS pointer is grabbed ([android.view.View.requestPointerCapture]) and raw
|
|
||||||
* relative deltas forward as `MouseMove` — FPS mouse-look. Engaged at stream start / by
|
|
||||||
* clicking into the stream when the "Capture pointer for games" setting is on, and toggled
|
|
||||||
* any time by Ctrl+Alt+Shift+Q (the cross-client chord). Focus loss releases it (the OS
|
|
||||||
* guarantees that); a click re-engages.
|
|
||||||
*
|
|
||||||
* Buttons ride [MotionEvent.ACTION_BUTTON_PRESS]/RELEASE edges (left/middle/right/back/forward →
|
|
||||||
* wire 1/2/3/4/5), the wheel rides [MotionEvent.ACTION_SCROLL] with fractional accumulation so
|
|
||||||
* high-resolution wheels don't lose sub-notch travel. Held buttons are tracked and flushed on
|
|
||||||
* capture loss / stream exit so nothing sticks on the host. Events reach this class from
|
|
||||||
* MainActivity's dispatch overrides (uncaptured) and the capture view's captured-pointer listener.
|
|
||||||
*/
|
|
||||||
class MouseForwarder(
|
|
||||||
private val handle: Long,
|
|
||||||
private val invertScroll: Boolean,
|
|
||||||
private val captureWanted: Boolean,
|
|
||||||
private val surfaceSize: () -> Pair<Int, Int>,
|
|
||||||
) {
|
|
||||||
/** Capture plumbing, owned by StreamScreen (the focusable capture view). */
|
|
||||||
var onRequestCapture: (() -> Unit)? = null
|
|
||||||
var onReleaseCapture: (() -> Unit)? = null
|
|
||||||
|
|
||||||
/** Live capture state, updated from [android.app.Activity.onPointerCaptureChanged]. */
|
|
||||||
var captured = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
/** Chord-released: no auto re-engage (start / click) until the user opts back in. */
|
|
||||||
private var userReleased = false
|
|
||||||
|
|
||||||
private val heldButtons = mutableSetOf<Int>()
|
|
||||||
private var scrollAccV = 0f
|
|
||||||
private var scrollAccH = 0f
|
|
||||||
private var moveAccX = 0f
|
|
||||||
private var moveAccY = 0f
|
|
||||||
|
|
||||||
/** Uncaptured mouse events on the TOUCH stream (position while a button is down). */
|
|
||||||
fun onTouchEvent(ev: MotionEvent): Boolean {
|
|
||||||
when (ev.actionMasked) {
|
|
||||||
MotionEvent.ACTION_DOWN -> {
|
|
||||||
if (captureWanted && !captured && !userReleased) {
|
|
||||||
// The engaging click: grab the pointer and swallow the click (desktop
|
|
||||||
// parity — the click that captures never reaches the host). The paired
|
|
||||||
// BUTTON_RELEASE is dropped by the held-set guard in [button].
|
|
||||||
onRequestCapture?.invoke()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
sendAbs(ev)
|
|
||||||
}
|
|
||||||
MotionEvent.ACTION_MOVE -> sendAbs(ev)
|
|
||||||
// Button edges are documented on the generic stream, but be robust to either.
|
|
||||||
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
|
|
||||||
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Uncaptured mouse events on the GENERIC stream (hover motion, wheel, button edges). */
|
|
||||||
fun onGenericMotion(ev: MotionEvent): Boolean {
|
|
||||||
when (ev.actionMasked) {
|
|
||||||
MotionEvent.ACTION_HOVER_MOVE -> sendAbs(ev)
|
|
||||||
MotionEvent.ACTION_SCROLL -> wheel(ev)
|
|
||||||
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
|
|
||||||
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
|
|
||||||
MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_EXIT -> {}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Captured-pointer events (the view holds [android.view.View.requestPointerCapture]): x/y ARE
|
|
||||||
* the relative deltas ([InputDevice.SOURCE_MOUSE_RELATIVE]), batched samples included. A
|
|
||||||
* captured touchpad reports absolute finger coordinates instead — not handled (the touch
|
|
||||||
* gesture layer is the touchpad story); returning false leaves those to the framework.
|
|
||||||
*/
|
|
||||||
fun onCapturedPointer(ev: MotionEvent): Boolean {
|
|
||||||
if (!ev.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
|
|
||||||
when (ev.actionMasked) {
|
|
||||||
MotionEvent.ACTION_MOVE -> {
|
|
||||||
var dx = 0f
|
|
||||||
var dy = 0f
|
|
||||||
for (i in 0 until ev.historySize) {
|
|
||||||
dx += ev.getHistoricalX(i)
|
|
||||||
dy += ev.getHistoricalY(i)
|
|
||||||
}
|
|
||||||
dx += ev.x
|
|
||||||
dy += ev.y
|
|
||||||
moveAccX += dx
|
|
||||||
moveAccY += dy
|
|
||||||
val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept w/ sign
|
|
||||||
val oy = moveAccY.toInt()
|
|
||||||
if (ox != 0 || oy != 0) {
|
|
||||||
NativeBridge.nativeSendPointerMove(handle, ox, oy)
|
|
||||||
moveAccX -= ox
|
|
||||||
moveAccY -= oy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
|
|
||||||
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
|
|
||||||
MotionEvent.ACTION_SCROLL -> wheel(ev)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ctrl+Alt+Shift+Q: release the grab, or (re-)engage it — works even when auto-capture is off. */
|
|
||||||
fun toggleCapture() {
|
|
||||||
if (captured) {
|
|
||||||
userReleased = true
|
|
||||||
onReleaseCapture?.invoke()
|
|
||||||
} else {
|
|
||||||
userReleased = false
|
|
||||||
onRequestCapture?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Auto-engage at stream start (setting on + a mouse actually present). */
|
|
||||||
fun engageFromStart() {
|
|
||||||
if (captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
|
|
||||||
onRequestCapture?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** From [android.app.Activity.onPointerCaptureChanged] — the OS is the source of truth. */
|
|
||||||
fun onCaptureChanged(has: Boolean) {
|
|
||||||
captured = has
|
|
||||||
// Losing the grab (focus loss, chord) must not leave buttons held on the host.
|
|
||||||
if (!has) flushButtons()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stream teardown: lift anything held and let the grab go. */
|
|
||||||
fun release() {
|
|
||||||
flushButtons()
|
|
||||||
if (captured) onReleaseCapture?.invoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendAbs(ev: MotionEvent) {
|
|
||||||
val (w, h) = surfaceSize()
|
|
||||||
if (w <= 0 || h <= 0) return
|
|
||||||
NativeBridge.nativeSendPointerAbs(
|
|
||||||
handle,
|
|
||||||
ev.x.roundToInt().coerceIn(0, w - 1),
|
|
||||||
ev.y.roundToInt().coerceIn(0, h - 1),
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun wheel(ev: MotionEvent) {
|
|
||||||
val dir = if (invertScroll) -1f else 1f
|
|
||||||
// Android: AXIS_VSCROLL + = up/away, AXIS_HSCROLL + = right — the wire's convention too.
|
|
||||||
scrollAccV += ev.getAxisValue(MotionEvent.AXIS_VSCROLL) * 120f * dir
|
|
||||||
scrollAccH += ev.getAxisValue(MotionEvent.AXIS_HSCROLL) * 120f * dir
|
|
||||||
val v = scrollAccV.toInt()
|
|
||||||
if (v != 0) {
|
|
||||||
NativeBridge.nativeSendScroll(handle, 0, v)
|
|
||||||
scrollAccV -= v
|
|
||||||
}
|
|
||||||
val h = scrollAccH.toInt()
|
|
||||||
if (h != 0) {
|
|
||||||
NativeBridge.nativeSendScroll(handle, 1, h)
|
|
||||||
scrollAccH -= h
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun button(actionButton: Int, down: Boolean) {
|
|
||||||
val b = when (actionButton) {
|
|
||||||
MotionEvent.BUTTON_PRIMARY -> 1
|
|
||||||
MotionEvent.BUTTON_TERTIARY -> 2
|
|
||||||
MotionEvent.BUTTON_SECONDARY -> 3
|
|
||||||
MotionEvent.BUTTON_BACK -> 4
|
|
||||||
MotionEvent.BUTTON_FORWARD -> 5
|
|
||||||
else -> return
|
|
||||||
}
|
|
||||||
if (down) {
|
|
||||||
heldButtons.add(b)
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, b, true)
|
|
||||||
} else if (heldButtons.remove(b)) {
|
|
||||||
// Only release what we pressed — drops the release of a swallowed engaging click
|
|
||||||
// and anything that raced a capture transition.
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, b, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun flushButtons() {
|
|
||||||
heldButtons.forEach { NativeBridge.nativeSendPointerButton(handle, it, false) }
|
|
||||||
heldButtons.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package io.unom.punktfunk
|
|
||||||
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.view.Choreographer
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
|
||||||
import kotlin.math.hypot
|
|
||||||
|
|
||||||
// Hold this long on SELECT (pointer-mode toggle) / PLAY-PAUSE (keyboard toggle) for the long-press
|
|
||||||
// action instead of the tap action.
|
|
||||||
private const val LONG_PRESS_MS = 800L
|
|
||||||
|
|
||||||
// D-pad glide ballistics, in screen-widths per second: start slow enough to hit a close button,
|
|
||||||
// ramp over RAMP_S seconds of continuous hold so crossing the desktop doesn't take all day.
|
|
||||||
private const val SPEED_MIN = 0.14f
|
|
||||||
private const val SPEED_MAX = 0.70f
|
|
||||||
private const val RAMP_S = 1.2f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Android TV remote as a pointer — the Android analogue of the Apple client's Siri-remote pointer,
|
|
||||||
* adapted for D-pad-only remotes (most Android TV remotes have no touch surface). For the
|
|
||||||
* "TV as a desktop client" use case, where a plain remote is often the only thing in hand.
|
|
||||||
*
|
|
||||||
* While streaming on a TV, **hold SELECT ≈ 0.8 s** to toggle pointer mode. While active:
|
|
||||||
* * D-pad (held) glides the host cursor with ramping acceleration (relative `MouseMove`,
|
|
||||||
* Choreographer-paced, diagonal-normalized);
|
|
||||||
* * SELECT tap = left click; PLAY/PAUSE tap = right click (Siri-remote parity);
|
|
||||||
* * PLAY/PAUSE held = toggle the on-screen keyboard; BACK = leave pointer mode
|
|
||||||
* (a second BACK then leaves the stream as usual).
|
|
||||||
* While inactive, everything except the SELECT long-press passes through untouched (D-pad =
|
|
||||||
* arrow keys, SELECT tap = Enter — synthesized on release, since the down was held back to
|
|
||||||
* disambiguate the long-press).
|
|
||||||
*
|
|
||||||
* Only consulted for non-gamepad key events on TV devices (MainActivity gates the calls); all
|
|
||||||
* state lives on the main thread.
|
|
||||||
*/
|
|
||||||
class RemotePointer(
|
|
||||||
private val handle: Long,
|
|
||||||
private val surfaceWidth: () -> Int,
|
|
||||||
private val onActiveChanged: (Boolean) -> Unit,
|
|
||||||
private val onKeyboardToggle: () -> Unit,
|
|
||||||
) {
|
|
||||||
var active = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
|
||||||
private val held = mutableSetOf<Int>() // D-pad keycodes currently down
|
|
||||||
private var moveAccX = 0f
|
|
||||||
private var moveAccY = 0f
|
|
||||||
private var lastFrameNs = 0L
|
|
||||||
private var rampSec = 0f
|
|
||||||
private var tickerRunning = false
|
|
||||||
private var centerLongFired = false
|
|
||||||
private var playLongFired = false
|
|
||||||
|
|
||||||
private val centerLong = Runnable {
|
|
||||||
centerLongFired = true
|
|
||||||
toggle()
|
|
||||||
}
|
|
||||||
private val playLong = Runnable {
|
|
||||||
playLongFired = true
|
|
||||||
onKeyboardToggle()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val frame = object : Choreographer.FrameCallback {
|
|
||||||
override fun doFrame(nowNs: Long) {
|
|
||||||
if (!tickerRunning) return
|
|
||||||
if (held.isEmpty() || !active) {
|
|
||||||
tickerRunning = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val dt = if (lastFrameNs == 0L) {
|
|
||||||
1f / 60f
|
|
||||||
} else {
|
|
||||||
((nowNs - lastFrameNs) / 1e9f).coerceIn(0.001f, 0.1f)
|
|
||||||
}
|
|
||||||
lastFrameNs = nowNs
|
|
||||||
rampSec += dt
|
|
||||||
var vx = 0f
|
|
||||||
var vy = 0f
|
|
||||||
if (KeyEvent.KEYCODE_DPAD_LEFT in held) vx -= 1f
|
|
||||||
if (KeyEvent.KEYCODE_DPAD_RIGHT in held) vx += 1f
|
|
||||||
if (KeyEvent.KEYCODE_DPAD_UP in held) vy -= 1f
|
|
||||||
if (KeyEvent.KEYCODE_DPAD_DOWN in held) vy += 1f
|
|
||||||
val mag = hypot(vx, vy)
|
|
||||||
if (mag > 0f) {
|
|
||||||
val w = surfaceWidth().coerceAtLeast(640)
|
|
||||||
val speed = w * (SPEED_MIN + (SPEED_MAX - SPEED_MIN) * (rampSec / RAMP_S).coerceAtMost(1f))
|
|
||||||
moveAccX += vx / mag * speed * dt
|
|
||||||
moveAccY += vy / mag * speed * dt
|
|
||||||
val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept
|
|
||||||
val oy = moveAccY.toInt()
|
|
||||||
if (ox != 0 || oy != 0) {
|
|
||||||
NativeBridge.nativeSendPointerMove(handle, ox, oy)
|
|
||||||
moveAccX -= ox
|
|
||||||
moveAccY -= oy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Choreographer.getInstance().postFrameCallback(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One remote key event; true = consumed. Ignore key repeats — the ticker owns motion. */
|
|
||||||
fun onKey(event: KeyEvent): Boolean {
|
|
||||||
val down = event.action == KeyEvent.ACTION_DOWN
|
|
||||||
when (event.keyCode) {
|
|
||||||
KeyEvent.KEYCODE_DPAD_CENTER -> {
|
|
||||||
if (down) {
|
|
||||||
if (event.repeatCount == 0) {
|
|
||||||
centerLongFired = false
|
|
||||||
handler.postDelayed(centerLong, LONG_PRESS_MS)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
handler.removeCallbacks(centerLong)
|
|
||||||
if (!centerLongFired) {
|
|
||||||
if (active) {
|
|
||||||
click(1)
|
|
||||||
} else {
|
|
||||||
// The down was held back to disambiguate the long-press, so the
|
|
||||||
// normal path never saw it — synthesize the Enter here instead.
|
|
||||||
NativeBridge.nativeSendKey(handle, 0x0D, true, 0)
|
|
||||||
NativeBridge.nativeSendKey(handle, 0x0D, false, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
|
|
||||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT,
|
|
||||||
-> {
|
|
||||||
if (!active) return false
|
|
||||||
if (down) {
|
|
||||||
if (held.add(event.keyCode) && held.size == 1) startTicker()
|
|
||||||
} else {
|
|
||||||
held.remove(event.keyCode)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
|
||||||
if (!active) return false // inactive: the media-key VK path owns it
|
|
||||||
if (down) {
|
|
||||||
if (event.repeatCount == 0) {
|
|
||||||
playLongFired = false
|
|
||||||
handler.postDelayed(playLong, LONG_PRESS_MS)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
handler.removeCallbacks(playLong)
|
|
||||||
if (!playLongFired) click(3)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_BACK -> {
|
|
||||||
if (!active) return false
|
|
||||||
if (!down) toggle() // leave pointer mode; the next BACK leaves the stream
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stream teardown: stop timers/ticker; nothing wire-held to flush (clicks are edges). */
|
|
||||||
fun release() {
|
|
||||||
handler.removeCallbacks(centerLong)
|
|
||||||
handler.removeCallbacks(playLong)
|
|
||||||
active = false
|
|
||||||
held.clear()
|
|
||||||
tickerRunning = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toggle() {
|
|
||||||
active = !active
|
|
||||||
if (!active) {
|
|
||||||
held.clear()
|
|
||||||
tickerRunning = false
|
|
||||||
}
|
|
||||||
onActiveChanged(active)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startTicker() {
|
|
||||||
rampSec = 0f
|
|
||||||
lastFrameNs = 0L
|
|
||||||
if (!tickerRunning) {
|
|
||||||
tickerRunning = true
|
|
||||||
Choreographer.getInstance().postFrameCallback(frame)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun click(button: Int) {
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, button, true)
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, button, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.Display
|
import android.view.Display
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,14 +14,6 @@ data class Settings(
|
|||||||
val height: Int = 0,
|
val height: Int = 0,
|
||||||
val hz: Int = 0,
|
val hz: Int = 0,
|
||||||
val bitrateKbps: Int = 0,
|
val bitrateKbps: Int = 0,
|
||||||
/**
|
|
||||||
* Render-resolution multiplier: the client asks the host to render/encode at `chosen mode ×
|
|
||||||
* renderScale` and the compositor downscales the larger decoded frame to the SurfaceView
|
|
||||||
* (`> 1` supersamples for sharpness, at more bandwidth AND decode; `< 1` renders under native
|
|
||||||
* for a lighter host/link). `1.0` = Native. Applied at connect via [RenderScale.apply], clamped
|
|
||||||
* even + to the codec's max dimension. Mirrors the Apple/Linux clients' render scale.
|
|
||||||
*/
|
|
||||||
val renderScale: Double = 1.0,
|
|
||||||
/**
|
/**
|
||||||
* Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that
|
* Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that
|
||||||
* can actually present HDR10 (see [displaySupportsHdr]) — on an SDR display HDR is never
|
* can actually present HDR10 (see [displaySupportsHdr]) — on an SDR display HDR is never
|
||||||
@@ -92,44 +82,6 @@ data class Settings(
|
|||||||
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
||||||
*/
|
*/
|
||||||
val autoWakeEnabled: Boolean = true,
|
val autoWakeEnabled: Boolean = true,
|
||||||
/**
|
|
||||||
* Opt-in: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
|
||||||
* phone's own vibration motor — for clip-on gamepads that ship without rumble motors, where
|
|
||||||
* the phone body is the only actuator in the player's hands. Off by default; read once per
|
|
||||||
* session by StreamScreen (it hands GamepadFeedback the device vibrator only when set). The
|
|
||||||
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
|
|
||||||
*/
|
|
||||||
val rumbleOnPhone: Boolean = false,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
|
|
||||||
* and pass it through AS-IS: the host presents a real `28DE:1302` that its Steam drives
|
|
||||||
* directly (Linux hosts). ON by default — it engages only when such a controller is actually
|
|
||||||
* present at stream start, so it costs nothing otherwise; the toggle exists for the rare
|
|
||||||
* setup where the OS-level pad (lizard mode) is preferred.
|
|
||||||
*/
|
|
||||||
val sc2Capture: Boolean = true,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lock a physical mouse to the stream ([android.view.View.requestPointerCapture]) and forward
|
|
||||||
* raw relative motion — FPS mouse-look, the iPad "Capture pointer for games" twin. Engages at
|
|
||||||
* stream start and on a click into the stream; Ctrl+Alt+Shift+Q toggles it live (the chord
|
|
||||||
* works even with this off). Off (default): a mouse points absolutely, desktop-style.
|
|
||||||
*/
|
|
||||||
val pointerCapture: Boolean = false,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flip scroll direction — the mouse wheel and the two-finger touch scroll both. Parity with
|
|
||||||
* the Apple/GTK clients' "Invert scroll direction".
|
|
||||||
*/
|
|
||||||
val invertScroll: Boolean = false,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync text copied on this device to the host and vice versa while streaming (the desktop
|
|
||||||
* clients' shared clipboard, text-only here). Only effective when the host advertises the
|
|
||||||
* clipboard capability; the protocol is opt-in per session either way.
|
|
||||||
*/
|
|
||||||
val clipboardSync: Boolean = true,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** [Settings.touchMode] values; persisted by name. */
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
@@ -166,7 +118,6 @@ class SettingsStore(context: Context) {
|
|||||||
height = prefs.getInt(K_H, 0),
|
height = prefs.getInt(K_H, 0),
|
||||||
hz = prefs.getInt(K_HZ, 0),
|
hz = prefs.getInt(K_HZ, 0),
|
||||||
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
||||||
renderScale = prefs.getFloat(K_RENDER_SCALE, 1.0f).toDouble(),
|
|
||||||
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
||||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||||
@@ -191,11 +142,6 @@ class SettingsStore(context: Context) {
|
|||||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
|
||||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
|
||||||
pointerCapture = prefs.getBoolean(K_POINTER_CAPTURE, false),
|
|
||||||
invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false),
|
|
||||||
clipboardSync = prefs.getBoolean(K_CLIPBOARD_SYNC, true),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: Settings) {
|
fun save(s: Settings) {
|
||||||
@@ -204,7 +150,6 @@ class SettingsStore(context: Context) {
|
|||||||
.putInt(K_H, s.height)
|
.putInt(K_H, s.height)
|
||||||
.putInt(K_HZ, s.hz)
|
.putInt(K_HZ, s.hz)
|
||||||
.putInt(K_BITRATE, s.bitrateKbps)
|
.putInt(K_BITRATE, s.bitrateKbps)
|
||||||
.putFloat(K_RENDER_SCALE, s.renderScale.toFloat())
|
|
||||||
.putBoolean(K_HDR, s.hdrEnabled)
|
.putBoolean(K_HDR, s.hdrEnabled)
|
||||||
.putInt(K_COMPOSITOR, s.compositor)
|
.putInt(K_COMPOSITOR, s.compositor)
|
||||||
.putInt(K_GAMEPAD, s.gamepad)
|
.putInt(K_GAMEPAD, s.gamepad)
|
||||||
@@ -217,11 +162,6 @@ class SettingsStore(context: Context) {
|
|||||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
|
||||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
|
||||||
.putBoolean(K_POINTER_CAPTURE, s.pointerCapture)
|
|
||||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
|
||||||
.putBoolean(K_CLIPBOARD_SYNC, s.clipboardSync)
|
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +170,6 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_H = "height"
|
const val K_H = "height"
|
||||||
const val K_HZ = "hz"
|
const val K_HZ = "hz"
|
||||||
const val K_BITRATE = "bitrate_kbps"
|
const val K_BITRATE = "bitrate_kbps"
|
||||||
const val K_RENDER_SCALE = "render_scale"
|
|
||||||
const val K_HDR = "hdr_enabled"
|
const val K_HDR = "hdr_enabled"
|
||||||
const val K_COMPOSITOR = "compositor"
|
const val K_COMPOSITOR = "compositor"
|
||||||
const val K_GAMEPAD = "gamepad"
|
const val K_GAMEPAD = "gamepad"
|
||||||
@@ -258,11 +197,6 @@ class SettingsStore(context: Context) {
|
|||||||
*/
|
*/
|
||||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
|
||||||
const val K_SC2_CAPTURE = "sc2_capture"
|
|
||||||
const val K_POINTER_CAPTURE = "pointer_capture"
|
|
||||||
const val K_INVERT_SCROLL = "invert_scroll"
|
|
||||||
const val K_CLIPBOARD_SYNC = "clipboard_sync"
|
|
||||||
|
|
||||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
const val K_TRACKPAD = "trackpad_mode"
|
const val K_TRACKPAD = "trackpad_mode"
|
||||||
@@ -292,25 +226,11 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
|||||||
*/
|
*/
|
||||||
fun displaySupportsHdr(context: Context): Boolean {
|
fun displaySupportsHdr(context: Context): Boolean {
|
||||||
val display = runCatching { context.display }.getOrNull() ?: return false
|
val display = runCatching { context.display }.getOrNull() ?: return false
|
||||||
val types = buildSet {
|
@Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31
|
||||||
// API 34+: the sanctioned per-mode query (Display.Mode.getSupportedHdrTypes). The
|
val caps = display.hdrCapabilities ?: return false
|
||||||
// deprecated Display-level hdrCapabilities can return EMPTY on Android 14+ devices
|
return caps.supportedHdrTypes.any {
|
||||||
// (Pixel-class panels included), which would make a genuinely HDR display advertise
|
|
||||||
// no-HDR and pin the whole session to 8-bit SDR.
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
display.mode.supportedHdrTypes.forEach { add(it) }
|
|
||||||
}
|
|
||||||
// Union the legacy query defensively — the supported one on minSdk 31, and some vendors
|
|
||||||
// populate only this on newer APIs.
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
display.hdrCapabilities?.supportedHdrTypes?.forEach { add(it) }
|
|
||||||
}
|
|
||||||
// HDR10/HDR10+ only: the stream is BT.2020 PQ — a Dolby-Vision/HLG-only panel can't present it.
|
|
||||||
val supported = types.any {
|
|
||||||
it == Display.HdrCapabilities.HDR_TYPE_HDR10 || it == Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS
|
it == Display.HdrCapabilities.HDR_TYPE_HDR10 || it == Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS
|
||||||
}
|
}
|
||||||
Log.i("punktfunk", "display HDR types=$types → advertise HDR10=$supported")
|
|
||||||
return supported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
||||||
@@ -322,54 +242,6 @@ fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
|||||||
return Triple(w, h, hz)
|
return Triple(w, h, hz)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Client-side render-scale geometry — the Kotlin twin of `punktfunk-core`'s `render_scale` module
|
|
||||||
* (and the Apple client's `RenderScale`). Multiply a base size, preserve aspect, even-floor (the
|
|
||||||
* host rejects odd sizes), and clamp uniformly to the codec's per-axis ceiling so a connect can't
|
|
||||||
* ask for a size the encoder rejects. `1.0` = Native. Pure + covered by [RenderScaleTest].
|
|
||||||
*/
|
|
||||||
object RenderScale {
|
|
||||||
val PRESETS = listOf(0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0)
|
|
||||||
|
|
||||||
/** H.264 tops out at 4096 px/axis; HEVC/AV1/auto at 8192 — the host's `codec.rs` walls. */
|
|
||||||
fun maxDimension(codec: String): Int = if (codec == "h264") 4096 else 8192
|
|
||||||
|
|
||||||
/** Clamp a raw multiplier into [0.5, 4.0]; a missing / non-positive / NaN value → 1.0. */
|
|
||||||
fun sanitize(raw: Double): Double = if (raw > 0.0) raw.coerceIn(0.5, 4.0) else 1.0
|
|
||||||
|
|
||||||
/** "Native (1×)" / "1.5×" / "2× · supersample" — the picker label. */
|
|
||||||
fun label(scale: Double): String = when {
|
|
||||||
scale == 1.0 -> "Native (1×)"
|
|
||||||
scale > 1.0 -> "${trim(scale)}× · supersample"
|
|
||||||
else -> "${trim(scale)}×"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun trim(s: Double): String =
|
|
||||||
if (s == s.toLong().toDouble()) s.toLong().toString() else s.toString()
|
|
||||||
|
|
||||||
/** Apply [scale] to a base size → a host-valid even, aspect-preserved, codec-clamped (w, h). */
|
|
||||||
fun apply(baseW: Int, baseH: Int, scale: Double, maxDim: Int): Pair<Int, Int> {
|
|
||||||
val s = sanitize(scale)
|
|
||||||
var w = maxOf(baseW, 1) * s
|
|
||||||
var h = maxOf(baseH, 1) * s
|
|
||||||
val cap = maxDim.toDouble()
|
|
||||||
val over = maxOf(w / cap, h / cap)
|
|
||||||
if (over > 1.0) {
|
|
||||||
w /= over
|
|
||||||
h /= over
|
|
||||||
}
|
|
||||||
return Pair(evenFloor(w, 320), evenFloor(h, 200))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun evenFloor(value: Double, minimum: Int): Int {
|
|
||||||
val v = maxOf(kotlin.math.floor(value).toInt(), minimum).coerceAtLeast(0)
|
|
||||||
return v / 2 * 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** (scale, label) for the render-scale picker. `1.0` = Native. */
|
|
||||||
val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it) }
|
|
||||||
|
|
||||||
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
|
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
|
||||||
|
|
||||||
/** (width, height, label). `(0,0)` = native display. */
|
/** (width, height, label). `(0,0)` = native display. */
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import io.unom.punktfunk.kit.VideoDecoders
|
import io.unom.punktfunk.kit.VideoDecoders
|
||||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||||
@@ -333,15 +332,6 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
|||||||
update(s.copy(bitrateKbps = kbps))
|
update(s.copy(bitrateKbps = kbps))
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingDropdown(
|
|
||||||
label = "Render scale",
|
|
||||||
options = RENDER_SCALE_OPTIONS,
|
|
||||||
// Snap the stored value (a Float round-tripped to Double) to the nearest preset so the
|
|
||||||
// exact Double keys match. > 1 supersamples for sharpness (more bandwidth AND decode);
|
|
||||||
// < 1 renders under native for a lighter host — this device resamples to the display.
|
|
||||||
selected = RenderScale.PRESETS.minByOrNull { kotlin.math.abs(it - s.renderScale) } ?: 1.0,
|
|
||||||
) { scale -> update(s.copy(renderScale = scale)) }
|
|
||||||
|
|
||||||
// AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the
|
// AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the
|
||||||
// host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable
|
// host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable
|
||||||
// device stays visible so the selection is always representable.
|
// device stays visible so the selection is always representable.
|
||||||
@@ -412,27 +402,6 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
ToggleRow(
|
|
||||||
title = "Capture pointer for games",
|
|
||||||
subtitle = "Lock a connected mouse to the stream and send raw relative motion " +
|
|
||||||
"(mouse-look). Ctrl+Alt+Shift+Q toggles it live; click the stream to re-capture. " +
|
|
||||||
"Off: the mouse points at the desktop directly",
|
|
||||||
checked = s.pointerCapture,
|
|
||||||
onCheckedChange = { on -> update(s.copy(pointerCapture = on)) },
|
|
||||||
)
|
|
||||||
ToggleRow(
|
|
||||||
title = "Invert scroll direction",
|
|
||||||
subtitle = "Flip the mouse wheel and two-finger touch scrolling",
|
|
||||||
checked = s.invertScroll,
|
|
||||||
onCheckedChange = { on -> update(s.copy(invertScroll = on)) },
|
|
||||||
)
|
|
||||||
ToggleRow(
|
|
||||||
title = "Shared clipboard",
|
|
||||||
subtitle = "Text copied here pastes on the host and vice versa (hosts with " +
|
|
||||||
"clipboard sharing enabled)",
|
|
||||||
checked = s.clipboardSync,
|
|
||||||
onCheckedChange = { on -> update(s.copy(clipboardSync = on)) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
SettingDropdown(
|
SettingDropdown(
|
||||||
@@ -445,26 +414,6 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
|
|||||||
subtitle = "What the app detects, with a live input test",
|
subtitle = "What the app detects, with a live input test",
|
||||||
onClick = onOpenControllers,
|
onClick = onOpenControllers,
|
||||||
)
|
)
|
||||||
// Only where the device has a body vibrator to mirror onto (a TV box doesn't).
|
|
||||||
val context = LocalContext.current
|
|
||||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
|
||||||
if (hasBodyVibrator) {
|
|
||||||
ToggleRow(
|
|
||||||
title = "Rumble on this phone",
|
|
||||||
subtitle = "Also play controller 1's rumble on this phone's own vibration " +
|
|
||||||
"motor — for clip-on pads without rumble motors",
|
|
||||||
checked = s.rumbleOnPhone,
|
|
||||||
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
|
|
||||||
)
|
|
||||||
ToggleRow(
|
|
||||||
title = "Steam Controller 2 passthrough",
|
|
||||||
subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " +
|
|
||||||
"Bluetooth): it navigates these menus and streams as-is — Steam on the " +
|
|
||||||
"host drives it like the physical pad (trackpads, gyro, haptics)",
|
|
||||||
checked = s.sc2Capture,
|
|
||||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ internal fun StatsOverlay(
|
|||||||
s: DoubleArray,
|
s: DoubleArray,
|
||||||
verbosity: StatsVerbosity,
|
verbosity: StatsVerbosity,
|
||||||
decoderLabel: String = "",
|
decoderLabel: String = "",
|
||||||
codecLabel: String = "",
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||||
@@ -67,7 +66,7 @@ internal fun StatsOverlay(
|
|||||||
statLine(decoderLabel, Color(0xFFB0D0FF))
|
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||||
}
|
}
|
||||||
if (detailed) {
|
if (detailed) {
|
||||||
videoFeedLine(s, codecLabel)?.let { statLine(it, Color.White) }
|
videoFeedLine(s)?.let { statLine(it, Color.White) }
|
||||||
}
|
}
|
||||||
if (latValid) {
|
if (latValid) {
|
||||||
// Display stage (s[22]–s[25], from OnFrameRendered): when a render timestamp landed
|
// Display stage (s[22]–s[25], from OnFrameRendered): when a render timestamp landed
|
||||||
@@ -152,15 +151,14 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format the negotiated video-feed descriptor from [codecLabel] plus the trailing four stats
|
* Format the negotiated video-feed descriptor from the trailing four stats doubles
|
||||||
* doubles `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||||
* `AV1 · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
|
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
|
||||||
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
|
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
|
||||||
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4.
|
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The
|
||||||
* [codecLabel] is the host-resolved codec (`nativeVideoCodecLabel`); a blank one falls back to
|
* Android decoder is always HEVC (`video/hevc`).
|
||||||
* `HEVC` (the pre-negotiation default) for the brief window before it's resolved.
|
|
||||||
*/
|
*/
|
||||||
private fun videoFeedLine(s: DoubleArray, codecLabel: String): String? {
|
private fun videoFeedLine(s: DoubleArray): String? {
|
||||||
if (s.size < 14) return null
|
if (s.size < 14) return null
|
||||||
val bitDepth = s[10].toInt()
|
val bitDepth = s[10].toInt()
|
||||||
val primaries = s[11].toInt()
|
val primaries = s[11].toInt()
|
||||||
@@ -177,6 +175,5 @@ private fun videoFeedLine(s: DoubleArray, codecLabel: String): String? {
|
|||||||
2 -> "4:2:2"
|
2 -> "4:2:2"
|
||||||
else -> "4:2:0"
|
else -> "4:2:0"
|
||||||
}
|
}
|
||||||
val codec = codecLabel.ifEmpty { "HEVC" }
|
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
||||||
return "$codec · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,20 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.hardware.usb.UsbManager
|
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.text.InputType
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import android.view.View
|
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.view.inputmethod.BaseInputConnection
|
|
||||||
import android.view.inputmethod.EditorInfo
|
|
||||||
import android.view.inputmethod.InputConnection
|
|
||||||
import android.view.inputmethod.InputMethodManager
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -39,11 +24,9 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
@@ -51,9 +34,7 @@ import androidx.core.view.WindowInsetsCompat
|
|||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import io.unom.punktfunk.kit.GamepadFeedback
|
import io.unom.punktfunk.kit.GamepadFeedback
|
||||||
import io.unom.punktfunk.kit.GamepadRouter
|
import io.unom.punktfunk.kit.GamepadRouter
|
||||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
import io.unom.punktfunk.kit.Sc2Capture
|
|
||||||
import io.unom.punktfunk.kit.VideoDecoders
|
import io.unom.punktfunk.kit.VideoDecoders
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -84,7 +65,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
val initialSettings = remember { SettingsStore(context).load() }
|
val initialSettings = remember { SettingsStore(context).load() }
|
||||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||||
var decoderLabel by remember { mutableStateOf("") }
|
var decoderLabel by remember { mutableStateOf("") }
|
||||||
var codecLabel by remember { mutableStateOf("") }
|
|
||||||
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
||||||
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
||||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||||
@@ -100,9 +80,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
LaunchedEffect(handle, statsOn) {
|
LaunchedEffect(handle, statsOn) {
|
||||||
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
||||||
if (statsOn) {
|
if (statsOn) {
|
||||||
// Codec is resolved at the handshake (Welcome) — fixed for the session, so read its
|
|
||||||
// label once up front (before the first snapshot renders the video-feed line).
|
|
||||||
if (codecLabel.isEmpty()) codecLabel = NativeBridge.nativeVideoCodecLabel(handle)
|
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
stats = NativeBridge.nativeVideoStats(handle)
|
stats = NativeBridge.nativeVideoStats(handle)
|
||||||
@@ -172,18 +149,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
}.onEach { it.setReferenceCounted(false) }
|
}.onEach { it.setReferenceCounted(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// True while the gamepad exit chord (Select+Start+L1+R1) is held and counting down — drives the
|
|
||||||
// "hold to quit" hint overlay. Set from the router's onExitArmed (main thread).
|
|
||||||
var exitArming by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// True while the TV remote is acting as a pointer (hold SELECT toggles) — drives the mode hint.
|
|
||||||
var remotePointerOn by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Focus anchor the soft keyboard is summoned onto AND the pointer-capture grab target (a grab
|
|
||||||
// needs a focusable view; captured-pointer events land on it). Declared before the effect
|
|
||||||
// below so the capture callbacks can reach the view once it exists.
|
|
||||||
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
|
|
||||||
|
|
||||||
DisposableEffect(handle) {
|
DisposableEffect(handle) {
|
||||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
wifiLocks.forEach { lock ->
|
wifiLocks.forEach { lock ->
|
||||||
@@ -201,12 +166,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||||
}
|
}
|
||||||
// The soft keyboard (three-finger swipe up → KeyCaptureView below) must OVERLAY the
|
|
||||||
// stream, never pan/resize it — the video is a fixed-mode surface, not a document.
|
|
||||||
// Scoped to the stream; the app's other screens keep the default for their text fields.
|
|
||||||
val priorSoftInput = window?.attributes?.softInputMode
|
|
||||||
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
|
|
||||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
|
||||||
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
|
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
|
||||||
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
|
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
|
||||||
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
||||||
@@ -226,143 +185,22 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// the same way the Back gesture does.
|
// the same way the Back gesture does.
|
||||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||||
router.onExitChord = { activity?.requestStreamExit?.invoke() }
|
router.onExitChord = { activity?.requestStreamExit?.invoke() }
|
||||||
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
|
||||||
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
|
||||||
router.onExitArmed = { armed -> exitArming = armed }
|
|
||||||
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
|
|
||||||
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
|
|
||||||
// The local cursor is hidden over the stream — the host's own cursor, composited into
|
|
||||||
// the video, is the one the user sees (twin of the desktop clients' hidden cursor).
|
|
||||||
val decor = window?.decorView
|
|
||||||
val priorPointerIcon = decor?.pointerIcon
|
|
||||||
decor?.pointerIcon = android.view.PointerIcon.getSystemIcon(
|
|
||||||
context,
|
|
||||||
android.view.PointerIcon.TYPE_NULL,
|
|
||||||
)
|
|
||||||
val mouse = MouseForwarder(
|
|
||||||
handle,
|
|
||||||
invertScroll = initialSettings.invertScroll,
|
|
||||||
captureWanted = initialSettings.pointerCapture,
|
|
||||||
surfaceSize = { (decor?.width ?: 0) to (decor?.height ?: 0) },
|
|
||||||
)
|
|
||||||
mouse.onRequestCapture = {
|
|
||||||
// The grab needs the (focusable) capture view: focus it, then ask. Posted so a
|
|
||||||
// request racing view attach/focus settles on the next frame.
|
|
||||||
keyCapture?.let { v ->
|
|
||||||
v.post {
|
|
||||||
v.requestFocus()
|
|
||||||
v.requestPointerCapture()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mouse.onReleaseCapture = { keyCapture?.releasePointerCapture() }
|
|
||||||
activity?.mouseForwarder = mouse
|
|
||||||
// TV remote-as-pointer: hold SELECT ≈ 0.8 s to toggle; the D-pad then glides the host
|
|
||||||
// cursor (see RemotePointer). TV only — a phone's remote-less keys stay on the VK path.
|
|
||||||
val remote = if (isTv) {
|
|
||||||
RemotePointer(
|
|
||||||
handle,
|
|
||||||
surfaceWidth = { decor?.width ?: 1920 },
|
|
||||||
onActiveChanged = { on -> remotePointerOn = on },
|
|
||||||
onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
activity?.remotePointer = remote
|
|
||||||
// Shared clipboard (text v1): only when the user setting is on AND the host has a
|
|
||||||
// working clipboard service. Protocol-level opt-in + the poll thread live in the sync.
|
|
||||||
val clip = if (initialSettings.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
|
|
||||||
ClipboardSync(context, handle).also { it.start() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||||
// index via the router; poll threads stopped + joined before the router is released and the
|
// index via the router; poll threads stopped + joined before the router is released and the
|
||||||
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
|
// session closed.
|
||||||
// rumble onto the device's own vibrator — for clip-on pads without rumble motors.
|
val feedback = GamepadFeedback(handle, router).also { it.start() }
|
||||||
val feedback = GamepadFeedback(
|
|
||||||
handle,
|
|
||||||
router,
|
|
||||||
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
|
|
||||||
).also { it.start() }
|
|
||||||
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
||||||
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||||
router.onSlotClosed = feedback::onDeviceRemoved
|
router.onSlotClosed = feedback::onDeviceRemoved
|
||||||
// Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an
|
|
||||||
// already-paired BLE one — and forward its raw reports; the host mirrors a real
|
|
||||||
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
|
|
||||||
// through feedback.onHidRaw onto the physical controller. Engages only when such a pad is
|
|
||||||
// actually present; the wire slot is claimed lazily on its first state report.
|
|
||||||
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
|
|
||||||
// claim the interfaces; it resumes in onDispose once the stream releases them.
|
|
||||||
activity?.stopSc2MenuNav()
|
|
||||||
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
|
|
||||||
var sc2UsbReceiver: BroadcastReceiver? = null
|
|
||||||
if (sc2 != null) {
|
|
||||||
feedback.onHidRaw = sc2::onHidRaw
|
|
||||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
|
||||||
val usbDev = sc2.findUsbDevice()
|
|
||||||
when {
|
|
||||||
usbDev != null && usbManager.hasPermission(usbDev) -> sc2.startUsb(usbDev)
|
|
||||||
usbDev != null -> {
|
|
||||||
// One-time system dialog; capture engages on grant (Android remembers the
|
|
||||||
// grant for as long as the device stays attached).
|
|
||||||
val action = "io.unom.punktfunk.SC2_USB_PERMISSION"
|
|
||||||
val receiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(c: Context?, intent: Intent?) {
|
|
||||||
if (intent?.action != action) return
|
|
||||||
val ok = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
|
||||||
if (ok) sc2.startUsb(usbDev) else Log.i("punktfunk", "SC2 USB permission denied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sc2UsbReceiver = receiver
|
|
||||||
ContextCompat.registerReceiver(
|
|
||||||
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
|
|
||||||
)
|
|
||||||
usbManager.requestPermission(
|
|
||||||
usbDev,
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
context, 0,
|
|
||||||
Intent(action).setPackage(context.packageName),
|
|
||||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
|
||||||
PendingIntent.FLAG_MUTABLE,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED -> {
|
|
||||||
sc2.pairedBleAddress()?.let { addr ->
|
|
||||||
Log.i("punktfunk", "SC2: no USB pad — using the paired BLE controller $addr")
|
|
||||||
sc2.startBle(addr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onDispose {
|
onDispose {
|
||||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||||
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
|
|
||||||
feedback.onHidRaw = null
|
|
||||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
|
||||||
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
|
|
||||||
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
|
||||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||||
activity?.gamepadRouter = null
|
activity?.gamepadRouter = null
|
||||||
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
|
|
||||||
mouse.release()
|
|
||||||
activity?.mouseForwarder = null
|
|
||||||
remote?.release()
|
|
||||||
activity?.remotePointer = null
|
|
||||||
decor?.pointerIcon = priorPointerIcon
|
|
||||||
activity?.streamHandle = 0L
|
activity?.streamHandle = 0L
|
||||||
activity?.requestStreamExit = null
|
activity?.requestStreamExit = null
|
||||||
// Back in the menus: the SC2 (if present) resumes driving the console UI.
|
|
||||||
activity?.startSc2MenuNav()
|
|
||||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||||
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
|
||||||
window?.setSoftInputMode(priorSoftInput)
|
|
||||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
@@ -383,13 +221,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||||
|
|
||||||
// Auto-engage pointer capture at stream start (setting on + a mouse actually present).
|
|
||||||
// Delayed a beat: the grab needs window focus and the capture view attached.
|
|
||||||
LaunchedEffect(handle) {
|
|
||||||
delay(400)
|
|
||||||
activity?.mouseForwarder?.engageFromStart()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -437,40 +268,11 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||||
if (statsOn) {
|
if (statsOn) {
|
||||||
stats?.let {
|
stats?.let {
|
||||||
StatsOverlay(it, statsVerbosity, decoderLabel, codecLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
|
|
||||||
// hold, so without this cue a couch user reads the (deliberately no-longer-instant) chord as
|
|
||||||
// broken. Purely visual; it sits above the video and below the gesture layer.
|
|
||||||
if (exitArming) {
|
|
||||||
ExitChordHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
|
|
||||||
}
|
|
||||||
// Remote-pointer mode hint — the remote's keys are remapped while it's on, so say so.
|
|
||||||
if (remotePointerOn) {
|
|
||||||
RemotePointerHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
|
|
||||||
}
|
|
||||||
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe up
|
|
||||||
// in the mouse modes) AND the pointer-capture grab target — it never draws or takes
|
|
||||||
// touches, it just owns IME focus and receives captured-pointer events.
|
|
||||||
AndroidView(
|
|
||||||
modifier = Modifier.size(1.dp),
|
|
||||||
factory = { ctx ->
|
|
||||||
KeyCaptureView(ctx).also { v ->
|
|
||||||
keyCapture = v
|
|
||||||
// Real IME text path when the host types committed text (see KeyCaptureView).
|
|
||||||
v.textHandle =
|
|
||||||
if (NativeBridge.nativeTextInputSupported(handle)) handle else 0L
|
|
||||||
v.setOnCapturedPointerListener { _, ev ->
|
|
||||||
(ctx as? MainActivity)?.mouseForwarder?.onCapturedPointer(ev) ?: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
|
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||||
// keyboard gesture: its fingers belong to the host verbatim (a swipe there may BE a
|
|
||||||
// host-OS gesture), so intercepting three fingers would corrupt real multi-touch.
|
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||||
when (touchMode) {
|
when (touchMode) {
|
||||||
@@ -478,211 +280,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
else -> streamTouchInput(
|
else -> streamTouchInput(
|
||||||
handle,
|
handle,
|
||||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||||
invertScroll = initialSettings.invertScroll,
|
|
||||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||||
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
|
|
||||||
* chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms
|
|
||||||
* the press registered and tells the user to keep holding. Purely visual; [GamepadRouter.onExitArmed]
|
|
||||||
* toggles its visibility.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ExitChordHint(modifier: Modifier = Modifier) {
|
|
||||||
Text(
|
|
||||||
"Hold to quit…",
|
|
||||||
modifier = modifier
|
|
||||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
|
||||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The remote-pointer mode cue: while active the remote's keys are remapped (D-pad glides the host
|
|
||||||
* cursor, SELECT clicks), so the overlay both confirms the toggle and teaches the vocabulary.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun RemotePointerHint(modifier: Modifier = Modifier) {
|
|
||||||
Text(
|
|
||||||
"Remote pointer — SELECT click · play/pause right-click · hold SELECT to exit",
|
|
||||||
modifier = modifier
|
|
||||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
|
||||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
|
|
||||||
* onto this view. Two IME models, picked by the host's capabilities:
|
|
||||||
* * **Text path** ([textHandle] set — the host advertised `HOST_CAP_TEXT_INPUT`): a real
|
|
||||||
* editable [HostTextConnection], so the IME gives autocorrect, gesture typing, non-Latin
|
|
||||||
* composition and emoji, all mirrored to the host as committed text + diffs.
|
|
||||||
* * **Fallback** (older host): `TYPE_NULL` puts the IME in "dumb keyboard" mode — raw
|
|
||||||
* [KeyEvent]s flow through `MainActivity.dispatchKeyEvent` → `Keymap.toVk` → the host, the
|
|
||||||
* exact path a hardware keyboard takes (with the IME-shift wrap documented there).
|
|
||||||
*
|
|
||||||
* Doubles as the pointer-capture grab target: a grab needs a focusable view, and captured-pointer
|
|
||||||
* events are delivered to it (routed to [MouseForwarder.onCapturedPointer] via the listener the
|
|
||||||
* stream screen installs).
|
|
||||||
*/
|
|
||||||
private class KeyCaptureView(context: Context) : View(context) {
|
|
||||||
init {
|
|
||||||
isFocusable = true
|
|
||||||
isFocusableInTouchMode = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The session handle when the host types committed text; `0` = VK-only fallback. */
|
|
||||||
var textHandle: Long = 0L
|
|
||||||
|
|
||||||
/** Whether [setImeVisible] last showed the IME — for toggle-style callers (remote pointer). */
|
|
||||||
var imeShown = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
override fun onCheckIsTextEditor(): Boolean = true
|
|
||||||
|
|
||||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
|
|
||||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or
|
|
||||||
EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_ENTER_ACTION
|
|
||||||
return if (textHandle != 0L) {
|
|
||||||
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or
|
|
||||||
InputType.TYPE_TEXT_FLAG_AUTO_CORRECT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
|
|
||||||
HostTextConnection(this, textHandle)
|
|
||||||
} else {
|
|
||||||
outAttrs.inputType = InputType.TYPE_NULL
|
|
||||||
BaseInputConnection(this, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setImeVisible(show: Boolean) {
|
|
||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
|
||||||
?: return
|
|
||||||
imeShown = show
|
|
||||||
if (show) {
|
|
||||||
requestFocus()
|
|
||||||
imm.showSoftInput(this, 0)
|
|
||||||
} else {
|
|
||||||
imm.hideSoftInputFromWindow(windowToken, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IME → host text bridge (the `HOST_CAP_TEXT_INPUT` path): a real **editable** connection, so
|
|
||||||
* the IME runs its full machinery (autocorrect, gesture typing, non-Latin composition), mirrored
|
|
||||||
* to the host as it happens. The one piece of host-side state tracked is *what the host currently
|
|
||||||
* shows of the active composition* ([sentComposition]): composing updates send a common-prefix
|
|
||||||
* diff (backspaces + the new suffix) so corrections materialize live on the host; a commit
|
|
||||||
* settles it. [setComposingRegion] adopts already-committed text as the active composition
|
|
||||||
* (autocorrect-revert / backspace-into-word flows), so the next update diffs against it instead
|
|
||||||
* of retyping. Newlines become Enter taps; [deleteSurroundingText] becomes Backspace/Delete taps.
|
|
||||||
*
|
|
||||||
* Known approximation: diff lengths are counted in Unicode scalars, assuming one host Backspace
|
|
||||||
* deletes one scalar — true for the composition text IMEs actually produce (emoji and other
|
|
||||||
* multi-unit graphemes commit directly rather than composing).
|
|
||||||
*/
|
|
||||||
private class HostTextConnection(
|
|
||||||
view: KeyCaptureView,
|
|
||||||
private val handle: Long,
|
|
||||||
) : BaseInputConnection(view, true) {
|
|
||||||
/** What the host currently shows of the active composition ("" = none). */
|
|
||||||
private var sentComposition = ""
|
|
||||||
|
|
||||||
override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
|
|
||||||
retype(text.toString())
|
|
||||||
sentComposition = ""
|
|
||||||
val ok = super.commitText(text, newCursorPosition)
|
|
||||||
trimEditable()
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean {
|
|
||||||
retype(text.toString())
|
|
||||||
return super.setComposingText(text, newCursorPosition)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun finishComposingText(): Boolean {
|
|
||||||
// The composition text stands as committed — the host already shows it verbatim.
|
|
||||||
sentComposition = ""
|
|
||||||
return super.finishComposingText()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setComposingRegion(start: Int, end: Int): Boolean {
|
|
||||||
val e = editable
|
|
||||||
if (e != null) {
|
|
||||||
val a = start.coerceIn(0, e.length)
|
|
||||||
val b = end.coerceIn(0, e.length)
|
|
||||||
sentComposition = e.subSequence(minOf(a, b), maxOf(a, b)).toString()
|
|
||||||
}
|
|
||||||
return super.setComposingRegion(start, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
|
|
||||||
repeat(beforeLength.coerceIn(0, MAX_TAPS)) { tapVk(VK_BACK) }
|
|
||||||
repeat(afterLength.coerceIn(0, MAX_TAPS)) { tapVk(VK_DELETE) }
|
|
||||||
return super.deleteSurroundingText(beforeLength, afterLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun performEditorAction(actionCode: Int): Boolean {
|
|
||||||
tapVk(VK_RETURN)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Replace the host's view of the composition with [text] via a common-prefix diff. */
|
|
||||||
private fun retype(text: String) {
|
|
||||||
var common = sentComposition.commonPrefixWith(text)
|
|
||||||
// Never split a surrogate pair mid-diff — back off to the pair boundary.
|
|
||||||
if (common.isNotEmpty() && common.last().isHighSurrogate()) {
|
|
||||||
common = common.dropLast(1)
|
|
||||||
}
|
|
||||||
val stale = sentComposition.substring(common.length)
|
|
||||||
repeat(stale.codePointCount(0, stale.length).coerceAtMost(MAX_TAPS)) { tapVk(VK_BACK) }
|
|
||||||
sendText(text.substring(common.length))
|
|
||||||
sentComposition = text
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Forward literal text, turning newlines into Enter taps (control chars never ride text). */
|
|
||||||
private fun sendText(s: String) {
|
|
||||||
var chunk = StringBuilder()
|
|
||||||
for (ch in s) {
|
|
||||||
if (ch == '\n') {
|
|
||||||
if (chunk.isNotEmpty()) {
|
|
||||||
NativeBridge.nativeSendText(handle, chunk.toString())
|
|
||||||
chunk = StringBuilder()
|
|
||||||
}
|
|
||||||
tapVk(VK_RETURN)
|
|
||||||
} else {
|
|
||||||
chunk.append(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (chunk.isNotEmpty()) NativeBridge.nativeSendText(handle, chunk.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tapVk(vk: Int) {
|
|
||||||
NativeBridge.nativeSendKey(handle, vk, true, 0)
|
|
||||||
NativeBridge.nativeSendKey(handle, vk, false, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bound the mirror buffer: once nothing is composing, old text serves no purpose. */
|
|
||||||
private fun trimEditable() {
|
|
||||||
val e = editable ?: return
|
|
||||||
if (getComposingSpanStart(e) == -1 && e.length > 4000) e.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val VK_BACK = 0x08
|
|
||||||
const val VK_RETURN = 0x0D
|
|
||||||
const val VK_DELETE = 0x2E
|
|
||||||
const val MAX_TAPS = 256
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,10 +19,6 @@ private const val TAP_SLOP = 12f
|
|||||||
private const val TAP_DRAG_MS = 250L
|
private const val TAP_DRAG_MS = 250L
|
||||||
private const val SCROLL_DIV = 4f
|
private const val SCROLL_DIV = 4f
|
||||||
|
|
||||||
// Three-finger vertical swipe: the fraction of the view height the centroid must travel to
|
|
||||||
// summon (up) / dismiss (down) the local soft keyboard.
|
|
||||||
private const val KB_SWIPE_FRACTION = 0.10f
|
|
||||||
|
|
||||||
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
|
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
|
||||||
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
|
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
|
||||||
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
||||||
@@ -44,9 +40,7 @@ private const val ACCEL_MAX = 3.0f
|
|||||||
*
|
*
|
||||||
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||||
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier);
|
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
|
||||||
* three-finger swipe up/down = [onKeyboard] (summon/dismiss the local soft keyboard, for
|
|
||||||
* typing on the host).
|
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||||
@@ -99,11 +93,8 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
|
|||||||
internal suspend fun PointerInputScope.streamTouchInput(
|
internal suspend fun PointerInputScope.streamTouchInput(
|
||||||
handle: Long,
|
handle: Long,
|
||||||
trackpad: Boolean,
|
trackpad: Boolean,
|
||||||
invertScroll: Boolean,
|
|
||||||
onCycleStats: () -> Unit,
|
onCycleStats: () -> Unit,
|
||||||
onKeyboard: (show: Boolean) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val scrollDir = if (invertScroll) -1 else 1
|
|
||||||
var lastTapUp = 0L
|
var lastTapUp = 0L
|
||||||
var lastTapX = 0f
|
var lastTapX = 0f
|
||||||
var lastTapY = 0f
|
var lastTapY = 0f
|
||||||
@@ -137,12 +128,6 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
var maxFingers = 1
|
var maxFingers = 1
|
||||||
var scrolling = false
|
var scrolling = false
|
||||||
var scrollCount = 0 // pointer count the scroll centroid is anchored at
|
var scrollCount = 0 // pointer count the scroll centroid is anchored at
|
||||||
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the
|
|
||||||
// scroll anchor) and a once-per-gesture latch.
|
|
||||||
var kbCount = 0
|
|
||||||
var kbAnchorX = 0f
|
|
||||||
var kbAnchorY = 0f
|
|
||||||
var kbFired = false
|
|
||||||
var prevCx = startX
|
var prevCx = startX
|
||||||
var prevCy = startY
|
var prevCy = startY
|
||||||
var upTime = down.uptimeMillis
|
var upTime = down.uptimeMillis
|
||||||
@@ -163,12 +148,9 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (pressed.size > maxFingers) maxFingers = pressed.size
|
if (pressed.size > maxFingers) maxFingers = pressed.size
|
||||||
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3
|
|
||||||
// bounce re-anchors instead of reading the count change as swipe travel.
|
|
||||||
if (pressed.size < 3) kbCount = 0
|
|
||||||
|
|
||||||
if (pressed.size == 2) {
|
if (pressed.size >= 2) {
|
||||||
// Two fingers → scroll by the centroid delta; never move the cursor.
|
// Two+ fingers → scroll by the centroid delta; never move the cursor.
|
||||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||||
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
|
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
|
||||||
@@ -186,45 +168,15 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
|
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
|
||||||
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
|
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
|
||||||
if (sy != 0) {
|
if (sy != 0) {
|
||||||
NativeBridge.nativeSendScroll(handle, 0, sy * 120 * scrollDir)
|
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
||||||
prevCy = cy
|
prevCy = cy
|
||||||
moved = true
|
moved = true
|
||||||
}
|
}
|
||||||
if (sx != 0) {
|
if (sx != 0) {
|
||||||
NativeBridge.nativeSendScroll(handle, 1, sx * 120 * scrollDir)
|
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
||||||
prevCx = cx
|
prevCx = cx
|
||||||
moved = true
|
moved = true
|
||||||
}
|
}
|
||||||
} else if (pressed.size >= 3) {
|
|
||||||
// Three+ fingers → the keyboard swipe, never scroll (the documented
|
|
||||||
// vocabulary is TWO-finger scroll; 3+ only fell into the scroll path as an
|
|
||||||
// accident of its old `>= 2` bound). Anchor the centroid per finger count
|
|
||||||
// (same reasoning as the scroll anchor above) and fire once per gesture when
|
|
||||||
// the vertical travel crosses the threshold: up = show, down = hide.
|
|
||||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
|
||||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
|
||||||
if (pressed.size != kbCount) {
|
|
||||||
kbCount = pressed.size
|
|
||||||
kbAnchorX = cx
|
|
||||||
kbAnchorY = cy
|
|
||||||
} else {
|
|
||||||
val dy = cy - kbAnchorY
|
|
||||||
// Real centroid travel disqualifies the tap classification below (else a
|
|
||||||
// sub-threshold swipe would still fire the three-finger stats tap).
|
|
||||||
if (abs(dy) > TAP_SLOP || abs(cx - kbAnchorX) > TAP_SLOP) moved = true
|
|
||||||
if (!kbFired && abs(dy) >= size.height * KB_SWIPE_FRACTION) {
|
|
||||||
kbFired = true
|
|
||||||
onKeyboard(dy < 0) // finger up → show, finger down → hide
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel
|
|
||||||
// notch; clearing it makes a return to two fingers re-anchor fresh. Same for
|
|
||||||
// the trackpad's tracked finger: its prev position froze while 3+ fingers were
|
|
||||||
// down, so dropping straight back to one finger must re-anchor (zero delta),
|
|
||||||
// not replay the whole 3-finger phase as one cursor jump.
|
|
||||||
scrolling = false
|
|
||||||
scrollCount = 0
|
|
||||||
trackId = PointerId(Long.MIN_VALUE)
|
|
||||||
} else if (!scrolling) {
|
} else if (!scrolling) {
|
||||||
// One finger (skipped once a gesture turned into a scroll, so dropping
|
// One finger (skipped once a gesture turned into a scroll, so dropping
|
||||||
// back to one finger doesn't jerk the cursor).
|
// back to one finger doesn't jerk the cursor).
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
package io.unom.punktfunk
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure JVM test of the client-side render-scale geometry ([RenderScale]) — the Kotlin twin of
|
|
||||||
* `punktfunk-core`'s `render_scale` module. Run: `./gradlew :app:testDebugUnitTest`.
|
|
||||||
*/
|
|
||||||
class RenderScaleTest {
|
|
||||||
@Test
|
|
||||||
fun sanitizeClampsAndDefaults() {
|
|
||||||
assertEquals(1.0, RenderScale.sanitize(0.0), 0.0) // absent / zero → Native
|
|
||||||
assertEquals(1.0, RenderScale.sanitize(-2.0), 0.0)
|
|
||||||
assertEquals(1.0, RenderScale.sanitize(Double.NaN), 0.0)
|
|
||||||
assertEquals(0.5, RenderScale.sanitize(0.1), 0.0) // below the floor
|
|
||||||
assertEquals(4.0, RenderScale.sanitize(9.0), 0.0) // above the ceiling
|
|
||||||
assertEquals(1.5, RenderScale.sanitize(1.5), 0.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun maxDimensionIsCodecAware() {
|
|
||||||
assertEquals(4096, RenderScale.maxDimension("h264"))
|
|
||||||
assertEquals(8192, RenderScale.maxDimension("hevc"))
|
|
||||||
assertEquals(8192, RenderScale.maxDimension("av1"))
|
|
||||||
assertEquals(8192, RenderScale.maxDimension("auto"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nativeIsIdentity() {
|
|
||||||
assertEquals(1920 to 1080, RenderScale.apply(1920, 1080, 1.0, 8192))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun supersampleDoubles() {
|
|
||||||
assertEquals(3840 to 2160, RenderScale.apply(1920, 1080, 2.0, 8192))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun underRenderHalves() {
|
|
||||||
assertEquals(960 to 540, RenderScale.apply(1920, 1080, 0.5, 8192))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun resultsAreEven() {
|
|
||||||
// 1366×768 × 1.5 = 2049×1152 → even-floored to 2048×1152.
|
|
||||||
val (w, h) = RenderScale.apply(1366, 768, 1.5, 8192)
|
|
||||||
assertEquals(0, w % 2)
|
|
||||||
assertEquals(0, h % 2)
|
|
||||||
assertEquals(2048 to 1152, w to h)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun overCeilingClampsUniformly() {
|
|
||||||
// 4K × 4 = 15360×8640; both exceed 8192 → width lands on cap, 16:9 kept (8192×4608).
|
|
||||||
val (w, h) = RenderScale.apply(3840, 2160, 4.0, 8192)
|
|
||||||
assertTrue(w <= 8192 && h <= 8192)
|
|
||||||
assertEquals(8192 to 4608, w to h)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun h264CeilingIsTighter() {
|
|
||||||
// 1080p × 4 = 7680×4320; under H.264's 4096 wall → 4096×2304.
|
|
||||||
assertEquals(4096 to 2304, RenderScale.apply(1920, 1080, 4.0, 4096))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun minimumFloorHonoured() {
|
|
||||||
val (w, h) = RenderScale.apply(400, 300, 0.5, 8192)
|
|
||||||
assertTrue(w >= 320 && h >= 200)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -214,7 +214,6 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
|||||||
),
|
),
|
||||||
verbosity = verbosity,
|
verbosity = verbosity,
|
||||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||||
codecLabel = "HEVC",
|
|
||||||
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,16 +36,6 @@ object Gamepad {
|
|||||||
const val BTN_X = 0x4000
|
const val BTN_X = 0x4000
|
||||||
const val BTN_Y = 0x8000
|
const val BTN_Y = 0x8000
|
||||||
|
|
||||||
// Extended bits (Moonlight `buttonFlags2 << 16` namespace — `input.rs::gamepad`): the four
|
|
||||||
// back grips (Steam L4/L5/R4/R5 ≙ Elite P1–P4), touchpad click, and the misc/QAM button.
|
|
||||||
// Android's standard InputDevice path never produces these; the SC2 capture link does.
|
|
||||||
const val BTN_PADDLE1 = 0x10000
|
|
||||||
const val BTN_PADDLE2 = 0x20000
|
|
||||||
const val BTN_PADDLE3 = 0x40000
|
|
||||||
const val BTN_PADDLE4 = 0x80000
|
|
||||||
const val BTN_TOUCHPAD = 0x100000
|
|
||||||
const val BTN_MISC1 = 0x200000
|
|
||||||
|
|
||||||
// Axis ids — must equal `input.rs::gamepad::AXIS_*`.
|
// Axis ids — must equal `input.rs::gamepad::AXIS_*`.
|
||||||
const val AXIS_LS_X = 0
|
const val AXIS_LS_X = 0
|
||||||
const val AXIS_LS_Y = 1
|
const val AXIS_LS_Y = 1
|
||||||
@@ -64,8 +54,6 @@ object Gamepad {
|
|||||||
const val PREF_STEAMDECK = 6
|
const val PREF_STEAMDECK = 6
|
||||||
const val PREF_DUALSENSEEDGE = 7
|
const val PREF_DUALSENSEEDGE = 7
|
||||||
const val PREF_SWITCHPRO = 8
|
const val PREF_SWITCHPRO = 8
|
||||||
const val PREF_STEAMCONTROLLER2 = 9
|
|
||||||
const val PREF_STEAMCONTROLLER2_PUCK = 10
|
|
||||||
|
|
||||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||||
private const val VID_SONY = 0x054C
|
private const val VID_SONY = 0x054C
|
||||||
@@ -93,12 +81,6 @@ object Gamepad {
|
|||||||
private val PID_STEAMDECK = setOf(0x1205)
|
private val PID_STEAMDECK = setOf(0x1205)
|
||||||
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
|
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
|
||||||
|
|
||||||
// Steam Controller 2: wired (0x1302), BLE (0x1303), and Puck dongles (0x1304/0x1305).
|
|
||||||
// Sc2Capture normally claims these directly; the plain InputDevice path is only a degraded
|
|
||||||
// fallback. Keep Puck distinct so even that path requests the native multi-interface identity.
|
|
||||||
private val PID_STEAMCONTROLLER2 = setOf(0x1302, 0x1303)
|
|
||||||
private val PID_STEAMCONTROLLER2_PUCK = setOf(0x1304, 0x1305)
|
|
||||||
|
|
||||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||||
private val PID_XBOXONE = setOf(
|
private val PID_XBOXONE = setOf(
|
||||||
@@ -125,9 +107,6 @@ object Gamepad {
|
|||||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||||
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
||||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
|
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
|
||||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER2_PUCK ->
|
|
||||||
PREF_STEAMCONTROLLER2_PUCK
|
|
||||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER2 -> PREF_STEAMCONTROLLER2
|
|
||||||
vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO
|
vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO
|
||||||
else -> PREF_XBOX360
|
else -> PREF_XBOX360
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package io.unom.punktfunk.kit
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.hardware.lights.Light
|
import android.hardware.lights.Light
|
||||||
import android.hardware.lights.LightState
|
import android.hardware.lights.LightState
|
||||||
@@ -34,24 +33,16 @@ import java.nio.ByteBuffer
|
|||||||
*
|
*
|
||||||
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
|
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
|
||||||
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
|
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
|
||||||
*
|
|
||||||
* [deviceVibrator] is the opt-in phone mirror ("Rumble on this phone", off by default): when
|
|
||||||
* non-null, rumble the host addresses to wire pad 0 (controller 1) is ALSO played on this
|
|
||||||
* device's own vibration motor — for clip-on gamepads that ship without rumble motors, where the
|
|
||||||
* phone body is the only actuator in the player's hands. StreamScreen passes it only when the
|
|
||||||
* setting is on (see [deviceBodyVibrator]).
|
|
||||||
*/
|
*/
|
||||||
class GamepadFeedback(
|
class GamepadFeedback(private val handle: Long, private val router: GamepadRouter?) {
|
||||||
private val handle: Long,
|
|
||||||
private val router: GamepadRouter?,
|
|
||||||
private val deviceVibrator: Vibrator? = null,
|
|
||||||
) {
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "pf.feedback"
|
const val TAG = "pf.feedback"
|
||||||
const val TAG_LED: Byte = 0x01
|
const val TAG_LED: Byte = 0x01
|
||||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||||
const val TAG_TRIGGER: Byte = 0x03
|
const val TAG_TRIGGER: Byte = 0x03
|
||||||
const val TAG_HID_RAW: Byte = 0x05
|
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
|
||||||
|
// A new host renews far below this, so it never actually holds this long there.
|
||||||
|
const val LEGACY_RUMBLE_MS = 60_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||||
@@ -92,26 +83,25 @@ class GamepadFeedback(
|
|||||||
while (running) {
|
while (running) {
|
||||||
val ev = NativeBridge.nativeNextRumble(handle)
|
val ev = NativeBridge.nativeNextRumble(handle)
|
||||||
if (ev < 0L) continue // timeout / closed
|
if (ev < 0L) continue // timeout / closed
|
||||||
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
|
// ev bits 49..52 = wire pad index; bit 48 = has a v2 lease; bits 32..47 = ttl_ms;
|
||||||
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
|
// 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl.
|
||||||
// rumble policy engine — it owns every lease/staleness/close decision (uniform
|
// 0xFFFF) is a real lease — no in-band sentinel. No lease (legacy host) → the prior
|
||||||
// across all clients; the old 60 s legacy-host exposure is gone) and emits
|
// long one-shot.
|
||||||
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
|
|
||||||
// the backstop (the hardware net under a stalled poll thread).
|
|
||||||
val pad = ((ev ushr 49) and 0xFL).toInt()
|
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||||
val backstopMs = ((ev ushr 32) and 0xFFFF)
|
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
|
||||||
|
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
|
||||||
|
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
|
||||||
renderRumble(
|
renderRumble(
|
||||||
pad,
|
pad,
|
||||||
((ev ushr 16) and 0xFFFF).toInt(),
|
((ev ushr 16) and 0xFFFF).toInt(),
|
||||||
(ev and 0xFFFF).toInt(),
|
(ev and 0xFFFF).toInt(),
|
||||||
backstopMs,
|
durationMs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||||
|
|
||||||
hidoutThread = Thread({
|
hidoutThread = Thread({
|
||||||
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
|
val buf = ByteBuffer.allocateDirect(64)
|
||||||
val buf = ByteBuffer.allocateDirect(128)
|
|
||||||
while (running) {
|
while (running) {
|
||||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||||
if (n < 0) continue // timeout / closed
|
if (n < 0) continue // timeout / closed
|
||||||
@@ -137,9 +127,7 @@ class GamepadFeedback(
|
|||||||
runCatching { hidoutThread?.join() }
|
runCatching { hidoutThread?.join() }
|
||||||
rumbleThread = null
|
rumbleThread = null
|
||||||
hidoutThread = null
|
hidoutThread = null
|
||||||
// Threads are dead — drop any held rumble (incl. the phone mirror's) and close every
|
// Threads are dead — drop any held rumble and close every lights session.
|
||||||
// lights session.
|
|
||||||
runCatching { deviceVibrator?.cancel() }
|
|
||||||
synchronized(bindsLock) {
|
synchronized(bindsLock) {
|
||||||
for (b in rumbleBinds.values) b?.let {
|
for (b in rumbleBinds.values) b?.let {
|
||||||
runCatching { it.vm?.cancel() }
|
runCatching { it.vm?.cancel() }
|
||||||
@@ -209,18 +197,12 @@ class GamepadFeedback(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
|
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
|
||||||
* addressed to wire pad [pad]. `durationMs` is the engine command's backstop — the one-shot's
|
* addressed to wire pad [pad]. `durationMs` is the host's v2 envelope TTL — the one-shot self-
|
||||||
* self-termination net under a stalled poll thread; the engine emits explicit zero commands at
|
* terminates after it unless the host renews, so a lost stop (or a dead host) silences at the
|
||||||
* every policy stop (lease expiry, legacy staleness, session close), so cancel-on-zero is the
|
* lease instead of the old fixed 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS].
|
||||||
* real stop mechanism.
|
|
||||||
*/
|
*/
|
||||||
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
||||||
Log.i(TAG, "rumble pad=$pad low=$low high=$high backstopMs=$durationMs") // verification line — BEFORE any no-op return
|
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
||||||
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
|
|
||||||
// serves have no vibrator of their own, so their bind below is null. It follows
|
|
||||||
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
|
||||||
// already decided the bind, and the user opted in.
|
|
||||||
if (pad == 0) renderDeviceRumble(low, high, durationMs)
|
|
||||||
val bind = rumbleBindFor(pad) ?: return
|
val bind = rumbleBindFor(pad) ?: return
|
||||||
val lo = toAmplitude(low)
|
val lo = toAmplitude(low)
|
||||||
val hi = toAmplitude(high)
|
val hi = toAmplitude(high)
|
||||||
@@ -264,29 +246,6 @@ class GamepadFeedback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The opt-in phone mirror: play a wire-pad-0 rumble on this device's own vibration motor —
|
|
||||||
* one physical actuator, so both wire motors blend into one effect (the same blend as the
|
|
||||||
* single-motor controller path). Same envelope semantics too: a one-shot held for the host's
|
|
||||||
* TTL, cancel on (0,0).
|
|
||||||
*/
|
|
||||||
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
|
|
||||||
val v = deviceVibrator ?: return
|
|
||||||
val lo = toAmplitude(low)
|
|
||||||
val hi = toAmplitude(high)
|
|
||||||
if (lo == 0 && hi == 0) {
|
|
||||||
runCatching { v.cancel() } // (0,0) = stop
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
|
||||||
runCatching {
|
|
||||||
v.vibrate(
|
|
||||||
if (v.hasAmplitudeControl()) oneShot(a, durationMs)
|
|
||||||
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||||
private fun toAmplitude(v16: Int): Int {
|
private fun toAmplitude(v16: Int): Int {
|
||||||
val a = (v16 ushr 8) and 0xFF
|
val a = (v16 ushr 8) and 0xFF
|
||||||
@@ -331,32 +290,10 @@ class GamepadFeedback(
|
|||||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
TAG_HID_RAW -> {
|
|
||||||
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
|
|
||||||
// [kind: 0=output, 1=feature][report bytes, id first]. Handed to the capture link
|
|
||||||
// for verbatim replay on the physical controller; dropped when no link owns the pad.
|
|
||||||
val kind = buf.get().toInt() and 0xFF
|
|
||||||
val len = n - 3
|
|
||||||
if (len > 0) {
|
|
||||||
val data = ByteArray(len)
|
|
||||||
buf.get(data)
|
|
||||||
onHidRaw?.invoke(pad, kind, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> Log.d(TAG, "hidout: unknown kind, dropped")
|
else -> Log.d(TAG, "hidout: unknown kind, dropped")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw HID-report replay hook for the as-is Steam Controller 2 passthrough: invoked (on the
|
|
||||||
* hidout poll thread) with the wire pad index, the report kind (0 = output report, 1 =
|
|
||||||
* feature report), and the full report bytes (id first) the host's hidraw consumer wrote.
|
|
||||||
* `StreamScreen` wires this to the SC2 capture so Steam's rumble/settings land on the
|
|
||||||
* physical controller.
|
|
||||||
*/
|
|
||||||
@Volatile
|
|
||||||
var onHidRaw: ((pad: Int, kind: Int, data: ByteArray) -> Unit)? = null
|
|
||||||
|
|
||||||
/** hid-playstation 5-LED pattern → player index 1..4 (0 = off); falls back to a bit count. */
|
/** hid-playstation 5-LED pattern → player index 1..4 (0 = off); falls back to a bit count. */
|
||||||
private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
|
private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
|
||||||
0b00000 -> 0
|
0b00000 -> 0
|
||||||
@@ -412,18 +349,3 @@ class GamepadFeedback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This device's own body vibrator (the phone, not a controller), or null where there is none
|
|
||||||
* (TVs) — gates the "Rumble on this phone" setting's visibility and feeds
|
|
||||||
* [GamepadFeedback.deviceVibrator] when it's on.
|
|
||||||
*/
|
|
||||||
fun deviceBodyVibrator(context: Context): Vibrator? {
|
|
||||||
val v = if (Build.VERSION.SDK_INT >= 31) {
|
|
||||||
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
|
||||||
}
|
|
||||||
return v?.takeIf { it.hasVibrator() }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -57,14 +57,6 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
*/
|
*/
|
||||||
var onExitChord: (() -> Unit)? = null
|
var onExitChord: (() -> Unit)? = null
|
||||||
|
|
||||||
/**
|
|
||||||
* Invoked (main thread) with `true` the moment the exit chord completes and the hold countdown
|
|
||||||
* starts, and `false` when it's cancelled (a button lifted early) or the timer elapses. `StreamScreen`
|
|
||||||
* wires this to a "hold to quit" hint so the hold is discoverable — the chord no longer quits on a
|
|
||||||
* quick press, and without an on-screen cue that reads as the shortcut being broken.
|
|
||||||
*/
|
|
||||||
var onExitArmed: ((armed: Boolean) -> Unit)? = null
|
|
||||||
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
||||||
private var pendingExit: Runnable? = null
|
private var pendingExit: Runnable? = null
|
||||||
@@ -92,37 +84,28 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
|
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
|
||||||
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
|
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
|
||||||
* slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
|
* slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
|
||||||
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly
|
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly;
|
||||||
* ([onExitArmed] fires so the UI can show a "hold to quit" hint); [onExitChord] fires only if the
|
* [onExitChord] fires only if the chord is still held at expiry (a brief accidental brush is
|
||||||
* chord is still held at expiry (a brief accidental brush is ignored), matching `DISCONNECT_HOLD`
|
* ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave.
|
||||||
* on the SDL/Apple clients. Any controller can leave.
|
|
||||||
*/
|
*/
|
||||||
fun onButton(event: KeyEvent, bit: Int) {
|
fun onButton(event: KeyEvent, bit: Int) {
|
||||||
val slot = slotFor(event.device) ?: return
|
val slot = slotFor(event.device) ?: return
|
||||||
when (event.action) {
|
when (event.action) {
|
||||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
KeyEvent.ACTION_DOWN -> {
|
||||||
KeyEvent.ACTION_DOWN -> slotButton(slot, bit, down = true, send = event.repeatCount == 0)
|
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||||
KeyEvent.ACTION_UP -> slotButton(slot, bit, down = false, send = true)
|
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||||
}
|
slot.held = slot.held or bit
|
||||||
}
|
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||||
|
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||||
/**
|
}
|
||||||
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
|
KeyEvent.ACTION_UP -> {
|
||||||
* transitions: forward the wire event, track held state, and arm/disarm the exit chord.
|
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||||
*/
|
slot.held = slot.held and bit.inv()
|
||||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||||
if (down) {
|
// holds the full chord.
|
||||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
|
||||||
slot.held = slot.held or bit
|
disarmExit()
|
||||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
}
|
||||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
|
||||||
} else {
|
|
||||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
|
||||||
slot.held = slot.held and bit.inv()
|
|
||||||
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
|
||||||
// holds the full chord.
|
|
||||||
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
|
|
||||||
disarmExit()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +115,6 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
if (pendingExit != null) return // already counting down
|
if (pendingExit != null) return // already counting down
|
||||||
val r = Runnable {
|
val r = Runnable {
|
||||||
pendingExit = null
|
pendingExit = null
|
||||||
onExitArmed?.invoke(false) // countdown over — drop the hint whether or not we leave
|
|
||||||
// Fire only if the chord survived the full hold on some pad.
|
// Fire only if the chord survived the full hold on some pad.
|
||||||
val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD }
|
val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD }
|
||||||
if (held.isNotEmpty()) {
|
if (held.isNotEmpty()) {
|
||||||
@@ -144,15 +126,12 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
}
|
}
|
||||||
pendingExit = r
|
pendingExit = r
|
||||||
mainHandler.postDelayed(r, EXIT_HOLD_MS)
|
mainHandler.postDelayed(r, EXIT_HOLD_MS)
|
||||||
onExitArmed?.invoke(true) // chord complete → show the "hold to quit" hint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel a pending exit-chord hold timer. */
|
/** Cancel a pending exit-chord hold timer. */
|
||||||
private fun disarmExit() {
|
private fun disarmExit() {
|
||||||
val wasArmed = pendingExit != null
|
|
||||||
pendingExit?.let { mainHandler.removeCallbacks(it) }
|
pendingExit?.let { mainHandler.removeCallbacks(it) }
|
||||||
pendingExit = null
|
pendingExit = null
|
||||||
if (wasArmed) onExitArmed?.invoke(false) // released early — drop the hint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,9 +152,8 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The controller currently mapped to wire pad [pad], for feedback routing; null if that index
|
* The controller currently mapped to wire pad [pad], for feedback routing; null if that index
|
||||||
* holds no live slot (a pad that just unplugged — the update is then dropped) OR the slot is
|
* holds no live slot (a pad that just unplugged — the update is then dropped). Read from the
|
||||||
* an [ExternalPad] (its synthetic id resolves to no InputDevice, so rumble binds naturally
|
* feedback poll threads.
|
||||||
* fall through to the capture link's own feedback path). Read from the feedback poll threads.
|
|
||||||
*/
|
*/
|
||||||
fun deviceForPad(pad: Int): InputDevice? {
|
fun deviceForPad(pad: Int): InputDevice? {
|
||||||
for ((deviceId, slot) in slots) {
|
for ((deviceId, slot) in slots) {
|
||||||
@@ -184,50 +162,6 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam
|
|
||||||
* Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares
|
|
||||||
* the real slots' lifecycle: a stable lowest-free index, Arrival-before-input, held-state
|
|
||||||
* flush + Remove on [close], and full participation in the emergency exit chord.
|
|
||||||
*/
|
|
||||||
inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) {
|
|
||||||
// Live lookup instead of a captured reference: after [close] (or a router release) the
|
|
||||||
// slot is gone from the table and every entry point below degrades to a safe no-op.
|
|
||||||
private val slot get() = slots[syntheticId]
|
|
||||||
|
|
||||||
/** One button transition (a wire [Gamepad].BTN_* bit). On-change only — the caller diffs. */
|
|
||||||
fun button(bit: Int, down: Boolean) {
|
|
||||||
slot?.let { slotButton(it, bit, down, send = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
|
|
||||||
fun axis(id: Int, value: Int) {
|
|
||||||
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
|
|
||||||
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
|
|
||||||
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
|
|
||||||
fun close() = closeSlot(syntheticId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a slot for a capture-link pad, declaring [pref] as its kind; null when all 16 wire
|
|
||||||
* indices are taken. Main thread (like the hot-plug callbacks).
|
|
||||||
*/
|
|
||||||
fun openExternal(pref: Int): ExternalPad? {
|
|
||||||
val index = lowestFreeIndex() ?: return null
|
|
||||||
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
|
|
||||||
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
|
|
||||||
val syntheticId = EXTERNAL_ID_BASE - index
|
|
||||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
|
||||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
|
||||||
return ExternalPad(syntheticId, index)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
||||||
* the feedback poll threads are joined (they read [deviceForPad]).
|
* the feedback poll threads are joined (they read [deviceForPad]).
|
||||||
@@ -316,14 +250,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
|
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
|
||||||
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||||
|
|
||||||
/**
|
/** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */
|
||||||
* How long the exit chord must be held before the stream leaves — long enough that an
|
const val EXIT_HOLD_MS = 1500L
|
||||||
* accidental brush of the four buttons doesn't quit, short enough to feel responsive (the
|
|
||||||
* on-screen hint covers the gap). Roughly matches SDL/Apple `DISCONNECT_HOLD`.
|
|
||||||
*/
|
|
||||||
const val EXIT_HOLD_MS = 1000L
|
|
||||||
|
|
||||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
|
||||||
const val EXTERNAL_ID_BASE = -1000
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,17 +106,6 @@ object Keymap {
|
|||||||
KeyEvent.KEYCODE_DPAD_UP -> 0x26
|
KeyEvent.KEYCODE_DPAD_UP -> 0x26
|
||||||
KeyEvent.KEYCODE_DPAD_RIGHT -> 0x27
|
KeyEvent.KEYCODE_DPAD_RIGHT -> 0x27
|
||||||
KeyEvent.KEYCODE_DPAD_DOWN -> 0x28
|
KeyEvent.KEYCODE_DPAD_DOWN -> 0x28
|
||||||
// TV-remote SELECT = Enter (a gamepad's press routes via SOURCE_GAMEPAD before this).
|
|
||||||
KeyEvent.KEYCODE_DPAD_CENTER -> 0x0D
|
|
||||||
|
|
||||||
// Consumer/media keys — forwarded to the host while streaming (volume stays local:
|
|
||||||
// MainActivity's pass-through list wins before the map is consulted).
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY,
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> 0xB3 // VK_MEDIA_PLAY_PAUSE
|
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT -> 0xB0 // VK_MEDIA_NEXT_TRACK
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> 0xB1 // VK_MEDIA_PREV_TRACK
|
|
||||||
KeyEvent.KEYCODE_MEDIA_STOP -> 0xB2 // VK_MEDIA_STOP
|
|
||||||
|
|
||||||
// Modifiers (L/R-specific VKs; the host folds the generic ones onto the left variant)
|
// Modifiers (L/R-specific VKs; the host folds the generic ones onto the left variant)
|
||||||
KeyEvent.KEYCODE_SHIFT_LEFT -> 0xA0
|
KeyEvent.KEYCODE_SHIFT_LEFT -> 0xA0
|
||||||
|
|||||||
@@ -85,16 +85,6 @@ object NativeBridge {
|
|||||||
name: String,
|
name: String,
|
||||||
): String
|
): String
|
||||||
|
|
||||||
/**
|
|
||||||
* The machine token of the most recent failed [nativeConnect]/[nativePair], cleared on read
|
|
||||||
* (`""` when none) — call right after a `0` handle / `""` fingerprint. A typed host rejection
|
|
||||||
* yields its wire token ("not-armed", "denied", "approval-timeout", "superseded", "busy",
|
|
||||||
* "rate-limited", "bound-other", "identity-required", "wire-version"); transport-level causes
|
|
||||||
* yield "crypto" (wrong PIN / identity mismatch), "timeout", "io", or "error". Lets the UI say
|
|
||||||
* WHY instead of the old catch-all that blamed the PIN for dead network paths.
|
|
||||||
*/
|
|
||||||
external fun nativeTakeLastError(): String
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signal a **deliberate** user disconnect on [handle] before [nativeClose]: the session closes
|
* Signal a **deliberate** user disconnect on [handle] before [nativeClose]: the session closes
|
||||||
* with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive
|
* with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive
|
||||||
@@ -161,14 +151,6 @@ object NativeBridge {
|
|||||||
*/
|
*/
|
||||||
external fun nativeVideoMime(handle: Long): String
|
external fun nativeVideoMime(handle: Long): String
|
||||||
|
|
||||||
/**
|
|
||||||
* A short human label for the codec the host resolved (`"H.264"` / `"HEVC"` / `"AV1"` /
|
|
||||||
* `"PyroWave"`), for the stats HUD's video-feed line, or `""` on a `0` handle. Distinct from
|
|
||||||
* [nativeVideoMime] because the MIME collapses PyroWave onto `video/hevc` and can't name it.
|
|
||||||
* Fixed for the session (resolved at the handshake); read once. Cheap; UI-safe.
|
|
||||||
*/
|
|
||||||
external fun nativeVideoCodecLabel(handle: Long): String
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
* Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
|
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
|
||||||
@@ -287,50 +269,6 @@ object NativeBridge {
|
|||||||
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
||||||
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the host advertised committed-text injection (`HOST_CAP_TEXT_INPUT`) — its inject
|
|
||||||
* backend can type Unicode text directly. Picks the real IME `InputConnection` (autocorrect,
|
|
||||||
* gesture typing, non-Latin scripts) over the TYPE_NULL raw-key fallback. False on `0`.
|
|
||||||
*/
|
|
||||||
external fun nativeTextInputSupported(handle: Long): Boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Committed IME text → one `TextInput` wire event per Unicode scalar, in order. Control
|
|
||||||
* characters are skipped natively (Enter/Backspace ride [nativeSendKey]). Only meaningful
|
|
||||||
* when [nativeTextInputSupported] returned true — older hosts ignore the events.
|
|
||||||
*/
|
|
||||||
external fun nativeSendText(handle: Long, text: String)
|
|
||||||
|
|
||||||
// ---- Shared clipboard (text v1): Kotlin drives ClipboardManager, Rust the protocol ----
|
|
||||||
// Opt-in per session (nativeClipControl). Local copies are announced as lazy offers; bytes
|
|
||||||
// cross only when the host pastes (a "fetch:" event answered by nativeClipServeText). Host
|
|
||||||
// copies arrive as "offer:" events, fetched eagerly into the system clipboard.
|
|
||||||
|
|
||||||
/** Whether the host advertised a working shared-clipboard service (HOST_CAP_CLIPBOARD). */
|
|
||||||
external fun nativeClipSupported(handle: Long): Boolean
|
|
||||||
|
|
||||||
/** Session-level clipboard opt-in/out; nothing happens until enabled=true crosses. */
|
|
||||||
external fun nativeClipControl(handle: Long, enabled: Boolean)
|
|
||||||
|
|
||||||
/** Announce "this device's clipboard now holds text". [seq]: monotonic, newest wins. */
|
|
||||||
external fun nativeClipOfferText(handle: Long, seq: Int)
|
|
||||||
|
|
||||||
/** Pull the text of the host's offer [seq] → transfer id echoed on "data:"/"error:", or -1. */
|
|
||||||
external fun nativeClipFetchText(handle: Long, seq: Int): Int
|
|
||||||
|
|
||||||
/** Answer a "fetch:" event with the clipboard's current text (the host is pasting). */
|
|
||||||
external fun nativeClipServeText(handle: Long, reqId: Int, text: String)
|
|
||||||
|
|
||||||
/** Abort a clipboard transfer by id (either direction). */
|
|
||||||
external fun nativeClipCancel(handle: Long, id: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Block ≤250 ms for the next clipboard event, as a compact string: `state:<0|1>` ·
|
|
||||||
* `offer:<seq>:<hasText>` · `fetch:<reqId>` · `data:<xferId>:<text>` · `cancel:<id>` ·
|
|
||||||
* `error:<id>:<code>` · `closed` (session gone) — null on timeout. Dedicated poll thread.
|
|
||||||
*/
|
|
||||||
external fun nativeNextClip(handle: Long): String?
|
|
||||||
|
|
||||||
// ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
|
// ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
|
||||||
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
|
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
|
||||||
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
|
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
|
||||||
@@ -353,14 +291,6 @@ object NativeBridge {
|
|||||||
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
|
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
|
||||||
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
|
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
|
||||||
|
|
||||||
/**
|
|
||||||
* One raw HID input report from a client-captured controller (the as-is Steam Controller 2
|
|
||||||
* passthrough), forwarded verbatim on the rich-input plane. [buf] is a DIRECT ByteBuffer whose
|
|
||||||
* first [len] bytes are the report, id byte first (0x42/0x45/0x47 state, 0x43 battery, …);
|
|
||||||
* len is clamped to 64. Called from the capture thread at the controller's own report rate.
|
|
||||||
*/
|
|
||||||
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
|
|
||||||
|
|
||||||
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -372,11 +302,10 @@ object NativeBridge {
|
|||||||
external fun nativeNextRumble(handle: Long): Long
|
external fun nativeNextRumble(handle: Long): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block up to ~100 ms for the next HID-output event, written into [buf] (a direct ByteBuffer,
|
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
|
||||||
* capacity >= 128) as `[pad][kind][fields…]` (leading pad = the wire pad index to route to):
|
* ByteBuffer, capacity >= 64) as `[pad][kind][fields…]` (leading pad = the wire pad index to
|
||||||
* Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…, raw as-is
|
* route to): Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…. Returns the
|
||||||
* passthrough report=pad 05 kind report-bytes (kind 0 = output report, 1 = feature report).
|
* byte count, or -1 on timeout / session closed.
|
||||||
* Returns the byte count, or -1 on timeout / session closed.
|
|
||||||
*/
|
*/
|
||||||
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
|
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
package io.unom.punktfunk.kit
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.bluetooth.BluetoothGatt
|
|
||||||
import android.bluetooth.BluetoothGattCallback
|
|
||||||
import android.bluetooth.BluetoothGattCharacteristic
|
|
||||||
import android.bluetooth.BluetoothGattDescriptor
|
|
||||||
import android.bluetooth.BluetoothManager
|
|
||||||
import android.bluetooth.BluetoothProfile
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import java.util.UUID
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BLE transport for a Steam Controller 2 paired directly with the device (no Puck). The standard
|
|
||||||
* HID service (0x1812) is claimed by the OS (and would feed the pad through the ordinary input
|
|
||||||
* stack in lizard-crippled form), so this talks Valve's vendor GATT service instead — the same
|
|
||||||
* approach Steam itself uses on hosts without a dongle.
|
|
||||||
*
|
|
||||||
* GATT operations are serialized by a small state machine (connect → MTU → discover → subscribe
|
|
||||||
* each notify char → lizard-off → ready); duplicate callbacks (the Android stack sometimes fires
|
|
||||||
* `onMtuChanged` twice) are ignored. Notified state reports arrive with the report-id byte
|
|
||||||
* stripped by the transport, so `0x45` (`ID_STATE_BLE`) is re-prepended for ≥40-byte payloads —
|
|
||||||
* the wire then carries the same id-first framing as USB.
|
|
||||||
*
|
|
||||||
* Requires BLUETOOTH_CONNECT (the caller gates on it); connection priority is bumped to HIGH to
|
|
||||||
* pull the connection interval from ~50 ms down to ~11 ms.
|
|
||||||
*/
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
class Sc2BleLink(
|
|
||||||
private val context: Context,
|
|
||||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
|
||||||
private val onClosed: () -> Unit,
|
|
||||||
) {
|
|
||||||
private enum class State { IDLE, CONNECTING, MTU_REQUESTED, DISCOVERING, SUBSCRIBING, READY }
|
|
||||||
|
|
||||||
private val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
|
||||||
|
|
||||||
private var gatt: BluetoothGatt? = null
|
|
||||||
private var writeChar: BluetoothGattCharacteristic? = null
|
|
||||||
private val pendingSubs = mutableListOf<BluetoothGattCharacteristic>()
|
|
||||||
private var subsIndex = 0
|
|
||||||
private val writeBusy = AtomicBoolean(false)
|
|
||||||
private var lizardTicker: Thread? = null
|
|
||||||
|
|
||||||
@Volatile private var state = State.IDLE
|
|
||||||
|
|
||||||
/** Bonded devices that look like a Steam Controller (name heuristic — BLE exposes no PID here). */
|
|
||||||
fun pairedControllers(): List<BluetoothDevice> = runCatching {
|
|
||||||
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
|
|
||||||
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
|
|
||||||
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
|
|
||||||
}
|
|
||||||
}.getOrDefault(emptyList())
|
|
||||||
|
|
||||||
/** Connect to the bonded controller at [address]. Reports start flowing once READY. */
|
|
||||||
fun start(address: String): Boolean {
|
|
||||||
val adapter = manager.adapter ?: return false
|
|
||||||
if (!adapter.isEnabled) return false
|
|
||||||
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return false
|
|
||||||
state = State.CONNECTING
|
|
||||||
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replay one raw report from the host: output reports (rumble) ride WRITE_NO_RESPONSE so they
|
|
||||||
* can't queue behind acks at the 25 Hz resend rate; feature reports (settings) use an acked
|
|
||||||
* write. The report-id byte stays in the payload (the firmware's vendor-channel framing).
|
|
||||||
*/
|
|
||||||
fun writeRaw(kind: Int, data: ByteArray) {
|
|
||||||
if (state != State.READY || data.isEmpty()) return
|
|
||||||
val g = gatt ?: return
|
|
||||||
val ch = writeChar ?: return
|
|
||||||
runCatching {
|
|
||||||
ch.value = data
|
|
||||||
ch.writeType = if (kind == 0) {
|
|
||||||
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
|
||||||
} else {
|
|
||||||
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
|
||||||
}
|
|
||||||
g.writeCharacteristic(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendLizardOff() {
|
|
||||||
if (state != State.READY) return
|
|
||||||
val g = gatt ?: return
|
|
||||||
val ch = writeChar ?: return
|
|
||||||
if (!writeBusy.compareAndSet(false, true)) return // previous acked write still in flight
|
|
||||||
runCatching {
|
|
||||||
ch.value = Sc2Device.DISABLE_LIZARD
|
|
||||||
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
|
||||||
if (!g.writeCharacteristic(ch)) writeBusy.set(false)
|
|
||||||
}.onFailure { writeBusy.set(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Disconnect and stop the lizard ticker. Idempotent; does not fire [onClosed]. */
|
|
||||||
fun stop() {
|
|
||||||
lizardTicker?.interrupt()
|
|
||||||
lizardTicker = null
|
|
||||||
runCatching { gatt?.disconnect() }
|
|
||||||
runCatching { gatt?.close() }
|
|
||||||
gatt = null
|
|
||||||
writeChar = null
|
|
||||||
pendingSubs.clear()
|
|
||||||
subsIndex = 0
|
|
||||||
state = State.IDLE
|
|
||||||
}
|
|
||||||
|
|
||||||
private val callback = object : BluetoothGattCallback() {
|
|
||||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
|
||||||
when (newState) {
|
|
||||||
BluetoothProfile.STATE_CONNECTED -> {
|
|
||||||
// ~11 ms connection interval instead of the ~50 ms default — input latency.
|
|
||||||
g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
|
|
||||||
if (state == State.CONNECTING) {
|
|
||||||
state = State.MTU_REQUESTED
|
|
||||||
if (!g.requestMtu(DESIRED_MTU)) {
|
|
||||||
state = State.DISCOVERING
|
|
||||||
g.discoverServices()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
|
||||||
val wasLive = state != State.IDLE
|
|
||||||
runCatching { g.close() }
|
|
||||||
gatt = null
|
|
||||||
writeChar = null
|
|
||||||
pendingSubs.clear()
|
|
||||||
subsIndex = 0
|
|
||||||
state = State.IDLE
|
|
||||||
if (wasLive) onClosed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
|
|
||||||
if (state != State.MTU_REQUESTED) return // fired twice on some stacks — act once
|
|
||||||
state = State.DISCOVERING
|
|
||||||
g.discoverServices()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
|
||||||
if (state != State.DISCOVERING || status != BluetoothGatt.GATT_SUCCESS) return
|
|
||||||
val valve = g.getService(VALVE_SERVICE) ?: run {
|
|
||||||
Log.e(TAG, "Valve vendor service missing — not an SC2?")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pendingSubs.clear()
|
|
||||||
writeChar = null
|
|
||||||
for (ch in valve.characteristics) {
|
|
||||||
val short = shortUuid(ch.uuid) ?: continue
|
|
||||||
val canNotify = ch.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
|
|
||||||
val canWrite = ch.properties and (
|
|
||||||
BluetoothGattCharacteristic.PROPERTY_WRITE or
|
|
||||||
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE
|
|
||||||
) != 0
|
|
||||||
if (canNotify && short in NOTIFY_LOW..NOTIFY_HIGH) pendingSubs.add(ch)
|
|
||||||
if (canWrite && short in WRITE_LOW..WRITE_HIGH && writeChar == null) writeChar = ch
|
|
||||||
}
|
|
||||||
subsIndex = 0
|
|
||||||
state = State.SUBSCRIBING
|
|
||||||
subscribeNext(g)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDescriptorWrite(g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int) {
|
|
||||||
if (state == State.SUBSCRIBING) subscribeNext(g)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
|
|
||||||
writeBusy.set(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) {
|
|
||||||
val data = ch.value ?: return
|
|
||||||
// BLE strips the report-id prefix; restore 0x45 on state-sized payloads so the raw
|
|
||||||
// wire framing matches USB. Short payloads (battery/status) pass through as-is.
|
|
||||||
if (data.size >= 40) {
|
|
||||||
val framed = ByteArray(data.size + 1)
|
|
||||||
framed[0] = Sc2Device.ID_STATE_BLE.toByte()
|
|
||||||
System.arraycopy(data, 0, framed, 1, data.size)
|
|
||||||
onReport(framed, framed.size)
|
|
||||||
} else {
|
|
||||||
onReport(data, data.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun subscribeNext(g: BluetoothGatt) {
|
|
||||||
if (subsIndex >= pendingSubs.size) {
|
|
||||||
state = State.READY
|
|
||||||
Log.i(TAG, "SC2 BLE link up (${pendingSubs.size} notify chars)")
|
|
||||||
sendLizardOff()
|
|
||||||
// The firmware watchdog re-enables lizard mode; refresh on SDL's cadence until the
|
|
||||||
// host's Steam takes over via the raw plane (its writes land through writeRaw too).
|
|
||||||
lizardTicker = Thread({
|
|
||||||
while (state == State.READY) {
|
|
||||||
try {
|
|
||||||
Thread.sleep(Sc2Device.LIZARD_REFRESH_MS)
|
|
||||||
} catch (_: InterruptedException) {
|
|
||||||
return@Thread
|
|
||||||
}
|
|
||||||
sendLizardOff()
|
|
||||||
}
|
|
||||||
}, "pf-sc2-lizard").apply { isDaemon = true; start() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val ch = pendingSubs[subsIndex++]
|
|
||||||
g.setCharacteristicNotification(ch, true)
|
|
||||||
val cccd = ch.getDescriptor(CCCD) ?: return subscribeNext(g)
|
|
||||||
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
|
||||||
if (!g.writeDescriptor(cccd)) subscribeNext(g) // lose this one, try the rest
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The 32-bit short id of a Valve vendor UUID, or null for foreign UUIDs. */
|
|
||||||
private fun shortUuid(uuid: UUID): Long? {
|
|
||||||
val s = uuid.toString()
|
|
||||||
if (!s.endsWith(VALVE_UUID_TAIL)) return null
|
|
||||||
return s.substring(0, 8).toLongOrNull(16)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "Sc2BleLink"
|
|
||||||
|
|
||||||
val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
|
|
||||||
const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
|
|
||||||
const val NOTIFY_LOW = 0x100f6c75L
|
|
||||||
const val NOTIFY_HIGH = 0x100f6c7aL
|
|
||||||
const val WRITE_LOW = 0x100f6cb5L
|
|
||||||
const val WRITE_HIGH = 0x100f6cbeL
|
|
||||||
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
|
||||||
|
|
||||||
val NAME_HINTS = listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
|
|
||||||
|
|
||||||
/** Enough for a state payload (45 B) + ATT header with margin. */
|
|
||||||
const val DESIRED_MTU = 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
package io.unom.punktfunk.kit
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.hardware.usb.UsbDevice
|
|
||||||
import android.util.Log
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One captured Steam Controller 2 — the glue between a transport link ([Sc2UsbLink] /
|
|
||||||
* [Sc2BleLink]) and one of two consumers:
|
|
||||||
*
|
|
||||||
* **Stream mode** (`router != null`, owned by StreamScreen):
|
|
||||||
* - **Raw plane (the point):** every input report is forwarded verbatim
|
|
||||||
* ([GamepadRouter.ExternalPad.hidReport]) for the host's as-is virtual `28DE:1302` pad, which
|
|
||||||
* Steam Input drives like the physical controller.
|
|
||||||
* - **Typed mirror:** buttons/sticks/triggers are ALSO diffed onto the ordinary per-transition
|
|
||||||
* plane, so the emergency exit chord works, and a host that degraded the kind (no UHID → the
|
|
||||||
* Xbox 360 pad) still gets a playable controller.
|
|
||||||
* - **Raw return:** the host's hidraw writes (Steam's `0x80` rumble output reports, lizard/IMU
|
|
||||||
* feature settings) arrive via [GamepadFeedback.onHidRaw] → [onHidRaw] → the link, landing on
|
|
||||||
* the real controller's motors/firmware.
|
|
||||||
*
|
|
||||||
* **UI mode** (`router == null`, owned by MainActivity while NOT streaming): the lizard-mode
|
|
||||||
* kb/mouse never produces gamepad events, so an uncaptured SC2 can't drive the console UI at
|
|
||||||
* all. Here the parsed state is edge-detected into [onUiKey] navigation transitions instead
|
|
||||||
* (D-pad + face buttons + Start/Select; the left stick synthesizes one D-pad step per push,
|
|
||||||
* mirroring MainActivity's stick-to-focus behavior for ordinary pads).
|
|
||||||
*
|
|
||||||
* The wire slot is claimed lazily on the FIRST state report — a Puck with no controller powered
|
|
||||||
* on stays invisible to the host — and released (with a wireless-disconnect event or on [stop])
|
|
||||||
* so pad indices never leak. Report callbacks arrive on the link's own thread; the router's slot
|
|
||||||
* table and chord timer are thread-safe for this (same contract as the feedback poll threads),
|
|
||||||
* and UI-mode consumers hop to the main thread themselves.
|
|
||||||
*/
|
|
||||||
class Sc2Capture(
|
|
||||||
context: Context,
|
|
||||||
private val router: GamepadRouter? = null,
|
|
||||||
) {
|
|
||||||
private val usb = Sc2UsbLink(context, ::onReport, ::onLinkClosed)
|
|
||||||
private val ble = Sc2BleLink(context, ::onReport, ::onLinkClosed)
|
|
||||||
private var activeLink: Int = LINK_NONE
|
|
||||||
|
|
||||||
/** True when the USB link is a Puck dongle — the only transport whose wireless-status
|
|
||||||
* reports are authoritative. A WIRED pad also emits them, truthfully reporting "no radio
|
|
||||||
* link" — acting on that tore the slot down 255 ms after creation (first on-glass run). */
|
|
||||||
private var dongleLink = false
|
|
||||||
|
|
||||||
private var pad: GamepadRouter.ExternalPad? = null
|
|
||||||
private val rawBuf: ByteBuffer = ByteBuffer.allocateDirect(64)
|
|
||||||
/** Puck connect arrives before its first state report (and therefore before a wire pad exists).
|
|
||||||
* Preserve it so the native virtual Puck slot sees the same connect edge before state. */
|
|
||||||
private val pendingWireless = ByteArray(2)
|
|
||||||
private var pendingWirelessLen = 0
|
|
||||||
|
|
||||||
// Typed-mirror diff state (wire units).
|
|
||||||
private val state = Sc2Device.State()
|
|
||||||
private var wireButtons = 0
|
|
||||||
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
|
|
||||||
|
|
||||||
/** Report ids seen so far — each logged once, for remote diagnosis of what the pad emits. */
|
|
||||||
private val seenIds = HashSet<Int>()
|
|
||||||
|
|
||||||
// UI-mode state (router == null): held navigation keys + the stick's current synth direction.
|
|
||||||
private var uiHeld = HashSet<Int>()
|
|
||||||
private var uiStickDir = 0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UI-mode sink: one navigation key transition (an Android `KeyEvent.KEYCODE_*`), invoked on
|
|
||||||
* the LINK thread — the consumer hops to the main thread. Set before [startUsb]/[startBle].
|
|
||||||
*/
|
|
||||||
@Volatile
|
|
||||||
var onUiKey: ((keyCode: Int, down: Boolean) -> Unit)? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fired (link thread) when the capture engages or drops — lets the app surface "SC2
|
|
||||||
* connected" in the console-UI gate and the Controllers screen.
|
|
||||||
*/
|
|
||||||
@Volatile
|
|
||||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
|
||||||
|
|
||||||
val isActive: Boolean get() = activeLink != LINK_NONE
|
|
||||||
|
|
||||||
/** First attached SC2/Puck USB device, for the permission flow. */
|
|
||||||
fun findUsbDevice(): UsbDevice? = usb.findDevice()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The first already-bonded BLE Steam Controller's address, or null. The caller checks
|
|
||||||
* BLUETOOTH_CONNECT first (without it the bonded list reads as empty anyway).
|
|
||||||
*/
|
|
||||||
fun pairedBleAddress(): String? = ble.pairedControllers().firstOrNull()?.address
|
|
||||||
|
|
||||||
/** Start capturing [dev] over USB (permission already granted). */
|
|
||||||
fun startUsb(dev: UsbDevice): Boolean {
|
|
||||||
if (activeLink != LINK_NONE) return false
|
|
||||||
val ok = usb.start(dev)
|
|
||||||
if (ok) {
|
|
||||||
activeLink = LINK_USB
|
|
||||||
dongleLink = dev.productId != Sc2Device.PID_WIRED
|
|
||||||
onActiveChanged?.invoke(true)
|
|
||||||
}
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Start capturing the bonded BLE controller at [address]. */
|
|
||||||
fun startBle(address: String): Boolean {
|
|
||||||
if (activeLink != LINK_NONE) return false
|
|
||||||
val ok = ble.start(address)
|
|
||||||
if (ok) {
|
|
||||||
activeLink = LINK_BLE
|
|
||||||
onActiveChanged?.invoke(true)
|
|
||||||
}
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Replay a host raw write on the physical pad — wire to [GamepadFeedback.onHidRaw]. */
|
|
||||||
fun onHidRaw(padIndex: Int, kind: Int, data: ByteArray) {
|
|
||||||
if (padIndex != pad?.index) return // addressed to some other controller
|
|
||||||
when (activeLink) {
|
|
||||||
LINK_USB -> usb.writeRaw(kind, data)
|
|
||||||
LINK_BLE -> ble.writeRaw(kind, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
|
||||||
fun stop() {
|
|
||||||
val wasActive = activeLink != LINK_NONE
|
|
||||||
when (activeLink) {
|
|
||||||
LINK_USB -> usb.stop()
|
|
||||||
LINK_BLE -> ble.stop()
|
|
||||||
}
|
|
||||||
activeLink = LINK_NONE
|
|
||||||
dongleLink = false
|
|
||||||
releaseSlot()
|
|
||||||
releaseUiKeys()
|
|
||||||
if (wasActive) onActiveChanged?.invoke(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- link callbacks (link thread) ----
|
|
||||||
|
|
||||||
private fun onReport(report: ByteArray, len: Int) {
|
|
||||||
val id = report[0].toInt() and 0xFF
|
|
||||||
if (seenIds.add(id)) Log.i(TAG, "SC2 report id=0x%02x seen (len=%d)".format(id, len))
|
|
||||||
// Wireless status: authoritative ONLY through a Puck dongle (powering the pad off frees
|
|
||||||
// its wire index + the host's virtual device). A wired/BLE pad emits it too — truthfully
|
|
||||||
// saying "no radio link" — and must NOT tear the slot down (SDL's wired path likewise
|
|
||||||
// marks the controller connected unconditionally and reconnects on any state report).
|
|
||||||
if ((id == Sc2Device.ID_WIRELESS || id == Sc2Device.ID_WIRELESS_X) && len >= 2) {
|
|
||||||
if (dongleLink) {
|
|
||||||
when (report[1].toInt() and 0xFF) {
|
|
||||||
Sc2Device.WIRELESS_CONNECT -> {
|
|
||||||
pendingWireless[0] = report[0]
|
|
||||||
pendingWireless[1] = report[1]
|
|
||||||
pendingWirelessLen = 2
|
|
||||||
}
|
|
||||||
Sc2Device.WIRELESS_DISCONNECT -> {
|
|
||||||
pendingWirelessLen = 0
|
|
||||||
Log.i(TAG, "Puck reports controller powered off — releasing wire slot")
|
|
||||||
releaseSlot()
|
|
||||||
releaseUiKeys()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!Sc2Device.parseState(report, len, state)) {
|
|
||||||
// Battery/status and future report types still belong to the as-is stream.
|
|
||||||
forwardRaw(report, len)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (router == null) {
|
|
||||||
mirrorUi()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val pref = if (dongleLink) {
|
|
||||||
Gamepad.PREF_STEAMCONTROLLER2_PUCK
|
|
||||||
} else {
|
|
||||||
Gamepad.PREF_STEAMCONTROLLER2
|
|
||||||
}
|
|
||||||
val p = pad ?: router.openExternal(pref)?.also {
|
|
||||||
pad = it
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"SC2 captured → wire pad ${it.index} (${if (dongleLink) "Puck" else "direct"} passthrough)",
|
|
||||||
)
|
|
||||||
if (pendingWirelessLen > 0) {
|
|
||||||
forwardRaw(pendingWireless, pendingWirelessLen)
|
|
||||||
pendingWirelessLen = 0
|
|
||||||
}
|
|
||||||
} ?: return // all 16 wire indices taken — drop until one frees
|
|
||||||
forwardRaw(report, len)
|
|
||||||
mirrorTyped(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun forwardRaw(report: ByteArray, len: Int) {
|
|
||||||
val p = pad ?: return
|
|
||||||
val n = len.coerceAtMost(rawBuf.capacity())
|
|
||||||
rawBuf.clear()
|
|
||||||
rawBuf.put(report, 0, n)
|
|
||||||
p.hidReport(rawBuf, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
|
|
||||||
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
|
|
||||||
val wired = Sc2Device.wireButtons(state.buttons)
|
|
||||||
var changed = wired xor wireButtons
|
|
||||||
while (changed != 0) {
|
|
||||||
val bit = changed and -changed // lowest changed bit
|
|
||||||
p.button(bit, wired and bit != 0)
|
|
||||||
changed = changed and bit.inv()
|
|
||||||
}
|
|
||||||
wireButtons = wired
|
|
||||||
axis(p, Gamepad.AXIS_LS_X, state.lsX)
|
|
||||||
axis(p, Gamepad.AXIS_LS_Y, state.lsY)
|
|
||||||
axis(p, Gamepad.AXIS_RS_X, state.rsX)
|
|
||||||
axis(p, Gamepad.AXIS_RS_Y, state.rsY)
|
|
||||||
axis(p, Gamepad.AXIS_LT, state.lt)
|
|
||||||
axis(p, Gamepad.AXIS_RT, state.rt)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun axis(p: GamepadRouter.ExternalPad, id: Int, v: Int) {
|
|
||||||
if (lastAxis[id] == v) return
|
|
||||||
lastAxis[id] = v
|
|
||||||
p.axis(id, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UI mode: edge-detect the parsed state into navigation key transitions. Buttons map to
|
|
||||||
* their Android keycodes (press AND release, so the focus system sees real holds); the left
|
|
||||||
* stick synthesizes ONE D-pad step per push past half deflection — the same single-move
|
|
||||||
* behavior MainActivity gives ordinary pads' sticks.
|
|
||||||
*/
|
|
||||||
private fun mirrorUi() {
|
|
||||||
val sink = onUiKey ?: return
|
|
||||||
val held = HashSet<Int>(8)
|
|
||||||
var i = 0
|
|
||||||
while (i < UI_KEY_MAP.size) {
|
|
||||||
if (state.buttons and UI_KEY_MAP[i] != 0) held.add(UI_KEY_MAP[i + 1])
|
|
||||||
i += 2
|
|
||||||
}
|
|
||||||
for (key in held) if (key !in uiHeld) sink(key, true)
|
|
||||||
for (key in uiHeld) if (key !in held) sink(key, false)
|
|
||||||
uiHeld = held
|
|
||||||
// Left stick → a HELD D-pad direction (device convention: +y = up): pressed while
|
|
||||||
// deflected, released on centre/direction change. The console UI's probe machinery
|
|
||||||
// turns a held direction into its own auto-repeat, exactly like a physical D-pad; the
|
|
||||||
// focus-hook path moves once per press edge either way.
|
|
||||||
val dir = when {
|
|
||||||
state.lsX <= -STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_LEFT
|
|
||||||
state.lsX >= STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_RIGHT
|
|
||||||
state.lsY >= STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_UP
|
|
||||||
state.lsY <= -STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_DOWN
|
|
||||||
else -> 0
|
|
||||||
}
|
|
||||||
if (dir != uiStickDir) {
|
|
||||||
// The D-pad bits share these keycodes; don't release a direction the physical
|
|
||||||
// D-pad itself still holds (uiHeld tracks the button-sourced state).
|
|
||||||
if (uiStickDir != 0 && uiStickDir !in uiHeld) sink(uiStickDir, false)
|
|
||||||
if (dir != 0 && dir !in uiHeld) sink(dir, true)
|
|
||||||
uiStickDir = dir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Release every held UI-mode key (link drop / stop) so nothing sticks in the focus system. */
|
|
||||||
private fun releaseUiKeys() {
|
|
||||||
val sink = onUiKey
|
|
||||||
if (sink != null) {
|
|
||||||
for (key in uiHeld) sink(key, false)
|
|
||||||
if (uiStickDir != 0 && uiStickDir !in uiHeld) sink(uiStickDir, false)
|
|
||||||
}
|
|
||||||
uiHeld = HashSet()
|
|
||||||
uiStickDir = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onLinkClosed() {
|
|
||||||
Log.i(TAG, "SC2 link closed (unplug / power-off)")
|
|
||||||
activeLink = LINK_NONE
|
|
||||||
dongleLink = false
|
|
||||||
releaseSlot()
|
|
||||||
releaseUiKeys()
|
|
||||||
onActiveChanged?.invoke(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releaseSlot() {
|
|
||||||
pad?.close()
|
|
||||||
pad = null
|
|
||||||
wireButtons = 0
|
|
||||||
lastAxis.fill(Int.MIN_VALUE)
|
|
||||||
pendingWirelessLen = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "Sc2Capture"
|
|
||||||
const val LINK_NONE = 0
|
|
||||||
const val LINK_USB = 1
|
|
||||||
const val LINK_BLE = 2
|
|
||||||
|
|
||||||
/** Half deflection (device i16 range) — the stick-to-focus threshold. */
|
|
||||||
const val STICK_NAV = 16384
|
|
||||||
|
|
||||||
/** UI-mode mapping: SC2 button bit → Android keycode, as (bit, key) pairs. */
|
|
||||||
val UI_KEY_MAP = intArrayOf(
|
|
||||||
Sc2Device.DPAD_UP, android.view.KeyEvent.KEYCODE_DPAD_UP,
|
|
||||||
Sc2Device.DPAD_DOWN, android.view.KeyEvent.KEYCODE_DPAD_DOWN,
|
|
||||||
Sc2Device.DPAD_LEFT, android.view.KeyEvent.KEYCODE_DPAD_LEFT,
|
|
||||||
Sc2Device.DPAD_RIGHT, android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
|
|
||||||
Sc2Device.A, android.view.KeyEvent.KEYCODE_BUTTON_A,
|
|
||||||
Sc2Device.B, android.view.KeyEvent.KEYCODE_BUTTON_B,
|
|
||||||
Sc2Device.X, android.view.KeyEvent.KEYCODE_BUTTON_X,
|
|
||||||
Sc2Device.Y, android.view.KeyEvent.KEYCODE_BUTTON_Y,
|
|
||||||
Sc2Device.LB, android.view.KeyEvent.KEYCODE_BUTTON_L1,
|
|
||||||
Sc2Device.RB, android.view.KeyEvent.KEYCODE_BUTTON_R1,
|
|
||||||
Sc2Device.MENU, android.view.KeyEvent.KEYCODE_BUTTON_START,
|
|
||||||
Sc2Device.VIEW, android.view.KeyEvent.KEYCODE_BUTTON_SELECT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
package io.unom.punktfunk.kit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Steam Controller 2 (2026, Valve "Ibex" / SDL "Triton") protocol constants + the light state
|
|
||||||
* parser the CLIENT needs. The full report rides the wire verbatim (`nativeSendPadHidReport` →
|
|
||||||
* the host's as-is virtual pad); this parser only extracts what the client itself consumes: the
|
|
||||||
* button word for the typed mirror + exit chord, and sticks/triggers for the degrade path.
|
|
||||||
*
|
|
||||||
* Protocol ground truth: SDL's `SDL_hidapi_steam_triton.c` + `steam/controller_structs.h`
|
|
||||||
* (Valve-maintained), mirrored host-side in `punktfunk-host`'s `triton_proto.rs`.
|
|
||||||
*/
|
|
||||||
object Sc2Device {
|
|
||||||
const val VID_VALVE = 0x28DE
|
|
||||||
|
|
||||||
/** Wired controller. */
|
|
||||||
const val PID_WIRED = 0x1302
|
|
||||||
|
|
||||||
/** Direct BLE identity (transport handled by [Sc2BleLink], not USB). */
|
|
||||||
const val PID_BLE = 0x1303
|
|
||||||
|
|
||||||
/** The wireless Puck dongles (Proteus / Nereid) — controller on USB interfaces 2..5. */
|
|
||||||
const val PID_DONGLE_PROTEUS = 0x1304
|
|
||||||
const val PID_DONGLE_NEREID = 0x1305
|
|
||||||
|
|
||||||
val USB_PIDS = setOf(PID_WIRED, PID_DONGLE_PROTEUS, PID_DONGLE_NEREID)
|
|
||||||
|
|
||||||
/** Dongle interface range that carries controllers (SDL: "interfaces 2..5, currently"). */
|
|
||||||
val DONGLE_IFACES = 2..5
|
|
||||||
|
|
||||||
// Input report ids (`ETritonReportIDTypes`). State layouts share every offset the client
|
|
||||||
// reads (seq/buttons/triggers/sticks); 0x47 only diverges from byte 18 (trackpad timestamp).
|
|
||||||
const val ID_STATE = 0x42
|
|
||||||
const val ID_BATTERY = 0x43
|
|
||||||
const val ID_STATE_BLE = 0x45
|
|
||||||
const val ID_WIRELESS_X = 0x46
|
|
||||||
const val ID_STATE_TIMESTAMP = 0x47
|
|
||||||
const val ID_WIRELESS = 0x79
|
|
||||||
|
|
||||||
/** Wireless status payload byte: controller connected/disconnected through the Puck. */
|
|
||||||
const val WIRELESS_DISCONNECT = 1
|
|
||||||
const val WIRELESS_CONNECT = 2
|
|
||||||
|
|
||||||
// Button bits in the state report's u32 (SDL `TritonButtons`).
|
|
||||||
const val A = 0x00000001
|
|
||||||
const val B = 0x00000002
|
|
||||||
const val X = 0x00000004
|
|
||||||
const val Y = 0x00000008
|
|
||||||
const val QAM = 0x00000010
|
|
||||||
const val R3 = 0x00000020
|
|
||||||
const val VIEW = 0x00000040
|
|
||||||
const val R4 = 0x00000080
|
|
||||||
const val R5 = 0x00000100
|
|
||||||
const val RB = 0x00000200
|
|
||||||
const val DPAD_DOWN = 0x00000400
|
|
||||||
const val DPAD_RIGHT = 0x00000800
|
|
||||||
const val DPAD_LEFT = 0x00001000
|
|
||||||
const val DPAD_UP = 0x00002000
|
|
||||||
const val MENU = 0x00004000
|
|
||||||
const val L3 = 0x00008000
|
|
||||||
const val STEAM = 0x00010000
|
|
||||||
const val L4 = 0x00020000
|
|
||||||
const val L5 = 0x00040000
|
|
||||||
const val LB = 0x00080000
|
|
||||||
const val RPAD_CLICK = 0x00400000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The feature report that turns lizard mode (built-in keyboard/mouse emulation) off:
|
|
||||||
* `[report id 1][ID_SET_SETTINGS_VALUES 0x87][length 3][SETTING_LIZARD_MODE 9]
|
|
||||||
* [LIZARD_MODE_OFF u16]`, zero-padded to the 64-byte feature size. The firmware watchdog
|
|
||||||
* re-enables lizard mode after a few seconds of silence, so this is re-sent every
|
|
||||||
* [LIZARD_REFRESH_MS] (SDL's cadence) — and the host's Steam sends its own through the raw
|
|
||||||
* plane once it grabs the virtual pad, which lands here too.
|
|
||||||
*/
|
|
||||||
val DISABLE_LIZARD: ByteArray = ByteArray(64).also {
|
|
||||||
it[0] = 0x01 // feature report id
|
|
||||||
it[1] = 0x87.toByte() // ID_SET_SETTINGS_VALUES
|
|
||||||
it[2] = 3 // one ControllerSetting {u8 num, u16 value}
|
|
||||||
it[3] = 9 // SETTING_LIZARD_MODE
|
|
||||||
// [4..6] = LIZARD_MODE_OFF (0) — already zero
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force firmware-calibrated signed i16 stick coordinates. Steam sends this during physical
|
|
||||||
* controller initialization (`SETTING_ENABLE_RAW_JOYSTICK` = 0x2e, value 0); without it a
|
|
||||||
* controller previously opened in raw mode reports ADC coordinates around 0..3200, which a
|
|
||||||
* Triton consumer interprets as only a few percent of full travel.
|
|
||||||
*/
|
|
||||||
val NORMALIZE_JOYSTICKS: ByteArray = ByteArray(64).also {
|
|
||||||
it[0] = 0x01 // feature report id
|
|
||||||
it[1] = 0x87.toByte() // ID_SET_SETTINGS_VALUES
|
|
||||||
it[2] = 3 // one ControllerSetting {u8 num, u16 value}
|
|
||||||
it[3] = 0x2E // SETTING_ENABLE_RAW_JOYSTICK
|
|
||||||
// [4..6] = disabled (0) — firmware emits calibrated signed i16 values
|
|
||||||
}
|
|
||||||
|
|
||||||
const val LIZARD_REFRESH_MS = 3000L
|
|
||||||
|
|
||||||
/** Wire mapping: SC2 button bit → punktfunk `Gamepad.BTN_*`, the inverse of the host's
|
|
||||||
* typed-fallback mapping (`triton_proto::from_gamepad`): paddles R4/L4/R5/L5 =
|
|
||||||
* PADDLE1/2/3/4, QAM = MISC1, right-pad click = the touchpad wire bit. */
|
|
||||||
private val WIRE_MAP = intArrayOf(
|
|
||||||
A, Gamepad.BTN_A,
|
|
||||||
B, Gamepad.BTN_B,
|
|
||||||
X, Gamepad.BTN_X,
|
|
||||||
Y, Gamepad.BTN_Y,
|
|
||||||
LB, Gamepad.BTN_LB,
|
|
||||||
RB, Gamepad.BTN_RB,
|
|
||||||
VIEW, Gamepad.BTN_BACK,
|
|
||||||
MENU, Gamepad.BTN_START,
|
|
||||||
STEAM, Gamepad.BTN_GUIDE,
|
|
||||||
L3, Gamepad.BTN_LS_CLICK,
|
|
||||||
R3, Gamepad.BTN_RS_CLICK,
|
|
||||||
DPAD_UP, Gamepad.BTN_DPAD_UP,
|
|
||||||
DPAD_DOWN, Gamepad.BTN_DPAD_DOWN,
|
|
||||||
DPAD_LEFT, Gamepad.BTN_DPAD_LEFT,
|
|
||||||
DPAD_RIGHT, Gamepad.BTN_DPAD_RIGHT,
|
|
||||||
QAM, Gamepad.BTN_MISC1,
|
|
||||||
R4, Gamepad.BTN_PADDLE1,
|
|
||||||
L4, Gamepad.BTN_PADDLE2,
|
|
||||||
R5, Gamepad.BTN_PADDLE3,
|
|
||||||
L5, Gamepad.BTN_PADDLE4,
|
|
||||||
RPAD_CLICK, Gamepad.BTN_TOUCHPAD,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Translate an SC2 button word into the wire `Gamepad.BTN_*` bitmask. */
|
|
||||||
fun wireButtons(sc2: Int): Int {
|
|
||||||
var out = 0
|
|
||||||
var i = 0
|
|
||||||
while (i < WIRE_MAP.size) {
|
|
||||||
if (sc2 and WIRE_MAP[i] != 0) out = out or WIRE_MAP[i + 1]
|
|
||||||
i += 2
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The typed-mirror fields of one state report (buttons/sticks/triggers only). */
|
|
||||||
class State {
|
|
||||||
var buttons = 0 // SC2 bit layout
|
|
||||||
var lsX = 0; var lsY = 0 // i16, +y = up (device convention = wire convention)
|
|
||||||
var rsX = 0; var rsY = 0
|
|
||||||
var lt = 0; var rt = 0 // 0..255 (device 0..32767 scaled down)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the client-consumed fields out of a state report (`0x42`/`0x45`/`0x47` — identical
|
|
||||||
* offsets for everything read here) into [out]. Returns false for non-state / short reports.
|
|
||||||
*/
|
|
||||||
fun parseState(report: ByteArray, len: Int, out: State): Boolean {
|
|
||||||
if (len < 18) return false
|
|
||||||
when (report[0].toInt() and 0xFF) {
|
|
||||||
ID_STATE, ID_STATE_BLE, ID_STATE_TIMESTAMP -> {}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
fun i16(o: Int) = ((report[o + 1].toInt() shl 8) or (report[o].toInt() and 0xFF)).toShort().toInt()
|
|
||||||
out.buttons = (report[2].toInt() and 0xFF) or
|
|
||||||
((report[3].toInt() and 0xFF) shl 8) or
|
|
||||||
((report[4].toInt() and 0xFF) shl 16) or
|
|
||||||
((report[5].toInt() and 0xFF) shl 24)
|
|
||||||
out.lt = (i16(6).coerceIn(0, 32767)) shr 7
|
|
||||||
out.rt = (i16(8).coerceIn(0, 32767)) shr 7
|
|
||||||
out.lsX = i16(10); out.lsY = i16(12)
|
|
||||||
out.rsX = i16(14); out.rsY = i16(16)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
package io.unom.punktfunk.kit
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.hardware.usb.UsbConstants
|
|
||||||
import android.hardware.usb.UsbDevice
|
|
||||||
import android.hardware.usb.UsbDeviceConnection
|
|
||||||
import android.hardware.usb.UsbEndpoint
|
|
||||||
import android.hardware.usb.UsbInterface
|
|
||||||
import android.hardware.usb.UsbManager
|
|
||||||
import android.hardware.usb.UsbRequest
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
|
||||||
import java.util.concurrent.TimeoutException
|
|
||||||
|
|
||||||
/**
|
|
||||||
* USB transport for a Steam Controller 2 — wired (`28DE:1302`) or through the wireless Puck
|
|
||||||
* dongle (`1304`/`1305`). Claims the controller interface(s) — detaching the OS input stack, so
|
|
||||||
* the pad can't double-drive the ordinary InputDevice path — runs a multiplexed [UsbRequest]
|
|
||||||
* read loop, keeps lizard mode off on the firmware watchdog cadence, and replays the host's raw
|
|
||||||
* writes (Steam's rumble output reports / settings feature reports) back to the device.
|
|
||||||
*
|
|
||||||
* **The Puck claims ALL controller interfaces (2..5):** the dongle hosts up to four pads, one
|
|
||||||
* HID interface each, and there is no way to know which slot a controller bonded to — claiming
|
|
||||||
* only interface 2 read silence while Android's input stack kept the others (the round-2
|
|
||||||
* on-glass symptom: the pad surfaced as a generic InputDevice → Xbox360). Whichever interface
|
|
||||||
* streams state becomes the write target for rumble/settings.
|
|
||||||
*
|
|
||||||
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
|
|
||||||
* (round 2's wired disconnect was the 5 s silence heuristic firing on an idle pad). The real
|
|
||||||
* signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
|
|
||||||
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
|
|
||||||
*/
|
|
||||||
class Sc2UsbLink(
|
|
||||||
private val context: Context,
|
|
||||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
|
||||||
private val onClosed: () -> Unit,
|
|
||||||
) {
|
|
||||||
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
|
||||||
|
|
||||||
/** One claimed interface: its endpoints + the read state the reader thread owns. */
|
|
||||||
private class Claim(
|
|
||||||
val iface: UsbInterface,
|
|
||||||
val epIn: UsbEndpoint,
|
|
||||||
val epOut: UsbEndpoint?,
|
|
||||||
) {
|
|
||||||
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
|
|
||||||
var inReq: UsbRequest? = null
|
|
||||||
var outReq: UsbRequest? = null
|
|
||||||
var outBusy = false
|
|
||||||
var reports = 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
private var connection: UsbDeviceConnection? = null
|
|
||||||
private var device: UsbDevice? = null
|
|
||||||
private var claims: List<Claim> = emptyList()
|
|
||||||
|
|
||||||
/** The claim whose IN endpoint last produced data — where rumble/settings writes go.
|
|
||||||
* Written by the reader thread, read by the feedback thread (feature control transfers). */
|
|
||||||
@Volatile private var activeClaim: Claim? = null
|
|
||||||
|
|
||||||
/** Pending OUT reports (Steam's forwarded haptics), submitted by the reader thread — only
|
|
||||||
* one thread may drive a connection's [UsbRequest]s ([UsbDeviceConnection.requestWait]
|
|
||||||
* returns ANY completed request; a second waiter would steal the reader's completions). */
|
|
||||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
|
||||||
|
|
||||||
private var reader: Thread? = null
|
|
||||||
private var detachReceiver: BroadcastReceiver? = null
|
|
||||||
|
|
||||||
@Volatile private var running = false
|
|
||||||
|
|
||||||
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
|
|
||||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull {
|
|
||||||
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
|
||||||
* obtained USB permission. Returns false when nothing could be claimed.
|
|
||||||
*/
|
|
||||||
fun start(dev: UsbDevice): Boolean {
|
|
||||||
if (!usb.hasPermission(dev)) {
|
|
||||||
Log.e(TAG, "no USB permission for ${dev.deviceName}")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val conn = usb.openDevice(dev) ?: run {
|
|
||||||
Log.e(TAG, "openDevice failed for ${dev.deviceName}")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val claimed = claimControllerInterfaces(dev, conn)
|
|
||||||
if (claimed.isEmpty()) {
|
|
||||||
Log.e(TAG, "no claimable SC2 interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
|
|
||||||
conn.close()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
connection = conn
|
|
||||||
device = dev
|
|
||||||
claims = claimed
|
|
||||||
running = true
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"SC2 USB link up: PID=0x%04x ifaces=%s".format(
|
|
||||||
dev.productId,
|
|
||||||
claimed.joinToString {
|
|
||||||
"%d(in=0x%02x out=%s)".format(
|
|
||||||
it.iface.id, it.epIn.address,
|
|
||||||
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
|
|
||||||
val receiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(c: Context?, intent: Intent?) {
|
|
||||||
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
|
|
||||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
|
||||||
if (gone?.deviceName == dev.deviceName) {
|
|
||||||
Log.i(TAG, "SC2 USB detached (${dev.deviceName})")
|
|
||||||
if (running) {
|
|
||||||
running = false
|
|
||||||
onClosed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
detachReceiver = receiver
|
|
||||||
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
|
||||||
if (Build.VERSION.SDK_INT >= 33) {
|
|
||||||
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
|
||||||
} else {
|
|
||||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
|
||||||
context.registerReceiver(receiver, filter)
|
|
||||||
}
|
|
||||||
claimed.forEach { configureInputMode(conn, it.iface.id) }
|
|
||||||
reader = Thread({ readLoop(conn, claimed) }, "pf-sc2-usb").apply {
|
|
||||||
isDaemon = true
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Claim every candidate controller interface: the wired pad's single HID interface, or ALL
|
|
||||||
* of a Puck's controller slots (interfaces 2..5 — the controller may be bonded to any of
|
|
||||||
* them). `force = true` detaches the kernel/OS driver, so the pad also vanishes from
|
|
||||||
* Android's own input stack while captured.
|
|
||||||
*/
|
|
||||||
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
|
|
||||||
val dongle = dev.productId != Sc2Device.PID_WIRED
|
|
||||||
val out = mutableListOf<Claim>()
|
|
||||||
for (i in 0 until dev.interfaceCount) {
|
|
||||||
val iface = dev.getInterface(i)
|
|
||||||
if (dongle && iface.id !in Sc2Device.DONGLE_IFACES) continue
|
|
||||||
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
|
|
||||||
iface.interfaceClass == 0xFF
|
|
||||||
if (!hidOrVendor) continue
|
|
||||||
var inEp: UsbEndpoint? = null
|
|
||||||
var outEp: UsbEndpoint? = null
|
|
||||||
for (e in 0 until iface.endpointCount) {
|
|
||||||
val ep = iface.getEndpoint(e)
|
|
||||||
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
|
|
||||||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
|
|
||||||
if (!usable) continue
|
|
||||||
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
|
|
||||||
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
|
|
||||||
}
|
|
||||||
if (inEp == null) continue
|
|
||||||
if (conn.claimInterface(iface, true)) {
|
|
||||||
out.add(Claim(iface, inEp, outEp))
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "could not claim iface ${iface.id}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
|
|
||||||
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
|
|
||||||
*/
|
|
||||||
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
|
|
||||||
val live = claims.filter { c ->
|
|
||||||
val req = UsbRequest()
|
|
||||||
if (!req.initialize(conn, c.epIn)) {
|
|
||||||
Log.w(TAG, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
|
|
||||||
return@filter false
|
|
||||||
}
|
|
||||||
req.clientData = c
|
|
||||||
c.inReq = req
|
|
||||||
c.epOut?.let { ep ->
|
|
||||||
val o = UsbRequest()
|
|
||||||
if (o.initialize(conn, ep)) {
|
|
||||||
o.clientData = c
|
|
||||||
c.outReq = o
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.inBuf.clear()
|
|
||||||
req.queue(c.inBuf)
|
|
||||||
}
|
|
||||||
if (live.isEmpty()) {
|
|
||||||
Log.e(TAG, "no IN request could be queued")
|
|
||||||
finishReader(claims)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val scratch = ByteArray(64)
|
|
||||||
var lastLizard = android.os.SystemClock.elapsedRealtime()
|
|
||||||
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
|
|
||||||
try {
|
|
||||||
while (running) {
|
|
||||||
val now = android.os.SystemClock.elapsedRealtime()
|
|
||||||
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
|
|
||||||
// Refresh both required firmware modes. The raw-joystick setting is normally
|
|
||||||
// persistent, but replaying it also repairs a host/driver that enabled ADC
|
|
||||||
// coordinates after capture started.
|
|
||||||
val target = activeClaim
|
|
||||||
if (target != null) configureInputMode(conn, target.iface.id)
|
|
||||||
else live.forEach { configureInputMode(conn, it.iface.id) }
|
|
||||||
lastLizard = now
|
|
||||||
}
|
|
||||||
// Submit the next pending OUT report on the active (else first) interface.
|
|
||||||
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
|
|
||||||
if (outTarget != null) {
|
|
||||||
outQueue.poll()?.let { data ->
|
|
||||||
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val done = try {
|
|
||||||
conn.requestWait(READ_TIMEOUT_MS)
|
|
||||||
} catch (_: TimeoutException) {
|
|
||||||
// A quiet controller is NOT an unplug — keep listening indefinitely; the
|
|
||||||
// detach broadcast is the real signal.
|
|
||||||
errorsSince = 0L
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (done == null) {
|
|
||||||
// Hard error. On a real unplug these storm continuously (the detach
|
|
||||||
// broadcast usually beats us to it); tolerate transient ones.
|
|
||||||
if (errorsSince == 0L) errorsSince = now
|
|
||||||
if (now - errorsSince >= ERROR_UNPLUG_MS) {
|
|
||||||
Log.i(TAG, "SC2 USB request errors persisting ${now - errorsSince} ms — treating as unplug")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
errorsSince = 0L
|
|
||||||
val claim = done.clientData as? Claim ?: continue
|
|
||||||
if (done === claim.inReq) {
|
|
||||||
val n = claim.inBuf.position()
|
|
||||||
if (n > 0) {
|
|
||||||
claim.inBuf.flip()
|
|
||||||
claim.inBuf.get(scratch, 0, n)
|
|
||||||
if (claim.reports++ == 0L) {
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"SC2 first report on iface %d: id=0x%02x len=%d".format(
|
|
||||||
claim.iface.id, scratch[0].toInt() and 0xFF, n,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
activeClaim = claim
|
|
||||||
onReport(scratch, n)
|
|
||||||
}
|
|
||||||
claim.inBuf.clear()
|
|
||||||
if (!claim.inReq!!.queue(claim.inBuf)) {
|
|
||||||
Log.i(TAG, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} else if (done === claim.outReq) {
|
|
||||||
claim.outBusy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
finishReader(claims)
|
|
||||||
}
|
|
||||||
if (running) {
|
|
||||||
running = false
|
|
||||||
onClosed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun finishReader(claims: List<Claim>) {
|
|
||||||
for (c in claims) {
|
|
||||||
runCatching { c.inReq?.cancel(); c.inReq?.close() }
|
|
||||||
runCatching { c.outReq?.cancel(); c.outReq?.close() }
|
|
||||||
c.inReq = null
|
|
||||||
c.outReq = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replay one raw report from the host on the device: kind 0 = output report (Steam's `0x80`
|
|
||||||
* rumble & friends — the active interface's interrupt-OUT, else a `SET_REPORT(Output)`
|
|
||||||
* control transfer), kind 1 = feature report (`SET_REPORT(Feature)`). [data] is the full
|
|
||||||
* report, id byte first, exactly as hidapi framed it host-side.
|
|
||||||
*/
|
|
||||||
fun writeRaw(kind: Int, data: ByteArray) {
|
|
||||||
if (data.isEmpty()) return
|
|
||||||
when (kind) {
|
|
||||||
0 -> {
|
|
||||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
|
||||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
|
||||||
// newest-wins: these are level-styled commands the host re-sends anyway.
|
|
||||||
while (outQueue.size >= 32) outQueue.poll()
|
|
||||||
outQueue.offer(data)
|
|
||||||
} else {
|
|
||||||
setReport(REPORT_TYPE_OUTPUT, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setReport(type: Int, data: ByteArray) {
|
|
||||||
val conn = connection ?: return
|
|
||||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
|
||||||
sendReport(conn, ifId, type, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun configureInputMode(conn: UsbDeviceConnection, ifaceId: Int) {
|
|
||||||
sendFeature(conn, ifaceId, Sc2Device.DISABLE_LIZARD)
|
|
||||||
sendFeature(conn, ifaceId, Sc2Device.NORMALIZE_JOYSTICKS)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendFeature(conn: UsbDeviceConnection, ifaceId: Int, data: ByteArray) {
|
|
||||||
sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
|
|
||||||
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
|
|
||||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
|
||||||
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
|
||||||
*/
|
|
||||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
|
||||||
val id = data[0].toInt() and 0xFF
|
|
||||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
|
||||||
conn.controlTransfer(
|
|
||||||
0x21, // host→device, class, interface
|
|
||||||
0x09, // SET_REPORT
|
|
||||||
(type shl 8) or id,
|
|
||||||
ifaceId,
|
|
||||||
payload,
|
|
||||||
payload.size,
|
|
||||||
WRITE_TIMEOUT_MS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
|
||||||
fun stop() {
|
|
||||||
running = false
|
|
||||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
|
||||||
detachReceiver = null
|
|
||||||
runCatching { reader?.join(1000) }
|
|
||||||
reader = null
|
|
||||||
outQueue.clear()
|
|
||||||
activeClaim = null
|
|
||||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
|
||||||
claims = emptyList()
|
|
||||||
runCatching { connection?.close() }
|
|
||||||
connection = null
|
|
||||||
device = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "Sc2UsbLink"
|
|
||||||
const val READ_TIMEOUT_MS = 100L
|
|
||||||
const val WRITE_TIMEOUT_MS = 250
|
|
||||||
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
|
|
||||||
const val ERROR_UNPLUG_MS = 2000L
|
|
||||||
const val REPORT_TYPE_OUTPUT = 0x02
|
|
||||||
const val REPORT_TYPE_FEATURE = 0x03
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,633 +0,0 @@
|
|||||||
//! The event-driven async MediaCodec decode loop (default) + its feeder/dispatch/present helpers.
|
|
||||||
|
|
||||||
use ndk::data_space::DataSpace;
|
|
||||||
use ndk::media::media_codec::{AsyncNotifyCallback, MediaCodec, MediaCodecDirection};
|
|
||||||
use ndk::media::media_format::MediaFormat;
|
|
||||||
use ndk::native_window::NativeWindow;
|
|
||||||
use punktfunk_core::client::NativeClient;
|
|
||||||
use punktfunk_core::error::PunktfunkError;
|
|
||||||
use punktfunk_core::reanchor::{GateVerdict, ReanchorGate};
|
|
||||||
use punktfunk_core::session::Frame;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
|
||||||
use std::sync::{mpsc, Arc, Mutex};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use super::display::{
|
|
||||||
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
|
|
||||||
};
|
|
||||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
|
|
||||||
use super::setup::{
|
|
||||||
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
|
||||||
configure_low_latency, create_codec, try_set_frame_rate,
|
|
||||||
};
|
|
||||||
use super::{DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, PENDING_SPLIT_CAP};
|
|
||||||
|
|
||||||
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
|
|
||||||
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
|
|
||||||
/// wall-clock instant the output callback fired — the spec's `decoded` point ("decoder output
|
|
||||||
/// frame available"), stamped at the callback so the event-channel hop + coalescing wait in the
|
|
||||||
/// loop never inflates the decode stage.
|
|
||||||
struct OutputReady {
|
|
||||||
index: usize,
|
|
||||||
pts_us: u64,
|
|
||||||
decoded_ns: i128,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
|
|
||||||
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
|
||||||
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
|
||||||
enum DecodeEvent {
|
|
||||||
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
|
|
||||||
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
|
|
||||||
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
|
|
||||||
Au(Frame, bool),
|
|
||||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
|
||||||
InputAvailable(usize),
|
|
||||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
|
||||||
OutputAvailable {
|
|
||||||
index: usize,
|
|
||||||
pts_us: u64,
|
|
||||||
decoded_ns: i128,
|
|
||||||
},
|
|
||||||
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
|
||||||
FormatChanged,
|
|
||||||
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
|
||||||
Error { fatal: bool },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The event-driven async decode loop (default; see [`run`]/[`USE_ASYNC_DECODE`]). The codec drives
|
|
||||||
/// us: an async-notify callback fires the instant an input buffer frees or a frame finishes
|
|
||||||
/// decoding, so a decoded frame is presented immediately instead of waiting out a poll interval (the
|
|
||||||
/// latency the sync loop left on the table). The callbacks run on the codec's internal looper thread
|
|
||||||
/// and only *push events* — every `AMediaCodec` buffer op stays on this thread, which owns the codec,
|
|
||||||
/// sidestepping the self-reference that would arise from a callback calling back into the codec it's
|
|
||||||
/// stored in. A small `pf-decode-feed` thread blocks on the network so this loop never does.
|
|
||||||
pub(super) fn run_async(
|
|
||||||
client: Arc<NativeClient>,
|
|
||||||
window: NativeWindow,
|
|
||||||
shutdown: Arc<AtomicBool>,
|
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
|
||||||
opts: DecodeOptions,
|
|
||||||
) {
|
|
||||||
let DecodeOptions {
|
|
||||||
decoder_name,
|
|
||||||
ll_feature,
|
|
||||||
low_latency_mode,
|
|
||||||
is_tv,
|
|
||||||
} = opts;
|
|
||||||
boost_thread_priority();
|
|
||||||
let mode = client.mode();
|
|
||||||
let mime = codec_mime(client.codec);
|
|
||||||
let mut codec = match create_codec(mime, decoder_name.as_deref()) {
|
|
||||||
Some(c) => c,
|
|
||||||
None => {
|
|
||||||
log::error!("decode: no {mime} decoder on this device");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let codec_name = codec.name().unwrap_or_default();
|
|
||||||
stats.set_decoder(&codec_name, ll_feature);
|
|
||||||
log::info!(
|
|
||||||
"decode: codec mime = {mime}, decoder = {codec_name} (async, low-latency feature: {ll_feature})"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The event channel: the callbacks + feeder push, this loop pulls. `Sender` is `Send`, so the
|
|
||||||
// callback closures (each capturing a clone) satisfy the async-notify `Send` bound.
|
|
||||||
let (ev_tx, ev_rx) = mpsc::channel::<DecodeEvent>();
|
|
||||||
// Install the callbacks BEFORE configure()/start() so we're in async mode from the first buffer.
|
|
||||||
// Each just forwards an index/flag — no codec access here (the codec owns these closures).
|
|
||||||
{
|
|
||||||
let out_tx = ev_tx.clone();
|
|
||||||
let in_tx = ev_tx.clone();
|
|
||||||
let fmt_tx = ev_tx.clone();
|
|
||||||
let err_tx = ev_tx.clone();
|
|
||||||
let cb = AsyncNotifyCallback {
|
|
||||||
on_input_available: Some(Box::new(move |idx| {
|
|
||||||
let _ = in_tx.send(DecodeEvent::InputAvailable(idx));
|
|
||||||
})),
|
|
||||||
on_output_available: Some(Box::new(move |idx, info| {
|
|
||||||
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
|
||||||
index: idx,
|
|
||||||
pts_us: info.presentation_time_us().max(0) as u64,
|
|
||||||
// The `decoded` HUD point: stamp HERE, on the codec's looper thread, so the
|
|
||||||
// decode stage ends when the frame actually became available — not after the
|
|
||||||
// channel hop + whatever work the loop coalesces in front of presenting it.
|
|
||||||
decoded_ns: now_realtime_ns(),
|
|
||||||
});
|
|
||||||
})),
|
|
||||||
on_format_changed: Some(Box::new(move |_fmt| {
|
|
||||||
let _ = fmt_tx.send(DecodeEvent::FormatChanged);
|
|
||||||
})),
|
|
||||||
on_error: Some(Box::new(move |e, code, _detail| {
|
|
||||||
let fatal = !code.is_recoverable() && !code.is_transient();
|
|
||||||
if fatal {
|
|
||||||
log::error!("decode: fatal codec error — stream will stop: {e:?}");
|
|
||||||
} else {
|
|
||||||
log::warn!("decode: codec error {e:?} (recoverable)");
|
|
||||||
}
|
|
||||||
let _ = err_tx.send(DecodeEvent::Error { fatal });
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
if let Err(e) = codec.set_async_notify_callback(Some(cb)) {
|
|
||||||
log::error!("decode: set_async_notify_callback failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the low-latency format (identical keys to the sync path).
|
|
||||||
let mut format = MediaFormat::new();
|
|
||||||
format.set_str("mime", mime);
|
|
||||||
format.set_i32("width", mode.width as i32);
|
|
||||||
format.set_i32("height", mode.height as i32);
|
|
||||||
format.set_i32(
|
|
||||||
"max-input-size",
|
|
||||||
(mode.width * mode.height).max(2_000_000) as i32,
|
|
||||||
);
|
|
||||||
configure_low_latency(&mut format, &codec_name, low_latency_mode);
|
|
||||||
if client.color.is_hdr() {
|
|
||||||
match client.next_hdr_meta(Duration::from_millis(250)) {
|
|
||||||
Ok(meta) => {
|
|
||||||
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
|
|
||||||
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
|
|
||||||
log::error!("decode: configure failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Err(e) = codec.start() {
|
|
||||||
log::error!("decode: start failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log::info!(
|
|
||||||
"decode: decoder started (async) at {}x{}",
|
|
||||||
mode.width,
|
|
||||||
mode.height
|
|
||||||
);
|
|
||||||
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
|
|
||||||
// off, every form factor gets the original soft seamless hint.
|
|
||||||
if mode.refresh_hz > 0
|
|
||||||
&& !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode)
|
|
||||||
{
|
|
||||||
log::debug!(
|
|
||||||
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
|
||||||
mode.refresh_hz
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skew-corrected latency stats (spec: design/stats-unification.md). Receipt stamps (keyed by the
|
|
||||||
// pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded
|
|
||||||
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
|
||||||
// HUD is visible.
|
|
||||||
let clock_offset = client.clock_offset_shared();
|
|
||||||
// Whether the adaptive-bitrate controller wants the `decode` stage as its decoder-backlog
|
|
||||||
// signal (Automatic, non-PyroWave): then `in_flight` is fed regardless of the HUD.
|
|
||||||
let measure_decode = client.wants_decode_latency();
|
|
||||||
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
|
||||||
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
|
||||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
|
||||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
|
||||||
// reclaimed after the codec is dropped below.
|
|
||||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
|
||||||
let render_cb = install_render_callback(&codec, &tracker);
|
|
||||||
|
|
||||||
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
|
||||||
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
|
|
||||||
let feeder = {
|
|
||||||
let client = client.clone();
|
|
||||||
let stats = stats.clone();
|
|
||||||
let in_flight = in_flight.clone();
|
|
||||||
let clock_offset = clock_offset.clone();
|
|
||||||
let shutdown = shutdown.clone();
|
|
||||||
let ev_tx = ev_tx.clone();
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("pf-decode-feed".into())
|
|
||||||
.spawn(move || {
|
|
||||||
feeder_loop(
|
|
||||||
client,
|
|
||||||
stats,
|
|
||||||
measure_decode,
|
|
||||||
in_flight,
|
|
||||||
clock_offset,
|
|
||||||
shutdown,
|
|
||||||
ev_tx,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.ok()
|
|
||||||
};
|
|
||||||
drop(ev_tx); // only the feeder + callbacks keep the channel alive now
|
|
||||||
|
|
||||||
// ADPF: same as the sync path — register this thread now, create the session lazily on the first
|
|
||||||
// presented frame (by when the pump + audio + feeder threads have registered their tids too).
|
|
||||||
let frame_period_ns = if mode.refresh_hz > 0 {
|
|
||||||
1_000_000_000i64 / mode.refresh_hz as i64
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
client.register_hot_thread();
|
|
||||||
let mut hint: Option<crate::adpf::HintSession> = None;
|
|
||||||
let mut hint_tried = false;
|
|
||||||
|
|
||||||
let mut free_inputs: VecDeque<usize> = VecDeque::new();
|
|
||||||
let mut pending_aus: VecDeque<Frame> = VecDeque::new();
|
|
||||||
let mut ready: Vec<OutputReady> = Vec::new();
|
|
||||||
let mut applied_ds: Option<DataSpace> = None;
|
|
||||||
let mut fed: u64 = 0;
|
|
||||||
let mut rendered: u64 = 0;
|
|
||||||
let mut discarded: u64 = 0;
|
|
||||||
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
|
||||||
let mut oversized_dropped: u64 = 0;
|
|
||||||
// Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap
|
|
||||||
// (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable
|
|
||||||
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
|
|
||||||
// `present_ready` (present), keyed by the codec-echoed pts.
|
|
||||||
let mut gate = ReanchorGate::new(client.frames_dropped());
|
|
||||||
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
|
||||||
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
|
||||||
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
|
|
||||||
let mut work_accum_ns: i64 = 0;
|
|
||||||
let mut fatal = false;
|
|
||||||
|
|
||||||
while !shutdown.load(Ordering::Relaxed) && !fatal {
|
|
||||||
// Block for the next event (idle wait — excluded from the work tally). The short timeout
|
|
||||||
// drives loss-recovery housekeeping when the pipeline is momentarily quiet.
|
|
||||||
let ev0 = match ev_rx.recv_timeout(Duration::from_millis(5)) {
|
|
||||||
Ok(ev) => Some(ev),
|
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => None,
|
|
||||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
|
||||||
};
|
|
||||||
let work_t0 = Instant::now();
|
|
||||||
let mut fmt_dirty = false;
|
|
||||||
let mut aus_dropped: u64 = 0;
|
|
||||||
if let Some(ev) = ev0 {
|
|
||||||
aus_dropped += u64::from(dispatch_event(
|
|
||||||
ev,
|
|
||||||
&mut pending_aus,
|
|
||||||
&mut free_inputs,
|
|
||||||
&mut ready,
|
|
||||||
&mut fmt_dirty,
|
|
||||||
&mut fatal,
|
|
||||||
&mut gate,
|
|
||||||
&mut recovery_flags,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
|
||||||
// presentation across a decode burst, and batched feeding.
|
|
||||||
while let Ok(ev) = ev_rx.try_recv() {
|
|
||||||
aus_dropped += u64::from(dispatch_event(
|
|
||||||
ev,
|
|
||||||
&mut pending_aus,
|
|
||||||
&mut free_inputs,
|
|
||||||
&mut ready,
|
|
||||||
&mut fmt_dirty,
|
|
||||||
&mut fatal,
|
|
||||||
&mut gate,
|
|
||||||
&mut recovery_flags,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
|
||||||
if fmt_dirty {
|
|
||||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
|
||||||
}
|
|
||||||
feed_ready(
|
|
||||||
&codec,
|
|
||||||
&client,
|
|
||||||
&mut pending_aus,
|
|
||||||
&mut free_inputs,
|
|
||||||
&mut fed,
|
|
||||||
&mut oversized_dropped,
|
|
||||||
);
|
|
||||||
let had_output = !ready.is_empty();
|
|
||||||
present_ready(
|
|
||||||
&codec,
|
|
||||||
&client,
|
|
||||||
measure_decode,
|
|
||||||
&mut ready,
|
|
||||||
&stats,
|
|
||||||
&in_flight,
|
|
||||||
clock_offset.load(Ordering::Relaxed),
|
|
||||||
&tracker,
|
|
||||||
&mut rendered,
|
|
||||||
&mut discarded,
|
|
||||||
&mut gate,
|
|
||||||
&mut recovery_flags,
|
|
||||||
);
|
|
||||||
|
|
||||||
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
|
||||||
if had_output {
|
|
||||||
if !hint_tried {
|
|
||||||
hint_tried = true;
|
|
||||||
let tids = client.hot_thread_ids();
|
|
||||||
// The pump/audio priority boost is part of the experimental low-latency stack; the
|
|
||||||
// ADPF session itself predates it and always runs (max-performance bias gated inside).
|
|
||||||
if low_latency_mode {
|
|
||||||
boost_hot_threads(&tids);
|
|
||||||
}
|
|
||||||
hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode);
|
|
||||||
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;
|
|
||||||
if rendered > 0 && rendered % 300 == 0 {
|
|
||||||
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Loss recovery + overdue backstop, folded through the gate. A parked-AU overflow drop is itself
|
|
||||||
// a loss, so it arms the freeze directly; the gate's `poll` then arms on a dropped-count climb
|
|
||||||
// and re-asks on an overdue freeze. All keyframe intents route through the shared 100 ms
|
|
||||||
// throttle so a multi-frame recovery gap can't flood the control stream.
|
|
||||||
let now = Instant::now();
|
|
||||||
if aus_dropped > 0 {
|
|
||||||
gate.arm(now);
|
|
||||||
}
|
|
||||||
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0)
|
|
||||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
|
||||||
{
|
|
||||||
last_kf_req = Some(now);
|
|
||||||
let _ = client.request_keyframe();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = codec.stop();
|
|
||||||
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
|
|
||||||
if let Some(j) = feeder {
|
|
||||||
let _ = j.join();
|
|
||||||
}
|
|
||||||
drop(codec); // AMediaCodec_delete — after this no render callback can fire
|
|
||||||
if let Some(ud) = render_cb {
|
|
||||||
// SAFETY: the codec was dropped above; this registration's single reclaim.
|
|
||||||
unsafe { release_render_callback(ud) };
|
|
||||||
}
|
|
||||||
log::info!("decode: stopped (async, fed={fed} rendered={rendered} discarded={discarded})");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `pf-decode-feed` thread: block on the connector for the next access unit so the async loop
|
|
||||||
/// never has to. Records the `received` HUD stat (receipt point) — including the Phase-2 host/network
|
|
||||||
/// split from any matching 0xCF host timings — then hands the AU to the loop via the event channel.
|
|
||||||
/// Exits when `shutdown` is set, the session closes, or the loop's receiver is gone.
|
|
||||||
fn feeder_loop(
|
|
||||||
client: Arc<NativeClient>,
|
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
|
||||||
measure_decode: bool,
|
|
||||||
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
|
||||||
clock_offset: Arc<AtomicI64>,
|
|
||||||
shutdown: Arc<AtomicBool>,
|
|
||||||
ev_tx: mpsc::Sender<DecodeEvent>,
|
|
||||||
) {
|
|
||||||
// Received AUs awaiting their 0xCF host timing (Phase-2 split), as (pts_ns, capture→received µs).
|
|
||||||
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
|
|
||||||
while !shutdown.load(Ordering::Relaxed) {
|
|
||||||
match client.next_frame(Duration::from_millis(5)) {
|
|
||||||
Ok(frame) => {
|
|
||||||
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-frame-
|
|
||||||
// invalidation request so an RFI-capable host recovers with a cheap clean P-frame
|
|
||||||
// instead of a full IDR (the frames_dropped keyframe path is the backstop). The gap
|
|
||||||
// verdict rides the Au event so the decode loop arms its freeze gate on the same signal.
|
|
||||||
let gap = client.note_frame_index(frame.frame_index);
|
|
||||||
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
|
||||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
|
||||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
|
||||||
if stats.enabled() || measure_decode {
|
|
||||||
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
|
|
||||||
// here would fold the hand-off queue wait into the network latency figure
|
|
||||||
// (a client-side standing backlog masquerading as network). 0 = older core.
|
|
||||||
let received_ns = if frame.received_ns > 0 {
|
|
||||||
frame.received_ns as i128
|
|
||||||
} else {
|
|
||||||
now_realtime_ns()
|
|
||||||
};
|
|
||||||
{
|
|
||||||
let mut g = in_flight
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
g.push_back((frame.pts_ns / 1000, received_ns));
|
|
||||||
if g.len() > IN_FLIGHT_CAP {
|
|
||||||
g.pop_front(); // stale — codec never echoed it back
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if stats.enabled() {
|
|
||||||
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
|
||||||
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
|
||||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
|
||||||
.then_some((lat_ns / 1000) as u64);
|
|
||||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ev_tx.send(DecodeEvent::Au(frame, gap)).is_err() {
|
|
||||||
break; // the decode loop is gone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(PunktfunkError::NoFrame) => {} // timeout — re-check shutdown and poll again
|
|
||||||
Err(_) => break, // session closed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Route one [`DecodeEvent`] into the loop's working sets. Returns `true` only when a parked AU was
|
|
||||||
/// dropped on overflow (the caller then requests a keyframe).
|
|
||||||
#[allow(clippy::too_many_arguments)] // two call sites; the freeze gate + flag map are threaded in
|
|
||||||
fn dispatch_event(
|
|
||||||
ev: DecodeEvent,
|
|
||||||
pending_aus: &mut VecDeque<Frame>,
|
|
||||||
free_inputs: &mut VecDeque<usize>,
|
|
||||||
ready: &mut Vec<OutputReady>,
|
|
||||||
fmt_dirty: &mut bool,
|
|
||||||
fatal: &mut bool,
|
|
||||||
gate: &mut ReanchorGate,
|
|
||||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
|
||||||
) -> bool {
|
|
||||||
match ev {
|
|
||||||
DecodeEvent::Au(f, gap) => {
|
|
||||||
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
|
|
||||||
// fold `on_decoded` (keyed by the pts the codec will echo).
|
|
||||||
if gap {
|
|
||||||
gate.arm(Instant::now());
|
|
||||||
}
|
|
||||||
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
|
|
||||||
if recovery_flags.len() > IN_FLIGHT_CAP {
|
|
||||||
recovery_flags.pop_front();
|
|
||||||
}
|
|
||||||
pending_aus.push_back(f);
|
|
||||||
if pending_aus.len() > FRAME_PARK_CAP {
|
|
||||||
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DecodeEvent::InputAvailable(i) => free_inputs.push_back(i),
|
|
||||||
DecodeEvent::OutputAvailable {
|
|
||||||
index,
|
|
||||||
pts_us,
|
|
||||||
decoded_ns,
|
|
||||||
} => ready.push(OutputReady {
|
|
||||||
index,
|
|
||||||
pts_us,
|
|
||||||
decoded_ns,
|
|
||||||
}),
|
|
||||||
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
|
||||||
DecodeEvent::Error { fatal: f } => {
|
|
||||||
if f {
|
|
||||||
*fatal = true;
|
|
||||||
} else {
|
|
||||||
// A recoverable/transient codec error is a decode hiccup on a broken reference chain —
|
|
||||||
// arm the freeze so the concealed output it recovers into is held off the screen.
|
|
||||||
gate.arm(Instant::now());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
|
|
||||||
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
|
|
||||||
/// submitted; an AU larger than the buffer is DROPPED (+ a recovery keyframe requested) — a
|
|
||||||
/// truncated AU is corrupt input the decoder chews on silently, poisoning the reference chain.
|
|
||||||
fn feed_ready(
|
|
||||||
codec: &MediaCodec,
|
|
||||||
client: &NativeClient,
|
|
||||||
pending_aus: &mut VecDeque<Frame>,
|
|
||||||
free_inputs: &mut VecDeque<usize>,
|
|
||||||
fed: &mut u64,
|
|
||||||
oversized_dropped: &mut u64,
|
|
||||||
) {
|
|
||||||
while !pending_aus.is_empty() && !free_inputs.is_empty() {
|
|
||||||
let idx = free_inputs.pop_front().unwrap();
|
|
||||||
let frame = pending_aus.pop_front().unwrap();
|
|
||||||
let pts_us = frame.pts_ns / 1000;
|
|
||||||
let Some(dst) = codec.input_buffer(idx) else {
|
|
||||||
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let au = &frame.data;
|
|
||||||
if au.len() > dst.len() {
|
|
||||||
// The slot was never queued, so it stays ours — recycle it for the next AU.
|
|
||||||
free_inputs.push_front(idx);
|
|
||||||
*oversized_dropped += 1;
|
|
||||||
log::warn!(
|
|
||||||
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
|
|
||||||
au.len(),
|
|
||||||
dst.len(),
|
|
||||||
*oversized_dropped
|
|
||||||
);
|
|
||||||
let _ = client.request_keyframe();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let n = au.len();
|
|
||||||
// SAFETY: `au` (wire AU) and `dst` (codec input buffer) are distinct allocations, both valid
|
|
||||||
// for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n].
|
|
||||||
unsafe {
|
|
||||||
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
|
|
||||||
}
|
|
||||||
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, 0) {
|
|
||||||
log::warn!("decode: queue_input_buffer_by_index: {e}");
|
|
||||||
} else {
|
|
||||||
*fed += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Present only the NEWEST ready output (render = true) and release the rest without rendering — a
|
|
||||||
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
|
|
||||||
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
|
|
||||||
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
|
|
||||||
/// receipt-map eviction stays monotonic. The presented frame's `(pts, decoded stamp)` is parked in
|
|
||||||
/// `tracker` for the OnFrameRendered callback — the `display` stage's other endpoint. `ready` is
|
|
||||||
/// drained.
|
|
||||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
|
||||||
fn present_ready(
|
|
||||||
codec: &MediaCodec,
|
|
||||||
client: &NativeClient,
|
|
||||||
measure_decode: bool,
|
|
||||||
ready: &mut Vec<OutputReady>,
|
|
||||||
stats: &crate::stats::VideoStats,
|
|
||||||
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
|
||||||
clock_offset: i64,
|
|
||||||
tracker: &DisplayTracker,
|
|
||||||
rendered: &mut u64,
|
|
||||||
discarded: &mut u64,
|
|
||||||
gate: &mut ReanchorGate,
|
|
||||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
|
||||||
) {
|
|
||||||
if ready.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Pair each output's decode stage (feeds the ABR decode signal always; the HUD histogram only
|
|
||||||
// while visible) — both consume the receipt map, so enter for either.
|
|
||||||
if stats.enabled() || measure_decode {
|
|
||||||
let mut g = in_flight
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
for o in ready.iter() {
|
|
||||||
note_decoded_pts(
|
|
||||||
client,
|
|
||||||
measure_decode,
|
|
||||||
stats,
|
|
||||||
&mut g,
|
|
||||||
clock_offset,
|
|
||||||
o.pts_us,
|
|
||||||
o.decoded_ns,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
|
|
||||||
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
|
|
||||||
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
|
|
||||||
let now = Instant::now();
|
|
||||||
let last = ready.len() - 1;
|
|
||||||
let mut skipped: u64 = 0;
|
|
||||||
for (i, o) in ready.drain(..).enumerate() {
|
|
||||||
let flags = take_flags(recovery_flags, o.pts_us);
|
|
||||||
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
|
||||||
let render = i == last && present;
|
|
||||||
match codec.release_output_buffer_by_index(o.index, render) {
|
|
||||||
Ok(()) if render => {
|
|
||||||
*rendered += 1;
|
|
||||||
if stats.enabled() {
|
|
||||||
tracker.note_rendered(o.pts_us, o.decoded_ns);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(()) => {
|
|
||||||
*discarded += 1;
|
|
||||||
skipped += 1;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!(
|
|
||||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
|
||||||
o.index
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins + held-off drops); no-op hidden
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
//! Display/frame-rendered tracking, render-callback registration, HDR dataspace mapping.
|
|
||||||
|
|
||||||
use ndk::data_space::DataSpace;
|
|
||||||
use ndk::media::media_codec::MediaCodec;
|
|
||||||
use ndk::native_window::NativeWindow;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::ffi::c_void;
|
|
||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use super::latency::now_realtime_ns;
|
|
||||||
use super::RENDERED_CAP;
|
|
||||||
|
|
||||||
/// `CLOCK_MONOTONIC` now in nanoseconds — the base of the `systemNano` render timestamp the
|
|
||||||
/// `OnFrameRendered` callback reports (Android's `System.nanoTime`), read only to re-base that
|
|
||||||
/// stamp onto `CLOCK_REALTIME` (see [`on_frame_rendered`]).
|
|
||||||
fn now_monotonic_ns() -> i128 {
|
|
||||||
let mut ts = libc::timespec {
|
|
||||||
tv_sec: 0,
|
|
||||||
tv_nsec: 0,
|
|
||||||
};
|
|
||||||
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
|
|
||||||
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
|
|
||||||
ts.tv_sec as i128 * 1_000_000_000 + ts.tv_nsec as i128
|
|
||||||
}
|
|
||||||
|
|
||||||
/// State shared between the decode loop and the `AMediaCodec` `OnFrameRendered` callback (which
|
|
||||||
/// fires on a codec-internal thread): rendered frames awaiting their render timestamp, so the HUD
|
|
||||||
/// gets the spec's `display` stage (decoded→displayed) and the `capture→displayed` end-to-end
|
|
||||||
/// headline (`design/stats-unification.md` — this replaces Android's v1 `capture→decoded`
|
|
||||||
/// endpoint whenever the platform delivers render callbacks).
|
|
||||||
pub(super) struct DisplayTracker {
|
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
|
||||||
/// Live host-minus-client clock offset (ns) for the skew-corrected end-to-end sample —
|
|
||||||
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
|
|
||||||
/// keeps the leaked render-callback refcount from pinning the whole session alive.
|
|
||||||
clock_offset: Arc<AtomicI64>,
|
|
||||||
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
|
|
||||||
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
|
|
||||||
/// callback early-outs) while the overlay is hidden.
|
|
||||||
rendered: Mutex<VecDeque<(u64, i128)>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DisplayTracker {
|
|
||||||
pub(super) fn new(
|
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
|
||||||
clock_offset: Arc<AtomicI64>,
|
|
||||||
) -> Arc<DisplayTracker> {
|
|
||||||
Arc::new(DisplayTracker {
|
|
||||||
stats,
|
|
||||||
clock_offset,
|
|
||||||
rendered: Mutex::new(VecDeque::new()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Park one just-rendered frame's `(pts, decoded stamp)` for the render callback to pair.
|
|
||||||
/// Caller gates on the HUD being visible.
|
|
||||||
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128) {
|
|
||||||
let mut g = self
|
|
||||||
.rendered
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
g.push_back((pts_us, decoded_ns));
|
|
||||||
if g.len() > RENDERED_CAP {
|
|
||||||
g.pop_front(); // render callbacks stopped coming (allowed under load) — evict
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`,
|
|
||||||
/// **API 33** — "Available since Android T" per the NDK header; only the *Java* listener dates
|
|
||||||
/// back further). That sits above the API-28 floor, so the entry point is dlsym-resolved at
|
|
||||||
/// runtime like [`try_set_frame_rate`] — hard-linking it (as 0.9.0 shipped) made
|
|
||||||
/// `System.loadLibrary` fail on every pre-Android-13 device, taking down all of `NativeBridge`.
|
|
||||||
/// The `ndk` wrapper has no binding and the call needs the raw codec pointer, which is what the
|
|
||||||
/// vendored crate's public `as_ptr` patch is for. Returns the userdata pointer holding a leaked
|
|
||||||
/// `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with [`release_render_callback`]
|
|
||||||
/// AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no further callback can
|
|
||||||
/// fire). `None` (nothing to reclaim) if the symbol is absent (API < 33) or the platform refused —
|
|
||||||
/// the HUD then simply has no `display` stage, exactly the pre-callback behaviour.
|
|
||||||
pub(super) fn install_render_callback(
|
|
||||||
codec: &MediaCodec,
|
|
||||||
tracker: &Arc<DisplayTracker>,
|
|
||||||
) -> Option<*const DisplayTracker> {
|
|
||||||
// media_status_t AMediaCodec_setOnFrameRenderedCallback(
|
|
||||||
// AMediaCodec*, AMediaCodecOnFrameRendered, void*) (API 33)
|
|
||||||
type SetOnFrameRenderedFn = unsafe extern "C" fn(
|
|
||||||
*mut ndk_sys::AMediaCodec,
|
|
||||||
ndk_sys::AMediaCodecOnFrameRendered,
|
|
||||||
*mut c_void,
|
|
||||||
) -> ndk_sys::media_status_t;
|
|
||||||
// SAFETY: `dlopen` of `libmediandk.so`, which the `ndk` media wrapper already links — always
|
|
||||||
// mapped, so this only bumps its refcount (never closed — process-lifetime handle). `dlsym`
|
|
||||||
// returns null when the symbol is absent (device below API 33), checked before transmuting the
|
|
||||||
// non-null pointer to its fn-pointer type.
|
|
||||||
let set_on_frame_rendered = unsafe {
|
|
||||||
let lib = libc::dlopen(c"libmediandk.so".as_ptr(), libc::RTLD_NOW);
|
|
||||||
if lib.is_null() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let sym = libc::dlsym(lib, c"AMediaCodec_setOnFrameRenderedCallback".as_ptr());
|
|
||||||
if sym.is_null() {
|
|
||||||
log::info!("decode: no render callback on this API level (<33) — no display stage");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
std::mem::transmute::<*mut c_void, SetOnFrameRenderedFn>(sym)
|
|
||||||
};
|
|
||||||
let ud = Arc::into_raw(tracker.clone());
|
|
||||||
// SAFETY: `codec.as_ptr()` is the live codec this thread owns; `ud` outlives the registration
|
|
||||||
// (reclaimed only after the codec is deleted, per this function's contract).
|
|
||||||
let status = unsafe {
|
|
||||||
set_on_frame_rendered(codec.as_ptr(), Some(on_frame_rendered), ud as *mut c_void)
|
|
||||||
};
|
|
||||||
if status == ndk_sys::media_status_t::AMEDIA_OK {
|
|
||||||
Some(ud)
|
|
||||||
} else {
|
|
||||||
log::warn!("decode: setOnFrameRenderedCallback failed ({status:?}) — no display stage");
|
|
||||||
// SAFETY: registration failed, so the codec never took the reference — reclaim it now.
|
|
||||||
unsafe { drop(Arc::from_raw(ud)) };
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reclaim [`install_render_callback`]'s leaked `Arc` refcount.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// Call exactly once, and only after the codec the callback was registered on has been dropped —
|
|
||||||
/// deleting the codec stops its internal threads, so no callback can still be running (or run
|
|
||||||
/// later) against this pointer.
|
|
||||||
pub(super) unsafe fn release_render_callback(ud: *const DisplayTracker) {
|
|
||||||
drop(Arc::from_raw(ud));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `AMediaCodecOnFrameRendered` trampoline: fires (possibly batched) on a codec-internal
|
|
||||||
/// thread once per output frame actually placed on the output surface, with SurfaceFlinger's
|
|
||||||
/// render timestamp. That timestamp (`system_nano`) is on `CLOCK_MONOTONIC`, so it is re-based
|
|
||||||
/// onto `CLOCK_REALTIME` here — against monotonic-now at callback time, which also cancels any lag
|
|
||||||
/// between the frame rendering and the (batchable) callback delivery — to subtract against the
|
|
||||||
/// receipt/decode stamps and the host capture pts. Records the HUD's `displayed` point:
|
|
||||||
/// `end-to-end` = capture→displayed (skew-corrected) and `display` = decoded→displayed
|
|
||||||
/// (single-clock local). Panic-free by construction (poison-proof lock, saturating math) — an
|
|
||||||
/// unwind out of an `extern "C"` fn would abort the process.
|
|
||||||
unsafe extern "C" fn on_frame_rendered(
|
|
||||||
_codec: *mut ndk_sys::AMediaCodec,
|
|
||||||
userdata: *mut c_void,
|
|
||||||
media_time_us: i64,
|
|
||||||
system_nano: i64,
|
|
||||||
) {
|
|
||||||
let t = &*(userdata as *const DisplayTracker);
|
|
||||||
if !t.stats.enabled() {
|
|
||||||
return; // HUD hidden — the ring is empty too (pushes are caller-gated)
|
|
||||||
}
|
|
||||||
let displayed_ns = now_realtime_ns() - (now_monotonic_ns() - system_nano as i128);
|
|
||||||
let pts_us = media_time_us.max(0) as u64;
|
|
||||||
// Pair the frame back to its release record, evicting older entries (their callbacks were
|
|
||||||
// dropped by the platform, or the entry predates a HUD toggle) — same monotonic-eviction
|
|
||||||
// discipline as `note_decoded_pts`.
|
|
||||||
let mut decoded_ns = None;
|
|
||||||
{
|
|
||||||
let mut g = t
|
|
||||||
.rendered
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
while let Some(&(p, d)) = g.front() {
|
|
||||||
if p > pts_us {
|
|
||||||
break; // future frame — leave it for its own callback
|
|
||||||
}
|
|
||||||
g.pop_front();
|
|
||||||
if p == pts_us {
|
|
||||||
decoded_ns = Some(d);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let e2e_ns =
|
|
||||||
displayed_ns + t.clock_offset.load(Ordering::Relaxed) 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 display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
|
|
||||||
t.stats.note_displayed(e2e_us, display_us);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
|
||||||
/// streams leave the default alone). The AMediaCodec analogue of the sync loop's `OutputFormatChanged`
|
|
||||||
/// handling; safe to call repeatedly (`applied_ds` dedups).
|
|
||||||
pub(super) fn apply_hdr_dataspace(
|
|
||||||
codec: &MediaCodec,
|
|
||||||
window: &NativeWindow,
|
|
||||||
applied_ds: &mut Option<DataSpace>,
|
|
||||||
) {
|
|
||||||
if let Some(ds) = hdr_dataspace(codec) {
|
|
||||||
if *applied_ds != Some(ds) {
|
|
||||||
match window.set_buffers_data_space(ds) {
|
|
||||||
Ok(()) => {
|
|
||||||
*applied_ds = Some(ds);
|
|
||||||
log::info!("decode: HDR stream → Surface dataspace {ds}");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("decode: set_buffers_data_space({ds}) failed (non-fatal): {e}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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).
|
|
||||||
pub(super) fn hdr_dataspace(codec: &MediaCodec) -> Option<DataSpace> {
|
|
||||||
let fmt = codec.output_format();
|
|
||||||
let full_range = fmt.i32("color-range") == Some(1);
|
|
||||||
match fmt.i32("color-transfer") {
|
|
||||||
Some(6) => Some(if full_range {
|
|
||||||
DataSpace::Bt2020Pq
|
|
||||||
} else {
|
|
||||||
DataSpace::Bt2020ItuPq
|
|
||||||
}),
|
|
||||||
Some(7) => Some(if full_range {
|
|
||||||
DataSpace::Bt2020Hlg
|
|
||||||
} else {
|
|
||||||
DataSpace::Bt2020ItuHlg
|
|
||||||
}),
|
|
||||||
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
//! Decode-latency bookkeeping: realtime clock + decoded-pts / user-flags stat recording.
|
|
||||||
|
|
||||||
use punktfunk_core::client::NativeClient;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
|
|
||||||
/// Wall-clock now in nanoseconds (CLOCK_REALTIME basis), to compare against the host-stamped
|
|
||||||
/// capture `pts_ns` after the skew offset is applied.
|
|
||||||
pub(super) fn now_realtime_ns() -> i128 {
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_nanos() as i128)
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// HUD `decoded` point for one dequeued output frame, keyed by the echoed `presentationTimeUs`:
|
|
||||||
/// 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 pts keys the receipt stamp in `in_flight`;
|
|
||||||
/// entries older than it 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).
|
|
||||||
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
|
|
||||||
/// stamp (async loop).
|
|
||||||
pub(super) fn note_decoded_pts(
|
|
||||||
client: &NativeClient,
|
|
||||||
measure_decode: bool,
|
|
||||||
stats: &crate::stats::VideoStats,
|
|
||||||
in_flight: &mut VecDeque<(u64, i128)>,
|
|
||||||
clock_offset: i64,
|
|
||||||
pts_us: u64,
|
|
||||||
decoded_ns: i128,
|
|
||||||
) {
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let decode_us = received_ns.map(|r| ((decoded_ns - r).max(0) / 1000) as u64);
|
|
||||||
// Adaptive bitrate: the `decode` stage (received→decoded, single-clock local) IS the decoder-
|
|
||||||
// backlog signal — the only bottleneck the host-side network signals can't see (a fast LAN
|
|
||||||
// feeding a slower mobile decoder). Report it whenever the controller is armed, regardless of
|
|
||||||
// the HUD; `report_decode_us` is a cheap accumulate the pump windows.
|
|
||||||
if measure_decode {
|
|
||||||
if let Some(us) = decode_us {
|
|
||||||
client.report_decode_us(us.min(u32::MAX as u64) as u32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// HUD histogram: only while the overlay is visible (a measure-only caller enters here for the
|
|
||||||
// ABR report alone). `end-to-end` = capture→decoded (skew-corrected) tiles the `decode` stage.
|
|
||||||
// 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.
|
|
||||||
if stats.enabled() {
|
|
||||||
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);
|
|
||||||
stats.note_decoded(e2e_us, decode_us);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery
|
|
||||||
/// signalling (FLAG_SOF IDR marker / RECOVERY_ANCHOR / RECOVERY_POINT) rides the AU's flags, which are
|
|
||||||
/// only in scope at feed time — so the feed side parks `(pts_us, flags)` here and the present side
|
|
||||||
/// looks them up to fold [`ReanchorGate::on_decoded`]. Decode order == input order (low-latency, no
|
|
||||||
/// B-frames), so this evicts entries older than `pts_us` as it goes; a miss (probe filler, or an entry
|
|
||||||
/// aged past the cap) reads `0` — no recovery flags, decoded normally.
|
|
||||||
pub(super) fn take_flags(map: &mut VecDeque<(u64, u32)>, pts_us: u64) -> u32 {
|
|
||||||
while let Some(&(p, f)) = map.front() {
|
|
||||||
if p > pts_us {
|
|
||||||
break; // future frame — leave it for its own output buffer
|
|
||||||
}
|
|
||||||
map.pop_front();
|
|
||||||
if p == pts_us {
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
//! Android video decode (android-only): pull HEVC access units from the connector and render them
|
|
||||||
//! to the SurfaceView via NDK `AMediaCodec` — hardware decode, zero per-frame JNI.
|
|
||||||
//!
|
|
||||||
//! One-in/one-out: the host opens every stream with an IDR carrying VPS/SPS/PPS **in-band**, so the
|
|
||||||
//! decoder needs no out-of-band codec-specific data — we configure with mime + the negotiated
|
|
||||||
//! WxH (from [`NativeClient::mode`]) and feed each access unit as it arrives. The decode thread owns
|
|
||||||
//! the codec + window for its whole life; [`crate::session`] signals it to stop via the shared flag.
|
|
||||||
|
|
||||||
mod async_loop;
|
|
||||||
mod display;
|
|
||||||
mod latency;
|
|
||||||
mod setup;
|
|
||||||
mod sync_loop;
|
|
||||||
|
|
||||||
use async_loop::run_async;
|
|
||||||
pub(crate) use setup::{codec_label, codec_mime};
|
|
||||||
use sync_loop::run_sync;
|
|
||||||
|
|
||||||
use ndk::native_window::NativeWindow;
|
|
||||||
use punktfunk_core::client::NativeClient;
|
|
||||||
use std::sync::atomic::AtomicBool;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
/// Cap on AUs parked in the async loop awaiting a free codec input slot. Matches the connector's
|
|
||||||
/// own frame-channel depth; on sustained overflow the oldest is dropped and a keyframe requested
|
|
||||||
/// (same recovery as a reassembler drop). In steady state this stays near-empty.
|
|
||||||
const FRAME_PARK_CAP: usize = 16;
|
|
||||||
|
|
||||||
/// 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;
|
|
||||||
|
|
||||||
/// Cap on rendered frames parked in [`DisplayTracker`] awaiting their `OnFrameRendered` render
|
|
||||||
/// timestamp: the callback trails its release by at most a vsync or two, so anything this deep
|
|
||||||
/// means the platform stopped delivering render callbacks (allowed under load, per the docs) and
|
|
||||||
/// gets evicted.
|
|
||||||
const RENDERED_CAP: usize = 64;
|
|
||||||
|
|
||||||
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
|
||||||
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
|
||||||
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
|
|
||||||
/// the user's "Low-latency mode" toggle is ON (now the default) — off, the sync loop always runs (the
|
|
||||||
/// original pipeline, kept as the per-device escape hatch).
|
|
||||||
const USE_ASYNC_DECODE: bool = true;
|
|
||||||
|
|
||||||
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
|
|
||||||
/// the decode loop. Bundled so the loop entry points don't sprout a wide argument list.
|
|
||||||
pub(crate) struct DecodeOptions {
|
|
||||||
/// The decoder Kotlin ranked from `MediaCodecList` (`VideoDecoders.pickDecoder`). `None`/empty ⇒
|
|
||||||
/// let the platform resolve the default decoder for the MIME.
|
|
||||||
pub decoder_name: Option<String>,
|
|
||||||
/// Whether Kotlin found the chosen decoder advertises `FEATURE_LowLatency` (queryable only via
|
|
||||||
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
|
|
||||||
pub ll_feature: bool,
|
|
||||||
/// The user's "Low-latency mode" master toggle. On (default) ⇒ the full fast pipeline: async
|
|
||||||
/// decode loop, per-SoC vendor keys, pipeline thread boosts, ADPF max-performance, forced TV
|
|
||||||
/// mode switch. Off ⇒ the original synchronous pre-overhaul pipeline, kept as the per-device
|
|
||||||
/// escape hatch.
|
|
||||||
pub low_latency_mode: bool,
|
|
||||||
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
|
||||||
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
|
||||||
pub is_tv: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
|
||||||
/// Both run until `shutdown` is set or the session closes.
|
|
||||||
pub fn run(
|
|
||||||
client: Arc<NativeClient>,
|
|
||||||
window: NativeWindow,
|
|
||||||
shutdown: Arc<AtomicBool>,
|
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
|
||||||
opts: DecodeOptions,
|
|
||||||
) {
|
|
||||||
if opts.low_latency_mode && USE_ASYNC_DECODE {
|
|
||||||
run_async(client, window, shutdown, stats, opts);
|
|
||||||
} else {
|
|
||||||
run_sync(client, window, shutdown, stats, opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
//! Codec creation, low-latency config, thread/frame-rate tuning, HDR static-info encode.
|
|
||||||
|
|
||||||
use ndk::media::media_codec::MediaCodec;
|
|
||||||
use ndk::media::media_format::MediaFormat;
|
|
||||||
use ndk::native_window::NativeWindow;
|
|
||||||
use std::ffi::c_void;
|
|
||||||
|
|
||||||
/// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). Shared by the decode
|
|
||||||
/// thread and `nativeVideoMime` (which tells Kotlin what to rank decoders for). AV1 uses the
|
|
||||||
/// AOSP `video/av01` type; anything not H.264/AV1 is treated as HEVC (every pre-negotiation host
|
|
||||||
/// emitted HEVC).
|
|
||||||
pub(crate) fn codec_mime(codec: u8) -> &'static str {
|
|
||||||
match codec {
|
|
||||||
punktfunk_core::quic::CODEC_H264 => "video/avc",
|
|
||||||
punktfunk_core::quic::CODEC_AV1 => "video/av01",
|
|
||||||
_ => "video/hevc",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A short human label for the codec the host resolved, for the stats HUD's video-feed line
|
|
||||||
/// (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`). Mirrors [`codec_mime`]'s fallback: anything
|
|
||||||
/// not H.264/AV1/PyroWave is reported as HEVC (every pre-negotiation host emitted HEVC). Kept
|
|
||||||
/// beside [`codec_mime`] because the MIME collapses PyroWave onto `video/hevc` and so can't name it.
|
|
||||||
pub(crate) fn codec_label(codec: u8) -> &'static str {
|
|
||||||
match codec {
|
|
||||||
punktfunk_core::quic::CODEC_H264 => "H.264",
|
|
||||||
punktfunk_core::quic::CODEC_AV1 => "AV1",
|
|
||||||
punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave",
|
|
||||||
_ => "HEVC",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create the decoder: prefer the specific codec Kotlin ranked from `MediaCodecList`
|
|
||||||
/// (`from_codec_name`), falling back to the platform's default decoder for the MIME
|
|
||||||
/// (`from_decoder_type`) if that name can't be created (codec busy / renamed across an OS update).
|
|
||||||
pub(super) fn create_codec(mime: &str, preferred: Option<&str>) -> Option<MediaCodec> {
|
|
||||||
if let Some(name) = preferred.filter(|n| !n.is_empty()) {
|
|
||||||
if let Some(c) = MediaCodec::from_codec_name(name) {
|
|
||||||
return Some(c);
|
|
||||||
}
|
|
||||||
log::warn!(
|
|
||||||
"decode: from_codec_name({name}) failed — falling back to default {mime} decoder"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
MediaCodec::from_decoder_type(mime)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply the low-latency MediaFormat keys for `codec_name`.
|
|
||||||
///
|
|
||||||
/// `aggressive` = the "Low-latency mode" master toggle. **Off** ⇒ the pre-overhaul key set,
|
|
||||||
/// byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor twin, `priority = 0` AND
|
|
||||||
/// `operating-rate = MAX` set together — kept as the per-device escape hatch (the profile every device
|
|
||||||
/// streamed with before the overhaul). **On** (default) ⇒ the Moonlight-parity
|
|
||||||
/// profile: MediaTek's `vdec-lowlatency` (unconditionally — ignored off MediaTek), the per-SoC
|
|
||||||
/// vendor extension keys (gated on the decoder-name prefix the way Moonlight-Android does, since a
|
|
||||||
/// key one vendor honours is meaningless on another), and one *mutually exclusive* clock hint.
|
|
||||||
///
|
|
||||||
/// Vendor keys mirror Moonlight's `MediaCodecHelper` (verified against current source): Qualcomm
|
|
||||||
/// picture-order + low-latency, Exynos (also Google Tensor), Amlogic, HiSilicon, MediaTek. NVIDIA
|
|
||||||
/// Tegra / Rockchip / Realtek expose no such key (nor does Moonlight) — they're covered by the
|
|
||||||
/// standard key + clock hint + being ranked first in `VideoDecoders`.
|
|
||||||
pub(super) fn configure_low_latency(format: &mut MediaFormat, codec_name: &str, aggressive: bool) {
|
|
||||||
// Standard key: request the no-reorder low-latency path where the platform decoder supports it.
|
|
||||||
format.set_i32("low-latency", 1);
|
|
||||||
if !aggressive {
|
|
||||||
// The original profile: the Qualcomm vendor twin set blind (unknown keys are ignored by
|
|
||||||
// other vendors' codecs), realtime priority, and the AOSP "unbounded" operating-rate
|
|
||||||
// sentinel — decode each frame at max clocks rather than pacing to the frame rate.
|
|
||||||
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
|
|
||||||
format.set_i32("priority", 0); // 0 = realtime
|
|
||||||
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// MediaTek's low-latency key — very common (mid/budget phones + many Google TV / Fire TV boxes).
|
|
||||||
// Set unconditionally like the standard key: MediaTek decoders honour it, others ignore it, so it
|
|
||||||
// covers MediaTek whatever the exact decoder name (omx.mtk / c2.mtk / an OEM rename). Moonlight
|
|
||||||
// does the same, and also relies on it for Amazon's Amlogic fork.
|
|
||||||
format.set_i32("vdec-lowlatency", 1);
|
|
||||||
let name = codec_name.to_ascii_lowercase();
|
|
||||||
let is = |prefix: &str| name.starts_with(prefix);
|
|
||||||
// Qualcomm Snapdragon (the most common phone SoC): picture-order forces decode-order output
|
|
||||||
// (kills the reorder buffer on decoders that predate the standard key); low-latency is the older
|
|
||||||
// vendor twin.
|
|
||||||
if is("omx.qcom") || is("c2.qti") {
|
|
||||||
format.set_i32("vendor.qti-ext-dec-picture-order.enable", 1);
|
|
||||||
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
|
|
||||||
}
|
|
||||||
// Samsung Exynos — also covers Google Tensor (Pixel 6+), whose hardware decoder is `c2.exynos.*`.
|
|
||||||
if is("omx.exynos") || is("c2.exynos") {
|
|
||||||
format.set_i32("vendor.rtc-ext-dec-low-latency.enable", 1);
|
|
||||||
}
|
|
||||||
// Amlogic — the Android TV boxes (onn 4K, Chromecast w/ Google TV, Homatics).
|
|
||||||
if is("omx.amlogic") || is("c2.amlogic") {
|
|
||||||
format.set_i32("vendor.low-latency.enable", 1);
|
|
||||||
}
|
|
||||||
// HiSilicon / Kirin (older Huawei; paired req/rdy keys).
|
|
||||||
if is("omx.hisi") || is("c2.hisi") {
|
|
||||||
format.set_i32(
|
|
||||||
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req",
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
format.set_i32(
|
|
||||||
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy",
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// NVIDIA Tegra (Shield TV) and Rockchip/Realtek (budget TV boxes / smart TVs) expose no
|
|
||||||
// low-latency vendor key (Moonlight has none either) — their decoders are already low-latency
|
|
||||||
// oriented, so the standard `low-latency` key + the clock hint below + being ranked first
|
|
||||||
// (see `VideoDecoders`) is their treatment.
|
|
||||||
//
|
|
||||||
// Clock hint, mutually exclusive (matching Moonlight): the AOSP "unbounded" operating-rate
|
|
||||||
// sentinel (Short.MAX) tells the decoder to run each frame at max clocks and finish ASAP rather
|
|
||||||
// than pace to the frame rate — shaving per-frame decode latency at a power/heat cost. Only
|
|
||||||
// Qualcomm is known to handle the sentinel; every other vendor mis-paces on it, so they get the
|
|
||||||
// plain realtime `priority` hint instead.
|
|
||||||
if decoder_supports_max_operating_rate(&name) {
|
|
||||||
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
|
||||||
} else {
|
|
||||||
format.set_i32("priority", 0); // 0 = realtime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a decoder tolerates `operating-rate = Short.MAX` rather than regressing on it. Follows
|
|
||||||
/// Moonlight's allowlist: Qualcomm decoders honour the sentinel (the Adreno 620 generation is the
|
|
||||||
/// known exception Moonlight excludes by GPU model — undetectable from native code here, so it
|
|
||||||
/// rides the master toggle as its escape hatch). Other vendors fall back to the plain `priority`
|
|
||||||
/// hint above.
|
|
||||||
fn decoder_supports_max_operating_rate(name_lower: &str) -> bool {
|
|
||||||
name_lower.starts_with("omx.qcom") || name_lower.starts_with("c2.qti")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Raise the pipeline's OTHER hot threads — the core's data-plane pump (UDP receive + FEC
|
|
||||||
/// reassembly) and the audio decode thread — toward the display band, matching this decode thread's
|
|
||||||
/// own boost. `setpriority(PRIO_PROCESS, tid)` targets any task in the process, so we do it from
|
|
||||||
/// here once their tids are known (the same set ADPF hints), without a per-platform priority hook
|
|
||||||
/// in the shared core. Slightly below the decode thread's -10 so the display path still wins.
|
|
||||||
/// Best-effort; skips this thread (already boosted) and is non-fatal if the platform refuses.
|
|
||||||
pub(super) fn boost_hot_threads(tids: &[i32]) {
|
|
||||||
// SAFETY: `gettid` is an always-safe syscall on the calling thread.
|
|
||||||
let self_tid = unsafe { libc::gettid() };
|
|
||||||
for &tid in tids {
|
|
||||||
if tid == self_tid {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// SAFETY: `setpriority` with PRIO_PROCESS + a live tid in our own process is an always-safe
|
|
||||||
// syscall; a refusal is reported via the return value, not UB.
|
|
||||||
unsafe {
|
|
||||||
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8) != 0 {
|
|
||||||
log::debug!("decode: setpriority(-8) on hot tid {tid} failed (non-fatal)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort: raise the decode thread toward Android's URGENT_DISPLAY band so background work
|
|
||||||
/// can't preempt it under load (which shows up as late/dropped frames). Non-fatal if the platform
|
|
||||||
/// refuses (foreground apps may set their own threads; the exact floor is policy-dependent).
|
|
||||||
pub(super) fn boost_thread_priority() {
|
|
||||||
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls. PRIO_PROCESS
|
|
||||||
// with a TID targets that one task on Linux — the same idiom `Process.setThreadPriority` uses.
|
|
||||||
unsafe {
|
|
||||||
let tid = libc::gettid();
|
|
||||||
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -10) != 0 {
|
|
||||||
log::warn!(
|
|
||||||
"decode: setpriority(-10) failed (non-fatal): {}",
|
|
||||||
std::io::Error::last_os_error()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the surface's frame-rate hint to the stream's refresh so SurfaceFlinger picks a matching
|
|
||||||
/// display mode and aligns vsync (no 60-in-120 judder). Both NDK entry points sit above our API-28
|
|
||||||
/// floor, so both are dlsym-resolved at runtime (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 [`crate::adpf`]):
|
|
||||||
/// - On a **TV** (`is_tv`): `ANativeWindow_setFrameRateWithChangeStrategy` (**API 31**) with
|
|
||||||
/// `changeFrameRateStrategy = ALWAYS`, which actively drives the HDMI output into the matching
|
|
||||||
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
|
|
||||||
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
|
|
||||||
/// phone. Falls through to the 2-arg hint on API 30.
|
|
||||||
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) with `compatibility = DEFAULT` — the
|
|
||||||
/// softer, seamless-preferred hint for phones/tablets and the universal fallback.
|
|
||||||
///
|
|
||||||
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
|
|
||||||
/// decline.
|
|
||||||
pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv: bool) -> bool {
|
|
||||||
// int32_t ANativeWindow_setFrameRate(ANativeWindow*, float frameRate, int8_t compatibility)
|
|
||||||
type SetFrameRateFn = unsafe extern "C" fn(*mut c_void, f32, i8) -> i32;
|
|
||||||
// int32_t ANativeWindow_setFrameRateWithChangeStrategy(
|
|
||||||
// ANativeWindow*, float frameRate, int8_t compatibility, int8_t changeFrameRateStrategy)
|
|
||||||
type SetFrameRateStrategyFn = unsafe extern "C" fn(*mut c_void, f32, i8, i8) -> i32;
|
|
||||||
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never closed —
|
|
||||||
// process-lifetime handle). Each `dlsym` returns null when the symbol is absent (device below the
|
|
||||||
// symbol's API level), 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;
|
|
||||||
}
|
|
||||||
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 = ALWAYS,
|
|
||||||
// compatibility 0 = DEFAULT). Absent on API 30 ⇒ fall through to the 2-arg hint below.
|
|
||||||
if is_tv {
|
|
||||||
let sym = libc::dlsym(
|
|
||||||
lib,
|
|
||||||
c"ANativeWindow_setFrameRateWithChangeStrategy".as_ptr(),
|
|
||||||
);
|
|
||||||
if !sym.is_null() {
|
|
||||||
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
|
|
||||||
return set(window.ptr().as_ptr().cast(), frame_rate, 0, 1) == 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize [`HdrMeta`](punktfunk_core::quic::HdrMeta) into Android's `KEY_HDR_STATIC_INFO`
|
|
||||||
/// (`hdr-static-info`) layout: a 25-byte CTA-861.3 / `HDRStaticInfo.Type1` blob — descriptor id 0,
|
|
||||||
/// then primaries in **R, G, B** order, white point, max/min display luminance, MaxCLL, MaxFALL, all
|
|
||||||
/// **little-endian** `u16`. Two conversions vs our wire form: HdrMeta stores primaries in ST.2086
|
|
||||||
/// **G, B, R** order (reorder to R, G, B), and `max_display_mastering_luminance` is in 0.0001-cd/m²
|
|
||||||
/// units while Android wants **whole nits** (min stays 0.0001-nit). Chromaticities (1/50000) and
|
|
||||||
/// MaxCLL/MaxFALL (nits) match 1:1.
|
|
||||||
pub(super) fn android_hdr_static_info(m: &punktfunk_core::quic::HdrMeta) -> [u8; 25] {
|
|
||||||
let [g, b_, r] = m.display_primaries; // ST.2086 G, B, R
|
|
||||||
let max_nits = (m.max_display_mastering_luminance / 10_000).min(u16::MAX as u32) as u16;
|
|
||||||
let min_units = m.min_display_mastering_luminance.min(u16::MAX as u32) as u16;
|
|
||||||
let fields: [u16; 12] = [
|
|
||||||
r[0],
|
|
||||||
r[1],
|
|
||||||
g[0],
|
|
||||||
g[1],
|
|
||||||
b_[0],
|
|
||||||
b_[1], // R, G, B primaries
|
|
||||||
m.white_point[0],
|
|
||||||
m.white_point[1], // white point
|
|
||||||
max_nits,
|
|
||||||
min_units, // max (nits) / min (0.0001-nit) display luminance
|
|
||||||
m.max_cll,
|
|
||||||
m.max_fall, // MaxCLL / MaxFALL (nits)
|
|
||||||
];
|
|
||||||
let mut out = [0u8; 25]; // out[0] = 0 (Type 1 descriptor id), already zero
|
|
||||||
for (i, v) in fields.iter().enumerate() {
|
|
||||||
out[1 + i * 2..3 + i * 2].copy_from_slice(&v.to_le_bytes());
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
@@ -1,553 +0,0 @@
|
|||||||
//! The synchronous MediaCodec decode loop (the original poll path) + its feed/drain helpers.
|
|
||||||
|
|
||||||
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::NativeWindow;
|
|
||||||
use punktfunk_core::client::NativeClient;
|
|
||||||
use punktfunk_core::error::PunktfunkError;
|
|
||||||
use punktfunk_core::reanchor::{GateVerdict, ReanchorGate};
|
|
||||||
use punktfunk_core::session::Frame;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use super::display::{
|
|
||||||
hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
|
|
||||||
};
|
|
||||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
|
|
||||||
use super::setup::{
|
|
||||||
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
|
||||||
configure_low_latency, create_codec, try_set_frame_rate,
|
|
||||||
};
|
|
||||||
use super::{DecodeOptions, IN_FLIGHT_CAP, PENDING_SPLIT_CAP};
|
|
||||||
|
|
||||||
/// The synchronous poll loop — the original decode path: the only one when low-latency mode is off,
|
|
||||||
/// and the [`USE_ASYNC_DECODE`] A/B fallback when it's on. Feeds and drains on this one thread; the
|
|
||||||
/// only blocking wait is a short output dequeue while input is backed up.
|
|
||||||
pub(super) fn run_sync(
|
|
||||||
client: Arc<NativeClient>,
|
|
||||||
window: NativeWindow,
|
|
||||||
shutdown: Arc<AtomicBool>,
|
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
|
||||||
opts: DecodeOptions,
|
|
||||||
) {
|
|
||||||
let DecodeOptions {
|
|
||||||
decoder_name,
|
|
||||||
ll_feature,
|
|
||||||
low_latency_mode,
|
|
||||||
is_tv,
|
|
||||||
} = opts;
|
|
||||||
boost_thread_priority();
|
|
||||||
let mode = client.mode();
|
|
||||||
// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). AMediaCodec needs no
|
|
||||||
// out-of-band extradata — the in-band VPS/SPS/PPS on every IDR configure it either way.
|
|
||||||
let mime = codec_mime(client.codec);
|
|
||||||
let codec = match create_codec(mime, decoder_name.as_deref()) {
|
|
||||||
Some(c) => c,
|
|
||||||
None => {
|
|
||||||
log::error!("decode: no {mime} decoder on this device");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// The decoder's *actual* resolved name (Kotlin's pick, or the platform default when it fell
|
|
||||||
// back) drives both the HUD label and which vendor low-latency keys apply below.
|
|
||||||
let codec_name = codec.name().unwrap_or_default();
|
|
||||||
stats.set_decoder(&codec_name, ll_feature);
|
|
||||||
log::info!(
|
|
||||||
"decode: codec mime = {mime}, decoder = {codec_name} (low-latency feature: {ll_feature})"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut format = MediaFormat::new();
|
|
||||||
format.set_str("mime", mime);
|
|
||||||
format.set_i32("width", mode.width as i32);
|
|
||||||
format.set_i32("height", mode.height as i32);
|
|
||||||
// Generous input buffer so a large keyframe AU is never truncated.
|
|
||||||
format.set_i32(
|
|
||||||
"max-input-size",
|
|
||||||
(mode.width * mode.height).max(2_000_000) as i32,
|
|
||||||
);
|
|
||||||
// Standard + per-SoC vendor low-latency keys and the clock hints, gated on the resolved decoder
|
|
||||||
// name and the master toggle (see `configure_low_latency`).
|
|
||||||
configure_low_latency(&mut format, &codec_name, low_latency_mode);
|
|
||||||
|
|
||||||
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
|
|
||||||
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
|
|
||||||
// MediaCodec wants it BEFORE configure(), and the host sends a 0xCE right after the handshake,
|
|
||||||
// so it's typically already queued; wait briefly otherwise. The Surface DataSpace (applied on
|
|
||||||
// OutputFormatChanged below) carries transfer/primaries regardless — this adds the luminance the
|
|
||||||
// tone-mapper needs. A non-HDR display still gets sensible SurfaceFlinger tone-mapping.
|
|
||||||
if client.color.is_hdr() {
|
|
||||||
match client.next_hdr_meta(Duration::from_millis(250)) {
|
|
||||||
Ok(meta) => {
|
|
||||||
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
|
|
||||||
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
|
|
||||||
log::error!("decode: configure failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Err(e) = codec.start() {
|
|
||||||
log::error!("decode: start failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log::info!(
|
|
||||||
"decode: {mime} decoder started at {}x{}",
|
|
||||||
mode.width,
|
|
||||||
mode.height
|
|
||||||
);
|
|
||||||
// Tell the display the stream's refresh so Android can pick a matching display mode and align
|
|
||||||
// vsync (no 60-in-120 judder on high-refresh panels). `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).
|
|
||||||
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
|
|
||||||
// off, every form factor gets the original soft seamless hint.
|
|
||||||
if mode.refresh_hz > 0
|
|
||||||
&& !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode)
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
|
||||||
let mut oversized_dropped: u64 = 0;
|
|
||||||
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
|
|
||||||
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
|
|
||||||
// round-trip) and we only pop the next one once it's queued.
|
|
||||||
let mut pending: Option<Frame> = None;
|
|
||||||
// Freeze-until-reanchor: the shared post-loss gate ([`punktfunk_core::reanchor::ReanchorGate`]).
|
|
||||||
// Armed on a frame-index gap or a dropped-count climb, it withholds the decoder's concealed output
|
|
||||||
// (released WITHOUT rendering — the SurfaceView keeps the last rendered frame on glass) until a
|
|
||||||
// proven clean re-anchor lifts it: an IDR (wire FLAG_SOF), an RFI anchor, or the 2nd recovery mark.
|
|
||||||
// `last_kf_req` throttles the keyframe intents it emits; `recovery_flags` carries each AU's
|
|
||||||
// user_flags from feed to present (keyed by the codec-echoed pts) so `on_decoded` reads the
|
|
||||||
// re-anchor signalling the platform decoder doesn't expose.
|
|
||||||
let mut gate = ReanchorGate::new(client.frames_dropped());
|
|
||||||
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
|
||||||
// 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_shared();
|
|
||||||
// Display stage (spec `display` + the capture→displayed headline): frames released with
|
|
||||||
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
|
||||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
|
||||||
// reclaimed after the codec is dropped below.
|
|
||||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
|
||||||
let render_cb = install_render_callback(&codec, &tracker);
|
|
||||||
// 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. Fed while the HUD is visible OR the adaptive-bitrate controller wants
|
|
||||||
// the decode signal (`measure_decode`) — the decoder-backlog bottleneck the network can't see.
|
|
||||||
let measure_decode = client.wants_decode_latency();
|
|
||||||
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;
|
|
||||||
// One thread feeds AND drains: the NDK AMediaCodec wrapper isn't documented thread-safe for
|
|
||||||
// cross-thread feed/drain, so instead of splitting threads the loop decouples the two — input
|
|
||||||
// dequeue is non-blocking (never stalls presentation of already-decoded frames) and the only
|
|
||||||
// blocking wait is a short output dequeue while input is backed up (decoder progress is exactly
|
|
||||||
// what frees the next input buffer).
|
|
||||||
while !shutdown.load(Ordering::Relaxed) {
|
|
||||||
if pending.is_none() {
|
|
||||||
match client.next_frame(Duration::from_millis(5)) {
|
|
||||||
Ok(frame) => {
|
|
||||||
// Loss recovery (RFI): feed the frame index so a forward gap fires a throttled
|
|
||||||
// reference-frame-invalidation request — an RFI-capable host (AMD LTR / NVENC)
|
|
||||||
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
|
|
||||||
// arms the freeze gate so the decoder's concealment is held off the screen until the
|
|
||||||
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
|
|
||||||
if client.note_frame_index(frame.frame_index) {
|
|
||||||
gate.arm(Instant::now());
|
|
||||||
}
|
|
||||||
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
|
|
||||||
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
|
|
||||||
recovery_flags.push_back((frame.pts_ns / 1000, frame.flags));
|
|
||||||
if recovery_flags.len() > IN_FLIGHT_CAP {
|
|
||||||
recovery_flags.pop_front();
|
|
||||||
}
|
|
||||||
if fed == 0 {
|
|
||||||
let p = &frame.data;
|
|
||||||
log::info!(
|
|
||||||
"decode: first AU {} bytes, head {:02x?}",
|
|
||||||
p.len(),
|
|
||||||
&p[..p.len().min(6)]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Receipt stamp for the `decode` stage pairing, parked in `in_flight` (keyed by
|
|
||||||
// the pts the codec echoes on its output buffer) whenever it's needed: the HUD
|
|
||||||
// being visible, or the ABR decode signal (`measure_decode`). The HUD-only
|
|
||||||
// samplers (`received` point, host/network split) stay gated on the overlay so
|
|
||||||
// the hidden steady state adds only a wall-clock read + the receipt push.
|
|
||||||
if stats.enabled() || measure_decode {
|
|
||||||
// Core reassembly-completion stamp (ABI v9), not the pull instant — see
|
|
||||||
// async_loop: a pull stamp folds hand-off queue wait into "network".
|
|
||||||
let received_ns = if frame.received_ns > 0 {
|
|
||||||
frame.received_ns as i128
|
|
||||||
} else {
|
|
||||||
now_realtime_ns()
|
|
||||||
};
|
|
||||||
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
|
|
||||||
}
|
|
||||||
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
|
||||||
// capture_pts.
|
|
||||||
if stats.enabled() {
|
|
||||||
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
|
||||||
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
|
||||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
|
||||||
.then_some((lat_ns / 1000) as u64);
|
|
||||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
Err(PunktfunkError::NoFrame) => {} // timeout — still drain output below
|
|
||||||
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,
|
|
||||||
&client,
|
|
||||||
&frame.data,
|
|
||||||
frame.pts_ns / 1000,
|
|
||||||
&mut oversized_dropped,
|
|
||||||
) {
|
|
||||||
fed += 1;
|
|
||||||
if fed % 300 == 0 {
|
|
||||||
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No input buffer free — transient back-pressure. Keep the AU and let `drain` block
|
|
||||||
// briefly below; a released output buffer is what recycles an input slot.
|
|
||||||
pending = Some(frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Drain every iteration. When input is blocked, wait ~2 ms on output so the loop rides
|
|
||||||
// decoder progress instead of busy-spinning against a full input queue.
|
|
||||||
let wait = if pending.is_some() {
|
|
||||||
Duration::from_millis(2)
|
|
||||||
} else {
|
|
||||||
Duration::ZERO
|
|
||||||
};
|
|
||||||
let (r, d) = drain(
|
|
||||||
&codec,
|
|
||||||
&client,
|
|
||||||
measure_decode,
|
|
||||||
&window,
|
|
||||||
&mut applied_ds,
|
|
||||||
wait,
|
|
||||||
&stats,
|
|
||||||
&mut in_flight,
|
|
||||||
clock_offset.load(Ordering::Relaxed),
|
|
||||||
&tracker,
|
|
||||||
&mut gate,
|
|
||||||
&mut recovery_flags,
|
|
||||||
);
|
|
||||||
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();
|
|
||||||
// The pump/audio priority boost is part of the experimental low-latency stack; the
|
|
||||||
// ADPF session itself predates it and always runs (max-performance bias gated inside).
|
|
||||||
if low_latency_mode {
|
|
||||||
boost_hot_threads(&tids);
|
|
||||||
}
|
|
||||||
hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode);
|
|
||||||
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 + overdue backstop, folded through the gate. Under infinite GOP the only
|
|
||||||
// recovery keyframe is one we request; the reassembler drops unrecoverable AUs (frames_dropped)
|
|
||||||
// and the decoder then conceals the reference-missing deltas and renders them without error, so
|
|
||||||
// a decode-error trigger rarely fires — the gate arms the freeze on the drop-count climb
|
|
||||||
// instead. An overdue freeze (held REANCHOR_FREEZE_MAX with no clean re-anchor) re-asks while it
|
|
||||||
// keeps holding: never resume to gray — a dead stream is the QUIC idle-timeout watchdog's job.
|
|
||||||
let now = Instant::now();
|
|
||||||
if gate.poll(client.frames_dropped(), now)
|
|
||||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
|
||||||
{
|
|
||||||
last_kf_req = Some(now);
|
|
||||||
let _ = client.request_keyframe();
|
|
||||||
log::debug!("decode: requested keyframe (loss recovery / overdue re-anchor)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = codec.stop();
|
|
||||||
drop(codec); // AMediaCodec_delete — after this no render callback can fire
|
|
||||||
if let Some(ud) = render_cb {
|
|
||||||
// SAFETY: the codec was dropped above; this registration's single reclaim.
|
|
||||||
unsafe { release_render_callback(ud) };
|
|
||||||
}
|
|
||||||
log::info!("decode: stopped (fed={fed} rendered={rendered} discarded={discarded})");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
|
|
||||||
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
|
|
||||||
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
|
|
||||||
/// parking it forever would wedge the loop on a broken codec). An AU larger than the input
|
|
||||||
/// buffer is DROPPED (+ a recovery keyframe requested), never truncated — a truncated AU is
|
|
||||||
/// corrupt input the decoder chews on silently, poisoning the reference chain.
|
|
||||||
fn feed(
|
|
||||||
codec: &MediaCodec,
|
|
||||||
client: &NativeClient,
|
|
||||||
au: &[u8],
|
|
||||||
pts_us: u64,
|
|
||||||
oversized_dropped: &mut u64,
|
|
||||||
) -> bool {
|
|
||||||
match codec.dequeue_input_buffer(Duration::ZERO) {
|
|
||||||
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
|
|
||||||
let n = {
|
|
||||||
let dst = buf.buffer_mut();
|
|
||||||
if au.len() > dst.len() {
|
|
||||||
*oversized_dropped += 1;
|
|
||||||
log::warn!(
|
|
||||||
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
|
|
||||||
au.len(),
|
|
||||||
dst.len(),
|
|
||||||
*oversized_dropped
|
|
||||||
);
|
|
||||||
let _ = client.request_keyframe();
|
|
||||||
0 // return the slot with zero valid bytes — a no-op input, not corrupt data
|
|
||||||
} else {
|
|
||||||
let n = au.len();
|
|
||||||
// SAFETY: `au` and `dst` are distinct allocations (wire AU vs. codec buffer),
|
|
||||||
// both valid for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so
|
|
||||||
// the cast write initializes exactly `dst[..n]`.
|
|
||||||
unsafe {
|
|
||||||
std::ptr::copy_nonoverlapping(
|
|
||||||
au.as_ptr(),
|
|
||||||
dst.as_mut_ptr().cast::<u8>(),
|
|
||||||
n,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
n
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
|
|
||||||
log::warn!("decode: queue_input_buffer: {e}");
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(DequeuedInputBufferResult::TryAgainLater) => false, // caller keeps the AU pending
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("decode: dequeue_input_buffer: {e}");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dequeue every ready output buffer and present only the NEWEST (render = true), discarding the
|
|
||||||
/// rest (render = false) — when decode falls behind, a back-to-back burst of stale frames on glass
|
|
||||||
/// is worse than skipping straight to the freshest one (the Apple client's 1-slot newest-ready
|
|
||||||
/// ring, ported). `first_wait` is the timeout for the first dequeue only: zero normally, ~2 ms when
|
|
||||||
/// the caller's input is blocked so the loop waits on decoder progress instead of busy-spinning.
|
|
||||||
/// Returns `(rendered, discarded)`. Also reacts to `OutputFormatChanged` (which can interleave
|
|
||||||
/// between buffers — handled without losing the held buffer) to signal HDR on the Surface.
|
|
||||||
///
|
|
||||||
/// Each dequeued buffer is also the HUD's `decoded` measurement point (rendered or not — the frame
|
|
||||||
/// finished decoding either way): end-to-end = decoded + clock_offset − capture pts, and the
|
|
||||||
/// `decode` stage pairs the buffer's echoed presentationTimeUs back to the receipt stamp in
|
|
||||||
/// `in_flight` (single-clock local difference, no skew involved). The presented frame's
|
|
||||||
/// `(pts, decoded stamp)` is additionally parked in `tracker` for the OnFrameRendered callback —
|
|
||||||
/// the `display` stage's other endpoint.
|
|
||||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the async loop's present_ready
|
|
||||||
fn drain(
|
|
||||||
codec: &MediaCodec,
|
|
||||||
client: &NativeClient,
|
|
||||||
measure_decode: bool,
|
|
||||||
window: &NativeWindow,
|
|
||||||
applied_ds: &mut Option<DataSpace>,
|
|
||||||
first_wait: Duration,
|
|
||||||
stats: &crate::stats::VideoStats,
|
|
||||||
in_flight: &mut VecDeque<(u64, i128)>,
|
|
||||||
clock_offset: i64,
|
|
||||||
tracker: &DisplayTracker,
|
|
||||||
gate: &mut ReanchorGate,
|
|
||||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
|
||||||
) -> (u64, u64) {
|
|
||||||
// Newest ready buffer so far (presented after the loop) with its HUD metadata —
|
|
||||||
// `Some((pts_us, decoded_ns))` only while the HUD is visible. `held_present` is the freeze gate's
|
|
||||||
// verdict for that newest buffer (`false` = a post-loss concealment to withhold).
|
|
||||||
let mut held: Option<(OutputBuffer<'_>, Option<(u64, i128)>)> = None;
|
|
||||||
let mut held_present = true;
|
|
||||||
let mut discarded: u64 = 0;
|
|
||||||
let mut wait = first_wait;
|
|
||||||
loop {
|
|
||||||
match codec.dequeue_output_buffer(wait) {
|
|
||||||
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
|
||||||
// Only the first dequeue may block; later ones poll (wait == ZERO).
|
|
||||||
wait = Duration::ZERO;
|
|
||||||
// Fold every dequeued frame through the gate in pts (== decode) order — even the ones
|
|
||||||
// the newest-wins policy discards — so the two-mark re-anchor count stays correct; the
|
|
||||||
// verdict of the newest (last folded) buffer decides whether it reaches glass.
|
|
||||||
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
|
||||||
let flags = take_flags(recovery_flags, pts_us);
|
|
||||||
held_present =
|
|
||||||
gate.on_decoded(flags, false, Instant::now()) == GateVerdict::Present;
|
|
||||||
let meta = if stats.enabled() || measure_decode {
|
|
||||||
// The dequeue IS the sync loop's decoded-availability instant.
|
|
||||||
let decoded_ns = now_realtime_ns();
|
|
||||||
note_decoded_pts(
|
|
||||||
client,
|
|
||||||
measure_decode,
|
|
||||||
stats,
|
|
||||||
in_flight,
|
|
||||||
clock_offset,
|
|
||||||
pts_us,
|
|
||||||
decoded_ns,
|
|
||||||
);
|
|
||||||
// The tracker's `display` stage is a HUD concern — park only when visible.
|
|
||||||
stats.enabled().then_some((pts_us, decoded_ns))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
if let Some((stale, _)) = held.replace((buf, meta)) {
|
|
||||||
// A newer frame is ready — drop the held one without rendering.
|
|
||||||
if let Err(e) = codec.release_output_buffer(stale, false) {
|
|
||||||
log::warn!("decode: release_output_buffer(discard): {e}");
|
|
||||||
}
|
|
||||||
discarded += 1;
|
|
||||||
stats.note_skipped(1); // HUD `skipped` counter; no-op while hidden
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
|
||||||
// The decoder has parsed the SPS and now reports the stream's real colour signalling
|
|
||||||
// (the AMediaCodec analogue of VideoToolbox's format description on the Apple client).
|
|
||||||
// If it's HDR (BT.2020 PQ/HLG), tell the Surface so the compositor/display switch to
|
|
||||||
// HDR; SDR streams leave the default dataspace alone. The decoder itself picks a
|
|
||||||
// Main10 path from the SPS — no profile override needed. Keep looping (buffers
|
|
||||||
// follow, and any held buffer stays held across this event).
|
|
||||||
wait = Duration::ZERO;
|
|
||||||
if let Some(ds) = hdr_dataspace(codec) {
|
|
||||||
if *applied_ds != Some(ds) {
|
|
||||||
match window.set_buffers_data_space(ds) {
|
|
||||||
Ok(()) => {
|
|
||||||
*applied_ds = Some(ds);
|
|
||||||
log::info!("decode: HDR stream → Surface dataspace {ds}");
|
|
||||||
}
|
|
||||||
Err(e) => log::warn!(
|
|
||||||
"decode: set_buffers_data_space({ds}) failed (non-fatal): {e}"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TryAgainLater / OutputBuffersChanged — nothing more to dequeue now.
|
|
||||||
Ok(_) => break,
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("decode: dequeue_output_buffer: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Present the newest ready frame — UNLESS the gate is withholding it as a post-loss concealment,
|
|
||||||
// in which case release it without rendering (the SurfaceView keeps the last rendered frame frozen
|
|
||||||
// on glass) and count it as a discard rather than a display.
|
|
||||||
let mut rendered = 0;
|
|
||||||
if let Some((buf, meta)) = held {
|
|
||||||
match codec.release_output_buffer(buf, held_present) {
|
|
||||||
Ok(()) if held_present => {
|
|
||||||
rendered = 1;
|
|
||||||
if let Some((pts_us, decoded_ns)) = meta {
|
|
||||||
tracker.note_rendered(pts_us, decoded_ns);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
|
|
||||||
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(rendered, discarded)
|
|
||||||
}
|
|
||||||
@@ -22,21 +22,15 @@ const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
|||||||
const TAG_LED: u8 = 0x01;
|
const TAG_LED: u8 = 0x01;
|
||||||
const TAG_PLAYER_LEDS: u8 = 0x02;
|
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||||
const TAG_TRIGGER: u8 = 0x03;
|
const TAG_TRIGGER: u8 = 0x03;
|
||||||
const TAG_HID_RAW: u8 = 0x05;
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next EFFECTIVE
|
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
||||||
/// rumble command from the core's shared policy engine (`design/rumble-root-fix.md` §D). The
|
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
|
||||||
/// engine owns ALL rumble policy — v2 lease expiry, legacy-host staleness (a uniform 1 s, ending
|
/// bits 32..47 = `ttl_ms`, bits 16..31 = `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` =
|
||||||
/// the old 60 s Android exposure), connection-close drain zeros — so Kotlin applies commands
|
/// stop). The lease flag is out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no
|
||||||
/// verbatim: `(0, 0)` = cancel now, non-zero = one-shot at this level.
|
/// in-band sentinel to collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and
|
||||||
///
|
/// Kotlin falls back to its long one-shot. `-1` on timeout / session closed (all packed values are
|
||||||
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bits 32..47 = the
|
/// positive, so `-1` stays unambiguous). Kotlin routes the update back to the controller holding that
|
||||||
/// command's `backstop_ms` (≤ 5000 — the one-shot duration, i.e. the hardware net under a stalled
|
/// wire `pad` index (multi-pad rumble). Run from a Kotlin poll thread.
|
||||||
/// poll thread; the engine emits explicit zeros at every policy stop, so it is never the stop
|
|
||||||
/// mechanism), bits 16..31 = `low`, bits 0..15 = `high` (0..=0xFFFF). `-1` on timeout / session
|
|
||||||
/// closed (all packed values are positive, so `-1` stays unambiguous). Kotlin routes the command
|
|
||||||
/// back to the controller holding that wire `pad` index (multi-pad rumble). Run from a Kotlin
|
|
||||||
/// poll thread.
|
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
@@ -48,17 +42,24 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
|||||||
if handle == 0 {
|
if handle == 0 {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_command is
|
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_ttl is &self on
|
||||||
// &self on the Sync connector — safe alongside the decode/audio/input threads. Kotlin
|
// the Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
||||||
// stops these poll threads (and joins them — unbounded) before nativeClose frees the
|
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
||||||
// handle.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
match h.client.next_rumble_ttl(PULL_TIMEOUT) {
|
||||||
Ok(cmd) => {
|
Ok((pad, low, high, ttl)) => {
|
||||||
(jlong::from(cmd.pad & 0xF) << 49)
|
// The reorder gate already ran in the core, so this update is fresh. Encode the
|
||||||
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
|
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. The pad
|
||||||
| (jlong::from(cmd.low) << 16)
|
// index rides above the lease flag (bits 49..52), keeping the whole word positive.
|
||||||
| jlong::from(cmd.high)
|
let (lease_flag, ttl_bits) = match ttl {
|
||||||
|
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
|
||||||
|
None => (0, 0),
|
||||||
|
};
|
||||||
|
(jlong::from(pad & 0xF) << 49)
|
||||||
|
| lease_flag
|
||||||
|
| ttl_bits
|
||||||
|
| (jlong::from(low) << 16)
|
||||||
|
| jlong::from(high)
|
||||||
}
|
}
|
||||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||||
}
|
}
|
||||||
@@ -142,20 +143,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|||||||
// rumble already rides the universal 0xCA plane).
|
// rumble already rides the universal 0xCA plane).
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
HidOutput::HidRaw { pad, kind, data } => {
|
|
||||||
// As-is SC2 passthrough: the host's hidraw consumer (Steam) wrote this report to
|
|
||||||
// the virtual pad; Kotlin replays it verbatim on the physical controller.
|
|
||||||
// `[pad][0x05][kind][report…]` — kind 0 = output report, 1 = feature report.
|
|
||||||
let n = 3 + data.len();
|
|
||||||
if cap < n {
|
|
||||||
return -1; // reports are ≤ 64 bytes; Kotlin allocates 128
|
|
||||||
}
|
|
||||||
out[0] = pad;
|
|
||||||
out[1] = TAG_HID_RAW;
|
|
||||||
out[2] = kind;
|
|
||||||
out[3..n].copy_from_slice(&data);
|
|
||||||
n
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
n as jint
|
n as jint
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
//! Shared-clipboard plane (text-only v1): Kotlin drives the Android `ClipboardManager`, these
|
|
||||||
//! shims drive [`punktfunk_core::client::NativeClient`]'s clipboard surface.
|
|
||||||
//!
|
|
||||||
//! Model (mirrors the desktop clients): opt-in via `nativeClipControl(true)`; local copies are
|
|
||||||
//! announced lazily as format-list offers (`nativeClipOfferText`) and the bytes cross only when
|
|
||||||
//! the host pastes (a `fetch` event answered by `nativeClipServeText`); a host copy arrives as an
|
|
||||||
//! `offer` event, which the Kotlin side fetches eagerly (Android's clipboard has no lazy provider
|
|
||||||
//! path worth the complexity) and lands in the system clipboard on the `data` event.
|
|
||||||
//!
|
|
||||||
//! Events cross to Kotlin as compact strings from the blocking `nativeNextClip` poll (drained on
|
|
||||||
//! a dedicated thread, same pattern as `nativeNextRumble`):
|
|
||||||
//! `state:<0|1>` · `offer:<seq>:<has_text 0|1>` · `fetch:<req_id>` · `data:<xfer_id>:<text>` ·
|
|
||||||
//! `cancel:<id>` · `error:<id>:<code>` · `closed` — null on a poll timeout. Non-text fetch
|
|
||||||
//! requests are cancelled natively (only text is ever offered, so they shouldn't occur).
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use jni::objects::{JObject, JString};
|
|
||||||
use jni::sys::{jboolean, jint, jlong, jstring};
|
|
||||||
use jni::JNIEnv;
|
|
||||||
use punktfunk_core::clipboard::ClipEventCore;
|
|
||||||
use punktfunk_core::error::PunktfunkError;
|
|
||||||
use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD};
|
|
||||||
|
|
||||||
use super::SessionHandle;
|
|
||||||
|
|
||||||
/// The portable wire MIME both ends map to their platform text type.
|
|
||||||
const TEXT_MIME: &str = "text/plain;charset=utf-8";
|
|
||||||
|
|
||||||
/// Deref the opaque handle (`0` → `None`).
|
|
||||||
///
|
|
||||||
/// SAFETY: live handle per the nativeConnect/nativeClose contract; every method used is `&self`
|
|
||||||
/// on the `Sync` connector.
|
|
||||||
fn client(handle: jlong) -> Option<&'static SessionHandle> {
|
|
||||||
if handle == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// SAFETY: see the function docs — the Kotlin side guarantees the handle outlives the call.
|
|
||||||
Some(unsafe { &*(handle as *const SessionHandle) })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) -> jboolean {
|
|
||||||
client(handle).map_or(0, |h| {
|
|
||||||
u8::from(h.client.host_caps() & HOST_CAP_CLIPBOARD != 0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
|
|
||||||
/// clipboard-related happens on either side until an `enabled: true` crosses.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
enabled: jboolean,
|
|
||||||
) {
|
|
||||||
if let Some(h) = client(handle) {
|
|
||||||
let _ = h.client.clip_control(enabled != 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeClipOfferText(handle, seq)` — announce "the Android clipboard now holds
|
|
||||||
/// text" (format list only; bytes cross when the host fetches). `seq` is Kotlin's monotonic
|
|
||||||
/// counter, newest wins.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
seq: jint,
|
|
||||||
) {
|
|
||||||
if let Some(h) = client(handle) {
|
|
||||||
let _ = h.client.clip_offer(
|
|
||||||
seq as u32,
|
|
||||||
vec![ClipKind {
|
|
||||||
mime: TEXT_MIME.into(),
|
|
||||||
size_hint: 0,
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeClipFetchText(handle, seq)` — pull the text of the host's offer `seq`.
|
|
||||||
/// Returns the transfer id echoed on the matching `data:`/`error:` event, or −1.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
seq: jint,
|
|
||||||
) -> jint {
|
|
||||||
client(handle)
|
|
||||||
.and_then(|h| {
|
|
||||||
h.client
|
|
||||||
.clip_fetch(seq as u32, TEXT_MIME.into(), CLIP_FILE_INDEX_NONE)
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
.map_or(-1, |xfer| xfer as jint)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeClipServeText(handle, reqId, text)` — answer a `fetch:` event with the
|
|
||||||
/// clipboard's current text (the host is pasting our offer).
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText(
|
|
||||||
mut env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
req_id: jint,
|
|
||||||
text: JString,
|
|
||||||
) {
|
|
||||||
let Some(h) = client(handle) else { return };
|
|
||||||
let Ok(s) = env.get_string(&text) else {
|
|
||||||
let _ = h.client.clip_cancel(req_id as u32);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let _ = h
|
|
||||||
.client
|
|
||||||
.clip_serve(req_id as u32, String::from(s).into_bytes(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction).
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
id: jint,
|
|
||||||
) {
|
|
||||||
if let Some(h) = client(handle) {
|
|
||||||
let _ = h.client.clip_cancel(id as u32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeNextClip(handle)` — block ≤250 ms for the next clipboard event, encoded
|
|
||||||
/// as a compact string (module docs); null on timeout, `"closed"` once the session is gone.
|
|
||||||
/// Call from a dedicated poll thread.
|
|
||||||
///
|
|
||||||
/// Text payloads ride `data:<xfer_id>:<text>` decoded lossily — safe because the phase-0
|
|
||||||
/// clipboard task delivers a whole payload in ONE event (`last = true`), so a chunk boundary
|
|
||||||
/// can never split a UTF-8 sequence.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip(
|
|
||||||
env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) -> jstring {
|
|
||||||
let Some(h) = client(handle) else {
|
|
||||||
return std::ptr::null_mut();
|
|
||||||
};
|
|
||||||
let msg = match h.client.next_clip(Duration::from_millis(250)) {
|
|
||||||
Ok(ClipEventCore::State { enabled, .. }) => format!("state:{}", u8::from(enabled)),
|
|
||||||
Ok(ClipEventCore::RemoteOffer { seq, kinds }) => {
|
|
||||||
let has_text = kinds.iter().any(|k| k.mime.starts_with("text/plain"));
|
|
||||||
format!("offer:{seq}:{}", u8::from(has_text))
|
|
||||||
}
|
|
||||||
Ok(ClipEventCore::FetchRequest { req_id, mime, .. }) => {
|
|
||||||
if mime.starts_with("text/plain") {
|
|
||||||
format!("fetch:{req_id}")
|
|
||||||
} else {
|
|
||||||
// We only ever offer text; cancel anything else rather than stall the host.
|
|
||||||
let _ = h.client.clip_cancel(req_id);
|
|
||||||
return std::ptr::null_mut();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(ClipEventCore::Data { xfer_id, bytes, .. }) => {
|
|
||||||
format!("data:{xfer_id}:{}", String::from_utf8_lossy(&bytes))
|
|
||||||
}
|
|
||||||
Ok(ClipEventCore::Cancelled { id }) => format!("cancel:{id}"),
|
|
||||||
Ok(ClipEventCore::Error { id, code }) => format!("error:{id}:{code}"),
|
|
||||||
Err(PunktfunkError::NoFrame) => return std::ptr::null_mut(),
|
|
||||||
Err(_) => "closed".into(),
|
|
||||||
};
|
|
||||||
env.new_string(msg)
|
|
||||||
.map(|s| s.into_raw())
|
|
||||||
.unwrap_or(std::ptr::null_mut())
|
|
||||||
}
|
|
||||||
@@ -11,43 +11,6 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use super::{hex32, jni_guard, parse_hex32, SessionHandle};
|
use super::{hex32, jni_guard, parse_hex32, SessionHandle};
|
||||||
|
|
||||||
/// Machine token of the most recent `nativeConnect`/`nativePair` failure, taken (and cleared)
|
|
||||||
/// by `nativeTakeLastError` so Kotlin can render a cause-specific message instead of the old
|
|
||||||
/// catch-all "wrong PIN, or the host isn't armed" (which blamed the PIN for dead network paths
|
|
||||||
/// — the moko0878-class support threads). The app runs one attempt at a time, so one slot
|
|
||||||
/// suffices; a stale token is harmless (it is taken immediately after the failed call).
|
|
||||||
static LAST_ERROR: Mutex<String> = Mutex::new(String::new());
|
|
||||||
|
|
||||||
/// Stable token for a failed pair/connect cause, matched by Kotlin (`ConnectErrors.kt`):
|
|
||||||
/// a typed host rejection yields its `RejectReason::as_str()` token ("not-armed", "denied",
|
|
||||||
/// "approval-timeout", …); transport-level causes map to "crypto" / "timeout" / "io" / "error".
|
|
||||||
fn note_error(e: &punktfunk_core::error::PunktfunkError) {
|
|
||||||
use punktfunk_core::error::PunktfunkError as E;
|
|
||||||
let token = match e {
|
|
||||||
E::Rejected(r) => r.as_str(),
|
|
||||||
E::Crypto => "crypto",
|
|
||||||
E::Timeout => "timeout",
|
|
||||||
E::Io(_) => "io",
|
|
||||||
_ => "error",
|
|
||||||
};
|
|
||||||
*LAST_ERROR.lock().unwrap() = token.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeTakeLastError(): String` — the machine token of the most recent failed
|
|
||||||
/// `nativeConnect`/`nativePair`, cleared on read (`""` when none). Call right after a `0`
|
|
||||||
/// handle / `""` fingerprint.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastError<'local>(
|
|
||||||
env: JNIEnv<'local>,
|
|
||||||
_this: JObject<'local>,
|
|
||||||
) -> jni::sys::jstring {
|
|
||||||
let token = std::mem::take(&mut *LAST_ERROR.lock().unwrap());
|
|
||||||
match env.new_string(token) {
|
|
||||||
Ok(s) => s.into_raw(),
|
|
||||||
Err(_) => JObject::null().into_raw(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
|
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
|
||||||
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
|
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
|
||||||
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
|
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
|
||||||
@@ -201,9 +164,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
|
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
|
||||||
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
|
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
|
||||||
None,
|
None,
|
||||||
// No non-video caps: this client does not render the host cursor locally (no shape/state
|
|
||||||
// planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less.
|
|
||||||
0,
|
|
||||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||||
pin, // Some → Crypto on host-fp mismatch
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||||
@@ -225,7 +185,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("nativeConnect to {host}:{port} failed: {e}");
|
log::error!("nativeConnect to {host}:{port} failed: {e}");
|
||||||
note_error(&e);
|
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,9 +318,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local
|
|||||||
Ok(host_fp) => hex32(&host_fp),
|
Ok(host_fp) => hex32(&host_fp),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
|
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
|
||||||
// The token lets Kotlin say WHICH (`nativeTakeLastError`).
|
|
||||||
log::error!("nativePair to {host}:{port} failed: {e}");
|
log::error!("nativePair to {host}:{port} failed: {e}");
|
||||||
note_error(&e);
|
|
||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,10 @@
|
|||||||
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
|
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
|
||||||
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
|
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
|
||||||
|
|
||||||
use jni::objects::{JByteBuffer, JObject, JString};
|
use jni::objects::JObject;
|
||||||
use jni::sys::{jboolean, jint, jlong};
|
use jni::sys::{jboolean, jint, jlong};
|
||||||
use jni::JNIEnv;
|
use jni::JNIEnv;
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
use punktfunk_core::quic::{RichInput, HID_REPORT_MAX, HOST_CAP_TEXT_INPUT};
|
|
||||||
|
|
||||||
use super::SessionHandle;
|
use super::SessionHandle;
|
||||||
|
|
||||||
@@ -145,45 +144,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
|||||||
send_event(handle, kind, vk as u32, 0, 0, mods as u32);
|
send_event(handle, kind, vk as u32, 0, 0, mods as u32);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeTextInputSupported(handle)` — whether the host advertised
|
|
||||||
/// `HOST_CAP_TEXT_INPUT` (its inject backend types committed text), so the Kotlin side can pick
|
|
||||||
/// the real IME `InputConnection` over the TYPE_NULL raw-key fallback. `0` handle → false.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) -> jboolean {
|
|
||||||
if handle == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; host_caps is &self.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
u8::from(h.client.host_caps() & HOST_CAP_TEXT_INPUT != 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per
|
|
||||||
/// Unicode scalar (`code` = the scalar; multi-char commits are consecutive events in order).
|
|
||||||
/// Control characters are skipped — Enter/Backspace/Tab ride the VK key path. Call only when
|
|
||||||
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported`] returned true.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
|
|
||||||
mut env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
text: JString,
|
|
||||||
) {
|
|
||||||
if handle == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Ok(s) = env.get_string(&text) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for ch in String::from(s).chars().filter(|c| !c.is_control()) {
|
|
||||||
send_event(handle, InputKind::TextInput, ch as u32, 0, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
||||||
// Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried
|
// Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried
|
||||||
// in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a
|
// in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a
|
||||||
@@ -276,43 +236,3 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
|||||||
) {
|
) {
|
||||||
send_event(handle, InputKind::GamepadRemove, 0, 0, 0, pad as u32);
|
send_event(handle, InputKind::GamepadRemove, 0, 0, 0, pad as u32);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendPadHidReport(handle, pad, buf, len)` — one raw HID input report from a
|
|
||||||
/// client-captured controller (the as-is Steam Controller 2 passthrough), forwarded verbatim on
|
|
||||||
/// the rich-input plane (`RichInput::HidReport`, 0xCC). `buf` is a DIRECT ByteBuffer whose first
|
|
||||||
/// `len` bytes are the report, id byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …);
|
|
||||||
/// `len` is clamped to the 64-byte wire body. Called from the capture thread at the controller's
|
|
||||||
/// own report rate (~250–500 Hz) — the direct-buffer read avoids a JNI array copy per report.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidReport(
|
|
||||||
env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
pad: jint,
|
|
||||||
buf: JByteBuffer,
|
|
||||||
len: jint,
|
|
||||||
) {
|
|
||||||
if handle == 0 || len <= 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cap = match env.get_direct_buffer_capacity(&buf) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
let ptr = match env.get_direct_buffer_address(&buf) {
|
|
||||||
Ok(p) if !p.is_null() => p,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
let n = (len as usize).min(cap).min(HID_REPORT_MAX);
|
|
||||||
let mut data = [0u8; HID_REPORT_MAX];
|
|
||||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call;
|
|
||||||
// `n` is bounded by both the buffer capacity and the fixed wire body.
|
|
||||||
data[..n].copy_from_slice(unsafe { std::slice::from_raw_parts(ptr, n) });
|
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
let _ = h.client.send_rich_input(RichInput::HidReport {
|
|
||||||
pad: (pad as u32 & 0xF) as u8,
|
|
||||||
len: n as u8,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
|
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
|
||||||
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
||||||
|
|
||||||
mod clipboard;
|
|
||||||
mod connect;
|
mod connect;
|
||||||
mod input;
|
mod input;
|
||||||
mod planes;
|
mod planes;
|
||||||
|
|||||||
@@ -102,31 +102,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeVideoCodecLabel(handle): String` — a short human label for the codec the
|
|
||||||
/// host resolved (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`), for the stats HUD's video-feed
|
|
||||||
/// line. Distinct from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime`] because the MIME
|
|
||||||
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
|
|
||||||
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
|
|
||||||
env: JNIEnv<'local>,
|
|
||||||
_this: JObject<'local>,
|
|
||||||
handle: jlong,
|
|
||||||
) -> jstring {
|
|
||||||
jni_guard(std::ptr::null_mut(), || {
|
|
||||||
if handle == 0 {
|
|
||||||
return std::ptr::null_mut();
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
match env.new_string(crate::decode::codec_label(h.client.codec)) {
|
|
||||||
Ok(s) => s.into_raw(),
|
|
||||||
Err(_) => std::ptr::null_mut(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
|
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
|
||||||
/// HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the decode thread has resolved one.
|
/// HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the decode thread has resolved one.
|
||||||
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
|
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
|
||||||
|
|||||||
@@ -19,32 +19,5 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>_punktfunk._udp</string>
|
<string>_punktfunk._udp</string>
|
||||||
</array>
|
</array>
|
||||||
<!-- Background keep-alive (opt-in, iOS/iPadOS): the ONLY sanctioned way to keep the long-lived
|
|
||||||
QUIC socket + pump-thread set alive while backgrounded is the audio background mode, backed
|
|
||||||
by the session's real, audible remote audio (AVAudioEngine keeps rendering). Video decode is
|
|
||||||
dropped; a bounded timer auto-disconnects. Never silence-as-keepalive (App Review 2.5.4).
|
|
||||||
tvOS ignores/tolerates the key; macOS is not gated by it. -->
|
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>audio</string>
|
|
||||||
</array>
|
|
||||||
<!-- Live Activities (iOS/iPadOS): the Lock-Screen / Dynamic-Island session surface. Updated
|
|
||||||
locally (pushType nil) from the alive app process — no aps-environment. tvOS/macOS ignore it. -->
|
|
||||||
<key>NSSupportsLiveActivities</key>
|
|
||||||
<true/>
|
|
||||||
<!-- Deep links: punktfunk://connect/<host-uuid>[?launch=<GameEntry.id>]. Emitted by the
|
|
||||||
launcher widget and Siri/Shortcuts; routed by ContentView.onOpenURL into the existing
|
|
||||||
connect path. Shared across all three targets (tvOS/macOS accept it harmlessly). -->
|
|
||||||
<key>CFBundleURLTypes</key>
|
|
||||||
<array>
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleURLName</key>
|
|
||||||
<string>io.unom.punktfunk.deeplink</string>
|
|
||||||
<key>CFBundleURLSchemes</key>
|
|
||||||
<array>
|
|
||||||
<string>punktfunk</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -73,15 +73,5 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
||||||
</array>
|
</array>
|
||||||
|
|
||||||
<!-- App Group: same shared UserDefaults suite as iOS (Config/Punktfunk.entitlements). Shared
|
|
||||||
here so a single HostStore code path (UserDefaults(suiteName:)) works on every platform;
|
|
||||||
macOS widgets that read it arrive with M5. macOS App Groups use the plain group id under
|
|
||||||
the App Store profile; a Developer-ID-signed build wants the team-prefixed form — the
|
|
||||||
Dev-ID codesign step in release.yml must verify this value against the Dev-ID profile. -->
|
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>group.io.unom.punktfunk</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -20,14 +20,5 @@
|
|||||||
is true on iOS/tvOS too. -->
|
is true on iOS/tvOS too. -->
|
||||||
<key>com.apple.developer.networking.multicast</key>
|
<key>com.apple.developer.networking.multicast</key>
|
||||||
<true/>
|
<true/>
|
||||||
<!-- App Group: the shared UserDefaults suite (group.io.unom.punktfunk) that both the app and
|
|
||||||
the Widget/Live-Activity extension read — the saved-host store moved there so a launcher
|
|
||||||
widget can see it (HostStore reads UserDefaults(suiteName:)). Must be registered on the
|
|
||||||
developer portal and enabled in the provisioning profile for BOTH app ids
|
|
||||||
(io.unom.punktfunk + io.unom.punktfunk.widgets). tvOS carries the key harmlessly. -->
|
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>group.io.unom.punktfunk</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -9,20 +9,13 @@ let package = Package(
|
|||||||
platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)],
|
platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "PunktfunkKit", targets: ["PunktfunkKit"]),
|
.library(name: "PunktfunkKit", targets: ["PunktfunkKit"]),
|
||||||
// Dependency-free foundation (stored-host model + JSON codec, settings keys, App-Group
|
|
||||||
// constant, deep-link grammar, Live Activity attributes). A separate PRODUCT so the widget
|
|
||||||
// extension — which must never link PunktfunkKit (Rust staticlib + presentation layer) —
|
|
||||||
// can link this and nothing else. PunktfunkKit re-exports it (see SharedReexport.swift).
|
|
||||||
.library(name: "PunktfunkShared", targets: ["PunktfunkShared"]),
|
|
||||||
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
|
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
|
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
|
||||||
// No dependencies by design — an extension process links this alone.
|
|
||||||
.target(name: "PunktfunkShared"),
|
|
||||||
.target(
|
.target(
|
||||||
name: "PunktfunkKit",
|
name: "PunktfunkKit",
|
||||||
dependencies: ["PunktfunkCore", "PunktfunkShared"],
|
dependencies: ["PunktfunkCore"],
|
||||||
// OSS attribution shown by the app's Acknowledgements screen. Bundled here (not in the
|
// OSS attribution shown by the app's Acknowledgements screen. Bundled here (not in the
|
||||||
// app target) so it rides along via Bundle.module in both `swift build` and the Xcode
|
// app target) so it rides along via Bundle.module in both `swift build` and the Xcode
|
||||||
// app, which links the PunktfunkKit product. Refresh with
|
// app, which links the PunktfunkKit product. Refresh with
|
||||||
@@ -49,14 +42,6 @@ let package = Package(
|
|||||||
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
||||||
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
|
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
|
||||||
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
||||||
.testTarget(
|
.testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"]),
|
||||||
name: "PunktfunkKitTests",
|
|
||||||
dependencies: ["PunktfunkKit", "PunktfunkShared", "PunktfunkCore"],
|
|
||||||
resources: [
|
|
||||||
// PyroWave golden fixtures: host-encoded AUs + upstream-decoded reference
|
|
||||||
// planes (regenerate with punktfunk-host's `pyrowave_dump_golden` on a
|
|
||||||
// Vulkan box — see PyroWaveDecoderTests.swift).
|
|
||||||
.copy("PyroWaveFixtures")
|
|
||||||
]),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,56 +11,14 @@
|
|||||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
|
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
|
||||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
|
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
|
||||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
||||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
|
|
||||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
|
|
||||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
|
||||||
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
|
||||||
E29556A7300948BA009F939C /* PBXContainerItemProxy */ = {
|
|
||||||
isa = PBXContainerItemProxy;
|
|
||||||
containerPortal = AA000000000000000000000D /* Project object */;
|
|
||||||
proxyType = 1;
|
|
||||||
remoteGlobalIDString = E2955696300948B9009F939C;
|
|
||||||
remoteInfo = PunktfunkWidgetsExtension;
|
|
||||||
};
|
|
||||||
/* End PBXContainerItemProxy section */
|
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
|
||||||
E29556AA300948BA009F939C /* Embed Foundation Extensions */ = {
|
|
||||||
isa = PBXCopyFilesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
dstPath = "";
|
|
||||||
dstSubfolderSpec = 13;
|
|
||||||
files = (
|
|
||||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */,
|
|
||||||
);
|
|
||||||
name = "Embed Foundation Extensions";
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
AA0000000000000000000001 /* Punktfunk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Punktfunk.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AA0000000000000000000001 /* Punktfunk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Punktfunk.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
BB0000000000000000000001 /* Punktfunk-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
BB0000000000000000000001 /* Punktfunk-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
CC0000000000000000000001 /* Punktfunk-tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
CC0000000000000000000001 /* Punktfunk-tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PunktfunkWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
E2955699300948B9009F939C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
|
||||||
E295569B300948B9009F939C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
|
||||||
E295577B30094CE5009F939C /* PunktfunkWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PunktfunkWidgetsExtension.entitlements; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
E29556AD300948BA009F939C /* Exceptions for "PunktfunkWidgets" folder in "PunktfunkWidgetsExtension" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
);
|
|
||||||
target = E2955696300948B9009F939C /* PunktfunkWidgetsExtension */;
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
AA0000000000000000000002 /* App */ = {
|
AA0000000000000000000002 /* App */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
@@ -72,14 +30,6 @@
|
|||||||
path = Sources/PunktfunkClient;
|
path = Sources/PunktfunkClient;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
E295569D300948B9009F939C /* PunktfunkWidgets */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
exceptions = (
|
|
||||||
E29556AD300948BA009F939C /* Exceptions for "PunktfunkWidgets" folder in "PunktfunkWidgetsExtension" target */,
|
|
||||||
);
|
|
||||||
path = PunktfunkWidgets;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -108,27 +58,14 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
E2955694300948B9009F939C /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */,
|
|
||||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */,
|
|
||||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */,
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
AA0000000000000000000007 = {
|
AA0000000000000000000007 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
E295577B30094CE5009F939C /* PunktfunkWidgetsExtension.entitlements */,
|
|
||||||
AA0000000000000000000002 /* App */,
|
AA0000000000000000000002 /* App */,
|
||||||
AA0000000000000000000003 /* Sources/PunktfunkClient */,
|
AA0000000000000000000003 /* Sources/PunktfunkClient */,
|
||||||
E295569D300948B9009F939C /* PunktfunkWidgets */,
|
|
||||||
E2955698300948B9009F939C /* Frameworks */,
|
|
||||||
AA0000000000000000000008 /* Products */,
|
AA0000000000000000000008 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -139,20 +76,10 @@
|
|||||||
AA0000000000000000000001 /* Punktfunk.app */,
|
AA0000000000000000000001 /* Punktfunk.app */,
|
||||||
BB0000000000000000000001 /* Punktfunk-iOS.app */,
|
BB0000000000000000000001 /* Punktfunk-iOS.app */,
|
||||||
CC0000000000000000000001 /* Punktfunk-tvOS.app */,
|
CC0000000000000000000001 /* Punktfunk-tvOS.app */,
|
||||||
E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */,
|
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
E2955698300948B9009F939C /* Frameworks */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
E2955699300948B9009F939C /* WidgetKit.framework */,
|
|
||||||
E295569B300948B9009F939C /* SwiftUI.framework */,
|
|
||||||
);
|
|
||||||
name = Frameworks;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -187,12 +114,10 @@
|
|||||||
BB000000000000000000000B /* Sources */,
|
BB000000000000000000000B /* Sources */,
|
||||||
BB0000000000000000000004 /* Frameworks */,
|
BB0000000000000000000004 /* Frameworks */,
|
||||||
BB000000000000000000000C /* Resources */,
|
BB000000000000000000000C /* Resources */,
|
||||||
E29556AA300948BA009F939C /* Embed Foundation Extensions */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
E29556A8300948BA009F939C /* PBXTargetDependency */,
|
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
AA0000000000000000000002 /* App */,
|
AA0000000000000000000002 /* App */,
|
||||||
@@ -231,29 +156,6 @@
|
|||||||
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
|
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
E2955696300948B9009F939C /* PunktfunkWidgetsExtension */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = E29556AE300948BA009F939C /* Build configuration list for PBXNativeTarget "PunktfunkWidgetsExtension" */;
|
|
||||||
buildPhases = (
|
|
||||||
E2955693300948B9009F939C /* Sources */,
|
|
||||||
E2955694300948B9009F939C /* Frameworks */,
|
|
||||||
E2955695300948B9009F939C /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
);
|
|
||||||
fileSystemSynchronizedGroups = (
|
|
||||||
E295569D300948B9009F939C /* PunktfunkWidgets */,
|
|
||||||
);
|
|
||||||
name = PunktfunkWidgetsExtension;
|
|
||||||
packageProductDependencies = (
|
|
||||||
E2CAFE000000000000000002 /* PunktfunkShared */,
|
|
||||||
);
|
|
||||||
productName = PunktfunkWidgetsExtension;
|
|
||||||
productReference = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */;
|
|
||||||
productType = "com.apple.product-type.app-extension";
|
|
||||||
};
|
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -261,15 +163,11 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2700;
|
|
||||||
LastUpgradeCheck = 2700;
|
LastUpgradeCheck = 2700;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
AA0000000000000000000009 = {
|
AA0000000000000000000009 = {
|
||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
};
|
};
|
||||||
E2955696300948B9009F939C = {
|
|
||||||
CreatedOnToolsVersion = 27.0;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = AA000000000000000000000E /* Build configuration list for PBXProject "Punktfunk" */;
|
buildConfigurationList = AA000000000000000000000E /* Build configuration list for PBXProject "Punktfunk" */;
|
||||||
@@ -292,7 +190,6 @@
|
|||||||
AA0000000000000000000009 /* Punktfunk */,
|
AA0000000000000000000009 /* Punktfunk */,
|
||||||
BB0000000000000000000009 /* Punktfunk-iOS */,
|
BB0000000000000000000009 /* Punktfunk-iOS */,
|
||||||
CC0000000000000000000009 /* Punktfunk-tvOS */,
|
CC0000000000000000000009 /* Punktfunk-tvOS */,
|
||||||
E2955696300948B9009F939C /* PunktfunkWidgetsExtension */,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -319,13 +216,6 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
E2955695300948B9009F939C /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -350,23 +240,8 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
E2955693300948B9009F939C /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
|
||||||
E29556A8300948BA009F939C /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = E2955696300948B9009F939C /* PunktfunkWidgetsExtension */;
|
|
||||||
targetProxy = E29556A7300948BA009F939C /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
/* End PBXTargetDependency section */
|
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
AA0000000000000000000010 /* Debug */ = {
|
AA0000000000000000000010 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@@ -504,7 +379,6 @@
|
|||||||
MARKETING_VERSION = 0.9.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -541,7 +415,6 @@
|
|||||||
MARKETING_VERSION = 0.9.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -563,6 +436,7 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||||
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||||
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
@@ -604,6 +478,7 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||||
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||||
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
@@ -691,97 +566,6 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
E29556AB300948BA009F939C /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CODE_SIGN_ENTITLEMENTS = PunktfunkWidgetsExtension.entitlements;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
"@executable_path/../../Frameworks",
|
|
||||||
);
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MARKETING_VERSION = 0.9.1;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
REGISTER_APP_GROUPS = YES;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SKIP_INSTALL = YES;
|
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
E29556AC300948BA009F939C /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CODE_SIGN_ENTITLEMENTS = PunktfunkWidgetsExtension.entitlements;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
"@executable_path/../../Frameworks",
|
|
||||||
);
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MARKETING_VERSION = 0.9.1;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
REGISTER_APP_GROUPS = YES;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SKIP_INSTALL = YES;
|
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
VALIDATE_PRODUCT = YES;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -821,15 +605,6 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
E29556AE300948BA009F939C /* Build configuration list for PBXNativeTarget "PunktfunkWidgetsExtension" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
E29556AB300948BA009F939C /* Debug */,
|
|
||||||
E29556AC300948BA009F939C /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
@@ -868,10 +643,6 @@
|
|||||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||||
productName = SwiftUINavigationTransitions;
|
productName = SwiftUINavigationTransitions;
|
||||||
};
|
};
|
||||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = PunktfunkShared;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = AA000000000000000000000D /* Project object */;
|
rootObject = AA000000000000000000000D /* Project object */;
|
||||||
|
|||||||
@@ -55,11 +55,6 @@
|
|||||||
value = "1"
|
value = "1"
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
<EnvironmentVariable
|
|
||||||
key = "MTL_HUD_ENABLED"
|
|
||||||
value = "1"
|
|
||||||
isEnabled = "YES">
|
|
||||||
</EnvironmentVariable>
|
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
shouldAutocreateTestPlan = "YES">
|
shouldAutocreateTestPlan = "YES">
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
|
|||||||
-120
@@ -1,120 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "2700"
|
|
||||||
wasCreatedForAppExtension = "YES"
|
|
||||||
version = "2.0">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES"
|
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "E2955696300948B9009F939C"
|
|
||||||
BuildableName = "PunktfunkWidgetsExtension.appex"
|
|
||||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "BB0000000000000000000009"
|
|
||||||
BuildableName = "Punktfunk-iOS.app"
|
|
||||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
shouldAutocreateTestPlan = "YES">
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = ""
|
|
||||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
|
||||||
launchStyle = "0"
|
|
||||||
askForAppToLaunch = "Yes"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES"
|
|
||||||
launchAutomaticallySubstyle = "2"
|
|
||||||
queueDebuggingEnabled = "No">
|
|
||||||
<RemoteRunnable
|
|
||||||
runnableDebuggingMode = "2"
|
|
||||||
BundleIdentifier = "com.apple.springboard">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "E2955696300948B9009F939C"
|
|
||||||
BuildableName = "PunktfunkWidgetsExtension.appex"
|
|
||||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</RemoteRunnable>
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "BB0000000000000000000009"
|
|
||||||
BuildableName = "Punktfunk-iOS.app"
|
|
||||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
<EnvironmentVariables>
|
|
||||||
<EnvironmentVariable
|
|
||||||
key = "_XCWidgetKind"
|
|
||||||
value = ""
|
|
||||||
isEnabled = "YES">
|
|
||||||
</EnvironmentVariable>
|
|
||||||
<EnvironmentVariable
|
|
||||||
key = "_XCWidgetDefaultView"
|
|
||||||
value = "timeline"
|
|
||||||
isEnabled = "YES">
|
|
||||||
</EnvironmentVariable>
|
|
||||||
<EnvironmentVariable
|
|
||||||
key = "_XCWidgetFamily"
|
|
||||||
value = "systemMedium"
|
|
||||||
isEnabled = "YES">
|
|
||||||
</EnvironmentVariable>
|
|
||||||
</EnvironmentVariables>
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
askForAppToLaunch = "Yes"
|
|
||||||
launchAutomaticallySubstyle = "2">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "BB0000000000000000000009"
|
|
||||||
BuildableName = "Punktfunk-iOS.app"
|
|
||||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "dark"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "tinted"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
// Home-Screen / Lock-Screen quick-launch widget (kind "PunktfunkHosts"). Reads the saved-host
|
|
||||||
// store from the shared App-Group suite, sorts most-recent-first, and deep-links each host into a
|
|
||||||
// session via `punktfunk://connect/<uuid>` — the app's onOpenURL routes it through the normal
|
|
||||||
// connect path (trust policy / WoL / approval all apply).
|
|
||||||
//
|
|
||||||
// No reachability probing in v1 (a UDP check has no place in a timeline build; WoL handles offline
|
|
||||||
// hosts on tap). Timeline is a single `.never` entry — the app pushes reloads on store changes
|
|
||||||
// (HostStore → WidgetCenter.reloadTimelines).
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import WidgetKit
|
|
||||||
|
|
||||||
import PunktfunkShared
|
|
||||||
|
|
||||||
// MARK: - Timeline
|
|
||||||
|
|
||||||
struct HostsEntry: TimelineEntry {
|
|
||||||
let date: Date
|
|
||||||
let hosts: [StoredHost]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HostsProvider: TimelineProvider {
|
|
||||||
func placeholder(in context: Context) -> HostsEntry {
|
|
||||||
HostsEntry(date: .now, hosts: [])
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSnapshot(in context: Context, completion: @escaping (HostsEntry) -> Void) {
|
|
||||||
completion(HostsEntry(date: .now, hosts: Self.loadHosts()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTimeline(in context: Context, completion: @escaping (Timeline<HostsEntry>) -> Void) {
|
|
||||||
// Single entry, never auto-refresh: the app reloads this timeline whenever the store
|
|
||||||
// changes (a new host, a fresh connect reordering by recency).
|
|
||||||
let entry = HostsEntry(date: .now, hosts: Self.loadHosts())
|
|
||||||
completion(Timeline(entries: [entry], policy: .never))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode the shared-suite host JSON (same wire format the app writes), most-recent first.
|
|
||||||
static func loadHosts() -> [StoredHost] {
|
|
||||||
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
|
||||||
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
|
||||||
else { return [] }
|
|
||||||
return hosts.sorted {
|
|
||||||
($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Widget
|
|
||||||
|
|
||||||
struct HostsWidget: Widget {
|
|
||||||
var body: some WidgetConfiguration {
|
|
||||||
StaticConfiguration(kind: "PunktfunkHosts", provider: HostsProvider()) { entry in
|
|
||||||
HostsWidgetView(entry: entry)
|
|
||||||
.containerBackground(.fill.tertiary, for: .widget)
|
|
||||||
}
|
|
||||||
.configurationDisplayName("Punktfunk Hosts")
|
|
||||||
.description("Quick-launch your recent streaming hosts.")
|
|
||||||
.supportedFamilies([
|
|
||||||
.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Views
|
|
||||||
|
|
||||||
struct HostsWidgetView: View {
|
|
||||||
@Environment(\.widgetFamily) private var family
|
|
||||||
let entry: HostsEntry
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
switch family {
|
|
||||||
case .systemMedium:
|
|
||||||
MediumHostsView(hosts: entry.hosts)
|
|
||||||
case .accessoryCircular:
|
|
||||||
CircularHostView(host: entry.hosts.first)
|
|
||||||
case .accessoryRectangular:
|
|
||||||
RectangularHostView(host: entry.hosts.first)
|
|
||||||
default: // systemSmall + fallback
|
|
||||||
SmallHostView(host: entry.hosts.first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deep link that connects to a stored host.
|
|
||||||
private func connectURL(_ host: StoredHost) -> URL {
|
|
||||||
DeepLink.connect(host: host.id, launchID: nil).url
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SmallHostView: View {
|
|
||||||
let host: StoredHost?
|
|
||||||
var body: some View {
|
|
||||||
if let host {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Image(systemName: "play.tv.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(Color.brand)
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
Text(host.displayName)
|
|
||||||
.font(.headline)
|
|
||||||
.lineLimit(2)
|
|
||||||
if let last = host.lastConnected {
|
|
||||||
Text(last, format: .relative(presentation: .named))
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
||||||
.widgetURL(connectURL(host))
|
|
||||||
} else {
|
|
||||||
EmptyHostView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MediumHostsView: View {
|
|
||||||
let hosts: [StoredHost]
|
|
||||||
var body: some View {
|
|
||||||
if hosts.isEmpty {
|
|
||||||
EmptyHostView()
|
|
||||||
} else {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Punktfunk")
|
|
||||||
.font(.caption).bold()
|
|
||||||
.foregroundStyle(Color.brand)
|
|
||||||
ForEach(hosts.prefix(4)) { host in
|
|
||||||
Link(destination: connectURL(host)) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "play.tv.fill")
|
|
||||||
.foregroundStyle(Color.brand)
|
|
||||||
Text(host.displayName)
|
|
||||||
.font(.subheadline)
|
|
||||||
.lineLimit(1)
|
|
||||||
Spacer()
|
|
||||||
if let last = host.lastConnected {
|
|
||||||
Text(last, format: .relative(presentation: .named))
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct CircularHostView: View {
|
|
||||||
let host: StoredHost?
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
AccessoryWidgetBackground()
|
|
||||||
Image(systemName: "play.tv.fill")
|
|
||||||
}
|
|
||||||
.widgetURL(host.map(connectURL))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct RectangularHostView: View {
|
|
||||||
let host: StoredHost?
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "play.tv.fill")
|
|
||||||
Text(host?.displayName ?? "Punktfunk")
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.widgetURL(host.map(connectURL))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct EmptyHostView: View {
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
Image(systemName: "play.tv")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text("Open Punktfunk to add a host.")
|
|
||||||
.font(.caption)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Previews (Xcode canvas)
|
|
||||||
//
|
|
||||||
// Select the PunktfunkWidgetsExtension scheme and open the canvas (⌥⌘↩). The widget
|
|
||||||
// `#Preview(as:widget:timeline:)` form feeds sample entries directly — the App-Group store is
|
|
||||||
// never read, so the canvas works without a paired device or saved hosts. The small preview's
|
|
||||||
// second entry shows the empty state one timeline click away.
|
|
||||||
|
|
||||||
private let previewHosts: [StoredHost] = [
|
|
||||||
StoredHost(
|
|
||||||
name: "Studio", address: "192.168.1.20",
|
|
||||||
lastConnected: .now.addingTimeInterval(-40 * 60)),
|
|
||||||
StoredHost(
|
|
||||||
name: "Living Room", address: "192.168.1.30",
|
|
||||||
lastConnected: .now.addingTimeInterval(-26 * 3600)),
|
|
||||||
StoredHost(
|
|
||||||
name: "Workstation", address: "10.0.0.5",
|
|
||||||
lastConnected: .now.addingTimeInterval(-6 * 86400)),
|
|
||||||
]
|
|
||||||
|
|
||||||
#Preview("Small", as: .systemSmall) {
|
|
||||||
HostsWidget()
|
|
||||||
} timeline: {
|
|
||||||
HostsEntry(date: .now, hosts: previewHosts)
|
|
||||||
HostsEntry(date: .now, hosts: [])
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Medium", as: .systemMedium) {
|
|
||||||
HostsWidget()
|
|
||||||
} timeline: {
|
|
||||||
HostsEntry(date: .now, hosts: previewHosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Lock Screen circular", as: .accessoryCircular) {
|
|
||||||
HostsWidget()
|
|
||||||
} timeline: {
|
|
||||||
HostsEntry(date: .now, hosts: previewHosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Lock Screen rectangular", as: .accessoryRectangular) {
|
|
||||||
HostsWidget()
|
|
||||||
} timeline: {
|
|
||||||
HostsEntry(date: .now, hosts: previewHosts)
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>NSExtension</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExtensionPointIdentifier</key>
|
|
||||||
<string>com.apple.widgetkit-extension</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// The widget extension's entry point. ONE extension target (bundle id io.unom.punktfunk.widgets,
|
|
||||||
// iOS only) hosts both the launcher widgets and the Live Activity UI. It links PunktfunkShared and
|
|
||||||
// NOTHING else — never PunktfunkKit (Rust staticlib + presentation layer would blow the widget
|
|
||||||
// process's ~30 MB budget).
|
|
||||||
//
|
|
||||||
// These files are NOT part of the SwiftPM package (Package.swift doesn't declare a PunktfunkWidgets
|
|
||||||
// target, so `swift build` ignores the directory). They compile only in the Xcode widget-extension
|
|
||||||
// target you add pointing at this folder — see design/apple-live-activities-and-widgets.md §M1 and
|
|
||||||
// the GUI checklist.
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import WidgetKit
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct PunktfunkWidgetBundle: WidgetBundle {
|
|
||||||
var body: some Widget {
|
|
||||||
HostsWidget()
|
|
||||||
PunktfunkSessionLiveActivity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
// The Live Activity UI (Lock Screen banner + Dynamic Island) for a running session. The app owns
|
|
||||||
// the Activity's lifecycle (SessionActivityController); this is only its presentation, rendered in
|
|
||||||
// the widget-extension process from the shared `PunktfunkSessionAttributes`.
|
|
||||||
//
|
|
||||||
// The End button runs `EndStreamIntent` (a LiveActivityIntent) IN THE APP's process, which posts
|
|
||||||
// .punktfunkEndActiveSession → the app disconnects. Elapsed time ticks client-side via
|
|
||||||
// Text(timerInterval:) — no per-second push.
|
|
||||||
|
|
||||||
import ActivityKit
|
|
||||||
import AppIntents
|
|
||||||
import SwiftUI
|
|
||||||
import WidgetKit
|
|
||||||
|
|
||||||
import PunktfunkShared
|
|
||||||
|
|
||||||
struct PunktfunkSessionLiveActivity: Widget {
|
|
||||||
var body: some WidgetConfiguration {
|
|
||||||
ActivityConfiguration(for: PunktfunkSessionAttributes.self) { context in
|
|
||||||
LockScreenView(context: context)
|
|
||||||
.activitySystemActionForegroundColor(.white)
|
|
||||||
} dynamicIsland: { context in
|
|
||||||
// Island layout (2026-07 rebuild): the EXPANDED island leads with identity (brand
|
|
||||||
// glyph + host), keeps the elapsed clock at the trailing edge, and gives the bottom
|
|
||||||
// region one purposeful row — session status + the live numbers on the left, the
|
|
||||||
// End action on the right. COMPACT shows the one number worth a glance: live
|
|
||||||
// latency while streaming (the sparse ~30 s pushes), the disconnect countdown while
|
|
||||||
// backgrounded, a state glyph otherwise. Brand purple is the identity accent
|
|
||||||
// everywhere `.tint` used to leak system blue.
|
|
||||||
DynamicIsland {
|
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: "play.tv.fill")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.brand)
|
|
||||||
Text(context.attributes.hostName)
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.padding(.leading, 4)
|
|
||||||
.padding(.top, 2)
|
|
||||||
}
|
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
|
||||||
ElapsedClock(startedAt: context.state.startedAt)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.frame(maxWidth: 64, alignment: .trailing)
|
|
||||||
.padding(.trailing, 4)
|
|
||||||
.padding(.top, 2)
|
|
||||||
}
|
|
||||||
DynamicIslandExpandedRegion(.center) {
|
|
||||||
if let title = context.attributes.launchTitle {
|
|
||||||
Text(title)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
|
||||||
// The expanded island's height is there to be used: one info row (status
|
|
||||||
// leading, live numbers trailing — the mode string stays on the Lock
|
|
||||||
// Screen), then the platform-conventional LARGE full-width action button.
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
HStack {
|
|
||||||
StatusLine(state: context.state)
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
StatsLine(state: context.state, showMode: false)
|
|
||||||
}
|
|
||||||
Spacer(minLength: 0) // any slack height goes here — button hugs the bottom
|
|
||||||
EndButton(fullWidth: true)
|
|
||||||
}
|
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
.padding(.top, 6)
|
|
||||||
}
|
|
||||||
} compactLeading: {
|
|
||||||
Image(systemName: "play.tv.fill")
|
|
||||||
.foregroundStyle(Color.brand)
|
|
||||||
} compactTrailing: {
|
|
||||||
CompactReadout(state: context.state)
|
|
||||||
} minimal: {
|
|
||||||
Image(systemName: "play.tv.fill")
|
|
||||||
.foregroundStyle(Color.brand)
|
|
||||||
}
|
|
||||||
.keylineTint(Color.brand)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Lock Screen banner
|
|
||||||
|
|
||||||
private struct LockScreenView: View {
|
|
||||||
let context: ActivityViewContext<PunktfunkSessionAttributes>
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .top, spacing: 12) {
|
|
||||||
Image(systemName: "play.tv.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(Color.brand)
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
HStack {
|
|
||||||
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
|
||||||
Spacer()
|
|
||||||
ElapsedClock(startedAt: context.state.startedAt)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
if let title = context.attributes.launchTitle {
|
|
||||||
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
||||||
}
|
|
||||||
StatusLine(state: context.state)
|
|
||||||
StatsLine(state: context.state)
|
|
||||||
}
|
|
||||||
if context.state.stage == .background {
|
|
||||||
EndButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Shared pieces
|
|
||||||
|
|
||||||
/// The ticking elapsed-session clock — client-side via `timerInterval`, no per-second push.
|
|
||||||
private struct ElapsedClock: View {
|
|
||||||
let startedAt: Date
|
|
||||||
var body: some View {
|
|
||||||
Text(timerInterval: startedAt...Date.distantFuture, countsDown: false)
|
|
||||||
.monospacedDigit()
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The session's state as a colored dot + label; while backgrounded with a deadline, the label
|
|
||||||
/// IS the countdown. One shared truth for the island bottom and the Lock Screen banner.
|
|
||||||
private struct StatusLine: View {
|
|
||||||
let state: PunktfunkSessionAttributes.ContentState
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 5) {
|
|
||||||
Circle()
|
|
||||||
.fill(color)
|
|
||||||
.frame(width: 6, height: 6)
|
|
||||||
label
|
|
||||||
.font(.caption2.weight(.medium))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var color: Color {
|
|
||||||
switch state.stage {
|
|
||||||
case .streaming: return .green
|
|
||||||
case .background: return .orange
|
|
||||||
case .reconnecting: return .yellow
|
|
||||||
case .ending: return .secondary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder private var label: some View {
|
|
||||||
switch state.stage {
|
|
||||||
case .streaming:
|
|
||||||
Text("Streaming")
|
|
||||||
case .background:
|
|
||||||
if let deadline = state.backgroundDeadline {
|
|
||||||
HStack(spacing: 3) {
|
|
||||||
Text("Background · ends in")
|
|
||||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
|
||||||
.monospacedDigit()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text("Running in background")
|
|
||||||
}
|
|
||||||
case .reconnecting:
|
|
||||||
Text("Reconnecting…")
|
|
||||||
case .ending:
|
|
||||||
Text("Session ended")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The live numbers, one quiet line: latency and bitrate (the sparse ~30 s pushes) ahead of the
|
|
||||||
/// mode (`showMode` false on the island, where the row shares space with the status line).
|
|
||||||
/// Anything unreported simply doesn't appear.
|
|
||||||
private struct StatsLine: View {
|
|
||||||
let state: PunktfunkSessionAttributes.ContentState
|
|
||||||
var showMode = true
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if let ms = state.latencyMs {
|
|
||||||
stat("bolt.fill", "\(ms) ms")
|
|
||||||
}
|
|
||||||
if let mbps = state.mbps {
|
|
||||||
stat("arrow.down", String(format: "%.0f Mb/s", mbps))
|
|
||||||
}
|
|
||||||
if showMode {
|
|
||||||
Text(state.modeLine)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stat(_ icon: String, _ text: String) -> some View {
|
|
||||||
HStack(spacing: 3) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.system(size: 8, weight: .semibold))
|
|
||||||
Text(text)
|
|
||||||
.font(.caption2)
|
|
||||||
.monospacedDigit()
|
|
||||||
}
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The compact trailing readout — the island's one glanceable number. Streaming: the live
|
|
||||||
/// latency once the app has reported one, the elapsed clock until then. Backgrounded: the
|
|
||||||
/// auto-disconnect countdown. Off-nominal stages show a state glyph instead of a number.
|
|
||||||
private struct CompactReadout: View {
|
|
||||||
let state: PunktfunkSessionAttributes.ContentState
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
switch state.stage {
|
|
||||||
case .streaming:
|
|
||||||
if let ms = state.latencyMs {
|
|
||||||
Text("\(ms) ms")
|
|
||||||
.font(.caption2.weight(.medium))
|
|
||||||
.monospacedDigit()
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
} else {
|
|
||||||
ElapsedClock(startedAt: state.startedAt)
|
|
||||||
.font(.caption2)
|
|
||||||
.frame(maxWidth: 48)
|
|
||||||
}
|
|
||||||
case .background:
|
|
||||||
if let deadline = state.backgroundDeadline {
|
|
||||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
|
||||||
.font(.caption2)
|
|
||||||
.monospacedDigit()
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(maxWidth: 48)
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "moon.fill").foregroundStyle(.orange)
|
|
||||||
}
|
|
||||||
case .reconnecting:
|
|
||||||
Image(systemName: "wifi.exclamationmark").foregroundStyle(.yellow)
|
|
||||||
case .ending:
|
|
||||||
Image(systemName: "stop.fill").foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
|
||||||
/// `fullWidth` is the expanded island's large bottom action (the platform convention there);
|
|
||||||
/// the compact form remains the Lock Screen banner's trailing button while backgrounded.
|
|
||||||
private struct EndButton: View {
|
|
||||||
var fullWidth = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if fullWidth {
|
|
||||||
Button(intent: EndStreamIntent()) {
|
|
||||||
Label("End Session", systemImage: "stop.fill")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
}
|
|
||||||
.tint(.red)
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
} else {
|
|
||||||
Button(intent: EndStreamIntent()) {
|
|
||||||
Label("End", systemImage: "stop.fill")
|
|
||||||
.font(.caption).bold()
|
|
||||||
}
|
|
||||||
.tint(.red)
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Previews (Xcode canvas)
|
|
||||||
//
|
|
||||||
// Select the PunktfunkWidgetsExtension scheme and open the canvas (⌥⌘↩) — the activity
|
|
||||||
// `#Preview(as:using:)` form renders every surface WITHOUT running the app or starting a real
|
|
||||||
// Activity: `.content` is the Lock Screen banner, `.dynamicIsland(.expanded/.compact/.minimal)`
|
|
||||||
// the island states (canvas device must be a Dynamic Island phone for those). Each listed
|
|
||||||
// content state becomes a frame in the canvas timeline strip, so all four session stages are one
|
|
||||||
// click apart. Sample state lives here (fileprivate), never in PunktfunkShared.
|
|
||||||
|
|
||||||
extension PunktfunkSessionAttributes {
|
|
||||||
fileprivate static var preview: PunktfunkSessionAttributes {
|
|
||||||
PunktfunkSessionAttributes(hostID: UUID(), hostName: "Studio", launchTitle: "Hades II")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PunktfunkSessionAttributes.ContentState {
|
|
||||||
fileprivate static var streaming: Self {
|
|
||||||
.init(
|
|
||||||
stage: .streaming, startedAt: .now.addingTimeInterval(-754),
|
|
||||||
modeLine: "2752×2064 @120 · HEVC · HDR", latencyMs: 8, mbps: 84.2)
|
|
||||||
}
|
|
||||||
fileprivate static var backgrounded: Self {
|
|
||||||
.init(
|
|
||||||
stage: .background, startedAt: .now.addingTimeInterval(-1975),
|
|
||||||
modeLine: "2752×2064 @120 · HEVC · HDR",
|
|
||||||
backgroundDeadline: .now.addingTimeInterval(9 * 60))
|
|
||||||
}
|
|
||||||
fileprivate static var reconnecting: Self {
|
|
||||||
.init(
|
|
||||||
stage: .reconnecting, startedAt: .now.addingTimeInterval(-754),
|
|
||||||
modeLine: "2752×2064 @120 · HEVC · HDR")
|
|
||||||
}
|
|
||||||
fileprivate static var ended: Self {
|
|
||||||
.init(
|
|
||||||
stage: .ending, startedAt: .now.addingTimeInterval(-3541),
|
|
||||||
modeLine: "2752×2064 @120 · HEVC · HDR")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Lock Screen", as: .content, using: PunktfunkSessionAttributes.preview) {
|
|
||||||
PunktfunkSessionLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
PunktfunkSessionAttributes.ContentState.streaming
|
|
||||||
PunktfunkSessionAttributes.ContentState.backgrounded
|
|
||||||
PunktfunkSessionAttributes.ContentState.reconnecting
|
|
||||||
PunktfunkSessionAttributes.ContentState.ended
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview(
|
|
||||||
"Island expanded", as: .dynamicIsland(.expanded),
|
|
||||||
using: PunktfunkSessionAttributes.preview
|
|
||||||
) {
|
|
||||||
PunktfunkSessionLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
PunktfunkSessionAttributes.ContentState.streaming
|
|
||||||
PunktfunkSessionAttributes.ContentState.backgrounded
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview(
|
|
||||||
"Island compact", as: .dynamicIsland(.compact),
|
|
||||||
using: PunktfunkSessionAttributes.preview
|
|
||||||
) {
|
|
||||||
PunktfunkSessionLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
PunktfunkSessionAttributes.ContentState.streaming
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview(
|
|
||||||
"Island minimal", as: .dynamicIsland(.minimal),
|
|
||||||
using: PunktfunkSessionAttributes.preview
|
|
||||||
) {
|
|
||||||
PunktfunkSessionLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
PunktfunkSessionAttributes.ContentState.streaming
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>group.io.unom.punktfunk</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import PunktfunkKit
|
|
||||||
|
|
||||||
/// A fresh `pair=required`/unknown host pending a trust decision: drives both the "request access
|
|
||||||
/// vs. pair with PIN" choice and the subsequent approval wait. `advertisedFingerprint` is the
|
|
||||||
/// discovered host's advertised cert (nil for a manually-typed host → trust-on-first-use).
|
|
||||||
struct ApprovalRequest {
|
|
||||||
let host: StoredHost
|
|
||||||
let advertisedFingerprint: Data?
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ struct ContentView: View {
|
|||||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||||
@AppStorage(DefaultsKey.renderScale) private var renderScale = 1.0
|
|
||||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||||
@@ -47,20 +46,10 @@ struct ContentView: View {
|
|||||||
case "h264": return PunktfunkConnection.codecH264
|
case "h264": return PunktfunkConnection.codecH264
|
||||||
case "hevc": return PunktfunkConnection.codecHEVC
|
case "hevc": return PunktfunkConnection.codecHEVC
|
||||||
case "av1": return PunktfunkConnection.codecAV1
|
case "av1": return PunktfunkConnection.codecAV1
|
||||||
case "pyrowave": return PunktfunkConnection.codecPyroWave
|
|
||||||
default: return 0
|
default: return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@State private var showAddHost = false
|
@State private var showAddHost = false
|
||||||
/// A `punktfunk://` deep link (widget / Siri / Shortcuts) couldn't be honored — unknown host, or
|
|
||||||
/// a live session is already up. Surfaced as an informational alert (distinct from the
|
|
||||||
/// "Connection failed" one, which is for actual connect errors).
|
|
||||||
@State private var deepLinkNotice: String?
|
|
||||||
#if os(iOS)
|
|
||||||
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
|
|
||||||
/// the session model's published state below; iPhone/iPad only.
|
|
||||||
@State private var liveActivity = SessionActivityController()
|
|
||||||
#endif
|
|
||||||
@State private var pairingTarget: StoredHost?
|
@State private var pairingTarget: StoredHost?
|
||||||
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
||||||
/// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b).
|
/// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b).
|
||||||
@@ -89,11 +78,6 @@ struct ContentView: View {
|
|||||||
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
||||||
@State private var showShortcutHint = false
|
@State private var showShortcutHint = false
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
|
||||||
/// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)`
|
|
||||||
/// — the disc must LEAVE the hierarchy so nothing composites over the metal layer).
|
|
||||||
@State private var showTouchExit = false
|
|
||||||
#endif
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
#endif
|
#endif
|
||||||
@@ -107,14 +91,6 @@ struct ContentView: View {
|
|||||||
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
||||||
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
||||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||||
/// Background keep-alive (Settings → General, iOS-only). Default OFF (today's freeze-on-background
|
|
||||||
/// is the default). When on, backgrounding a live session keeps audio + the connection alive and
|
|
||||||
/// drops video, auto-disconnecting after `backgroundTimeoutMinutes`.
|
|
||||||
@AppStorage(DefaultsKey.backgroundKeepAlive) private var backgroundKeepAlive = false
|
|
||||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) private var backgroundTimeoutMinutes = 10
|
|
||||||
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers — resign-active
|
|
||||||
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
|
||||||
private var gamepadUIActive: Bool {
|
private var gamepadUIActive: Bool {
|
||||||
GamepadUIEnvironment.isActive(
|
GamepadUIEnvironment.isActive(
|
||||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||||
@@ -136,71 +112,13 @@ struct ContentView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
seedDefaultModeIfNeeded()
|
seedDefaultModeIfNeeded()
|
||||||
autoConnectIfAsked()
|
autoConnectIfAsked()
|
||||||
#if os(iOS)
|
|
||||||
SessionActivityController.sweepOrphans() // end any Activity a prior killed launch left
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
// Deep links (widget quick-launch, Siri/Shortcuts): route into the SAME connect path a card
|
|
||||||
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
|
|
||||||
// parallel session — this drives the one `model` ContentView owns.
|
|
||||||
.onOpenURL { handleDeepLink($0) }
|
|
||||||
#if os(iOS)
|
|
||||||
// Background keep-alive driver (opt-in). Only .background/.active matter; .inactive (a
|
|
||||||
// transient peek) is ignored so the disconnect timer never starts for a Control-Center pull.
|
|
||||||
.onChange(of: scenePhase) { _, phase in
|
|
||||||
switch phase {
|
|
||||||
case .background:
|
|
||||||
if backgroundKeepAlive, model.phase == .streaming {
|
|
||||||
model.enterBackground(timeoutMinutes: backgroundTimeoutMinutes)
|
|
||||||
}
|
|
||||||
case .active:
|
|
||||||
model.exitBackground()
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Live Activity lifecycle, driven from the model's published state.
|
|
||||||
.onChange(of: model.phase) { _, phase in
|
|
||||||
switch phase {
|
|
||||||
case .streaming:
|
|
||||||
if let host = model.activeHost {
|
|
||||||
liveActivity.begin(
|
|
||||||
hostID: host.id, hostName: host.displayName,
|
|
||||||
launchTitle: nil, // no live foreground-app title mid-session (v1)
|
|
||||||
modeLine: currentModeLine(), startedAt: Date())
|
|
||||||
}
|
|
||||||
case .idle:
|
|
||||||
liveActivity.end()
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: model.isBackgrounded) { _, backgrounded in
|
|
||||||
liveActivity.update {
|
|
||||||
$0.stage = backgrounded ? .background : .streaming
|
|
||||||
$0.backgroundDeadline = model.backgroundDeadline
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The Live Activity's / Shortcuts' End button runs EndStreamIntent in-process, which posts
|
|
||||||
// this — tear the session down deliberately (quit-close the host).
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkEndActiveSession)) { _ in
|
|
||||||
model.disconnect(deliberate: true)
|
|
||||||
}
|
|
||||||
// Connect App Intent (Siri/Shortcuts): route its punktfunk:// URL through the same handler
|
|
||||||
// as a widget tap.
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkOpenDeepLink)) { note in
|
|
||||||
if let url = note.object as? URL { handleDeepLink(url) }
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
.onChange(of: model.phase) { _, phase in
|
.onChange(of: model.phase) { _, phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .streaming:
|
case .streaming:
|
||||||
#if os(macOS) || os(tvOS)
|
#if os(macOS) || os(tvOS)
|
||||||
showShortcutHint = true // the 6 s shortcut banner, per session start
|
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
|
||||||
showTouchExit = true // the off-tier exit disc's 8 s window, per session start
|
|
||||||
#endif
|
|
||||||
// A session actually started — remember it on the card ("Connected … ago"
|
// A session actually started — remember it on the card ("Connected … ago"
|
||||||
// plus the accent ring on the most recent host).
|
// plus the accent ring on the most recent host).
|
||||||
guard let host = model.activeHost else { break }
|
guard let host = model.activeHost else { break }
|
||||||
@@ -232,9 +150,6 @@ struct ContentView: View {
|
|||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.focusedSceneValue(\.sessionFocus, SessionFocus(
|
.focusedSceneValue(\.sessionFocus, SessionFocus(
|
||||||
isStreaming: model.connection != nil,
|
isStreaming: model.connection != nil,
|
||||||
clipboardAvailable: model.connection?.hostSupportsClipboard == true,
|
|
||||||
clipboardOn: model.clipboardEnabled,
|
|
||||||
toggleClipboard: { model.toggleClipboardSync() },
|
|
||||||
disconnect: { model.disconnect() }))
|
disconnect: { model.disconnect() }))
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -300,7 +215,7 @@ struct ContentView: View {
|
|||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
} message: { req in
|
} message: { req in
|
||||||
Text("\(req.host.displayName) requires pairing. Request access and approve this "
|
Text("\(req.host.displayName) requires pairing. Request access and approve this "
|
||||||
+ "device in the host's web console (port 47992 → Pairing) — no PIN needed. Or "
|
+ "device in the host's web console (port 3000 → Pairing) — no PIN needed. Or "
|
||||||
+ "pair with the 4-digit PIN it can display.")
|
+ "pair with the 4-digit PIN it can display.")
|
||||||
}
|
}
|
||||||
// One "Connection failed" surface for every home screen (touch grid, gamepad launcher) and
|
// One "Connection failed" surface for every home screen (touch grid, gamepad launcher) and
|
||||||
@@ -343,62 +258,9 @@ struct ContentView: View {
|
|||||||
Button("Cancel", role: .cancel) { model.disconnect() }
|
Button("Cancel", role: .cancel) { model.disconnect() }
|
||||||
} message: { req in
|
} message: { req in
|
||||||
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
|
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
|
||||||
+ "console (port 47992 → Pairing). This device connects automatically once you "
|
+ "console (port 3000 → Pairing). This device connects automatically once you "
|
||||||
+ "approve it — no need to reconnect.")
|
+ "approve it — no need to reconnect.")
|
||||||
}
|
}
|
||||||
// Informational deep-link outcome (unknown host / already streaming). Not an error.
|
|
||||||
.alert("Can't open", isPresented: deepLinkNoticePresented) {
|
|
||||||
Button("OK", role: .cancel) {}
|
|
||||||
} message: {
|
|
||||||
Text(deepLinkNotice ?? "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Presentation flag for the informational deep-link alert. Extracted from the `.alert` call so
|
|
||||||
/// the manual get/set Binding type-checks on its own instead of inflating the body chain's
|
|
||||||
/// budget (adding it inline tips SwiftUI's per-expression limit — see the split sections idiom).
|
|
||||||
private var deepLinkNoticePresented: Binding<Bool> {
|
|
||||||
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
/// The Live Activity mode line, e.g. "2560×1440 @120 · HEVC · HDR", from the live connection.
|
|
||||||
private func currentModeLine() -> String {
|
|
||||||
guard let c = model.connection else { return "" }
|
|
||||||
let codec: String
|
|
||||||
switch c.videoCodec {
|
|
||||||
case .h264: codec = "H.264"
|
|
||||||
case .hevc: codec = "HEVC"
|
|
||||||
case .av1: codec = "AV1"
|
|
||||||
case .pyrowave: codec = "PyroWave"
|
|
||||||
}
|
|
||||||
var line = "\(c.width)×\(c.height)"
|
|
||||||
if c.refreshHz > 0 { line += " @\(c.refreshHz)" }
|
|
||||||
line += " · \(codec)"
|
|
||||||
if c.isHDR { line += " · HDR" }
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Route a `punktfunk://` deep link into the existing connect path. Rules (per design):
|
|
||||||
/// unknown host → notice + no-op; a live session is up → ignore if it's the same host, else
|
|
||||||
/// tell the user to end the current one first (NEVER tear down a live session on a background
|
|
||||||
/// tap); otherwise the normal `connect` — trust policy, WoL and the approval sheet all apply.
|
|
||||||
private func handleDeepLink(_ url: URL) {
|
|
||||||
guard case let .connect(hostID, launchID)? = DeepLink(url) else { return }
|
|
||||||
guard let host = store.hosts.first(where: { $0.id == hostID }) else {
|
|
||||||
deepLinkNotice = "That host isn't saved on this device."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if model.phase != .idle {
|
|
||||||
guard model.activeHost?.id == hostID else {
|
|
||||||
let current = model.activeHost?.displayName ?? "a host"
|
|
||||||
deepLinkNotice = "Already streaming \(current). End that session first."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return // deep-linked to the host we're already on — nothing to do
|
|
||||||
}
|
|
||||||
connect(host, launchID: launchID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var home: some View {
|
private var home: some View {
|
||||||
@@ -500,19 +362,12 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
// The resize spinner rides over the (blurred) stream; suppressed under the trust
|
// The resize spinner rides over the (blurred) stream; suppressed under the trust
|
||||||
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
|
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
|
||||||
// steering and the next click still reaches the stream. Mounted ONLY while a
|
// steering and the next click still reaches the stream.
|
||||||
// resize is live: resident structure above the CAMetalLayer is what the stage-4
|
|
||||||
// direct-to-display hunt is eliminating — composited presents reach glass a full
|
|
||||||
// refresh later. The enter/exit fade rides the call-site transition + the
|
|
||||||
// .animation(value: resizing) below (the view's internal `if active` fade can't
|
|
||||||
// run when the whole view unmounts).
|
|
||||||
.overlay {
|
.overlay {
|
||||||
if pendingFingerprint == nil, model.resizing {
|
if pendingFingerprint == nil {
|
||||||
ResizeIndicatorView(active: true)
|
ResizeIndicatorView(active: model.resizing)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.92)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.22), value: model.resizing)
|
|
||||||
if let fp = pendingFingerprint {
|
if let fp = pendingFingerprint {
|
||||||
TrustCardView(
|
TrustCardView(
|
||||||
fingerprint: fp,
|
fingerprint: fp,
|
||||||
@@ -579,21 +434,13 @@ struct ContentView: View {
|
|||||||
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
||||||
},
|
},
|
||||||
onFrame: { [meter = model.meter, latency = model.latency,
|
onFrame: { [meter = model.meter, latency = model.latency,
|
||||||
split = model.latencySplit, queue = model.clientQueue,
|
split = model.latencySplit, offset = conn.clockOffsetNs] au in
|
||||||
offset = conn.clockOffsetNs] au in
|
|
||||||
meter.note(byteCount: au.data.count)
|
meter.note(byteCount: au.data.count)
|
||||||
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
||||||
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
||||||
// host/network split — drained by the 1 s stats tick). receivedNs is
|
// host/network split — drained by the 1 s stats tick).
|
||||||
// the core's reassembly stamp (ABI v9), so the split's network term no
|
|
||||||
// longer contains the client-queue wait...
|
|
||||||
split.recordReceipt(
|
split.recordReceipt(
|
||||||
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
|
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
|
||||||
// ...which is measured as its own term instead (receipt→pull, both
|
|
||||||
// client-local).
|
|
||||||
queue.record(
|
|
||||||
ptsNs: UInt64(bitPattern: au.receivedNs), atNs: au.pulledNs,
|
|
||||||
offsetNs: 0)
|
|
||||||
},
|
},
|
||||||
onSessionEnd: { [weak model] in
|
onSessionEnd: { [weak model] in
|
||||||
Task { @MainActor in model?.sessionEnded() }
|
Task { @MainActor in model?.sessionEnded() }
|
||||||
@@ -610,8 +457,7 @@ struct ContentView: View {
|
|||||||
},
|
},
|
||||||
endToEndMeter: model.endToEnd,
|
endToEndMeter: model.endToEnd,
|
||||||
decodeMeter: model.decodeStage,
|
decodeMeter: model.decodeStage,
|
||||||
displayMeter: model.displayStage,
|
displayMeter: model.displayStage
|
||||||
presentFloorMeter: model.presentFloor
|
|
||||||
)
|
)
|
||||||
.overlay(alignment: placement.alignment) {
|
.overlay(alignment: placement.alignment) {
|
||||||
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
||||||
@@ -660,27 +506,18 @@ struct ContentView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||||
// screen — the overlay off, or the compact pill (which carries no button) —
|
// screen — the overlay off, or the compact pill (which carries no button) —
|
||||||
// keep a minimal touch exit in a corner. It rides a material disc (like the
|
// keep a minimal always-reachable exit in a corner. It rides a material disc
|
||||||
// HUD) so the glyph stays legible over a bright frame.
|
// (like the HUD) so the glyph stays legible over a bright frame — this is the
|
||||||
//
|
// sole touch disconnect path in those tiers.
|
||||||
// In the OFF tier the disc shows for the first 8 s of a session, then leaves
|
|
||||||
// the hierarchy ENTIRELY (the shortcut-banner pattern): any composited overlay
|
|
||||||
// above the stream — a glass one doubly so, its blur SAMPLES the video layer —
|
|
||||||
// forces the CAMetalLayer through the compositor, costing ~a refresh of display
|
|
||||||
// latency and blocking direct-to-display promotion. Off is the immersive/
|
|
||||||
// measurement tier; after the fade, touch-only exits are backgrounding the app
|
|
||||||
// or re-enabling the stats overlay. Compact keeps its disc permanently — that
|
|
||||||
// tier composites a HUD pill anyway, so hiding the exit there wins nothing.
|
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
if captureEnabled,
|
if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
|
||||||
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
|
|
||||||
Button { model.disconnect() } label: {
|
Button { model.disconnect() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.headline.weight(.semibold))
|
.font(.headline.weight(.semibold))
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
// Floating glass disc over the frame (26+, material fallback).
|
// Sole touch exit in the off/compact tiers — a floating glass disc
|
||||||
// interactive: the disc IS the tap target, so the glass reacts
|
// over the frame (26+, material fallback). interactive: the disc
|
||||||
// to press.
|
// IS the tap target, so the glass reacts to press.
|
||||||
.glassBackground(Circle(), interactive: true)
|
.glassBackground(Circle(), interactive: true)
|
||||||
// Match the hit region to the visible disc so every tap also
|
// Match the hit region to the visible disc so every tap also
|
||||||
// triggers the interactive-glass press highlight.
|
// triggers the interactive-glass press highlight.
|
||||||
@@ -689,12 +526,6 @@ struct ContentView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.accessibilityLabel("Disconnect")
|
.accessibilityLabel("Disconnect")
|
||||||
.transition(.opacity)
|
|
||||||
.task {
|
|
||||||
guard statsVerbosity == .off else { return }
|
|
||||||
try? await Task.sleep(for: .seconds(8))
|
|
||||||
withAnimation(.easeOut(duration: 0.6)) { showTouchExit = false }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -778,17 +609,6 @@ struct ContentView: View {
|
|||||||
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
|
/// 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). `onUnreachable` hands a plain connect
|
/// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
|
||||||
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
|
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
|
||||||
/// The stream mode to request = the chosen resolution × the render scale, aspect-preserved,
|
|
||||||
/// even, and clamped to the codec's max dimension. > 1 supersamples for sharpness (the presenter
|
|
||||||
/// downscales the larger decoded frame to this display); < 1 renders under native and upscales.
|
|
||||||
/// The match-window path applies the SAME scale to the live window size in `MatchWindowFollower`.
|
|
||||||
private func scaledMode() -> (width: UInt32, height: UInt32) {
|
|
||||||
RenderScale.apply(
|
|
||||||
baseWidth: width, baseHeight: height,
|
|
||||||
scale: renderScale,
|
|
||||||
maxDimension: RenderScale.maxDimension(codec: codec))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startSessionDirect(
|
private func startSessionDirect(
|
||||||
_ host: StoredHost, launchID: String? = nil,
|
_ host: StoredHost, launchID: String? = nil,
|
||||||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
|
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
|
||||||
@@ -800,7 +620,7 @@ struct ContentView: View {
|
|||||||
if let approvalReq { awaitingApproval = approvalReq }
|
if let approvalReq { awaitingApproval = approvalReq }
|
||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: scaledMode().width, height: scaledMode().height,
|
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||||
hz: UInt32(clamping: hz),
|
hz: UInt32(clamping: hz),
|
||||||
compositor: PunktfunkConnection.Compositor(
|
compositor: PunktfunkConnection.Compositor(
|
||||||
rawValue: UInt32(clamping: compositor)) ?? .auto,
|
rawValue: UInt32(clamping: compositor)) ?? .auto,
|
||||||
@@ -983,7 +803,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: scaledMode().width, height: scaledMode().height,
|
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||||
hz: UInt32(clamping: hz),
|
hz: UInt32(clamping: hz),
|
||||||
compositor: pref,
|
compositor: pref,
|
||||||
gamepad: pad,
|
gamepad: pad,
|
||||||
@@ -994,3 +814,71 @@ struct ContentView: View {
|
|||||||
autoTrust: true)
|
autoTrust: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// Drives the hosting window in/out of native fullscreen from SwiftUI state, and mirrors the
|
||||||
|
/// window's ACTUAL fullscreen state back into `isFullscreen` (the user can also toggle it with the
|
||||||
|
/// green button / ⌃⌘F — ContentView keys the session view's safe-area handling off the real state,
|
||||||
|
/// not the setting). Mounted invisibly in the view tree; on each `active` change it captures the
|
||||||
|
/// window and toggles fullscreen only when the current state differs (so it never fights a toggle
|
||||||
|
/// already in flight, and never touches a window the user fullscreened manually unless `active`
|
||||||
|
/// says otherwise).
|
||||||
|
private struct FullscreenController: NSViewRepresentable {
|
||||||
|
let active: Bool
|
||||||
|
@Binding var isFullscreen: Bool
|
||||||
|
|
||||||
|
/// Holds the window's fullscreen-transition observers so they're rebound on a window change
|
||||||
|
/// and removed on dismantle.
|
||||||
|
final class Coordinator {
|
||||||
|
var observers: [NSObjectProtocol] = []
|
||||||
|
weak var observedWindow: NSWindow?
|
||||||
|
deinit { observers.forEach(NotificationCenter.default.removeObserver(_:)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSView { NSView() }
|
||||||
|
|
||||||
|
func updateNSView(_ view: NSView, context: Context) {
|
||||||
|
let want = active
|
||||||
|
let isFullscreen = $isFullscreen
|
||||||
|
let coordinator = context.coordinator
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let window = view.window else { return }
|
||||||
|
observeTransitions(of: window, coordinator: coordinator)
|
||||||
|
let isFull = window.styleMask.contains(.fullScreen)
|
||||||
|
if isFullscreen.wrappedValue != isFull { isFullscreen.wrappedValue = isFull }
|
||||||
|
if want != isFull { window.toggleFullScreen(nil) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `willEnter` (not did) so the video goes edge-to-edge while the title bar is already
|
||||||
|
/// animating away; `didExit` so the top inset returns only once the title bar is back —
|
||||||
|
/// no black gap in either direction.
|
||||||
|
private func observeTransitions(of window: NSWindow, coordinator: Coordinator) {
|
||||||
|
guard coordinator.observedWindow !== window else { return }
|
||||||
|
coordinator.observers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||||
|
coordinator.observers.removeAll()
|
||||||
|
coordinator.observedWindow = window
|
||||||
|
let isFullscreen = $isFullscreen
|
||||||
|
for (name, value) in [
|
||||||
|
(NSWindow.willEnterFullScreenNotification, true),
|
||||||
|
(NSWindow.didExitFullScreenNotification, false),
|
||||||
|
] {
|
||||||
|
coordinator.observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: name, object: window, queue: .main
|
||||||
|
) { _ in
|
||||||
|
isFullscreen.wrappedValue = value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// A fresh `pair=required`/unknown host pending a trust decision: drives both the "request access
|
||||||
|
/// vs. pair with PIN" choice and the subsequent approval wait. `advertisedFingerprint` is the
|
||||||
|
/// discovered host's advertised cert (nil for a manually-typed host → trust-on-first-use).
|
||||||
|
private struct ApprovalRequest {
|
||||||
|
let host: StoredHost
|
||||||
|
let advertisedFingerprint: Data?
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import PunktfunkKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
import AppKit
|
|
||||||
|
|
||||||
/// Drives the hosting window in/out of native fullscreen from SwiftUI state, and mirrors the
|
|
||||||
/// window's ACTUAL fullscreen state back into `isFullscreen` (the user can also toggle it with the
|
|
||||||
/// green button / ⌃⌘F — ContentView keys the session view's safe-area handling off the real state,
|
|
||||||
/// not the setting). Mounted invisibly in the view tree; on each `active` change it captures the
|
|
||||||
/// window and toggles fullscreen only when the current state differs (so it never fights a toggle
|
|
||||||
/// already in flight, and never touches a window the user fullscreened manually unless `active`
|
|
||||||
/// says otherwise).
|
|
||||||
struct FullscreenController: NSViewRepresentable {
|
|
||||||
let active: Bool
|
|
||||||
@Binding var isFullscreen: Bool
|
|
||||||
|
|
||||||
/// Holds the window's fullscreen-transition observers so they're rebound on a window change
|
|
||||||
/// and removed on dismantle.
|
|
||||||
final class Coordinator {
|
|
||||||
var observers: [NSObjectProtocol] = []
|
|
||||||
weak var observedWindow: NSWindow?
|
|
||||||
/// The last `active` value we DROVE the window to. We toggle only when `active` itself
|
|
||||||
/// changes (stream start/end) — never to correct a mismatch — so a deliberate mid-session
|
|
||||||
/// toggle (⌃⌘F / the green button) isn't snapped back on the next SwiftUI update.
|
|
||||||
var lastActive: Bool?
|
|
||||||
deinit { observers.forEach(NotificationCenter.default.removeObserver(_:)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSView { NSView() }
|
|
||||||
|
|
||||||
func updateNSView(_ view: NSView, context: Context) {
|
|
||||||
let want = active
|
|
||||||
let isFullscreen = $isFullscreen
|
|
||||||
let coordinator = context.coordinator
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
guard let window = view.window else { return }
|
|
||||||
observeTransitions(of: window, coordinator: coordinator)
|
|
||||||
let isFull = window.styleMask.contains(.fullScreen)
|
|
||||||
if isFullscreen.wrappedValue != isFull { isFullscreen.wrappedValue = isFull }
|
|
||||||
// Drive the window only on an `active` EDGE (stream start/end), not to close a mismatch —
|
|
||||||
// so a user's ⌃⌘F / green-button toggle stays put. First pass (lastActive == nil) just
|
|
||||||
// records the state without toggling, so mounting never yanks a window into fullscreen.
|
|
||||||
if coordinator.lastActive != want {
|
|
||||||
coordinator.lastActive = want
|
|
||||||
if want != isFull { window.toggleFullScreen(nil) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `willEnter` (not did) so the video goes edge-to-edge while the title bar is already
|
|
||||||
/// animating away; `didExit` so the top inset returns only once the title bar is back —
|
|
||||||
/// no black gap in either direction.
|
|
||||||
private func observeTransitions(of window: NSWindow, coordinator: Coordinator) {
|
|
||||||
guard coordinator.observedWindow !== window else { return }
|
|
||||||
coordinator.observers.forEach(NotificationCenter.default.removeObserver(_:))
|
|
||||||
coordinator.observers.removeAll()
|
|
||||||
coordinator.observedWindow = window
|
|
||||||
let isFullscreen = $isFullscreen
|
|
||||||
for (name, value) in [
|
|
||||||
(NSWindow.willEnterFullScreenNotification, true),
|
|
||||||
(NSWindow.didExitFullScreenNotification, false),
|
|
||||||
] {
|
|
||||||
coordinator.observers.append(NotificationCenter.default.addObserver(
|
|
||||||
forName: name, object: window, queue: .main
|
|
||||||
) { _ in
|
|
||||||
isFullscreen.wrappedValue = value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// The Stream menu's "Toggle Fullscreen" (⌃⌘F) and InputCapture's captured-state interception
|
|
||||||
// both post this; flip the KEY window only (posted app-wide, object nil). The transition
|
|
||||||
// observers above then mirror the real state back into the binding.
|
|
||||||
coordinator.observers.append(NotificationCenter.default.addObserver(
|
|
||||||
forName: .punktfunkToggleFullscreen, object: nil, queue: .main
|
|
||||||
) { [weak window] _ in
|
|
||||||
guard let window, window.isKeyWindow else { return }
|
|
||||||
window.toggleFullScreen(nil)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -20,12 +20,6 @@ struct AddHostSheet: View {
|
|||||||
@State private var address: String
|
@State private var address: String
|
||||||
@State private var port: Int
|
@State private var port: Int
|
||||||
@State private var mac: String
|
@State private var mac: String
|
||||||
#if os(macOS)
|
|
||||||
/// Share the clipboard with this host (macOS sessions only; design
|
|
||||||
/// clipboard-and-file-transfer.md §5.3). Off by default; honored only when the host
|
|
||||||
/// advertises the capability at connect.
|
|
||||||
@State private var clipboardSync: Bool
|
|
||||||
#endif
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
private enum EditField: String, Identifiable {
|
private enum EditField: String, Identifiable {
|
||||||
case name, address, port, mac
|
case name, address, port, mac
|
||||||
@@ -47,9 +41,6 @@ struct AddHostSheet: View {
|
|||||||
_port = State(initialValue: Int(existing?.port ?? 9777))
|
_port = State(initialValue: Int(existing?.port ?? 9777))
|
||||||
let stored = existing?.macAddresses ?? []
|
let stored = existing?.macAddresses ?? []
|
||||||
_mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", "))
|
_mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", "))
|
||||||
#if os(macOS)
|
|
||||||
_clipboardSync = State(initialValue: existing?.clipboardSync ?? false)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -105,9 +96,6 @@ struct AddHostSheet: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
|
||||||
Toggle("Share clipboard with this host", isOn: $clipboardSync)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
@@ -159,11 +147,6 @@ struct AddHostSheet: View {
|
|||||||
host.address = address.trimmingCharacters(in: .whitespaces)
|
host.address = address.trimmingCharacters(in: .whitespaces)
|
||||||
host.port = UInt16(clamping: port)
|
host.port = UInt16(clamping: port)
|
||||||
host.macAddresses = Self.parseMacs(mac)
|
host.macAddresses = Self.parseMacs(mac)
|
||||||
#if os(macOS)
|
|
||||||
// nil when off: the key stays absent from the saved JSON (forward-compat, and "never
|
|
||||||
// opted in" and "opted out" read the same — off).
|
|
||||||
host.clipboardSync = clipboardSync ? true : nil
|
|
||||||
#endif
|
|
||||||
onSave(host)
|
onSave(host)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
// Siri / Shortcuts / Spotlight surface (design §M4). Deliberately thin: every action already has an
|
|
||||||
// internal entry point — M0's deep-link router (connect / connect-and-launch), M3's in-process
|
|
||||||
// end-session hook, and the existing Wake-on-LAN path — so these intents only wrap them.
|
|
||||||
//
|
|
||||||
// Gated os(iOS): the AppShortcutsProvider bundles `EndStreamIntent`, which is a LiveActivityIntent
|
|
||||||
// (iPhone/iPad only). Connect/Wake themselves are plain AppIntents; they live here with the
|
|
||||||
// provider rather than being split across platforms. `HostEntity` (the parameter type) is in
|
|
||||||
// PunktfunkShared so the widget's configuration intent can share it.
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import AppIntents
|
|
||||||
import Foundation
|
|
||||||
import PunktfunkKit
|
|
||||||
|
|
||||||
/// Load a full saved host (MACs, address) from the shared App-Group store by id — HostEntity only
|
|
||||||
/// carries id + name.
|
|
||||||
private func loadStoredHost(_ id: UUID) -> StoredHost? {
|
|
||||||
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
|
||||||
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
|
||||||
else { return nil }
|
|
||||||
return hosts.first { $0.id == id }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a session with a stored host (optionally launching a title). Foregrounds the app and
|
|
||||||
/// routes through the SAME `.onOpenURL` path a widget tap uses — trust policy, WoL and the approval
|
|
||||||
/// sheet all apply, and its guards (unknown host, already-streaming) hold.
|
|
||||||
struct ConnectToHostIntent: AppIntent {
|
|
||||||
static let title: LocalizedStringResource = "Connect to Host"
|
|
||||||
static let description = IntentDescription("Start a Punktfunk streaming session with a host.")
|
|
||||||
static let openAppWhenRun = true
|
|
||||||
|
|
||||||
@Parameter(title: "Host") var host: HostEntity
|
|
||||||
@Parameter(title: "Game ID", description: "Optional store id like steam:570")
|
|
||||||
var launchID: String?
|
|
||||||
|
|
||||||
func perform() async throws -> some IntentResult {
|
|
||||||
let url = DeepLink.connect(host: host.id, launchID: launchID).url
|
|
||||||
await MainActor.run {
|
|
||||||
NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url)
|
|
||||||
}
|
|
||||||
return .result()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wake a sleeping host (magic packet). No `openAppWhenRun` — usable in automations ("when I get
|
|
||||||
/// home, wake the tower") without foregrounding the app.
|
|
||||||
struct WakeHostIntent: AppIntent {
|
|
||||||
static let title: LocalizedStringResource = "Wake Host"
|
|
||||||
static let description = IntentDescription("Send a Wake-on-LAN magic packet to a host.")
|
|
||||||
|
|
||||||
@Parameter(title: "Host") var host: HostEntity
|
|
||||||
|
|
||||||
func perform() async throws -> some IntentResult {
|
|
||||||
guard let stored = loadStoredHost(host.id), !stored.wakeMacs.isEmpty else {
|
|
||||||
throw IntentError.noWakeAddress
|
|
||||||
}
|
|
||||||
PunktfunkConnection.wakeOnLAN(macs: stored.wakeMacs, lastKnownIP: stored.address)
|
|
||||||
return .result()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors surfaced to Siri/Shortcuts. `CustomLocalizedStringResourceConvertible` makes the message
|
|
||||||
/// show as the intent's failure text.
|
|
||||||
enum IntentError: Error, CustomLocalizedStringResourceConvertible {
|
|
||||||
case noWakeAddress
|
|
||||||
|
|
||||||
var localizedStringResource: LocalizedStringResource {
|
|
||||||
switch self {
|
|
||||||
case .noWakeAddress:
|
|
||||||
// One string LITERAL — LocalizedStringResource is ExpressibleByStringLiteral, but a
|
|
||||||
// `"…" + "…"` concatenation is a runtime String it can't convert.
|
|
||||||
return "That host has no saved Wake-on-LAN address yet. Connect to it once so Punktfunk can learn it."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zero-setup Siri / Spotlight phrases. Parameterized phrases resolve a `HostEntity` by name; stays
|
|
||||||
/// well under the 10-shortcut cap.
|
|
||||||
struct PunktfunkShortcuts: AppShortcutsProvider {
|
|
||||||
static var appShortcuts: [AppShortcut] {
|
|
||||||
AppShortcut(
|
|
||||||
intent: ConnectToHostIntent(),
|
|
||||||
phrases: [
|
|
||||||
"Connect to \(\.$host) in \(.applicationName)",
|
|
||||||
"Stream \(\.$host) with \(.applicationName)",
|
|
||||||
],
|
|
||||||
shortTitle: "Connect", systemImageName: "play.tv.fill")
|
|
||||||
AppShortcut(
|
|
||||||
intent: WakeHostIntent(),
|
|
||||||
phrases: [
|
|
||||||
"Wake \(\.$host) with \(.applicationName)",
|
|
||||||
],
|
|
||||||
shortTitle: "Wake Host", systemImageName: "power")
|
|
||||||
AppShortcut(
|
|
||||||
intent: EndStreamIntent(),
|
|
||||||
phrases: [
|
|
||||||
"End the \(.applicationName) stream",
|
|
||||||
],
|
|
||||||
shortTitle: "End Stream", systemImageName: "stop.fill")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// Owns the ActivityKit Live Activity lifecycle for a streaming session (iPhone/iPad only). Driven
|
|
||||||
// by ContentView from the session model's published state (phase / isBackgrounded / deadline) so
|
|
||||||
// none of this leaks into the cross-platform SessionModel. Local updates only (`pushType: nil`) —
|
|
||||||
// the app process is alive whenever there's a session to report, so there's no push token plumbing.
|
|
||||||
//
|
|
||||||
// Gated os(iOS): ActivityKit is iPhone/iPad only. Minimum deployment is iOS 17, so no @available
|
|
||||||
// guards are needed (Activity has existed since 16.1).
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import ActivityKit
|
|
||||||
import Foundation
|
|
||||||
// PunktfunkKit re-exports PunktfunkShared (@_exported), so the app target sees PunktfunkSessionAttributes
|
|
||||||
// without linking the Shared product directly — same pattern as StoredHost in HostStore.
|
|
||||||
import PunktfunkKit
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class SessionActivityController {
|
|
||||||
private var activity: Activity<PunktfunkSessionAttributes>?
|
|
||||||
/// The last pushed state, so an update can mutate one field and keep the rest (notably
|
|
||||||
/// `startedAt`, which the Lock-Screen timer ticks from).
|
|
||||||
private var state: PunktfunkSessionAttributes.ContentState?
|
|
||||||
|
|
||||||
/// How far past the next expected update to mark the content stale — a frozen opt-out session
|
|
||||||
/// then greys out instead of showing a lying clock.
|
|
||||||
private static let staleWindow: TimeInterval = 90
|
|
||||||
|
|
||||||
var isActive: Bool { activity != nil }
|
|
||||||
|
|
||||||
/// End any Activity left over from a previous launch that was killed mid-session. Call once at
|
|
||||||
/// app start (ContentView.onAppear).
|
|
||||||
static func sweepOrphans() {
|
|
||||||
Task {
|
|
||||||
for activity in Activity<PunktfunkSessionAttributes>.activities {
|
|
||||||
await activity.end(nil, dismissalPolicy: .immediate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the Live Activity for a freshly-streaming session. No-op if the user disabled Live
|
|
||||||
/// Activities for the app, or one is already up.
|
|
||||||
func begin(hostID: UUID, hostName: String, launchTitle: String?, modeLine: String, startedAt: Date) {
|
|
||||||
guard ActivityAuthorizationInfo().areActivitiesEnabled, activity == nil else { return }
|
|
||||||
let attributes = PunktfunkSessionAttributes(
|
|
||||||
hostID: hostID, hostName: hostName, launchTitle: launchTitle)
|
|
||||||
let initial = PunktfunkSessionAttributes.ContentState(
|
|
||||||
stage: .streaming, startedAt: startedAt, modeLine: modeLine)
|
|
||||||
state = initial
|
|
||||||
do {
|
|
||||||
activity = try Activity.request(
|
|
||||||
attributes: attributes,
|
|
||||||
content: content(initial),
|
|
||||||
pushType: nil)
|
|
||||||
} catch {
|
|
||||||
activity = nil
|
|
||||||
state = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Coalesced update: mutate the running state in place (keeps `startedAt` etc.) and push once.
|
|
||||||
/// No-op when there's no live Activity.
|
|
||||||
func update(_ mutate: (inout PunktfunkSessionAttributes.ContentState) -> Void) {
|
|
||||||
guard let activity, var next = state else { return }
|
|
||||||
mutate(&next)
|
|
||||||
state = next
|
|
||||||
Task { await activity.update(content(next)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// End with a final "ended" state, dismissed a few seconds later.
|
|
||||||
func end() {
|
|
||||||
guard let activity, var final = state else {
|
|
||||||
self.activity = nil
|
|
||||||
state = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.activity = nil
|
|
||||||
state = nil
|
|
||||||
final.stage = .ending
|
|
||||||
final.backgroundDeadline = nil
|
|
||||||
Task {
|
|
||||||
await activity.end(content(final), dismissalPolicy: .after(.now + 4))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func content(_ s: PunktfunkSessionAttributes.ContentState)
|
|
||||||
-> ActivityContent<PunktfunkSessionAttributes.ContentState> {
|
|
||||||
ActivityContent(state: s, staleDate: Date().addingTimeInterval(Self.staleWindow))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -102,26 +102,6 @@ final class SessionModel: ObservableObject {
|
|||||||
@Published var decodeValid = false
|
@Published var decodeValid = false
|
||||||
@Published var displayP50Ms = 0.0
|
@Published var displayP50Ms = 0.0
|
||||||
@Published var displayValid = false
|
@Published var displayValid = false
|
||||||
/// Client-queue wait: core reassembly receipt → the pump's pull (`AccessUnit.pulledNs −
|
|
||||||
/// receivedNs`, ABI v9 receipt split — the 2026-07 two-pair investigation). ~0 on a healthy
|
|
||||||
/// stream; a persistent value is a client-side standing backlog that used to hide inside
|
|
||||||
/// "network". Shown in the detailed tier only when it says something (≥ ~2 ms).
|
|
||||||
@Published var clientQueueP50Ms = 0.0
|
|
||||||
@Published var clientQueueValid = false
|
|
||||||
/// The measured OS present floor (design/apple-presentation-rebuild.md): the deadline
|
|
||||||
/// engine's vend→glass pipeline depth — an OS property no client can pace under (~2 refresh
|
|
||||||
/// intervals composited; would read ~1 under direct-to-display). The HUD subtracts it from
|
|
||||||
/// the shown display/e2e so the numbers describe Punktfunk's own pipeline; raw values stay
|
|
||||||
/// in the detailed tier + the stats log. Invalid (0) on macOS arrival (sync-off ≈ no floor)
|
|
||||||
/// and under stage-1.
|
|
||||||
@Published var osFloorP50Ms = 0.0
|
|
||||||
@Published var osFloorValid = false
|
|
||||||
|
|
||||||
/// The floor-shaved values every HUD tier displays (raw − floor, never below 0). Identical
|
|
||||||
/// to the raw values whenever no floor is measured.
|
|
||||||
var displayAdjP50Ms: Double { max(0, displayP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
|
||||||
var endToEndAdjP50Ms: Double { max(0, endToEndP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
|
||||||
var endToEndAdjP95Ms: Double { max(0, endToEndP95Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
|
||||||
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
|
/// 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.
|
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
|
||||||
@Published var lostFrames = 0
|
@Published var lostFrames = 0
|
||||||
@@ -153,30 +133,12 @@ final class SessionModel: ObservableObject {
|
|||||||
let endToEnd = LatencyMeter()
|
let endToEnd = LatencyMeter()
|
||||||
let decodeStage = LatencyMeter()
|
let decodeStage = LatencyMeter()
|
||||||
let displayStage = LatencyMeter()
|
let displayStage = LatencyMeter()
|
||||||
/// Client-queue sampler (see `clientQueueP50Ms`) — fed per AU by the stream view's onFrame,
|
|
||||||
/// drained by the same 1 s tick as the stage meters.
|
|
||||||
let clientQueue = LatencyMeter()
|
|
||||||
/// The OS present floor sampler (see `osFloorP50Ms`) — fed one sample per display-link
|
|
||||||
/// update by the deadline engine, drained by the same 1 s tick as the stage meters.
|
|
||||||
let presentFloor = LatencyMeter()
|
|
||||||
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
|
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
|
||||||
private var lastFramesDropped: UInt64 = 0
|
private var lastFramesDropped: UInt64 = 0
|
||||||
private var statsTimer: Timer?
|
private var statsTimer: Timer?
|
||||||
private var audio: SessionAudio?
|
private var audio: SessionAudio?
|
||||||
private var gamepadCapture: GamepadCapture?
|
private var gamepadCapture: GamepadCapture?
|
||||||
private var gamepadFeedback: GamepadFeedback?
|
private var gamepadFeedback: GamepadFeedback?
|
||||||
#if os(macOS)
|
|
||||||
/// The live session's clipboard bridge (design/clipboard-and-file-transfer.md §5) — created
|
|
||||||
/// by `beginStreaming` when the per-host toggle is on and the host advertises
|
|
||||||
/// `HOST_CAP_CLIPBOARD`; stopped (off-main, drain joined) in `disconnect`.
|
|
||||||
private var clipboardSync: ClipboardSync?
|
|
||||||
#endif
|
|
||||||
/// Whether clipboard sync is live (host-acked `ClipState.enabled`) — drives the Stream menu
|
|
||||||
/// item's title and the settings footnote. Always false off-macOS.
|
|
||||||
@Published private(set) var clipboardEnabled = false
|
|
||||||
/// The host's last `ClipState.reason` (`CLIP_REASON_*`) — why an enable was refused
|
|
||||||
/// (backend unavailable / policy disabled / …); 0 = OK.
|
|
||||||
@Published private(set) var clipboardReason: UInt8 = 0
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
/// Siri Remote → host pointer while streaming (touch surface moves, press = left click,
|
/// Siri Remote → host pointer while streaming (touch surface moves, press = left click,
|
||||||
/// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See
|
/// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See
|
||||||
@@ -186,16 +148,6 @@ final class SessionModel: ObservableObject {
|
|||||||
|
|
||||||
var isBusy: Bool { phase != .idle }
|
var isBusy: Bool { phase != .idle }
|
||||||
|
|
||||||
/// True while a streaming session is running in the background under the opt-in keep-alive
|
|
||||||
/// (audio plays, video dropped, timeout armed). Drives the Live Activity's stage/countdown (M3)
|
|
||||||
/// and is cleared on foreground or teardown. iOS/iPadOS only in practice.
|
|
||||||
@Published private(set) var isBackgrounded = false
|
|
||||||
/// When the backgrounded keep-alive will auto-disconnect (nil unless backgrounded) — drives the
|
|
||||||
/// Live Activity countdown. Set alongside `backgroundTimer`.
|
|
||||||
@Published private(set) var backgroundDeadline: Date?
|
|
||||||
/// Bounded auto-disconnect for a backgrounded keep-alive session. Fires on `.main`.
|
|
||||||
private var backgroundTimer: DispatchSourceTimer?
|
|
||||||
|
|
||||||
/// `allowTofu` gates the trust-on-first-use prompt for an unpinned host: it is only true
|
/// `allowTofu` gates the trust-on-first-use prompt for an unpinned host: it is only true
|
||||||
/// when the host EXPLICITLY advertised `pair=optional` (rule 3a). For any other unpinned host
|
/// when the host EXPLICITLY advertised `pair=optional` (rule 3a). For any other unpinned host
|
||||||
/// — `pair=required`, a manually-typed host, or a discovered host with no/unknown `pair`
|
/// — `pair=required`, a manually-typed host, or a discovered host with no/unknown `pair`
|
||||||
@@ -236,13 +188,7 @@ final class SessionModel: ObservableObject {
|
|||||||
// metadata we apply (Step 2) when it IS HDR.
|
// metadata we apply (Step 2) when it IS HDR.
|
||||||
let displayHDR: Bool = {
|
let displayHDR: Bool = {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// POTENTIAL, not current, headroom: `maximumExtendedDynamicRangeColorComponentValue`
|
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
||||||
// is the CURRENTLY-ALLOCATED headroom, which macOS hands out on demand — on an idle
|
|
||||||
// SDR desktop it reads 1.0 even with HDR enabled and active (external HDR displays
|
|
||||||
// like the Samsung G95SC allocate EDR only when content asks). Gating on it means an
|
|
||||||
// HDR monitor never gets advertised at connect time. `maximumPotential…` is the
|
|
||||||
// mode-independent capability (the macOS analogue of the tvOS/iOS gates below).
|
|
||||||
return (NSScreen.main?.maximumPotentialExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
|
||||||
#elseif os(tvOS)
|
#elseif os(tvOS)
|
||||||
// NOT the EDR headroom here: on tvOS that reflects the CURRENT output mode, and
|
// NOT the EDR headroom here: on tvOS that reflects the CURRENT output mode, and
|
||||||
// Apple's recommended setup runs an SDR home screen with Match Content — an
|
// Apple's recommended setup runs an SDR home screen with Match Content — an
|
||||||
@@ -293,35 +239,13 @@ final class SessionModel: ObservableObject {
|
|||||||
// from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
// from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
||||||
var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
||||||
if AV1.hardwareDecodeSupported { videoCodecs |= PunktfunkConnection.codecAV1 }
|
if AV1.hardwareDecodeSupported { videoCodecs |= PunktfunkConnection.codecAV1 }
|
||||||
// PyroWave (wired LAN) is a pure opt-in: picking it in the codec setting both
|
|
||||||
// advertises the bit and prefers it — the host never auto-selects it, and the
|
|
||||||
// picker only offers it when the Metal decode probe passed (simdgroup floor ≈ A13;
|
|
||||||
// every M-series Mac and the ATV 4K gen 3 pass). The decoder self-configures from
|
|
||||||
// the per-frame sequence header (4:2:0/4:4:4, SDR/PQ — design/pyrowave-444-hdr.md),
|
|
||||||
// so the session keeps the user's HDR/10-bit/4:4:4 caps exactly like HEVC/AV1.
|
|
||||||
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
|
||||||
videoCodecs |= PunktfunkConnection.codecPyroWave
|
|
||||||
}
|
|
||||||
// Cursor channel (remote-desktop-sweep M2, macOS): sessions STARTING in the desktop
|
|
||||||
// mouse model advertise local cursor rendering — the host then stops compositing
|
|
||||||
// the pointer and forwards shape/state, which StreamView draws as the real
|
|
||||||
// NSCursor. Capture-mode sessions keep today's composited pointer.
|
|
||||||
#if os(macOS)
|
|
||||||
let clientCaps: UInt8 =
|
|
||||||
(MouseInputMode(
|
|
||||||
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.mouseMode) ?? "")
|
|
||||||
?? .capture) == .desktop ? 0x01 : 0
|
|
||||||
#else
|
|
||||||
let clientCaps: UInt8 = 0
|
|
||||||
#endif
|
|
||||||
let result = Result { try PunktfunkConnection(
|
let result = Result { try PunktfunkConnection(
|
||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
width: width, height: height, refreshHz: hz,
|
width: width, height: height, refreshHz: hz,
|
||||||
pinSHA256: pin, identity: identity, compositor: compositor,
|
pinSHA256: pin, identity: identity, compositor: compositor,
|
||||||
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||||
audioChannels: audioChannels,
|
audioChannels: audioChannels,
|
||||||
videoCodecs: videoCodecs, preferredCodec: preferredCodec,
|
videoCodecs: videoCodecs, preferredCodec: preferredCodec, launchID: launchID,
|
||||||
clientCaps: clientCaps, launchID: launchID,
|
|
||||||
// Delegated approval: the host holds this connect open until the operator approves
|
// Delegated approval: the host holds this connect open until the operator approves
|
||||||
// it (~180 s) — outwait that window so a slow approval still lands here. Normal
|
// it (~180 s) — outwait that window so a slow approval still lands here. Normal
|
||||||
// connects keep the snappy default.
|
// connects keep the snappy default.
|
||||||
@@ -360,15 +284,10 @@ final class SessionModel: ObservableObject {
|
|||||||
self.errorMessage = "\(host.displayName) is not paired yet. "
|
self.errorMessage = "\(host.displayName) is not paired yet. "
|
||||||
+ "Pair with its PIN before streaming."
|
+ "Pair with its PIN before streaming."
|
||||||
}
|
}
|
||||||
case .failure(let error):
|
case .failure:
|
||||||
self.phase = .idle
|
self.phase = .idle
|
||||||
self.activeHost = nil
|
self.activeHost = nil
|
||||||
if case PunktfunkClientError.rejected(let rejection) = error {
|
if let onUnreachable, !requestAccess {
|
||||||
// The host answered and stated its reason (declined / approval timed
|
|
||||||
// out / busy / versions differ) — show that, and never wake-retry a
|
|
||||||
// host that is demonstrably awake.
|
|
||||||
self.errorMessage = "\(host.displayName): \(rejection.userMessage)"
|
|
||||||
} else if let onUnreachable, !requestAccess {
|
|
||||||
// The caller owns recovery (wake-and-retry) — no error alert here; its
|
// The caller owns recovery (wake-and-retry) — no error alert here; its
|
||||||
// own overlay explains what's happening.
|
// own overlay explains what's happening.
|
||||||
onUnreachable()
|
onUnreachable()
|
||||||
@@ -377,7 +296,7 @@ final class SessionModel: ObservableObject {
|
|||||||
// operator didn't approve it before the host's park window elapsed (or
|
// operator didn't approve it before the host's park window elapsed (or
|
||||||
// the host was unreachable).
|
// the host was unreachable).
|
||||||
self.errorMessage = "\(host.displayName) didn't let this device in. "
|
self.errorMessage = "\(host.displayName) didn't let this device in. "
|
||||||
+ "Approve it in the host's web console (port 47992 → Pairing), then "
|
+ "Approve it in the host's web console (port 3000 → Pairing), then "
|
||||||
+ "request access again — the request expires after a few minutes."
|
+ "request access again — the request expires after a few minutes."
|
||||||
} else {
|
} else {
|
||||||
self.errorMessage = pin != nil
|
self.errorMessage = pin != nil
|
||||||
@@ -396,48 +315,6 @@ final class SessionModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Background keep-alive (opt-in, iOS)
|
|
||||||
|
|
||||||
/// Enter the backgrounded keep-alive state: keep audio playing, DROP video decode (no GPU work
|
|
||||||
/// off-screen), mute the mic (privacy), and arm a bounded auto-disconnect. The caller
|
|
||||||
/// (ContentView's scenePhase driver) gates this on the setting + `.streaming`; a no-op otherwise.
|
|
||||||
/// The video-drop seam is read by both pumps every iteration (`connection.isVideoDropped`).
|
|
||||||
func enterBackground(timeoutMinutes: Int) {
|
|
||||||
guard phase == .streaming, let conn = connection, !isBackgrounded else { return }
|
|
||||||
isBackgrounded = true
|
|
||||||
conn.setVideoDropped(true)
|
|
||||||
audio?.setMicMuted(true)
|
|
||||||
// Non-deliberate on fire (keep the host linger) so a user who returns late reconnects fast,
|
|
||||||
// exactly like today's network-drop path. min 1 minute guards a nonsense setting.
|
|
||||||
let minutes = max(1, timeoutMinutes)
|
|
||||||
backgroundDeadline = Date().addingTimeInterval(TimeInterval(minutes * 60))
|
|
||||||
let timer = DispatchSource.makeTimerSource(queue: .main)
|
|
||||||
timer.schedule(deadline: .now() + .seconds(minutes * 60))
|
|
||||||
timer.setEventHandler { [weak self] in
|
|
||||||
// The timer fires on `.main`, so the actor's executor is the main thread here.
|
|
||||||
MainActor.assumeIsolated { self?.disconnect(deliberate: false) }
|
|
||||||
}
|
|
||||||
backgroundTimer?.cancel()
|
|
||||||
backgroundTimer = timer
|
|
||||||
timer.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return to foreground: cancel the timeout, resume mic + video, and force a clean re-anchor —
|
|
||||||
/// request a fresh IDR (infinite GOP: it won't come on its own) and let the pump's freeze gate
|
|
||||||
/// withhold the concealed frames until it lands (it auto-arms on the resumed frame-index gap).
|
|
||||||
func exitBackground() {
|
|
||||||
guard isBackgrounded else { return }
|
|
||||||
isBackgrounded = false
|
|
||||||
backgroundDeadline = nil
|
|
||||||
backgroundTimer?.cancel()
|
|
||||||
backgroundTimer = nil
|
|
||||||
audio?.setMicMuted(false)
|
|
||||||
if let conn = connection {
|
|
||||||
conn.setVideoDropped(false)
|
|
||||||
conn.requestKeyframe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
|
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
|
||||||
func confirmTrust() -> Data? {
|
func confirmTrust() -> Data? {
|
||||||
guard case .awaitingTrust(let fingerprint) = phase else { return nil }
|
guard case .awaitingTrust(let fingerprint) = phase else { return nil }
|
||||||
@@ -455,11 +332,6 @@ final class SessionModel: ObservableObject {
|
|||||||
func disconnect(deliberate: Bool = true) {
|
func disconnect(deliberate: Bool = true) {
|
||||||
statsTimer?.invalidate()
|
statsTimer?.invalidate()
|
||||||
statsTimer = nil
|
statsTimer = nil
|
||||||
// Drop any armed background keep-alive (incl. the timeout that just fired us).
|
|
||||||
backgroundTimer?.cancel()
|
|
||||||
backgroundTimer = nil
|
|
||||||
isBackgrounded = false
|
|
||||||
backgroundDeadline = nil
|
|
||||||
let audio = self.audio
|
let audio = self.audio
|
||||||
self.audio = nil
|
self.audio = nil
|
||||||
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
||||||
@@ -472,12 +344,6 @@ final class SessionModel: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
let feedback = gamepadFeedback
|
let feedback = gamepadFeedback
|
||||||
gamepadFeedback = nil
|
gamepadFeedback = nil
|
||||||
#if os(macOS)
|
|
||||||
let clipboard = clipboardSync
|
|
||||||
clipboardSync = nil
|
|
||||||
#endif
|
|
||||||
clipboardEnabled = false
|
|
||||||
clipboardReason = 0
|
|
||||||
if let conn = connection {
|
if let conn = connection {
|
||||||
// Drain-thread teardown waits the pullers out and close() waits out in-flight
|
// Drain-thread teardown waits the pullers out and close() waits out in-flight
|
||||||
// polls + joins the Rust worker threads — keep all of it off the main actor,
|
// polls + joins the Rust worker threads — keep all of it off the main actor,
|
||||||
@@ -485,9 +351,6 @@ final class SessionModel: ObservableObject {
|
|||||||
Task.detached {
|
Task.detached {
|
||||||
audio?.stop()
|
audio?.stop()
|
||||||
feedback?.stop()
|
feedback?.stop()
|
||||||
#if os(macOS)
|
|
||||||
clipboard?.stop() // disables sync on the wire while the connection is still up
|
|
||||||
#endif
|
|
||||||
// Deliberate user quit → tell the host to skip the keep-alive linger (must precede close).
|
// Deliberate user quit → tell the host to skip the keep-alive linger (must precede close).
|
||||||
if deliberate { conn.disconnectQuit() }
|
if deliberate { conn.disconnectQuit() }
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -496,9 +359,6 @@ final class SessionModel: ObservableObject {
|
|||||||
Task.detached {
|
Task.detached {
|
||||||
audio?.stop()
|
audio?.stop()
|
||||||
feedback?.stop()
|
feedback?.stop()
|
||||||
#if os(macOS)
|
|
||||||
clipboard?.stop()
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection = nil
|
connection = nil
|
||||||
@@ -511,8 +371,6 @@ final class SessionModel: ObservableObject {
|
|||||||
endToEndValid = false
|
endToEndValid = false
|
||||||
decodeValid = false
|
decodeValid = false
|
||||||
displayValid = false
|
displayValid = false
|
||||||
clientQueueValid = false
|
|
||||||
osFloorValid = false
|
|
||||||
lostFrames = 0
|
lostFrames = 0
|
||||||
lostPct = 0
|
lostPct = 0
|
||||||
mouseCaptured = false
|
mouseCaptured = false
|
||||||
@@ -575,14 +433,6 @@ final class SessionModel: ObservableObject {
|
|||||||
let feedback = GamepadFeedback(connection: conn, manager: .shared)
|
let feedback = GamepadFeedback(connection: conn, manager: .shared)
|
||||||
feedback.start()
|
feedback.start()
|
||||||
gamepadFeedback = feedback
|
gamepadFeedback = feedback
|
||||||
#if os(macOS)
|
|
||||||
// Shared clipboard: opt-in per host AND host-advertised (older hosts / operator-disabled
|
|
||||||
// hosts never see a ClipControl). Same trust gate as audio — nothing is announced
|
|
||||||
// during the trust prompt.
|
|
||||||
if activeHost?.clipboardSync == true, conn.hostSupportsClipboard {
|
|
||||||
startClipboardSync(conn)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
let pointer = SiriRemotePointer(connection: conn)
|
let pointer = SiriRemotePointer(connection: conn)
|
||||||
pointer.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
pointer.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||||
@@ -591,40 +441,6 @@ final class SessionModel: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
/// Create + start the session's clipboard bridge and route its host acks into the published
|
|
||||||
/// UI state. `ClipboardSync.start()` sends the enable; the host's `.state` answer flips
|
|
||||||
/// `clipboardEnabled` (or leaves it false with a `clipboardReason` the UI can explain).
|
|
||||||
private func startClipboardSync(_ conn: PunktfunkConnection) {
|
|
||||||
let sync = ClipboardSync(connection: conn)
|
|
||||||
sync.onState = { [weak self] enabled, _, reason in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.clipboardEnabled = enabled
|
|
||||||
self?.clipboardReason = reason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sync.start()
|
|
||||||
clipboardSync = sync
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Flip clipboard sync mid-session (the Stream menu). Off → on requires the host cap; on →
|
|
||||||
/// off tears the bridge down (off-main — the drain join must not block the main actor) and
|
|
||||||
/// tells the host, which drops any selection we own there. No-op off-macOS or while idle.
|
|
||||||
func toggleClipboardSync() {
|
|
||||||
#if os(macOS)
|
|
||||||
guard let conn = connection, phase == .streaming else { return }
|
|
||||||
if let sync = clipboardSync {
|
|
||||||
clipboardSync = nil
|
|
||||||
clipboardEnabled = false
|
|
||||||
clipboardReason = 0
|
|
||||||
Task.detached { sync.stop() }
|
|
||||||
} else if conn.hostSupportsClipboard {
|
|
||||||
startClipboardSync(conn)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startStatsTimer() {
|
private func startStatsTimer() {
|
||||||
lastFramesDropped = 0 // a fresh connection's cumulative drop counter starts at 0
|
lastFramesDropped = 0 // a fresh connection's cumulative drop counter starts at 0
|
||||||
latencySplit.reset() // no stale receipts/samples from a previous session
|
latencySplit.reset() // no stale receipts/samples from a previous session
|
||||||
@@ -696,34 +512,15 @@ final class SessionModel: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.displayValid = false
|
self.displayValid = false
|
||||||
}
|
}
|
||||||
if let f = self.presentFloor.drain() {
|
|
||||||
self.osFloorP50Ms = f.p50Ms
|
|
||||||
self.osFloorValid = true
|
|
||||||
} else {
|
|
||||||
self.osFloorValid = false
|
|
||||||
}
|
|
||||||
if let q = self.clientQueue.drain() {
|
|
||||||
self.clientQueueP50Ms = q.p50Ms
|
|
||||||
self.clientQueueValid = true
|
|
||||||
} else {
|
|
||||||
self.clientQueueValid = false
|
|
||||||
}
|
|
||||||
// Mirror the window to the unified log (see statsLog) — one line per second,
|
// Mirror the window to the unified log (see statsLog) — one line per second,
|
||||||
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
||||||
// `presents` counts frames that reached glass (the display meter's sample count)
|
// `presents` counts frames that reached glass (the display meter's sample count)
|
||||||
// — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is
|
// — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is
|
||||||
// upstream (host capture/encode or the network).
|
// upstream (host capture/encode or the network).
|
||||||
if frames > 0 {
|
if frames > 0 {
|
||||||
// The classic fields stay RAW (cross-session comparability with every log
|
|
||||||
// captured before the 2026-07 floor policy); the appended trio carries the
|
|
||||||
// measured OS present floor and the floor-shaved values the HUD displays.
|
|
||||||
let line = String(
|
let line = String(
|
||||||
// Swift Int is 64-bit → %lld, NOT %d (which is a 32-bit C int); macOS 26's
|
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||||
// strict String(format:) validator rejects the %d/Int mismatch and drops
|
+ "decode_p50=%.1f display_p50=%.1f lost=%d",
|
||||||
// the whole line (a cascade error that also mis-blames the float args).
|
|
||||||
format: "fps=%lld presents=%lld e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
|
||||||
+ "decode_p50=%.1f display_p50=%.1f lost=%lld "
|
|
||||||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f",
|
|
||||||
frames,
|
frames,
|
||||||
displayWindow?.count ?? 0,
|
displayWindow?.count ?? 0,
|
||||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||||
@@ -731,11 +528,7 @@ final class SessionModel: ObservableObject {
|
|||||||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
||||||
self.decodeValid ? self.decodeP50Ms : -1,
|
self.decodeValid ? self.decodeP50Ms : -1,
|
||||||
self.displayValid ? self.displayP50Ms : -1,
|
self.displayValid ? self.displayP50Ms : -1,
|
||||||
lost,
|
lost)
|
||||||
self.osFloorValid ? self.osFloorP50Ms : -1,
|
|
||||||
self.displayValid ? self.displayAdjP50Ms : -1,
|
|
||||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
|
|
||||||
self.clientQueueValid ? self.clientQueueP50Ms : -1)
|
|
||||||
statsLog.info("\(line, privacy: .public)")
|
statsLog.info("\(line, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ import SwiftUI
|
|||||||
/// `.focusedSceneValue` so the Scene-level commands can drive it.
|
/// `.focusedSceneValue` so the Scene-level commands can drive it.
|
||||||
struct SessionFocus {
|
struct SessionFocus {
|
||||||
var isStreaming: Bool
|
var isStreaming: Bool
|
||||||
/// The connected host advertises `HOST_CAP_CLIPBOARD` (gates the Share Clipboard item —
|
|
||||||
/// macOS-only UI, but the fact is platform-neutral).
|
|
||||||
var clipboardAvailable: Bool
|
|
||||||
/// Clipboard sync is live (host-acked) — drives the item's Stop/Share title.
|
|
||||||
var clipboardOn: Bool
|
|
||||||
var toggleClipboard: () -> Void
|
|
||||||
var disconnect: () -> Void
|
var disconnect: () -> Void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,23 +58,6 @@ struct StreamCommands: Commands {
|
|||||||
}
|
}
|
||||||
.keyboardShortcut("q", modifiers: [.control, .option, .shift])
|
.keyboardShortcut("q", modifiers: [.control, .option, .shift])
|
||||||
.disabled(session?.isStreaming != true)
|
.disabled(session?.isStreaming != true)
|
||||||
#if os(macOS)
|
|
||||||
// Mid-session clipboard flip (design/clipboard-and-file-transfer.md §5.3). Greyed
|
|
||||||
// when the host doesn't advertise the cap (older host / operator policy off).
|
|
||||||
Button(session?.clipboardOn == true ? "Stop Sharing Clipboard" : "Share Clipboard") {
|
|
||||||
session?.toggleClipboard()
|
|
||||||
}
|
|
||||||
.keyboardShortcut("c", modifiers: [.control, .option, .shift])
|
|
||||||
.disabled(session?.isStreaming != true || session?.clipboardAvailable != true)
|
|
||||||
// Toggle the window's fullscreen. ⌃⌘F is the macOS-standard fullscreen combo; here it's
|
|
||||||
// explicit so it's discoverable AND survives capture — while streaming the stream view
|
|
||||||
// swallows keys, so InputCapture's monitor detects the same combo and posts the same
|
|
||||||
// notification the key window's FullscreenController observes.
|
|
||||||
Button("Toggle Fullscreen") {
|
|
||||||
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("f", modifiers: [.control, .command])
|
|
||||||
#endif
|
|
||||||
Divider()
|
Divider()
|
||||||
Button("Disconnect") { session?.disconnect() }
|
Button("Disconnect") { session?.disconnect() }
|
||||||
.keyboardShortcut("d", modifiers: [.control, .option, .shift])
|
.keyboardShortcut("d", modifiers: [.control, .option, .shift])
|
||||||
|
|||||||
@@ -65,9 +65,7 @@ struct StreamHUDView: View {
|
|||||||
private var compactLine: String {
|
private var compactLine: String {
|
||||||
var parts = ["\(model.fps) fps"]
|
var parts = ["\(model.fps) fps"]
|
||||||
if model.endToEndValid {
|
if model.endToEndValid {
|
||||||
// Floor-shaved (design/apple-presentation-rebuild.md): the OS present pipeline's
|
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
|
||||||
// fixed depth is excluded, so the headline describes Punktfunk's own latency.
|
|
||||||
parts.append(String(format: "%.1f ms", model.endToEndAdjP50Ms))
|
|
||||||
} else if model.hostNetworkValid {
|
} else if model.hostNetworkValid {
|
||||||
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
||||||
}
|
}
|
||||||
@@ -88,46 +86,24 @@ struct StreamHUDView: View {
|
|||||||
}
|
}
|
||||||
if model.endToEndValid {
|
if model.endToEndValid {
|
||||||
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
// 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
|
// corrected) — "(same-host clock)" when the host didn't answer the skew handshake.
|
||||||
// handshake. FLOOR-SHAVED (design/apple-presentation-rebuild.md): the OS present
|
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
||||||
// pipeline's fixed depth is excluded so the number describes Punktfunk's own
|
|
||||||
// latency; the detailed tier shows the excluded floor as its own line, and the
|
|
||||||
// stats log keeps the raw values.
|
|
||||||
Text("end-to-end \(model.endToEndAdjP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndAdjP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
// The equation (detailed tier only): the stages tiling the headline interval
|
// The equation (detailed tier only): the stages tiling the headline interval
|
||||||
// (per-window p50s — they only approximately sum to the directly-measured
|
// (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
|
// 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. The
|
// into host + network (phase 2); an old host keeps the combined term.
|
||||||
// display term is floor-shaved like the headline, so the equation still sums.
|
|
||||||
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||||
if model.splitValid {
|
if model.splitValid {
|
||||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
|
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))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
|
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if model.osFloorValid {
|
|
||||||
// The excluded OS term, kept visible for honesty: display-pipeline
|
|
||||||
// minimum no client can pace under (~2 refresh intervals composited).
|
|
||||||
Text("os present +\(model.osFloorP50Ms, specifier: "%.1f") excluded (display pipeline minimum)")
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
// Client-queue wait (reassembly receipt → decode pull, ABI v9 split): ~0 on
|
|
||||||
// a healthy stream and hidden as noise; shown from 2 ms — a persistent value
|
|
||||||
// is a client-side standing backlog that pre-split builds displayed as
|
|
||||||
// "network" (the 2026-07 two-pair plateau). The core's standing-latency
|
|
||||||
// bleed logs alongside when it acts on the same state.
|
|
||||||
if model.clientQueueValid && model.clientQueueP50Ms >= 2 {
|
|
||||||
Text("client queue +\(model.clientQueueP50Ms, specifier: "%.1f") (receive backlog — standing if it persists)")
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if model.hostNetworkValid {
|
} else if model.hostNetworkValid {
|
||||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ import PunktfunkKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS) || os(tvOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
#if os(iOS)
|
|
||||||
import CoreHaptics
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct GamepadSettingsView: View {
|
struct GamepadSettingsView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -40,15 +37,7 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||||
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
||||||
SettingsOptions.presentPriorityDefault
|
|
||||||
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
|
|
||||||
#if os(macOS)
|
|
||||||
@AppStorage(DefaultsKey.windowedSafePresent) private var windowedSafePresent = true
|
|
||||||
#endif
|
|
||||||
#if os(iOS)
|
|
||||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
|
||||||
#endif
|
|
||||||
@ObservedObject private var gamepads = GamepadManager.shared
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -241,7 +230,7 @@ struct GamepadSettingsView: View {
|
|||||||
.map { (label: "\($0) Hz", tag: $0) }
|
.map { (label: "\($0) Hz", tag: $0) }
|
||||||
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
||||||
let controllers = SettingsOptions.controllerOptions(gamepads)
|
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||||||
var list: [Row] = [
|
return [
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "resolution", header: "Stream", icon: "aspectratio",
|
id: "resolution", header: "Stream", icon: "aspectratio",
|
||||||
label: "Resolution",
|
label: "Resolution",
|
||||||
@@ -293,19 +282,11 @@ struct GamepadSettingsView: View {
|
|||||||
+ "hardware decode.",
|
+ "hardware decode.",
|
||||||
value: $enable444),
|
value: $enable444),
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
|
id: "presenter", icon: "rectangle.stack", label: "Presenter",
|
||||||
detail: "Lowest latency shows each frame the moment the display can take it; "
|
detail: "Stage 3 paces presents to the display — lowest display latency. "
|
||||||
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
|
+ "Stage 2 shows each frame on arrival. Applies from the next session.",
|
||||||
+ "from the next session.",
|
options: SettingsOptions.presenters, current: presenter
|
||||||
options: SettingsOptions.presentPriorities, current: presentPriority
|
) { presenter = $0 },
|
||||||
) { presentPriority = $0 },
|
|
||||||
choiceRow(
|
|
||||||
id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer",
|
|
||||||
detail: "How many frames Smoothness holds — each adds about a refresh of "
|
|
||||||
+ "display latency and absorbs about a refresh of jitter. Only applies "
|
|
||||||
+ "when prioritizing smoothness.",
|
|
||||||
options: SettingsOptions.smoothBuffers(refreshHz: hz), current: smoothBuffer
|
|
||||||
) { smoothBuffer = $0 },
|
|
||||||
|
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||||
@@ -348,39 +329,6 @@ struct GamepadSettingsView: View {
|
|||||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||||
value: $gamepadUIEnabled),
|
value: $gamepadUIEnabled),
|
||||||
]
|
]
|
||||||
#if os(macOS)
|
|
||||||
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
|
||||||
// the Video group) — macOS only, mirroring the touch SettingsView's Presentation row
|
|
||||||
// (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent).
|
|
||||||
if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) {
|
|
||||||
list.insert(
|
|
||||||
toggleRow(
|
|
||||||
id: "windowedSafePresent", icon: "macwindow.badge.plus",
|
|
||||||
label: "Safe windowed presentation",
|
|
||||||
detail: "Windowed streams present in step with the compositor — avoids a "
|
|
||||||
+ "macOS display-driver crash on high-refresh displays, at a small "
|
|
||||||
+ "latency cost. Fullscreen always uses the fastest path.",
|
|
||||||
value: $windowedSafePresent),
|
|
||||||
at: at + 1)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#if os(iOS)
|
|
||||||
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
|
||||||
// Controller group — the next row carries the "Interface" header). iPhone only in
|
|
||||||
// practice: hidden where the device itself can't play haptics (iPad).
|
|
||||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics,
|
|
||||||
let at = list.firstIndex(where: { $0.id == "padType" }) {
|
|
||||||
list.insert(
|
|
||||||
toggleRow(
|
|
||||||
id: "deviceRumble", icon: "iphone.radiowaves.left.and.right",
|
|
||||||
label: "Rumble on this iPhone",
|
|
||||||
detail: "Also play player 1's rumble on the phone's own Taptic Engine — "
|
|
||||||
+ "for clip-on pads without rumble motors.",
|
|
||||||
value: $rumbleOnDevice),
|
|
||||||
at: at + 1)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
return list
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import SwiftUI
|
|||||||
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
|
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
|
||||||
/// private) so the screenshot harness can open SettingsView on a specific category.
|
/// private) so the screenshot harness can open SettingsView on a specific category.
|
||||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||||
// The 2026-07 revamp's map: General = session/app behavior, Display = everything about the
|
case general, display, audio, controllers, advanced, about
|
||||||
// picture (resolution, quality, presentation, host output), Input = touch/keyboard/mouse.
|
|
||||||
// The old Advanced tab dissolved (its lone game-library toggle lives in General now).
|
|
||||||
case general, display, input, audio, controllers, about
|
|
||||||
|
|
||||||
var id: Self { self }
|
var id: Self { self }
|
||||||
|
|
||||||
@@ -19,9 +16,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .general: return "General"
|
case .general: return "General"
|
||||||
case .display: return "Display"
|
case .display: return "Display"
|
||||||
case .input: return "Input"
|
|
||||||
case .audio: return "Audio"
|
case .audio: return "Audio"
|
||||||
case .controllers: return "Controllers"
|
case .controllers: return "Controllers"
|
||||||
|
case .advanced: return "Advanced"
|
||||||
case .about: return "About"
|
case .about: return "About"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,9 +27,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .general: return "gearshape"
|
case .general: return "gearshape"
|
||||||
case .display: return "display"
|
case .display: return "display"
|
||||||
case .input: return "keyboard"
|
|
||||||
case .audio: return "speaker.wave.2"
|
case .audio: return "speaker.wave.2"
|
||||||
case .controllers: return "gamecontroller"
|
case .controllers: return "gamecontroller"
|
||||||
|
case .advanced: return "slider.horizontal.3"
|
||||||
case .about: return "info.circle"
|
case .about: return "info.circle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,31 +37,28 @@ enum SettingsOptions {
|
|||||||
static let hudPlacements: [(label: String, tag: String)] =
|
static let hudPlacements: [(label: String, tag: String)] =
|
||||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||||
|
|
||||||
/// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced
|
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` — see SessionPresenter's
|
||||||
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
|
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
|
||||||
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
|
static var presenters: [(label: String, tag: String)] {
|
||||||
/// PUNKTFUNK_PRESENTER debug env lever.
|
var options: [(label: String, tag: String)] = [
|
||||||
static let presentPriorities: [(label: String, tag: String)] = [
|
("Stage 2", "stage2"),
|
||||||
("Lowest latency", "latency"),
|
("Stage 3", "stage3"),
|
||||||
("Smoothness", "smooth"),
|
|
||||||
]
|
|
||||||
static let presentPriorityDefault = "latency"
|
|
||||||
|
|
||||||
/// Smoothness's jitter-buffer sizes (`DefaultsKey.smoothBuffer`; 0 = Automatic, currently 2
|
|
||||||
/// frames). The ms hints derive from the chosen refresh setting — each buffered frame costs
|
|
||||||
/// about one refresh interval of display latency and absorbs about one interval of arrival
|
|
||||||
/// jitter.
|
|
||||||
static func smoothBuffers(refreshHz: Int) -> [(label: String, tag: Int)] {
|
|
||||||
let periodMs = 1000.0 / Double(max(24, refreshHz))
|
|
||||||
func hint(_ frames: Int) -> String {
|
|
||||||
String(format: "+%.0f ms", Double(frames) * periodMs)
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
("Automatic", 0),
|
|
||||||
("1 frame (\(hint(1)))", 1),
|
|
||||||
("2 frames (\(hint(2)))", 2),
|
|
||||||
("3 frames (\(hint(3)))", 3),
|
|
||||||
]
|
]
|
||||||
|
#if DEBUG
|
||||||
|
options.append(("Stage 1 (debug)", "stage1"))
|
||||||
|
#endif
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The platform's presenter default (mirrors SessionPresenter's platformDefault — tvOS runs
|
||||||
|
/// glass pacing, everything else arrival). Views seed their @AppStorage display from this so
|
||||||
|
/// an untouched picker shows what actually runs.
|
||||||
|
static var presenterDefault: String {
|
||||||
|
#if os(tvOS)
|
||||||
|
"stage3"
|
||||||
|
#else
|
||||||
|
"stage2"
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
||||||
@@ -82,13 +79,6 @@ enum SettingsOptions {
|
|||||||
if AV1.hardwareDecodeSupported {
|
if AV1.hardwareDecodeSupported {
|
||||||
options.insert(("AV1", "av1"), at: 2)
|
options.insert(("AV1", "av1"), at: 2)
|
||||||
}
|
}
|
||||||
// PyroWave is the opt-in wired-LAN low-latency codec (100–400 Mbps all-intra wavelet,
|
|
||||||
// 8-bit SDR): selecting it advertises + prefers it for the session. Offered only when
|
|
||||||
// the Metal decode probe passes (same gate SessionModel advertises by) — elsewhere the
|
|
||||||
// host could never emit it.
|
|
||||||
if MetalWaveletDecoder.supported {
|
|
||||||
options.append(("PyroWave (wired LAN)", "pyrowave"))
|
|
||||||
}
|
|
||||||
return options
|
return options
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,22 @@
|
|||||||
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
||||||
// composed by the per-platform bodies in SettingsView.swift.
|
// composed by the per-platform bodies in SettingsView.swift.
|
||||||
//
|
|
||||||
// 2026-07 settings revamp: every field carries its explanation DIRECTLY under it in the same
|
|
||||||
// cell (the `described` helper in SettingsView+Support) — the old per-section footer paragraphs
|
|
||||||
// collected several fields' explanations into one blob nobody could match back to its row.
|
|
||||||
// Where a picker's meaning depends on the selection (touch mode, modifier layout, prioritize),
|
|
||||||
// the description is DYNAMIC — it explains the current choice. The only footers left are the
|
|
||||||
// one-line "Applies from the next session." form notes.
|
|
||||||
//
|
|
||||||
// Category map (SettingsCategory): General = session/app behavior, Display = everything about
|
|
||||||
// the picture (resolution lives HERE), Input = touch/keyboard/mouse, Audio, Controllers, About.
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import CoreHaptics
|
|
||||||
#endif
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension SettingsView {
|
extension SettingsView {
|
||||||
// MARK: - Display: Resolution
|
// MARK: - Sections (shared)
|
||||||
|
|
||||||
// NOTE: the Section content is deliberately split into the small named builders below — as one
|
// 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
|
// 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
|
// 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).
|
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
||||||
@ViewBuilder var resolutionSection: some View {
|
@ViewBuilder var streamModeSection: some View {
|
||||||
Section("Resolution") {
|
Section {
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS)
|
||||||
// Match-window (design/midstream-resolution-resize.md D1): follow the session
|
// Match-window (design/midstream-resolution-resize.md D1): follow the session
|
||||||
// window/scene, renegotiating the host mode on a resize. Off → the explicit mode below.
|
// window/scene, renegotiating the host mode on a resize. Off → the explicit mode below.
|
||||||
described(matchWindow
|
Toggle("Match window", isOn: $matchWindow)
|
||||||
? "The host resizes its output to follow this window — the picture stays "
|
|
||||||
+ "pixel-exact (1:1) through every resize."
|
|
||||||
: "Stream at the fixed mode below; a window at a different size shows it scaled.") {
|
|
||||||
Toggle("Match window", isOn: $matchWindow)
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
iosResolutionWheel
|
iosResolutionWheel
|
||||||
@@ -47,19 +29,30 @@ extension SettingsView {
|
|||||||
TextField("", value: $height, format: .number.grouping(.never))
|
TextField("", value: $height, format: .number.grouping(.never))
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
described("The host drives a real virtual output at exactly this size and refresh — "
|
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
||||||
+ "true pixels, no scaling.") {
|
|
||||||
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
|
||||||
}
|
|
||||||
LabeledContent("") {
|
LabeledContent("") {
|
||||||
Button("Use this display's mode") { fillFromMainScreen() }
|
Button("Use this display's mode") { fillFromMainScreen() }
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
#if !os(tvOS)
|
||||||
|
bitrateRows
|
||||||
|
#endif
|
||||||
|
} header: {
|
||||||
|
Text("Stream mode")
|
||||||
|
} footer: {
|
||||||
|
Text(matchWindow
|
||||||
|
? "The stream follows this window — the host resizes its virtual output to match "
|
||||||
|
+ "as you resize, so the picture stays pixel-exact (1:1) with no scaling. "
|
||||||
|
+ "\(Self.bitrateFooter)"
|
||||||
|
: "The host creates a virtual output at exactly this mode — native resolution, but "
|
||||||
|
+ "a window that isn't this size is scaled to fit. \(Self.bitrateFooter)")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// MARK: - Display: Resolution (iOS wheel)
|
// MARK: - Stream mode (iOS wheel)
|
||||||
|
|
||||||
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) — the
|
/// 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
|
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
|
||||||
@@ -78,11 +71,6 @@ extension SettingsView {
|
|||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.wheel)
|
.pickerStyle(.wheel)
|
||||||
.frame(maxHeight: 140)
|
.frame(maxHeight: 140)
|
||||||
Text("The host drives a real output at exactly this mode — true pixels, no scaling.")
|
|
||||||
.font(.geist(13, relativeTo: .footnote))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.frame(maxWidth: 360, alignment: .leading) // match the described-row caption cap
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,69 +162,10 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - Display: Quality
|
|
||||||
|
|
||||||
@ViewBuilder var qualitySection: some View {
|
|
||||||
Section("Quality") {
|
|
||||||
#if !os(tvOS)
|
|
||||||
renderScaleRow
|
|
||||||
bitrateRows
|
|
||||||
#endif
|
|
||||||
described("A preference — the host falls back if it can't encode it.") {
|
|
||||||
Picker("Video codec", selection: $codec) {
|
|
||||||
ForEach(SettingsOptions.codecs, id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
described("HDR10, when the host has HDR content and this display supports it. "
|
|
||||||
+ "HEVC only; otherwise the stream stays SDR.") {
|
|
||||||
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
|
||||||
}
|
|
||||||
described("Sharper text and UI for desktop work, at more bandwidth. For games the "
|
|
||||||
+ "bits are better spent at 4:2:0. HEVC only.") {
|
|
||||||
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
/// Render-scale picker + the resulting host resolution. > 1 supersamples (sharper, at more
|
|
||||||
/// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the
|
|
||||||
/// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives.
|
|
||||||
@ViewBuilder var renderScaleRow: some View {
|
|
||||||
described(renderScaleDescription) {
|
|
||||||
Picker("Render scale", selection: $renderScale) {
|
|
||||||
ForEach(RenderScale.presets, id: \.self) { scale in
|
|
||||||
Text(RenderScale.label(scale)).tag(scale)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render scale explained, with the CONCRETE host resolution when it applies — the cost made
|
|
||||||
/// legible. Only the explicit mode can show it (match-window derives the base from the live
|
|
||||||
/// window, not these fields).
|
|
||||||
private var renderScaleDescription: String {
|
|
||||||
var text = "Above native supersamples for sharpness; below renders lighter on the host "
|
|
||||||
+ "and the link."
|
|
||||||
if renderScale != 1.0, !matchWindow {
|
|
||||||
let mode = RenderScale.apply(
|
|
||||||
baseWidth: width, baseHeight: height,
|
|
||||||
scale: renderScale,
|
|
||||||
maxDimension: RenderScale.maxDimension(codec: codec))
|
|
||||||
text += " Host renders \(Int(mode.width))×\(Int(mode.height)); this device scales "
|
|
||||||
+ "it to your display."
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
||||||
@ViewBuilder private var bitrateRows: some View {
|
@ViewBuilder private var bitrateRows: some View {
|
||||||
described("The host's default 20 Mbps, clamped to what it supports. Turn off to set a "
|
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||||
+ "fixed rate — a host card's context menu has a network speed test.") {
|
|
||||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
|
||||||
}
|
|
||||||
if bitrateKbps != 0 {
|
if bitrateKbps != 0 {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Slider(value: bitrateSlider, in: 0...1) {
|
Slider(value: bitrateSlider, in: 0...1) {
|
||||||
@@ -256,262 +185,26 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - Display: Presentation
|
|
||||||
|
|
||||||
// The presentation intent (design/apple-presentation-rebuild.md — replaced the visible
|
|
||||||
// stage picker): latency (newest-wins, zero queue) vs smoothness (a small deliberate jitter
|
|
||||||
// buffer). The stage ladder survives only as the hidden PUNKTFUNK_PRESENTER debug env lever.
|
|
||||||
@ViewBuilder var presentationSection: some View {
|
|
||||||
Section("Presentation") {
|
|
||||||
described(presentPriority == "smooth"
|
|
||||||
? "A small frame buffer evens out network hiccups, at the buffer's worth of "
|
|
||||||
+ "added display latency."
|
|
||||||
: "Every frame shows the moment the display can take it — a network hiccup is "
|
|
||||||
+ "an occasional repeated or skipped frame.") {
|
|
||||||
Picker("Prioritize", selection: $presentPriority) {
|
|
||||||
ForEach(SettingsOptions.presentPriorities, id: \.tag) { option in
|
|
||||||
Text(option.tag == SettingsOptions.presentPriorityDefault
|
|
||||||
? "\(option.label) (default)" : option.label)
|
|
||||||
.tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if presentPriority == "smooth" {
|
|
||||||
described("Frames held back — each absorbs about one refresh of jitter and "
|
|
||||||
+ "adds one refresh of delay.") {
|
|
||||||
Picker("Buffer", selection: $smoothBuffer) {
|
|
||||||
ForEach(SettingsOptions.smoothBuffers(refreshHz: hz), id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh.
|
|
||||||
#if !os(tvOS)
|
|
||||||
described("A ProMotion or adaptive-sync display follows the stream's rate — "
|
|
||||||
+ "smoother motion. No effect on fixed-refresh displays.") {
|
|
||||||
Toggle("Allow VRR", isOn: $allowVRR)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice
|
|
||||||
// only exists on the Mac (the layer's own sync stays off — see MetalVideoPresenter).
|
|
||||||
#if os(macOS)
|
|
||||||
described("Flips align to the display's refresh — even pacing, up to one refresh "
|
|
||||||
+ "of added latency. Off shows frames as soon as they're ready.") {
|
|
||||||
Toggle("V-Sync", isOn: $vsync)
|
|
||||||
}
|
|
||||||
// The DCP swapID-panic mitigation's user handle (see DefaultsKey.windowedSafePresent
|
|
||||||
// for the saga). Default ON: turning it off re-arms a WHOLE-MACHINE kernel panic on
|
|
||||||
// affected setups, so the caption says so in plain words.
|
|
||||||
described(windowedSafePresent
|
|
||||||
? "Windowed streams present in step with the system compositor — avoids a macOS "
|
|
||||||
+ "display-driver crash seen on high-refresh displays, at a small latency "
|
|
||||||
+ "cost. Fullscreen always uses the fastest path."
|
|
||||||
: "Windowed streams use the fastest present path. On some high-refresh setups "
|
|
||||||
+ "this can crash macOS itself (kernel panic) — turn back on if your Mac "
|
|
||||||
+ "restarts during windowed streaming.") {
|
|
||||||
Toggle("Safe windowed presentation", isOn: $windowedSafePresent)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Display: Host output
|
|
||||||
|
|
||||||
@ViewBuilder var hostOutputSection: some View {
|
|
||||||
Section {
|
|
||||||
described("The backend the host uses for its virtual output. A specific choice "
|
|
||||||
+ "falls back to auto-detection when that backend isn't available.") {
|
|
||||||
Picker("Compositor", selection: $compositor) {
|
|
||||||
ForEach(SettingsOptions.compositors, id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Host output")
|
|
||||||
} footer: {
|
|
||||||
// The one form-level note (deliberately not repeated on every row above).
|
|
||||||
Text("Display changes apply from the next session.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - General: Session
|
|
||||||
|
|
||||||
@ViewBuilder var sessionSection: some View {
|
|
||||||
Section("Session") {
|
|
||||||
#if os(macOS)
|
|
||||||
described("Go fullscreen when a session starts; return to a window on the host "
|
|
||||||
+ "list.") {
|
|
||||||
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
described("Connecting to a saved host that's offline sends Wake-on-LAN and waits "
|
|
||||||
+ "for it to boot. Turn off if hosts behind a VPN look offline when they "
|
|
||||||
+ "aren't.") {
|
|
||||||
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
described("Audio and the connection stay live after you switch away; video pauses "
|
|
||||||
+ "to save power and resumes instantly when you return. Off, backgrounding "
|
|
||||||
+ "freezes the session.") {
|
|
||||||
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
|
|
||||||
}
|
|
||||||
if backgroundKeepAlive {
|
|
||||||
described("Ends a backgrounded session so it can't run down the battery.") {
|
|
||||||
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
|
|
||||||
Text("1 minute").tag(1)
|
|
||||||
Text("5 minutes").tag(5)
|
|
||||||
Text("10 minutes").tag(10)
|
|
||||||
Text("30 minutes").tag(30)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - General: Statistics overlay
|
|
||||||
|
|
||||||
@ViewBuilder var overlaySection: some View {
|
|
||||||
Section("Statistics") {
|
|
||||||
described(Self.statisticsDescription) {
|
|
||||||
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
|
||||||
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
|
||||||
Text(tier.label).tag(tier.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Picker("Position", selection: $hudPlacement) {
|
|
||||||
ForEach(HUDPlacement.allCases) { placement in
|
|
||||||
Text(placement.label).tag(placement.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - General: Library
|
|
||||||
|
|
||||||
@ViewBuilder var librarySection: some View {
|
|
||||||
Section("Library") {
|
|
||||||
described("Adds “Browse Library…” to paired hosts — list their Steam and custom "
|
|
||||||
+ "games and launch one directly. No extra host setup.") {
|
|
||||||
Toggle("Show game library", isOn: $libraryEnabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Input
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
/// 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 {
|
|
||||||
Section("Touch & pointer") {
|
|
||||||
described(touchModeDescription) {
|
|
||||||
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 UIDevice.current.userInterfaceIdiom == .pad {
|
|
||||||
described("Locks a hardware mouse for relative mouse-look in games; off sends "
|
|
||||||
+ "absolute positions. Needs the stream fullscreen and frontmost.") {
|
|
||||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The SELECTED touch mode explained — dynamic, so the caption always describes what the
|
|
||||||
/// picker currently does instead of narrating all three modes at once.
|
|
||||||
private var touchModeDescription: String {
|
|
||||||
switch TouchInputMode(rawValue: touchMode) ?? .trackpad {
|
|
||||||
case .trackpad:
|
|
||||||
return "Your finger drives the host cursor like a laptop trackpad — tap to click, "
|
|
||||||
+ "two-finger tap right-clicks, two-finger drag scrolls, tap-and-drag holds."
|
|
||||||
case .pointer:
|
|
||||||
return "The host cursor jumps to wherever you touch — tap is a click at that spot."
|
|
||||||
case .touch:
|
|
||||||
return "Real multi-touch reaches the host — for touch-native apps and games."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
/// Keyboard & mouse forwarding — applies wherever a hardware keyboard/mouse drives the stream
|
|
||||||
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
|
|
||||||
@ViewBuilder var inputSection: some View {
|
|
||||||
Section("Keyboard & mouse") {
|
|
||||||
#if os(macOS)
|
|
||||||
described(mouseModeDescription) {
|
|
||||||
Picker("Mouse input", selection: $mouseMode) {
|
|
||||||
Text("Capture (games)").tag(MouseInputMode.capture.rawValue)
|
|
||||||
Text("Desktop (absolute)").tag(MouseInputMode.desktop.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
described((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail) {
|
|
||||||
Picker("Modifier keys", selection: $modifierLayout) {
|
|
||||||
ForEach(ModifierLayout.allCases, id: \.self) { layout in
|
|
||||||
Text(layout.label).tag(layout.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
described("Reverses the wheel and trackpad scroll direction sent to the host.") {
|
|
||||||
Toggle("Invert scroll direction", isOn: $invertScroll)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
/// The SELECTED mouse model explained — dynamic, like the touch-mode caption.
|
|
||||||
private var mouseModeDescription: String {
|
|
||||||
switch MouseInputMode(rawValue: mouseMode) ?? .capture {
|
|
||||||
case .capture:
|
|
||||||
return "The pointer locks to the stream and sends relative motion — best for "
|
|
||||||
+ "games. ⌃⌥⇧M switches live; applies from the next capture otherwise."
|
|
||||||
case .desktop:
|
|
||||||
return "The pointer moves freely in and out of the stream and sends absolute "
|
|
||||||
+ "positions — best for remote desktop work. Unavailable on gamescope hosts."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Audio
|
|
||||||
|
|
||||||
@ViewBuilder var audioSection: some View {
|
@ViewBuilder var audioSection: some View {
|
||||||
Section {
|
Section {
|
||||||
described("The speaker layout requested from the host.") {
|
Picker("Audio channels", selection: $audioChannels) {
|
||||||
Picker("Audio channels", selection: $audioChannels) {
|
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
||||||
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
Text(option.label).tag(option.tag)
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
described("Host audio plays through this device; System default follows your "
|
Picker("Speaker", selection: $speakerUID) {
|
||||||
+ "Mac's output changes.") {
|
Text("System default").tag("")
|
||||||
Picker("Speaker", selection: $speakerUID) {
|
ForEach(outputDevices) { device in
|
||||||
Text("System default").tag("")
|
Text(device.name).tag(device.uid)
|
||||||
ForEach(outputDevices) { device in
|
}
|
||||||
Text(device.name).tag(device.uid)
|
if !speakerUID.isEmpty,
|
||||||
}
|
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
||||||
if !speakerUID.isEmpty,
|
Text("Unavailable device").tag(speakerUID)
|
||||||
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
|
||||||
Text("Unavailable device").tag(speakerUID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
described("This device's microphone feeds the host's virtual mic.") {
|
Toggle("Send microphone to the host", isOn: $micEnabled)
|
||||||
Toggle("Send microphone to the host", isOn: $micEnabled)
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Picker("Microphone", selection: $micUID) {
|
Picker("Microphone", selection: $micUID) {
|
||||||
Text("System default").tag("")
|
Text("System default").tag("")
|
||||||
@@ -527,27 +220,236 @@ extension SettingsView {
|
|||||||
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
|
// 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).
|
// pick it. Auto sums every channel (a lone hot mic still passes at full level).
|
||||||
if micChannelCount > 1 {
|
if micChannelCount > 1 {
|
||||||
described("Pick the input your mic is on; Auto sums every channel.") {
|
Picker("Microphone channel", selection: $micChannel) {
|
||||||
Picker("Microphone channel", selection: $micChannel) {
|
Text("Auto (all channels)").tag(0)
|
||||||
Text("Auto (all channels)").tag(0)
|
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||||
ForEach(1...micChannelCount, id: \.self) { ch in
|
Text("Channel \(ch)").tag(ch)
|
||||||
Text("Channel \(ch)").tag(ch)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.disabled(!micEnabled)
|
|
||||||
}
|
}
|
||||||
|
.disabled(!micEnabled)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
} header: {
|
} header: {
|
||||||
Text("Audio")
|
Text("Audio")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Applies from the next session.")
|
Text(Self.audioFooter)
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Controllers
|
#if os(iOS)
|
||||||
|
/// 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 {
|
||||||
|
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 UIDevice.current.userInterfaceIdiom == .pad {
|
||||||
|
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Touch & pointer")
|
||||||
|
} footer: {
|
||||||
|
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 moves the host cursor like a laptop touchpad — tap "
|
||||||
|
text += "to click, two-finger tap to right-click, two-finger drag to scroll, "
|
||||||
|
text += "tap-and-drag to hold, three-finger tap for the stats overlay. Direct pointer: "
|
||||||
|
text += "the cursor jumps to your finger. Touch passthrough: real multi-touch reaches "
|
||||||
|
text += "the host. Applies from the next touch."
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||||
|
text += " Pointer capture locks a hardware mouse for relative mouse-look; off sends "
|
||||||
|
text += "absolute positions. Needs the stream full-screen and frontmost."
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@ViewBuilder var compositorSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Compositor", selection: $compositor) {
|
||||||
|
ForEach(SettingsOptions.compositors, id: \.tag) { option in
|
||||||
|
Text(option.label).tag(option.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Host compositor")
|
||||||
|
} footer: {
|
||||||
|
Text("Which compositor drives the virtual output on the host. A specific "
|
||||||
|
+ "choice is honored only if that backend is available there — "
|
||||||
|
+ "otherwise the host falls back to auto-detection.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-wake on connect — fire Wake-on-LAN + wait for a sleeping saved host to come back before
|
||||||
|
/// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted).
|
||||||
|
@ViewBuilder var wakeSection: some View {
|
||||||
|
Section {
|
||||||
|
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
||||||
|
} header: {
|
||||||
|
Text("Wake-on-LAN")
|
||||||
|
} footer: {
|
||||||
|
Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN "
|
||||||
|
+ "packet and waits for it to come back before streaming. Turn off if a host that's "
|
||||||
|
+ "already on just isn't visible here (e.g. over a VPN), so connects go straight "
|
||||||
|
+ "through instead of waiting out the wake. A host's “Wake” action still works either "
|
||||||
|
+ "way.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var windowSection: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
Section {
|
||||||
|
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
|
||||||
|
} header: {
|
||||||
|
Text("Window")
|
||||||
|
} footer: {
|
||||||
|
Text("Take the window fullscreen when a session starts and restore it on the host "
|
||||||
|
+ "list, so only the stream is fullscreen — not the picker.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh to allow.
|
||||||
|
@ViewBuilder var vrrSection: some View {
|
||||||
|
#if !os(tvOS)
|
||||||
|
Section {
|
||||||
|
Toggle("Allow VRR", isOn: $allowVRR)
|
||||||
|
} header: {
|
||||||
|
Text("Variable refresh rate")
|
||||||
|
} footer: {
|
||||||
|
Text("Let a ProMotion or adaptive-sync display vary its refresh rate to match the "
|
||||||
|
+ "stream — smoother motion without tearing. No effect on fixed-refresh displays. "
|
||||||
|
+ "Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice only
|
||||||
|
// exists on the Mac (where the layer's own sync must stay off — see MetalVideoPresenter).
|
||||||
|
@ViewBuilder var vsyncSection: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
Section {
|
||||||
|
Toggle("V-Sync", isOn: $vsync)
|
||||||
|
} header: {
|
||||||
|
Text("Presentation")
|
||||||
|
} footer: {
|
||||||
|
Text("Off (default): each frame is shown as soon as it's ready — lowest latency, "
|
||||||
|
+ "but frame timing can look uneven and fullscreen may tear. On: frames flip "
|
||||||
|
+ "in step with the display's refresh — evenly paced, up to one refresh of "
|
||||||
|
+ "added latency. Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage-2 (Metal/VTDecompressionSession, present on frame arrival) is the proven default;
|
||||||
|
// stage-3 is the same pipeline with glass-gated present pacing — a user-visible A/B while the
|
||||||
|
// pacing work settles (see Stage2Pipeline's PresentPacing for the queue-saturation rationale).
|
||||||
|
// Stage-1 (compressed video straight to the system layer) stays a DEBUG-only diagnostic — it
|
||||||
|
// freezes hard on a lost HEVC reference.
|
||||||
|
@ViewBuilder var presenterSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Presenter", selection: $presenter) {
|
||||||
|
Text("Stage 2 (default)").tag("stage2")
|
||||||
|
Text("Stage 3 (experimental)").tag("stage3")
|
||||||
|
#if DEBUG
|
||||||
|
Text("Stage 1 (debug)").tag("stage1")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Video presenter")
|
||||||
|
} footer: {
|
||||||
|
Text("Stage 2: each frame is shown the moment it's decoded — proven, but on displays "
|
||||||
|
+ "running near the stream's frame rate, queued frames can add two to three "
|
||||||
|
+ "refreshes of display latency that never drains. Stage 3: presents are paced "
|
||||||
|
+ "to the display — at most one undisplayed frame in flight, always the freshest, "
|
||||||
|
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
|
||||||
|
+ "display time to compare. Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var hdrSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Video codec", selection: $codec) {
|
||||||
|
ForEach(SettingsOptions.codecs, id: \.tag) { option in
|
||||||
|
Text(option.label).tag(option.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
||||||
|
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
||||||
|
} header: {
|
||||||
|
Text("Video quality")
|
||||||
|
} footer: {
|
||||||
|
Text("Codec is a preference; the host falls back if it can't encode your choice. "
|
||||||
|
+ "HDR (HDR10) and full chroma (4:4:4) are HEVC-only, and each engages only when "
|
||||||
|
+ "both this device and the host support it — otherwise the stream stays 8-bit "
|
||||||
|
+ "4:2:0 SDR. 4:4:4 (off by default) sharpens text and UI — best for desktop "
|
||||||
|
+ "work; for games the bits are better spent at 4:2:0. Applies from the next "
|
||||||
|
+ "session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var statisticsSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
||||||
|
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
||||||
|
Text(tier.label).tag(tier.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Picker("Position", selection: $hudPlacement) {
|
||||||
|
ForEach(HUDPlacement.allCases) { placement in
|
||||||
|
Text(placement.label).tag(placement.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
||||||
|
} header: {
|
||||||
|
Text("Statistics")
|
||||||
|
} footer: {
|
||||||
|
Text(Self.statisticsFooter)
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var experimentalSection: some View {
|
||||||
|
Section {
|
||||||
|
Toggle("Show game library", isOn: $libraryEnabled)
|
||||||
|
} header: {
|
||||||
|
Text("Experimental")
|
||||||
|
} footer: {
|
||||||
|
Text("Adds a “Browse Library…” action to each host that lists its games "
|
||||||
|
+ "(Steam + custom); tap a title to launch it. Works once you've paired — no "
|
||||||
|
+ "extra host setup.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder var controllersSection: some View {
|
@ViewBuilder var controllersSection: some View {
|
||||||
Section {
|
Section {
|
||||||
@@ -559,37 +461,18 @@ extension SettingsView {
|
|||||||
controllerRow(controller)
|
controllerRow(controller)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
described("One controller is forwarded as player 1 — Automatic picks the most "
|
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||||
+ "recently connected.") {
|
ForEach(controllerOptions, id: \.tag) { option in
|
||||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
Text(option.label).tag(option.tag)
|
||||||
ForEach(controllerOptions, id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
described("The virtual pad created on the host. Automatic matches your controller "
|
Picker("Controller type", selection: $gamepadType) {
|
||||||
+ "— a DualSense keeps adaptive triggers, lightbar, touchpad and motion.") {
|
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
||||||
Picker("Controller type", selection: $gamepadType) {
|
Text(option.label).tag(option.tag)
|
||||||
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
|
||||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
|
||||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
|
||||||
described("Plays player 1's rumble on the phone's own Taptic Engine — for "
|
|
||||||
+ "clip-on controllers without motors of their own.") {
|
|
||||||
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
described("With a controller connected, the host list and library switch to a "
|
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||||
+ "controller-friendly layout — larger focus targets, a swipeable cover "
|
|
||||||
+ "browser.") {
|
|
||||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
Button("Test Controller…") { showControllerTest = true }
|
Button("Test Controller…") { showControllerTest = true }
|
||||||
@@ -599,9 +482,17 @@ extension SettingsView {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Controllers")
|
Text("Controllers")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Applies from the next session.")
|
// The gamepad-UI blurb is appended here, not merged into the shared
|
||||||
.font(.geist(12, relativeTo: .caption))
|
// `controllersFooter` constant — tvOS's `tvBody` reuses that exact string (line ~348)
|
||||||
.foregroundStyle(.secondary)
|
// for its own footer and has no such toggle to describe.
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(Self.controllersFooter)
|
||||||
|
#if !os(tvOS)
|
||||||
|
Text(Self.gamepadUIFooter)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,31 +9,6 @@ import PunktfunkKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension SettingsView {
|
extension SettingsView {
|
||||||
// MARK: - Described rows (the 2026-07 revamp's field idiom)
|
|
||||||
|
|
||||||
/// A control with its explanation attached to the SAME cell: the field, then a tight caption
|
|
||||||
/// directly under it. This replaced the per-section footer paragraphs — a description the eye
|
|
||||||
/// can't match to its field is one nobody reads. Keep captions to one or two sentences; when
|
|
||||||
/// a picker's meaning depends on the selection, pass a DYNAMIC string describing the current
|
|
||||||
/// choice.
|
|
||||||
@ViewBuilder
|
|
||||||
func described<Content: View>(
|
|
||||||
_ caption: String, @ViewBuilder content: () -> Content
|
|
||||||
) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
|
||||||
content()
|
|
||||||
Text(caption)
|
|
||||||
.font(.geist(13, relativeTo: .footnote))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true) // wrap, never truncate, in Form cells
|
|
||||||
// Cap the caption's line length well short of the cell: a full-width caption runs
|
|
||||||
// its text right up to the control column (toggles especially), reading as one
|
|
||||||
// colliding block. ~46 chars/line also just measures better.
|
|
||||||
.frame(maxWidth: 360, alignment: .leading)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Bitrate
|
// MARK: - Bitrate
|
||||||
|
|
||||||
/// Slider domain, log-scale: the useful range spans three orders of magnitude
|
/// Slider domain, log-scale: the useful range spans three orders of magnitude
|
||||||
@@ -42,7 +17,6 @@ extension SettingsView {
|
|||||||
private static let minSliderKbps = 2_000.0
|
private static let minSliderKbps = 2_000.0
|
||||||
private static let maxSliderKbps = 3_000_000.0
|
private static let maxSliderKbps = 3_000_000.0
|
||||||
|
|
||||||
/// tvOS's cluster caption (the touch/desktop forms describe bitrate per-row instead).
|
|
||||||
static let bitrateFooter =
|
static let bitrateFooter =
|
||||||
"Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice "
|
"Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice "
|
||||||
+ "to its supported range. Run a speed test from a host card's context menu to "
|
+ "to its supported range. Run a speed test from a host card's context menu to "
|
||||||
@@ -79,27 +53,48 @@ extension SettingsView {
|
|||||||
|
|
||||||
// MARK: - Statistics
|
// MARK: - Statistics
|
||||||
|
|
||||||
static var statisticsDescription: String {
|
static var statisticsFooter: String {
|
||||||
let base = "Live session stats in a corner overlay — Compact is a one-line pill, "
|
let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
|
||||||
+ "Detailed adds the latency stage breakdown."
|
+ "pill, Normal adds resolution and latency, Detailed adds the latency stage "
|
||||||
|
+ "breakdown."
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
return base + " ⌃⌥⇧S cycles the tiers any time."
|
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
return base + " ⌃⌥⇧S or a three-finger tap cycles the tiers any time."
|
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
|
||||||
|
+ "any time."
|
||||||
#else
|
#else
|
||||||
return base
|
return base
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio
|
||||||
|
|
||||||
|
static var audioFooter: String {
|
||||||
|
#if os(macOS)
|
||||||
|
return "Host audio plays through the chosen speaker; your microphone feeds the host's "
|
||||||
|
+ "virtual mic. System default follows your Mac's device changes. Applies from the "
|
||||||
|
+ "next session."
|
||||||
|
#else
|
||||||
|
return "Host audio plays locally; your microphone feeds the host's virtual mic. "
|
||||||
|
+ "Applies from the next session."
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Controllers
|
// MARK: - Controllers
|
||||||
|
|
||||||
/// tvOS's cluster caption (the touch/desktop form describes each row inline instead).
|
|
||||||
static let controllersFooter =
|
static let controllersFooter =
|
||||||
"One controller is forwarded as player 1 — Automatic picks the most recently "
|
"One controller is forwarded as player 1 — Automatic picks the most recently "
|
||||||
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
|
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
|
||||||
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||||
+ "Applies from the next session."
|
+ "Applies from the next session."
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
static let gamepadUIFooter =
|
||||||
|
"When a controller connects, the host list and library switch to a controller-"
|
||||||
|
+ "friendly layout — larger focus targets and a swipeable cover browser. Turn this "
|
||||||
|
+ "off to always use the standard layout."
|
||||||
|
#endif
|
||||||
|
|
||||||
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
|
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
|
||||||
var controllerOptions: [(label: String, tag: String)] {
|
var controllerOptions: [(label: String, tag: String)] {
|
||||||
SettingsOptions.controllerOptions(gamepads)
|
SettingsOptions.controllerOptions(gamepads)
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
// App settings. The host creates a virtual output at exactly the chosen size/refresh; the only
|
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
||||||
// deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device
|
// there is no scaling anywhere in the pipeline.
|
||||||
// downscales — supersampling for sharpness, or under-rendering for a lighter host/link).
|
|
||||||
//
|
//
|
||||||
// Navigation differs per platform, but all three follow the same category map (General =
|
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||||
// session/app behavior, Display = everything about the picture, Input, Audio, Controllers,
|
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||||
// About — see SettingsCategory): macOS uses a tabbed preferences window; iOS/iPadOS uses an
|
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||||||
// adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
|
||||||
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
||||||
// focus-native pushed-picker layout in the same order. The individual sections
|
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
|
||||||
// (`resolutionSection`, `audioSection`, …) are shared across all three so a setting is defined
|
// `audioSection`, …) are shared across all three so a setting is defined exactly once — they
|
||||||
// exactly once — they live in SettingsView+Sections.swift, with their helpers (including the
|
// live in SettingsView+Sections.swift, with their helpers in SettingsView+Support.swift.
|
||||||
// per-field `described` caption idiom) in SettingsView+Support.swift.
|
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -28,19 +25,12 @@ struct SettingsView: View {
|
|||||||
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
|
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
|
||||||
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
|
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
|
||||||
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
||||||
// Render-resolution multiplier: the host renders/encodes at chosen-resolution × this, and the
|
|
||||||
// presenter downscales (> 1 = supersampling for sharpness) or upscales (< 1 = a lighter host /
|
|
||||||
// link). 1.0 = Native (the prior behaviour).
|
|
||||||
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
|
|
||||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||||
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
|
@AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
|
||||||
SettingsOptions.presentPriorityDefault
|
|
||||||
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||||
@AppStorage(DefaultsKey.windowedSafePresent) var windowedSafePresent = true
|
|
||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
|
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
|
||||||
@@ -59,21 +49,12 @@ struct SettingsView: View {
|
|||||||
@ObservedObject var gamepads = GamepadManager.shared
|
@ObservedObject var gamepads = GamepadManager.shared
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||||
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
|
||||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
|
||||||
#if !os(tvOS)
|
|
||||||
// Keyboard & mouse forwarding (macOS + a hardware keyboard/mouse on iPad). Invert-scroll flips
|
|
||||||
// both wheel axes; modifier-layout relocates the ⌥/⌘ → Alt/Super roles by physical position.
|
|
||||||
@AppStorage(DefaultsKey.invertScroll) var invertScroll = false
|
|
||||||
@AppStorage(DefaultsKey.modifierLayout) var modifierLayout = ModifierLayout.mac.rawValue
|
|
||||||
#endif
|
|
||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
@State var showControllerTest = false
|
@State var showControllerTest = false
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
||||||
@AppStorage(DefaultsKey.rumbleOnDevice) var rumbleOnDevice = false
|
|
||||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||||
// General on iPad (a two-column layout should never open with an empty detail).
|
// General on iPad (a two-column layout should never open with an empty detail).
|
||||||
@@ -89,7 +70,6 @@ struct SettingsView: View {
|
|||||||
@State var customMode = false
|
@State var customMode = false
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.mouseMode) var mouseMode = MouseInputMode.capture.rawValue
|
|
||||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||||||
@@ -126,32 +106,26 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private var macBody: some View {
|
private var macBody: some View {
|
||||||
// Tab map mirrors SettingsCategory: General = session/app behavior, Display = the whole
|
|
||||||
// picture (resolution lives here), Input = keyboard & mouse.
|
|
||||||
TabView {
|
TabView {
|
||||||
Form {
|
Form {
|
||||||
sessionSection
|
streamModeSection
|
||||||
overlaySection
|
compositorSection
|
||||||
librarySection
|
wakeSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tabItem { Label("General", systemImage: "gearshape") }
|
.tabItem { Label("General", systemImage: "gearshape") }
|
||||||
|
|
||||||
Form {
|
Form {
|
||||||
resolutionSection
|
presenterSection
|
||||||
qualitySection
|
hdrSection
|
||||||
presentationSection
|
vrrSection
|
||||||
hostOutputSection
|
vsyncSection
|
||||||
|
windowSection
|
||||||
|
statisticsSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tabItem { Label("Display", systemImage: "display") }
|
.tabItem { Label("Display", systemImage: "display") }
|
||||||
|
|
||||||
Form {
|
|
||||||
inputSection
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
.tabItem { Label("Input", systemImage: "keyboard") }
|
|
||||||
|
|
||||||
Form {
|
Form {
|
||||||
audioSection
|
audioSection
|
||||||
}
|
}
|
||||||
@@ -179,10 +153,16 @@ struct SettingsView: View {
|
|||||||
.onDisappear { gamepads.stopDiscovery() }
|
.onDisappear { gamepads.stopDiscovery() }
|
||||||
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
||||||
|
|
||||||
|
Form {
|
||||||
|
experimentalSection
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
||||||
|
|
||||||
AcknowledgementsView()
|
AcknowledgementsView()
|
||||||
.tabItem { Label("About", systemImage: "info.circle") }
|
.tabItem { Label("About", systemImage: "info.circle") }
|
||||||
}
|
}
|
||||||
.frame(width: 500, height: 520)
|
.frame(width: 480, height: 460)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -257,31 +237,24 @@ struct SettingsView: View {
|
|||||||
switch category {
|
switch category {
|
||||||
case .general:
|
case .general:
|
||||||
Form {
|
Form {
|
||||||
sessionSection
|
streamModeSection
|
||||||
overlaySection
|
pointerSection
|
||||||
librarySection
|
compositorSection
|
||||||
|
wakeSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("General")
|
.navigationTitle("General")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
case .display:
|
case .display:
|
||||||
Form {
|
Form {
|
||||||
resolutionSection
|
presenterSection
|
||||||
qualitySection
|
hdrSection
|
||||||
presentationSection
|
vrrSection
|
||||||
hostOutputSection
|
statisticsSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("Display")
|
.navigationTitle("Display")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
case .input:
|
|
||||||
Form {
|
|
||||||
pointerSection
|
|
||||||
inputSection
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
.navigationTitle("Input")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
case .audio:
|
case .audio:
|
||||||
Form { audioSection }
|
Form { audioSection }
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
@@ -292,6 +265,11 @@ struct SettingsView: View {
|
|||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("Controllers")
|
.navigationTitle("Controllers")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
case .advanced:
|
||||||
|
Form { experimentalSection }
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle("Advanced")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
case .about:
|
case .about:
|
||||||
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
||||||
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
||||||
@@ -337,16 +315,6 @@ struct SettingsView: View {
|
|||||||
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
|
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One cluster caption, TV-legible — the 10-foot analogue of the touch/desktop per-row
|
|
||||||
/// `described` captions (per-row text doesn't scale to TV type sizes).
|
|
||||||
private func tvCaption(_ text: String) -> some View {
|
|
||||||
Text(text)
|
|
||||||
.font(.geist(20, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var tvBody: some View {
|
private var tvBody: some View {
|
||||||
let currentTag = "\(width)x\(height)x\(hz)"
|
let currentTag = "\(width)x\(height)x\(hz)"
|
||||||
let bounds = UIScreen.main.nativeBounds
|
let bounds = UIScreen.main.nativeBounds
|
||||||
@@ -359,55 +327,44 @@ struct SettingsView: View {
|
|||||||
if !options.contains(where: { $0.tag == currentTag }) {
|
if !options.contains(where: { $0.tag == currentTag }) {
|
||||||
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
|
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
|
||||||
}
|
}
|
||||||
// Row order mirrors the touch/desktop category map: Display (mode → quality →
|
|
||||||
// presentation → host output), then Audio, General, Statistics, Controllers — with one
|
|
||||||
// short caption per cluster (per-row captions don't scale to 10-foot type sizes).
|
|
||||||
return ScrollView {
|
return ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||||||
TVSelectionRow(
|
|
||||||
title: "Render scale",
|
|
||||||
options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) },
|
|
||||||
selection: $renderScale)
|
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Bitrate",
|
title: "Bitrate",
|
||||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||||
selection: $bitrateKbps)
|
selection: $bitrateKbps)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Audio channels",
|
||||||
|
options: SettingsOptions.audioChannels,
|
||||||
|
selection: $audioChannels)
|
||||||
if bitrateKbps > 1_000_000 {
|
if bitrateKbps > 1_000_000 {
|
||||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Compositor", options: SettingsOptions.compositors,
|
||||||
|
selection: $compositor)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Presenter",
|
||||||
|
options: SettingsOptions.presenters,
|
||||||
|
selection: $presenter)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "10-bit HDR",
|
title: "10-bit HDR",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||||
TVSelectionRow(
|
|
||||||
title: "Prioritize",
|
|
||||||
options: SettingsOptions.presentPriorities,
|
|
||||||
selection: $presentPriority)
|
|
||||||
if presentPriority == "smooth" {
|
|
||||||
TVSelectionRow(
|
|
||||||
title: "Smoothness buffer",
|
|
||||||
options: SettingsOptions.smoothBuffers(refreshHz: hz),
|
|
||||||
selection: $smoothBuffer)
|
|
||||||
}
|
|
||||||
TVSelectionRow(
|
|
||||||
title: "Compositor", options: SettingsOptions.compositors,
|
|
||||||
selection: $compositor)
|
|
||||||
tvCaption("The host drives a real output at exactly the chosen mode. "
|
|
||||||
+ "\(Self.bitrateFooter) Lowest latency shows frames immediately; "
|
|
||||||
+ "Smoothness buffers a few to even out network hiccups. A specific "
|
|
||||||
+ "compositor is honored only if available on the host.")
|
|
||||||
TVSelectionRow(
|
|
||||||
title: "Audio channels",
|
|
||||||
options: SettingsOptions.audioChannels,
|
|
||||||
selection: $audioChannels)
|
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Auto-wake on connect",
|
title: "Auto-wake on connect",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
||||||
tvCaption("Auto-wake sends Wake-on-LAN to a sleeping saved host and waits for "
|
Text("The host creates a virtual output at exactly this mode — native "
|
||||||
+ "it before streaming.")
|
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||||
|
+ "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a "
|
||||||
|
+ "sleeping saved host and waits for it before streaming.")
|
||||||
|
.font(.geist(20, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 8)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Statistics overlay",
|
title: "Statistics overlay",
|
||||||
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
||||||
@@ -427,7 +384,11 @@ struct SettingsView: View {
|
|||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Gamepad-optimized browsing",
|
title: "Gamepad-optimized browsing",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||||
tvCaption(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
|
.font(.geist(20, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 8)
|
||||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,32 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if canImport(WidgetKit)
|
|
||||||
import WidgetKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// `StoredHost` (the model + its JSON codec) now lives in PunktfunkShared so the widget extension
|
struct StoredHost: Identifiable, Codable, Hashable {
|
||||||
// can read the same store; PunktfunkKit re-exports it. The discovery-join helpers below stay here
|
var id = UUID()
|
||||||
// because they reference PunktfunkKit's `DiscoveredHost`/`HostDiscovery`.
|
var name: String
|
||||||
|
var address: String
|
||||||
|
var port: UInt16 = 9777
|
||||||
|
/// SHA-256 of the host's certificate, set after the user explicitly trusted it.
|
||||||
|
var pinnedSHA256: Data?
|
||||||
|
/// Last time a streaming session actually started (nil until the first one).
|
||||||
|
var lastConnected: Date?
|
||||||
|
/// Management-API port for the library browser (distinct from the data-plane `port`). Optional
|
||||||
|
/// (NOT a defaulted non-optional) so older saved hosts — whose JSON lacks this key — still
|
||||||
|
/// 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 {
|
extension StoredHost {
|
||||||
/// True when a live mDNS advert (`DiscoveredHost`) describes THIS saved host — drives the
|
/// True when a live mDNS advert (`DiscoveredHost`) describes THIS saved host — drives the
|
||||||
@@ -67,14 +86,8 @@ final class HostStore: ObservableObject {
|
|||||||
/// never advertises still reads Online. Not persisted (it's live reachability, not config).
|
/// never advertises still reads Online. Not persisted (it's live reachability, not config).
|
||||||
@Published var probedOnline: Set<StoredHost.ID> = []
|
@Published var probedOnline: Set<StoredHost.ID> = []
|
||||||
|
|
||||||
/// The App-Group suite — shared with the Widget/Live-Activity extension so a launcher widget
|
|
||||||
/// sees the same saved hosts. Falls back to `.standard` in an un-entitled process (see
|
|
||||||
/// `AppGroup.defaults`).
|
|
||||||
private let defaults = AppGroup.defaults
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
Self.migrateToAppGroupIfNeeded()
|
if let data = UserDefaults.standard.data(forKey: Self.key),
|
||||||
if let data = defaults.data(forKey: Self.key),
|
|
||||||
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
|
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
|
||||||
hosts = decoded
|
hosts = decoded
|
||||||
} else {
|
} else {
|
||||||
@@ -82,20 +95,6 @@ final class HostStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-time move of the saved-host JSON from `UserDefaults.standard` (where every build before
|
|
||||||
/// the App Group wrote it) into the shared suite. Idempotent: only fires when the suite has no
|
|
||||||
/// hosts yet but standard does. The old value is LEFT in place — during a staged TestFlight
|
|
||||||
/// rollout an older build still reads `.standard`, so tombstoning it now would hide hosts from
|
|
||||||
/// the not-yet-updated app. Remove the standard copy a release later.
|
|
||||||
private static func migrateToAppGroupIfNeeded() {
|
|
||||||
let suite = AppGroup.defaults
|
|
||||||
let standard = UserDefaults.standard
|
|
||||||
guard suite !== standard else { return } // un-entitled fallback: nothing to migrate
|
|
||||||
guard suite.data(forKey: key) == nil,
|
|
||||||
let legacy = standard.data(forKey: key) else { return }
|
|
||||||
suite.set(legacy, forKey: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func add(_ host: StoredHost) {
|
func add(_ host: StoredHost) {
|
||||||
hosts.append(host)
|
hosts.append(host)
|
||||||
}
|
}
|
||||||
@@ -113,7 +112,7 @@ final class HostStore: ObservableObject {
|
|||||||
|
|
||||||
func markConnected(_ hostID: UUID) {
|
func markConnected(_ hostID: UUID) {
|
||||||
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
||||||
hosts[i].lastConnected = Date() // didSet → persist() writes the shared suite + reloads widget
|
hosts[i].lastConnected = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently
|
/// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently
|
||||||
@@ -159,17 +158,7 @@ final class HostStore: ObservableObject {
|
|||||||
|
|
||||||
private func persist() {
|
private func persist() {
|
||||||
if let data = try? JSONEncoder().encode(hosts) {
|
if let data = try? JSONEncoder().encode(hosts) {
|
||||||
defaults.set(data, forKey: Self.key)
|
UserDefaults.standard.set(data, forKey: Self.key)
|
||||||
}
|
}
|
||||||
reloadHostsWidget() // the widget reads this store; any change refreshes its timeline
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ask WidgetKit to rebuild the hosts widget's timeline after any store change (add/remove/pin/
|
|
||||||
/// last-connected). iOS-only and a no-op where WidgetKit is absent; the widget uses
|
|
||||||
/// `.never`-refresh entries and relies on this push.
|
|
||||||
private func reloadHostsWidget() {
|
|
||||||
#if canImport(WidgetKit) && os(iOS)
|
|
||||||
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts")
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// PIN pairing sheet. The host shows the pairing PIN in its web console (port 47992 →
|
// PIN pairing sheet. The host shows the pairing PIN in its web console (port 3000 →
|
||||||
// Pairing; also printed in the host's log when armed via --allow-pairing); the user
|
// Pairing; also printed in the host's log when armed via --allow-pairing); the user
|
||||||
// types it here. The ceremony is SPAKE2, so a wrong PIN buys an
|
// types it here. The ceremony is SPAKE2, so a wrong PIN buys an
|
||||||
// attacker exactly one online guess — for the user a typo just means "try again" (the
|
// attacker exactly one online guess — for the user a typo just means "try again" (the
|
||||||
@@ -45,7 +45,7 @@ struct PairSheet: View {
|
|||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
Text("The PIN is shown in the host's web console "
|
Text("The PIN is shown in the host's web console "
|
||||||
+ "(https://<host>:47992 → Pairing). "
|
+ "(http://<host>:3000 → Pairing). "
|
||||||
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||||
+ "needed.")
|
+ "needed.")
|
||||||
.font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
|
.font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
|
||||||
@@ -118,7 +118,7 @@ struct PairSheet: View {
|
|||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("The PIN is shown in the host's web console "
|
Text("The PIN is shown in the host's web console "
|
||||||
+ "(https://<host>:47992 → Pairing). "
|
+ "(http://<host>:3000 → Pairing). "
|
||||||
+ "Pairing verifies both sides at once — no fingerprint "
|
+ "Pairing verifies both sides at once — no fingerprint "
|
||||||
+ "comparison needed.")
|
+ "comparison needed.")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
@@ -210,20 +210,16 @@ struct PairSheet: View {
|
|||||||
onPaired(fingerprint)
|
onPaired(fingerprint)
|
||||||
dismiss()
|
dismiss()
|
||||||
case .failure(PunktfunkClientError.wrongPIN):
|
case .failure(PunktfunkClientError.wrongPIN):
|
||||||
errorText = "Wrong PIN — check the host's web console (port 47992) "
|
errorText = "Wrong PIN — check the host's web console (port 3000) "
|
||||||
+ "and try again."
|
+ "and try again."
|
||||||
case .failure(PunktfunkClientError.rejected(let rejection)):
|
|
||||||
// The host answered and said why (not armed / rate-limited / armed for
|
|
||||||
// another device) — show that instead of the guessing-game fallback.
|
|
||||||
errorText = rejection.userMessage
|
|
||||||
case .failure(is ClientIdentityStore.IdentityError):
|
case .failure(is ClientIdentityStore.IdentityError):
|
||||||
errorText = "Can't store this Mac's identity in the Keychain, so the "
|
errorText = "Can't store this Mac's identity in the Keychain, so the "
|
||||||
+ "pairing would not survive a relaunch. Unlock the login "
|
+ "pairing would not survive a relaunch. Unlock the login "
|
||||||
+ "keychain and try again."
|
+ "keychain and try again."
|
||||||
case .failure:
|
case .failure:
|
||||||
errorText = "Pairing failed — the host didn't answer. Is it running, "
|
errorText = "Pairing failed. Is the host reachable, pairing armed "
|
||||||
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
|
+ "(web console → Pairing), and not mid-session? Retries are "
|
||||||
+ "isolation)?"
|
+ "rate-limited to one per 2 seconds."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,23 +180,6 @@ public final class SessionAudio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background keep-alive: silence the mic uplink while backgrounded (privacy — no room audio
|
|
||||||
/// leaves the device) and restore it on return. Pauses/resumes the capture engine; a no-op when
|
|
||||||
/// there's no uplink (playback-only / tvOS / mic disabled). The audio SESSION stays active for
|
|
||||||
/// background playback, so iOS may keep showing the recording indicator until a full reconfigure
|
|
||||||
/// — this stops the actual capture, which is the privacy-relevant part. Main thread.
|
|
||||||
public func setMicMuted(_ muted: Bool) {
|
|
||||||
stateLock.lock()
|
|
||||||
let capture = captureEngine
|
|
||||||
stateLock.unlock()
|
|
||||||
guard let capture else { return }
|
|
||||||
if muted {
|
|
||||||
capture.pause()
|
|
||||||
} else if !flag.isStopped {
|
|
||||||
try? capture.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Playback (host → speaker)
|
// MARK: - Playback (host → speaker)
|
||||||
|
|
||||||
private func startPlayback(speakerUID: String) {
|
private func startPlayback(speakerUID: String) {
|
||||||
|
|||||||
@@ -1,402 +0,0 @@
|
|||||||
// Shared clipboard, macOS client half (design/clipboard-and-file-transfer.md §5.2).
|
|
||||||
//
|
|
||||||
// Bridges NSPasteboard.general to the session's QUIC clipboard plane, both directions lazy:
|
|
||||||
//
|
|
||||||
// * **Local copy → host**: a changeCount poll announces the *format list* (`clipOffer`); the
|
|
||||||
// bytes cross only when a host app pastes (a `.fetchRequest` event, answered from the live
|
|
||||||
// pasteboard by `clipServe`).
|
|
||||||
// * **Host copy → local**: a `.remoteOffer` writes one NSPasteboardItem whose
|
|
||||||
// NSPasteboardItemDataProvider fires only when a Mac app actually pastes — the provider then
|
|
||||||
// blocks (on its provider thread, never main) on a `clipFetch` round-trip.
|
|
||||||
//
|
|
||||||
// Password-manager respect: pasteboards marked `org.nspasteboard.ConcealedType` or
|
|
||||||
// `org.nspasteboard.TransientType` are never announced, never fetchable. Echo suppression: the
|
|
||||||
// changeCount of every write WE make is recorded so the announce poll skips it (§3.4).
|
|
||||||
//
|
|
||||||
// Phase 1 formats only (text / RTF / HTML / PNG). Files (NSFilePromiseProvider) ride Phase 2.
|
|
||||||
#if os(macOS)
|
|
||||||
import AppKit
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// One live session's clipboard bridge. Created by the session model when streaming begins on a
|
|
||||||
/// host that advertises `HOST_CAP_CLIPBOARD` and whose per-host toggle is on; `stop()` before the
|
|
||||||
/// connection closes. All pasteboard traffic runs on one dedicated drain thread plus the
|
|
||||||
/// AppKit-owned provider threads (paste fulfillment).
|
|
||||||
public final class ClipboardSync: NSObject {
|
|
||||||
/// Wire MIME ↔ NSPasteboard type for the Phase-1 vocabulary (§3.5), in announce order.
|
|
||||||
private static let wireToPasteboard: [(wire: String, type: NSPasteboard.PasteboardType)] = [
|
|
||||||
("text/plain;charset=utf-8", .string),
|
|
||||||
("text/rtf", .rtf),
|
|
||||||
("text/html", .html),
|
|
||||||
("image/png", .png),
|
|
||||||
// Original image formats pass through VERBATIM beside the PNG floor — a copied JPEG
|
|
||||||
// never balloons into PNG, a GIF keeps its animation; the destination picks the richest
|
|
||||||
// kind it can place.
|
|
||||||
("image/jpeg", NSPasteboard.PasteboardType("public.jpeg")),
|
|
||||||
("image/gif", NSPasteboard.PasteboardType("com.compuserve.gif")),
|
|
||||||
]
|
|
||||||
/// Pasteboard marker types that must never cross the wire (password managers mark secrets
|
|
||||||
/// with these — see nspasteboard.org).
|
|
||||||
private static let concealed = NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType")
|
|
||||||
private static let transient = NSPasteboard.PasteboardType("org.nspasteboard.TransientType")
|
|
||||||
|
|
||||||
/// How long a blocked paste waits for the host's bytes before providing nothing (§5.2).
|
|
||||||
private static let fetchTimeout: TimeInterval = 10
|
|
||||||
/// Serve chunk size for host-side pastes of our data (bounds the per-call ABI copy).
|
|
||||||
private static let serveChunk = 4 << 20
|
|
||||||
|
|
||||||
private let connection: PunktfunkConnection
|
|
||||||
/// `CLIP_FLAG_*` sent with the enable (`CLIP_FLAG_FILES` when the session permits files —
|
|
||||||
/// always 0 in Phase 1).
|
|
||||||
private let controlFlags: UInt8
|
|
||||||
|
|
||||||
/// Host `.state` updates, delivered on the main queue — drives the toggle/footnote UI.
|
|
||||||
public var onState: ((_ enabled: Bool, _ policy: UInt8, _ reason: UInt8) -> Void)?
|
|
||||||
|
|
||||||
// Drain-thread state (touched only on the drain thread once started).
|
|
||||||
private var offerSeq: UInt32 = 0
|
|
||||||
private var lastSeenChangeCount = 0
|
|
||||||
/// The changeCount of the last pasteboard write WE made (echo suppression + "do we still
|
|
||||||
/// own the pasteboard" on teardown/clear).
|
|
||||||
private var ownedChangeCount = -1
|
|
||||||
/// The host offer currently installed on the local pasteboard (nil = none).
|
|
||||||
private var installedRemoteSeq: UInt32?
|
|
||||||
|
|
||||||
/// Outbound fetches a blocked paste is waiting on. Guarded by `fetchLock` — appended by the
|
|
||||||
/// drain thread (`.data` events), consumed by AppKit's provider threads.
|
|
||||||
private struct PendingFetch {
|
|
||||||
var buffer = Data()
|
|
||||||
let done = DispatchSemaphore(value: 0)
|
|
||||||
var failed = false
|
|
||||||
}
|
|
||||||
private let fetchLock = NSLock()
|
|
||||||
private var pendingFetches: [UInt32: PendingFetch] = [:]
|
|
||||||
|
|
||||||
private final class StopFlag: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var stopped = false
|
|
||||||
func stop() {
|
|
||||||
lock.lock()
|
|
||||||
stopped = true
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
var isStopped: Bool {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return stopped
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private let flag = StopFlag()
|
|
||||||
private let drainDone = DispatchSemaphore(value: 0)
|
|
||||||
private var started = false
|
|
||||||
/// Set by the app-activation observer, cleared by the drain loop: the user may have copied
|
|
||||||
/// elsewhere and is coming back to paste — announce immediately instead of waiting out the
|
|
||||||
/// poll interval.
|
|
||||||
private final class OneShot: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var raised = false
|
|
||||||
func raise() {
|
|
||||||
lock.lock()
|
|
||||||
raised = true
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
func takeIfRaised() -> Bool {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
let was = raised
|
|
||||||
raised = false
|
|
||||||
return was
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private let checkNow = OneShot()
|
|
||||||
private var activationObserver: NSObjectProtocol?
|
|
||||||
|
|
||||||
public init(connection: PunktfunkConnection, allowFiles: Bool = false) {
|
|
||||||
self.connection = connection
|
|
||||||
self.controlFlags = 0 // CLIP_FLAG_FILES rides Phase 2
|
|
||||||
_ = allowFiles
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit { flag.stop() }
|
|
||||||
|
|
||||||
/// Enable sync with the host and start the drain thread. The host answers the enable with a
|
|
||||||
/// `.state` event (surfaced via `onState`) — `BACKEND_UNAVAILABLE` et al. arrive there.
|
|
||||||
public func start() {
|
|
||||||
guard !started else { return }
|
|
||||||
started = true
|
|
||||||
connection.clipControl(enabled: true, flags: controlFlags)
|
|
||||||
// Baseline: whatever is on the pasteboard when sync starts is announced immediately —
|
|
||||||
// the "copy first, then connect and paste" flow must work.
|
|
||||||
lastSeenChangeCount = -1
|
|
||||||
activationObserver = NotificationCenter.default.addObserver(
|
|
||||||
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: nil
|
|
||||||
) { [checkNow] _ in checkNow.raise() }
|
|
||||||
let connection = self.connection
|
|
||||||
let flag = self.flag
|
|
||||||
let thread = Thread { [weak self] in
|
|
||||||
var lastAnnounceCheck = Date.distantPast
|
|
||||||
while !flag.isStopped {
|
|
||||||
// Drain events (bounded burst so a chatty host can't starve the announce poll).
|
|
||||||
var drained = 0
|
|
||||||
while drained < 32, !flag.isStopped {
|
|
||||||
let ev: PunktfunkConnection.ClipEvent?
|
|
||||||
do {
|
|
||||||
ev = try connection.nextClipboard(timeoutMs: drained == 0 ? 200 : 0)
|
|
||||||
} catch {
|
|
||||||
flag.stop() // session closed
|
|
||||||
break
|
|
||||||
}
|
|
||||||
guard let ev else { break }
|
|
||||||
drained += 1
|
|
||||||
self?.handle(ev)
|
|
||||||
}
|
|
||||||
// Announce poll: every 500 ms, or immediately after app activation (§5.2).
|
|
||||||
let now = Date()
|
|
||||||
if now.timeIntervalSince(lastAnnounceCheck) >= 0.5
|
|
||||||
|| self?.checkNow.takeIfRaised() == true
|
|
||||||
{
|
|
||||||
lastAnnounceCheck = now
|
|
||||||
self?.announceIfChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self?.drainDone.signal()
|
|
||||||
}
|
|
||||||
thread.name = "punktfunk-clipboard"
|
|
||||||
thread.qualityOfService = .utility
|
|
||||||
thread.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disable sync and join the drain thread. Called off-main before `connection.close()`
|
|
||||||
/// (the same discipline as the audio/feedback drains). If the local pasteboard still holds
|
|
||||||
/// our remote-offer items, they are cleared — their providers die with us.
|
|
||||||
public func stop() {
|
|
||||||
guard started else { return }
|
|
||||||
started = false
|
|
||||||
if let obs = activationObserver {
|
|
||||||
NotificationCenter.default.removeObserver(obs)
|
|
||||||
activationObserver = nil
|
|
||||||
}
|
|
||||||
connection.clipControl(enabled: false, flags: 0)
|
|
||||||
flag.stop()
|
|
||||||
drainDone.wait()
|
|
||||||
// Fail every paste still blocked on us so no provider thread waits out its timeout.
|
|
||||||
fetchLock.lock()
|
|
||||||
for (_, pending) in pendingFetches {
|
|
||||||
pending.done.signal()
|
|
||||||
}
|
|
||||||
pendingFetches.removeAll()
|
|
||||||
fetchLock.unlock()
|
|
||||||
let pb = NSPasteboard.general
|
|
||||||
if installedRemoteSeq != nil, pb.changeCount == ownedChangeCount {
|
|
||||||
pb.clearContents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Local copy → host (announce)
|
|
||||||
|
|
||||||
/// Announce the local pasteboard's format list when it changed (skipping our own writes and
|
|
||||||
/// concealed/transient pasteboards). Runs on the drain thread.
|
|
||||||
private func announceIfChanged() {
|
|
||||||
let pb = NSPasteboard.general
|
|
||||||
let count = pb.changeCount
|
|
||||||
guard count != lastSeenChangeCount else { return }
|
|
||||||
lastSeenChangeCount = count
|
|
||||||
if count == ownedChangeCount { return } // our own write (a remote offer) — never echo
|
|
||||||
installedRemoteSeq = nil // a local copy replaced the host's offer
|
|
||||||
let types = pb.types ?? []
|
|
||||||
if types.contains(Self.concealed) || types.contains(Self.transient) { return }
|
|
||||||
offerSeq &+= 1
|
|
||||||
var kinds = Self.wireToPasteboard
|
|
||||||
.filter { types.contains($0.type) }
|
|
||||||
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
|
|
||||||
// PNG floor: announce the portable `image/png` whenever ANY convertible image is present
|
|
||||||
// — native PNG, TIFF/HEIC (screenshots, Preview), or a JPEG/GIF original already being
|
|
||||||
// offered verbatim above. `readWireData` converts at fetch time (lazy, §3.5), so the
|
|
||||||
// fallback costs nothing unless a peer actually pastes it.
|
|
||||||
if !kinds.contains(where: { $0.mime == "image/png" }),
|
|
||||||
types.contains(.tiff)
|
|
||||||
|| types.contains(NSPasteboard.PasteboardType("public.heic"))
|
|
||||||
|| kinds.contains(where: { $0.mime.hasPrefix("image/") })
|
|
||||||
{
|
|
||||||
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
|
|
||||||
}
|
|
||||||
// Empty = the pasteboard holds nothing we sync (or was cleared) — clears the host side.
|
|
||||||
connection.clipOffer(seq: offerSeq, kinds: kinds)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Event handling (drain thread)
|
|
||||||
|
|
||||||
private func handle(_ ev: PunktfunkConnection.ClipEvent) {
|
|
||||||
switch ev {
|
|
||||||
case let .state(enabled, policy, reason):
|
|
||||||
if let onState {
|
|
||||||
DispatchQueue.main.async { onState(enabled, policy, reason) }
|
|
||||||
}
|
|
||||||
case let .remoteOffer(seq, kinds):
|
|
||||||
installRemoteOffer(seq: seq, kinds: kinds)
|
|
||||||
case let .fetchRequest(reqId, seq, _, mime):
|
|
||||||
serveFetch(reqId: reqId, seq: seq, mime: mime)
|
|
||||||
case let .data(xferId, chunk, last):
|
|
||||||
fetchLock.lock()
|
|
||||||
if var pending = pendingFetches[xferId] {
|
|
||||||
pending.buffer.append(chunk)
|
|
||||||
pendingFetches[xferId] = pending
|
|
||||||
if last {
|
|
||||||
pendingFetches[xferId]?.done.signal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchLock.unlock()
|
|
||||||
case let .cancelled(id), let .error(id, _):
|
|
||||||
fetchLock.lock()
|
|
||||||
if var pending = pendingFetches[id] {
|
|
||||||
pending.failed = true
|
|
||||||
pendingFetches[id] = pending
|
|
||||||
pending.done.signal()
|
|
||||||
}
|
|
||||||
fetchLock.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Host copy → local (lazy install + blocked-paste fetch)
|
|
||||||
|
|
||||||
/// Write one NSPasteboardItem advertising the host's formats, each backed by a lazy data
|
|
||||||
/// provider — bytes cross only when a Mac app pastes. Empty `kinds` = the host cleared its
|
|
||||||
/// clipboard: drop our item if it's still current.
|
|
||||||
private func installRemoteOffer(seq: UInt32, kinds: [PunktfunkConnection.ClipKind]) {
|
|
||||||
let pb = NSPasteboard.general
|
|
||||||
let types = kinds.compactMap { kind in
|
|
||||||
Self.wireToPasteboard.first(where: { $0.wire == kind.mime })?.type
|
|
||||||
}
|
|
||||||
guard !types.isEmpty else {
|
|
||||||
if installedRemoteSeq != nil, pb.changeCount == ownedChangeCount {
|
|
||||||
pb.clearContents()
|
|
||||||
ownedChangeCount = pb.changeCount
|
|
||||||
lastSeenChangeCount = pb.changeCount
|
|
||||||
}
|
|
||||||
installedRemoteSeq = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let item = NSPasteboardItem()
|
|
||||||
item.setDataProvider(RemoteOfferProvider(sync: self, seq: seq), forTypes: types)
|
|
||||||
pb.clearContents()
|
|
||||||
pb.writeObjects([item])
|
|
||||||
installedRemoteSeq = seq
|
|
||||||
ownedChangeCount = pb.changeCount
|
|
||||||
lastSeenChangeCount = pb.changeCount
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Blocked-paste fulfillment: fetch one wire format of host offer `seq` and wait (provider
|
|
||||||
/// thread) for the drain thread to assemble the chunks. Nil on timeout/cancel/error — the
|
|
||||||
/// paste then provides nothing rather than hanging (§3.4).
|
|
||||||
///
|
|
||||||
/// `fetchLock` is held ACROSS the `clipFetch` so the pending entry exists before the drain
|
|
||||||
/// thread can process the first `.data` event (its `handle` takes `fetchLock` after
|
|
||||||
/// releasing the connection's clipboard lock — no cycle).
|
|
||||||
fileprivate func fetchBlocking(seq: UInt32, wireMime: String) -> Data? {
|
|
||||||
fetchLock.lock()
|
|
||||||
guard let xferId = connection.clipFetch(seq: seq, mime: wireMime) else {
|
|
||||||
fetchLock.unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pendingFetches[xferId] = PendingFetch()
|
|
||||||
let done = pendingFetches[xferId]!.done
|
|
||||||
fetchLock.unlock()
|
|
||||||
let outcome = done.wait(timeout: .now() + Self.fetchTimeout)
|
|
||||||
fetchLock.lock()
|
|
||||||
let pending = pendingFetches.removeValue(forKey: xferId)
|
|
||||||
fetchLock.unlock()
|
|
||||||
if outcome == .timedOut {
|
|
||||||
connection.clipCancel(id: xferId)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let pending, !pending.failed else { return nil }
|
|
||||||
return pending.buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Host paste of our data (serve)
|
|
||||||
|
|
||||||
/// Answer a host paste of our offered data from the live pasteboard. A stale `seq` (the
|
|
||||||
/// local clipboard changed since that announce) is cancelled — never serve mismatched bytes.
|
|
||||||
private func serveFetch(reqId: UInt32, seq: UInt32, mime: String) {
|
|
||||||
let pb = NSPasteboard.general
|
|
||||||
guard seq == offerSeq, pb.changeCount == lastSeenChangeCount,
|
|
||||||
let data = Self.readWireData(pb, mime)
|
|
||||||
else {
|
|
||||||
connection.clipCancel(id: reqId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var offset = 0
|
|
||||||
while offset < data.count {
|
|
||||||
let end = min(offset + Self.serveChunk, data.count)
|
|
||||||
connection.clipServe(
|
|
||||||
reqId: reqId, data: data.subdata(in: offset..<end), last: end == data.count)
|
|
||||||
offset = end
|
|
||||||
}
|
|
||||||
if data.isEmpty {
|
|
||||||
connection.clipServe(reqId: reqId, data: Data(), last: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read one wire format from the pasteboard, converting where macOS stores a different
|
|
||||||
/// native type: `image/png` is served from a real `.png` entry when present, else converted
|
|
||||||
/// from whatever image representation the pasteboard holds (TIFF from screenshots/Preview,
|
|
||||||
/// WebP/AVIF/GIF from browsers — `NSImage` decodes them all) into PNG at fetch time.
|
|
||||||
private static func readWireData(_ pb: NSPasteboard, _ mime: String) -> Data? {
|
|
||||||
guard mime == "image/png" else {
|
|
||||||
guard let type = wireToPasteboard.first(where: { $0.wire == mime })?.type else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return pb.data(forType: type)
|
|
||||||
}
|
|
||||||
if let png = pb.data(forType: .png) {
|
|
||||||
return png
|
|
||||||
}
|
|
||||||
// No native PNG: decode whatever image the pasteboard carries and re-encode.
|
|
||||||
guard let img = NSImage(pasteboard: pb),
|
|
||||||
let tiff = img.tiffRepresentation,
|
|
||||||
let rep = NSBitmapImageRep(data: tiff)
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return rep.representation(using: .png, properties: [:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The lazy paste hook: AppKit calls `provideDataForType` only when a Mac app actually pastes;
|
|
||||||
/// the fetch then blocks this provider thread (never main) until the host's bytes arrive or the
|
|
||||||
/// timeout provides nothing. One provider per installed remote offer — a dead sync (weak) or a
|
|
||||||
/// superseded offer provides nothing.
|
|
||||||
private final class RemoteOfferProvider: NSObject, NSPasteboardItemDataProvider {
|
|
||||||
private weak var sync: ClipboardSync?
|
|
||||||
private let seq: UInt32
|
|
||||||
|
|
||||||
init(sync: ClipboardSync, seq: UInt32) {
|
|
||||||
self.sync = sync
|
|
||||||
self.seq = seq
|
|
||||||
}
|
|
||||||
|
|
||||||
func pasteboard(
|
|
||||||
_ pasteboard: NSPasteboard?, item: NSPasteboardItem,
|
|
||||||
provideDataForType type: NSPasteboard.PasteboardType
|
|
||||||
) {
|
|
||||||
guard let sync,
|
|
||||||
let wire = wireMime(for: type),
|
|
||||||
let data = sync.fetchBlocking(seq: seq, wireMime: wire)
|
|
||||||
else { return }
|
|
||||||
item.setData(data, forType: type)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func wireMime(for type: NSPasteboard.PasteboardType) -> String? {
|
|
||||||
switch type {
|
|
||||||
case .string: return "text/plain;charset=utf-8"
|
|
||||||
case .rtf: return "text/rtf"
|
|
||||||
case .html: return "text/html"
|
|
||||||
case .png: return "image/png"
|
|
||||||
case NSPasteboard.PasteboardType("public.jpeg"): return "image/jpeg"
|
|
||||||
case NSPasteboard.PasteboardType("com.compuserve.gif"): return "image/gif"
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -54,12 +54,6 @@ public func pair(
|
|||||||
switch rc {
|
switch rc {
|
||||||
case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed)
|
case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed)
|
||||||
case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN
|
case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN
|
||||||
default:
|
default: throw PunktfunkClientError.status(rc)
|
||||||
// A typed host rejection (pairing not armed / rate-limited / armed for another
|
|
||||||
// device) carries its own reason — never report it as a bad PIN or dead network.
|
|
||||||
if let rejection = HostRejection(status: rc) {
|
|
||||||
throw PunktfunkClientError.rejected(rejection)
|
|
||||||
}
|
|
||||||
throw PunktfunkClientError.status(rc)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user