Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efbcc4cdb6 | |||
| 168040005a | |||
| 99a1d1bed6 | |||
| 2123e4e580 | |||
| 232b41e88b | |||
| 59b766fb6c | |||
| 00f759ec72 | |||
| c131603f0d | |||
| a83acf3ee1 | |||
| 3ae5f222d3 | |||
| 0e977817f9 | |||
| 4f64125025 | |||
| a8c8b1bb13 | |||
| 75474dcc90 | |||
| ac0e73321c | |||
| dc20e4452e | |||
| 017b083e32 | |||
| e5eec51a78 | |||
| 9fe9c451dc | |||
| b89dbfa979 | |||
| 770994b7aa | |||
| 3ff1973d7f | |||
| 188edde2b3 | |||
| 4861824e7d | |||
| 5eb930e71d | |||
| 97cf15e3b7 | |||
| 94533bb071 | |||
| f407f41855 | |||
| 574e3e4e3f | |||
| ebd9967547 | |||
| 1e7c18b2c8 | |||
| 9aebc3f251 | |||
| 7f639f7cf5 | |||
| 2c0aee3979 | |||
| ec84b30eae | |||
| d579cd318e | |||
| bb755ef7d2 | |||
| 08a397cf08 | |||
| 11cca33300 | |||
| 4d2632a845 | |||
| 17a262aace | |||
| 205e5c5a59 | |||
| 3584b47fb8 | |||
| de651c0f1a | |||
| e7a8cf2bba | |||
| fb92dcd1c2 | |||
| 8e75f3d8bf | |||
| 5e1e64e50b | |||
| fbe1e62ef2 | |||
| 0c9d3ee33f | |||
| df04c2c54f | |||
| 9da38b8d9c | |||
| 1bc156aab0 | |||
| 399ea9a0f1 | |||
| df70bf00f9 | |||
| 9e6ab9b94d | |||
| 6f409caac7 | |||
| 5977b4b9f1 | |||
| 0a617e7779 | |||
| 75d1322b8a | |||
| 55e7f3fca9 | |||
| 055b95a181 | |||
| f4b52d0bf5 | |||
| bbe4380b41 | |||
| aedee2a4e3 | |||
| c28b10a5b9 | |||
| 78d018ae2f | |||
| 1b58130e68 | |||
| 12d4b025f7 | |||
| 871ebb31ce | |||
| 600693914f | |||
| 4d89dcd3d7 | |||
| 97c5778a36 | |||
| 22b352c1da | |||
| 15233a68cf | |||
| 2a637eaf3f | |||
| 6c976e9dc5 | |||
| c8ee4b9902 | |||
| 1197415216 | |||
| 18a5d93ae3 | |||
| 09849906e9 | |||
| 691c064a37 | |||
| 34519566ba | |||
| 86d9f49473 | |||
| 2064c0780c | |||
| f439b69451 | |||
| 1eef55016d | |||
| 61118cbdd4 | |||
| 22a61e0b48 | |||
| 13b1f36d4a | |||
| 9e6fc6e071 | |||
| 570ff504ad | |||
| e8b64ffe43 | |||
| 5d0e23d6a5 | |||
| ffa63a74f2 | |||
| 716875dd09 | |||
| da0578771e | |||
| 391f8fb9f7 | |||
| ef736cb9d7 | |||
| 4ef90d586d | |||
| 93c8dc4712 | |||
| f012ebbcba | |||
| 27a5d8daac | |||
| f6c6e4e594 | |||
| 0992548de7 | |||
| 94ca4041ca | |||
| 845a97601d | |||
| 85dd2bb077 | |||
| 3d9b329084 | |||
| 9a36ea2132 | |||
| 1de83ba51d | |||
| ccc4b08d45 | |||
| b168790e0a | |||
| 6824c1cc0c | |||
| 85bc5b9a3f | |||
| 6ea036766a | |||
| 3495d189e1 | |||
| c42ce88921 | |||
| 2e3208f75e | |||
| 47587827ec | |||
| e06ab59652 | |||
| f2a58f3a91 | |||
| 87114ab186 | |||
| aaa3dcec32 | |||
| 991d28909b | |||
| 9ea5c2a129 | |||
| 880634b4c1 | |||
| 265554b755 | |||
| cb7091e1d5 | |||
| dd462787ec | |||
| 63efe0ecd5 | |||
| 384f8e00aa | |||
| 46c0e0e483 | |||
| f7ca641d76 | |||
| 2067b5ac81 | |||
| 09600163e2 | |||
| ea23408d1d | |||
| 9bc70e59fc | |||
| 393b47a062 | |||
| 329cf7b5d5 | |||
| 68bcfdac3e | |||
| ff55d0a608 | |||
| ecfa71212d | |||
| 07e2836601 | |||
| 6ac7134e7c | |||
| 6d2e738070 | |||
| 09e2043ce0 | |||
| a513186424 | |||
| df6a5325d8 | |||
| 14c5e7c11c | |||
| 6a0a97b702 | |||
| 4cae1b8bb8 | |||
| 45c29a99d5 | |||
| a738de6cd8 | |||
| b45323c0be | |||
| 1a7e3a6e4f | |||
| 55e59458a2 | |||
| f910d23fb2 | |||
| c95e9125b9 | |||
| c2b9b32904 | |||
| 0899e53903 | |||
| 32ffe7d634 | |||
| 8374dfedf3 | |||
| e62cd5448e | |||
| 4ed5b88407 | |||
| 4ffa2665ac | |||
| 2def3ef49e | |||
| 571e22bc0f | |||
| ce085b8e3b | |||
| 405b005a0d | |||
| 89a08f83af | |||
| 5748706631 | |||
| 2ae5cf98ee | |||
| e61d655b1e | |||
| 7099266594 | |||
| fd8a062e2c | |||
| 3c38a5f0e8 | |||
| 1f519d44f9 | |||
| abecb5226c | |||
| 11045a0f70 | |||
| d466e3e2b2 | |||
| 532b313b8c | |||
| d381cdf7f4 | |||
| f901bedf22 | |||
| 44b71e7460 |
@@ -1,4 +1,4 @@
|
|||||||
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from
|
# Build the punktfunk-host / punktfunk-client / punktfunk-web / punktfunk-scripting 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
|
||||||
@@ -45,8 +45,9 @@ jobs:
|
|||||||
git nodejs rust clang cmake 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 AND is vendored as its runtime (PF_WITH_WEB=1);
|
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
|
||||||
# it's AUR-only on Arch, so bootstrap the official binary.
|
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so bootstrap
|
||||||
|
# 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
|
||||||
@@ -105,7 +106,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 \
|
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=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
|
||||||
|
|||||||
+121
-18
@@ -1,8 +1,18 @@
|
|||||||
# Build the punktfunk-host and punktfunk-client .debs and publish them to Gitea's Debian
|
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
||||||
# package registry, so Ubuntu boxes get new builds via `apt update && apt upgrade`. Runs
|
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
|
||||||
# inside the same Ubuntu 26.04 rust-ci builder image as ci.yml, so dpkg-shlibdeps pins the
|
# apt distribution/component:
|
||||||
# runtime lib package names (libavcodec62, libpipewire-0.3-0t64, …) to exactly what the
|
#
|
||||||
# target boxes run.
|
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
||||||
|
# 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
|
||||||
@@ -83,22 +93,15 @@ 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 host + client
|
- name: Build release clients
|
||||||
env:
|
env:
|
||||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (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).
|
# both client binaries must ship (build-client-deb.sh installs both). The HOST is built
|
||||||
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on
|
# separately in the build-publish-host job (Ubuntu 24.04 image + bundled FFmpeg 8).
|
||||||
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at
|
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session
|
||||||
# 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
|
||||||
@@ -128,10 +131,12 @@ jobs:
|
|||||||
- name: Build .debs
|
- name: Build .debs
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.bun/bin:$PATH"
|
export PATH="$HOME/.bun/bin:$PATH"
|
||||||
VERSION="$VERSION" bash packaging/debian/build-deb.sh
|
# host .deb is built in build-publish-host (Ubuntu 24.04 image); this job ships the rest.
|
||||||
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:
|
||||||
@@ -166,3 +171,101 @@ 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
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ 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
|
||||||
|
|||||||
@@ -258,9 +258,12 @@ 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. No -allowProvisioningUpdates → it stays OFFLINE and never cloud-signs
|
# entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and
|
||||||
# (the App-Manager ASC key can't), so the runner must have a macOS *development* profile
|
# regenerate the managed *development* profile — needed because the App Groups capability
|
||||||
# for io.unom.punktfunk installed. DISTRIBUTION signing happens in the export step below
|
# (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached
|
||||||
|
# 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
|
||||||
@@ -271,6 +274,10 @@ jobs:
|
|||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||||
-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"
|
||||||
@@ -308,19 +315,32 @@ 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. No -allowProvisioningUpdates → OFFLINE, never
|
# the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID
|
||||||
# cloud-signs (the App-Manager ASC key can't), so the runner needs an iOS *development*
|
# capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk
|
||||||
# profile for io.unom.punktfunk installed. DISTRIBUTION signing is the export step below
|
# AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups
|
||||||
# (manual, via the plist). A running Xcode.app prunes unrecognized profiles — quit it so the
|
# capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension)
|
||||||
# manually-installed App Store distribution profile survives for export.
|
# invalidated the cached managed dev profile, which had no widgets profile at all. This is
|
||||||
|
# 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" \
|
||||||
-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"
|
||||||
@@ -335,7 +355,10 @@ 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><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
|
<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
|
||||||
@@ -359,9 +382,11 @@ 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. No -allowProvisioningUpdates → OFFLINE, never cloud-signs (the
|
# AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities
|
||||||
# App-Manager ASC key can't), so the runner needs a tvOS *development* profile for
|
# and regenerate the managed *development* profile — the tvOS app carries the App Groups key
|
||||||
# io.unom.punktfunk installed. DISTRIBUTION signing is the export step below (manual, plist).
|
# (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against
|
||||||
|
# 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"
|
||||||
@@ -370,6 +395,10 @@ jobs:
|
|||||||
-destination 'generic/platform=tvOS' \
|
-destination 'generic/platform=tvOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||||
-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,9 +92,10 @@ jobs:
|
|||||||
echo "rpm $V-$R -> group '$GROUP'"
|
echo "rpm $V-$R -> group '$GROUP'"
|
||||||
|
|
||||||
- name: Build RPM
|
- name: Build RPM
|
||||||
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below
|
# PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 → also build the punktfunk-web console + the
|
||||||
# globs it in; the host RPM Recommends it). Needs bun (ensured in Prep).
|
# punktfunk-scripting runner subpackages (the publish loop globs them in; the host RPM
|
||||||
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 bash packaging/rpm/build-rpm.sh
|
# Recommends both). Both need bun (ensured in Prep).
|
||||||
|
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:
|
||||||
@@ -131,7 +132,8 @@ 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:
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 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 wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
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
|
||||||
- name: cargo fmt --check the safe-layer + gamepad drivers
|
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers
|
||||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
|
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --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,7 +1,8 @@
|
|||||||
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
||||||
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
||||||
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
|
# pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script runner,
|
||||||
# bun) from one signed setup.exe. Runs on a self-hosted windows-amd64 runner
|
# run by scheduled tasks on a bundled bun) from one signed setup.exe. Runs on a self-hosted
|
||||||
|
# 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).
|
||||||
#
|
#
|
||||||
@@ -23,10 +24,15 @@
|
|||||||
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
||||||
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
||||||
#
|
#
|
||||||
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
# GPU backends: the host builds with --features nvenc,amf-qsv,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).
|
||||||
@@ -40,9 +46,14 @@ 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'
|
||||||
@@ -89,6 +100,12 @@ 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
|
||||||
@@ -114,10 +131,11 @@ 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)
|
- name: Build (release, nvenc + amf-qsv + qsv)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
|
# All-vendor host: NVENC (NVIDIA, direct SDK) + native QSV (Intel, static VPL dispatcher)
|
||||||
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
|
# + AMF/QSV (AMD + the Intel ffmpeg fallback, libavcodec via FFMPEG_DIR).
|
||||||
|
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
|
||||||
@@ -127,9 +145,18 @@ 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 -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||||
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
cargo clippy --release -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
|
||||||
@@ -196,6 +223,24 @@ 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:
|
||||||
|
|||||||
@@ -38,3 +38,12 @@ 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/
|
||||||
|
|||||||
Generated
+316
-57
@@ -358,28 +358,6 @@ 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"
|
||||||
@@ -560,6 +538,12 @@ 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"
|
||||||
@@ -756,6 +740,12 @@ 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"
|
||||||
@@ -1022,12 +1012,6 @@ 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"
|
||||||
@@ -1141,6 +1125,15 @@ 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"
|
||||||
@@ -1273,12 +1266,6 @@ 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"
|
||||||
@@ -1495,6 +1482,16 @@ 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"
|
||||||
@@ -1988,6 +1985,23 @@ 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"
|
||||||
@@ -2145,7 +2159,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2248,6 +2262,14 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libvpl-sys"
|
||||||
|
version = "0.14.0"
|
||||||
|
dependencies = [
|
||||||
|
"bindgen",
|
||||||
|
"cmake",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -2277,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2386,6 +2408,16 @@ 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"
|
||||||
@@ -2754,9 +2786,29 @@ 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.14.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)",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2778,9 +2830,27 @@ 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.14.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.12.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2799,18 +2869,106 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-encode"
|
||||||
|
version = "0.14.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"ash",
|
||||||
|
"ffmpeg-next",
|
||||||
|
"libc",
|
||||||
|
"libloading",
|
||||||
|
"libvpl-sys",
|
||||||
|
"nvidia-video-codec-sdk",
|
||||||
|
"openh264",
|
||||||
|
"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.12.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-frame"
|
||||||
|
version = "0.14.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.14.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.14.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-inject"
|
||||||
|
version = "0.14.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.14.0"
|
||||||
|
dependencies = [
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2823,6 +2981,62 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-vdisplay"
|
||||||
|
version = "0.14.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.14.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.14.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"
|
||||||
@@ -2895,6 +3109,19 @@ 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"
|
||||||
@@ -2994,7 +3221,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3010,7 +3237,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3026,7 +3253,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3041,7 +3268,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
@@ -3060,7 +3287,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3091,7 +3318,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3103,9 +3330,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",
|
||||||
@@ -3116,14 +3343,22 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"mac_address",
|
"mac_address",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"nvidia-video-codec-sdk",
|
|
||||||
"openh264",
|
|
||||||
"opus",
|
"opus",
|
||||||
"parking_lot",
|
"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",
|
||||||
"pyrowave-sys",
|
|
||||||
"quinn",
|
"quinn",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
@@ -3165,7 +3400,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3179,24 +3414,30 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
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]]
|
[[package]]
|
||||||
name = "pyrowave-sys"
|
name = "pyrowave-sys"
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
@@ -3395,7 +3636,6 @@ 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",
|
||||||
@@ -3624,7 +3864,6 @@ 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",
|
||||||
@@ -3689,7 +3928,6 @@ 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",
|
||||||
@@ -5014,6 +5252,12 @@ 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"
|
||||||
@@ -5826,6 +6070,21 @@ 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"
|
||||||
|
|||||||
+13
-1
@@ -6,11 +6,23 @@ 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/pyrowave-sys",
|
||||||
|
"crates/libvpl-sys",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
"clients/session",
|
"clients/session",
|
||||||
@@ -36,7 +48,7 @@ exclude = [
|
|||||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.12.0"
|
version = "0.14.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+1334
-4
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
|||||||
|
# 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
|
||||||
@@ -30,7 +30,13 @@ 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 (w, h, hz) = settings.effectiveMode(context)
|
val (baseW, baseH, 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,6 +1,8 @@
|
|||||||
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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,6 +16,14 @@ 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
|
||||||
@@ -135,6 +145,7 @@ 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),
|
||||||
@@ -169,6 +180,7 @@ 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)
|
||||||
@@ -191,6 +203,7 @@ 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"
|
||||||
@@ -249,11 +262,25 @@ 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
|
||||||
@Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31
|
val types = buildSet {
|
||||||
val caps = display.hdrCapabilities ?: return false
|
// API 34+: the sanctioned per-mode query (Display.Mode.getSupportedHdrTypes). The
|
||||||
return caps.supportedHdrTypes.any {
|
// deprecated Display-level hdrCapabilities can return EMPTY on Android 14+ devices
|
||||||
|
// (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. */
|
||||||
@@ -265,6 +292,54 @@ 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. */
|
||||||
|
|||||||
@@ -333,6 +333,15 @@ 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.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ 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
|
||||||
@@ -66,7 +67,7 @@ internal fun StatsOverlay(
|
|||||||
statLine(decoderLabel, Color(0xFFB0D0FF))
|
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||||
}
|
}
|
||||||
if (detailed) {
|
if (detailed) {
|
||||||
videoFeedLine(s)?.let { statLine(it, Color.White) }
|
videoFeedLine(s, codecLabel)?.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
|
||||||
@@ -151,14 +152,15 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format the negotiated video-feed descriptor from the trailing four stats doubles
|
* Format the negotiated video-feed descriptor from [codecLabel] plus the trailing four stats
|
||||||
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
* doubles `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||||
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
|
* `AV1 · 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. The
|
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4.
|
||||||
* Android decoder is always HEVC (`video/hevc`).
|
* [codecLabel] is the host-resolved codec (`nativeVideoCodecLabel`); a blank one falls back to
|
||||||
|
* `HEVC` (the pre-negotiation default) for the brief window before it's resolved.
|
||||||
*/
|
*/
|
||||||
private fun videoFeedLine(s: DoubleArray): String? {
|
private fun videoFeedLine(s: DoubleArray, codecLabel: String): 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()
|
||||||
@@ -175,5 +177,6 @@ private fun videoFeedLine(s: DoubleArray): String? {
|
|||||||
2 -> "4:2:2"
|
2 -> "4:2:2"
|
||||||
else -> "4:2:0"
|
else -> "4:2:0"
|
||||||
}
|
}
|
||||||
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
val codec = codecLabel.ifEmpty { "HEVC" }
|
||||||
|
return "$codec · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ 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).
|
||||||
@@ -99,6 +100,9 @@ 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)
|
||||||
@@ -366,7 +370,7 @@ 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, Modifier.align(Alignment.TopStart).padding(12.dp))
|
StatsOverlay(it, statsVerbosity, decoderLabel, codecLabel, 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 to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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,6 +214,7 @@ 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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ class GamepadFeedback(
|
|||||||
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
|
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). */
|
||||||
@@ -95,19 +92,19 @@ 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; bit 48 = has a v2 lease; bits 32..47 = ttl_ms;
|
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
|
||||||
// 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl.
|
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
|
||||||
// 0xFFFF) is a real lease — no in-band sentinel. No lease (legacy host) → the prior
|
// rumble policy engine — it owns every lease/staleness/close decision (uniform
|
||||||
// long one-shot.
|
// across all clients; the old 60 s legacy-host exposure is gone) and emits
|
||||||
|
// 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 hasLease = ((ev ushr 48) and 0x1L) == 0x1L
|
val backstopMs = ((ev ushr 32) and 0xFFFF)
|
||||||
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(),
|
||||||
durationMs,
|
backstopMs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||||
@@ -212,12 +209,13 @@ 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 host's v2 envelope TTL — the one-shot self-
|
* addressed to wire pad [pad]. `durationMs` is the engine command's backstop — the one-shot's
|
||||||
* terminates after it unless the host renews, so a lost stop (or a dead host) silences at the
|
* self-termination net under a stalled poll thread; the engine emits explicit zero commands at
|
||||||
* lease instead of the old fixed 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS].
|
* every policy stop (lease expiry, legacy staleness, session close), so cancel-on-zero is the
|
||||||
|
* 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 ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
Log.i(TAG, "rumble pad=$pad low=$low high=$high backstopMs=$durationMs") // verification line — BEFORE any no-op return
|
||||||
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
|
// 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
|
// 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
|
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
||||||
|
|||||||
@@ -161,6 +161,14 @@ 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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,626 @@
|
|||||||
|
//! 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 {
|
||||||
|
let received_ns = 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
//! 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//! 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
//! 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 {
|
||||||
|
let received_ns = 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)
|
||||||
|
}
|
||||||
@@ -24,14 +24,19 @@ const TAG_PLAYER_LEDS: u8 = 0x02;
|
|||||||
const TAG_TRIGGER: u8 = 0x03;
|
const TAG_TRIGGER: u8 = 0x03;
|
||||||
const TAG_HID_RAW: u8 = 0x05;
|
const TAG_HID_RAW: u8 = 0x05;
|
||||||
|
|
||||||
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next EFFECTIVE
|
||||||
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
|
/// rumble command from the core's shared policy engine (`design/rumble-root-fix.md` §D). The
|
||||||
/// bits 32..47 = `ttl_ms`, bits 16..31 = `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` =
|
/// engine owns ALL rumble policy — v2 lease expiry, legacy-host staleness (a uniform 1 s, ending
|
||||||
/// stop). The lease flag is out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no
|
/// the old 60 s Android exposure), connection-close drain zeros — so Kotlin applies commands
|
||||||
/// in-band sentinel to collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and
|
/// verbatim: `(0, 0)` = cancel now, non-zero = one-shot at this level.
|
||||||
/// Kotlin falls back to its long one-shot. `-1` on timeout / session closed (all packed values are
|
///
|
||||||
/// positive, so `-1` stays unambiguous). Kotlin routes the update back to the controller holding that
|
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bits 32..47 = the
|
||||||
/// wire `pad` index (multi-pad rumble). Run from a Kotlin poll thread.
|
/// command's `backstop_ms` (≤ 5000 — the one-shot duration, i.e. the hardware net under a stalled
|
||||||
|
/// 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,
|
||||||
@@ -43,24 +48,17 @@ 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_ttl is &self on
|
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_command is
|
||||||
// the Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
// &self on the Sync connector — safe alongside the decode/audio/input threads. Kotlin
|
||||||
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
// stops these poll threads (and joins them — unbounded) before nativeClose frees the
|
||||||
|
// handle.
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
match h.client.next_rumble_ttl(PULL_TIMEOUT) {
|
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||||
Ok((pad, low, high, ttl)) => {
|
Ok(cmd) => {
|
||||||
// The reorder gate already ran in the core, so this update is fresh. Encode the
|
(jlong::from(cmd.pad & 0xF) << 49)
|
||||||
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. The pad
|
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
|
||||||
// index rides above the lease flag (bits 49..52), keeping the whole word positive.
|
| (jlong::from(cmd.low) << 16)
|
||||||
let (lease_flag, ttl_bits) = match ttl {
|
| jlong::from(cmd.high)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,31 @@ 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,5 +19,32 @@
|
|||||||
<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,5 +73,15 @@
|
|||||||
<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,5 +20,14 @@
|
|||||||
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,13 +9,20 @@ 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"],
|
dependencies: ["PunktfunkCore", "PunktfunkShared"],
|
||||||
// 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
|
||||||
@@ -43,7 +50,8 @@ let package = Package(
|
|||||||
// 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: [
|
resources: [
|
||||||
// PyroWave golden fixtures: host-encoded AUs + upstream-decoded reference
|
// PyroWave golden fixtures: host-encoded AUs + upstream-decoded reference
|
||||||
// planes (regenerate with punktfunk-host's `pyrowave_dump_golden` on a
|
// planes (regenerate with punktfunk-host's `pyrowave_dump_golden` on a
|
||||||
|
|||||||
@@ -11,14 +11,56 @@
|
|||||||
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 */; };
|
||||||
|
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
|
||||||
|
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
/* 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;
|
||||||
@@ -30,6 +72,14 @@
|
|||||||
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 */
|
||||||
@@ -58,14 +108,27 @@
|
|||||||
);
|
);
|
||||||
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>";
|
||||||
@@ -76,10 +139,20 @@
|
|||||||
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 */
|
||||||
@@ -114,10 +187,12 @@
|
|||||||
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 */,
|
||||||
@@ -156,6 +231,29 @@
|
|||||||
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 */
|
||||||
@@ -163,11 +261,15 @@
|
|||||||
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" */;
|
||||||
@@ -190,6 +292,7 @@
|
|||||||
AA0000000000000000000009 /* Punktfunk */,
|
AA0000000000000000000009 /* Punktfunk */,
|
||||||
BB0000000000000000000009 /* Punktfunk-iOS */,
|
BB0000000000000000000009 /* Punktfunk-iOS */,
|
||||||
CC0000000000000000000009 /* Punktfunk-tvOS */,
|
CC0000000000000000000009 /* Punktfunk-tvOS */,
|
||||||
|
E2955696300948B9009F939C /* PunktfunkWidgetsExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -216,6 +319,13 @@
|
|||||||
);
|
);
|
||||||
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 */
|
||||||
@@ -240,8 +350,23 @@
|
|||||||
);
|
);
|
||||||
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;
|
||||||
@@ -564,6 +689,97 @@
|
|||||||
};
|
};
|
||||||
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 = 1.0;
|
||||||
|
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 = 1.0;
|
||||||
|
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 */
|
||||||
@@ -603,6 +819,15 @@
|
|||||||
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 */
|
||||||
@@ -636,6 +861,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = PunktfunkKit;
|
productName = PunktfunkKit;
|
||||||
};
|
};
|
||||||
|
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = PunktfunkShared;
|
||||||
|
};
|
||||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
|
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
shouldAutocreateTestPlan = "YES">
|
shouldAutocreateTestPlan = "YES">
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Release"
|
||||||
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
@@ -0,0 +1,120 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// 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(.tint)
|
||||||
|
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(.tint)
|
||||||
|
ForEach(hosts.prefix(4)) { host in
|
||||||
|
Link(destination: connectURL(host)) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "play.tv.fill")
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// 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
|
||||||
|
DynamicIsland {
|
||||||
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
|
Label {
|
||||||
|
Text(context.attributes.hostName).font(.caption).lineLimit(1)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "play.tv.fill")
|
||||||
|
}
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
|
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||||
|
.font(.caption).monospacedDigit()
|
||||||
|
.frame(maxWidth: 56)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.center) {
|
||||||
|
if let title = context.attributes.launchTitle {
|
||||||
|
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text(context.state.modeLine)
|
||||||
|
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
StageLine(state: context.state)
|
||||||
|
EndButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} compactLeading: {
|
||||||
|
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||||
|
} compactTrailing: {
|
||||||
|
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(maxWidth: 44)
|
||||||
|
} minimal: {
|
||||||
|
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(.tint)
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack {
|
||||||
|
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||||
|
.font(.subheadline).monospacedDigit()
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let title = context.attributes.launchTitle {
|
||||||
|
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
}
|
||||||
|
Text(context.state.modeLine)
|
||||||
|
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
StageLine(state: context.state)
|
||||||
|
}
|
||||||
|
if context.state.stage == .background {
|
||||||
|
EndButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared pieces
|
||||||
|
|
||||||
|
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
|
||||||
|
private struct StageLine: View {
|
||||||
|
let state: PunktfunkSessionAttributes.ContentState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch state.stage {
|
||||||
|
case .streaming:
|
||||||
|
EmptyView()
|
||||||
|
case .background:
|
||||||
|
if let deadline = state.backgroundDeadline {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Text("Keeps running for")
|
||||||
|
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
badge("Running in background", .orange)
|
||||||
|
}
|
||||||
|
case .reconnecting:
|
||||||
|
badge("Reconnecting…", .yellow)
|
||||||
|
case .ending:
|
||||||
|
badge("Session ended", .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func badge(_ text: String, _ color: Color) -> some View {
|
||||||
|
Text(text).font(.caption2).foregroundStyle(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
||||||
|
private struct EndButton: View {
|
||||||
|
var body: some View {
|
||||||
|
Button(intent: EndStreamIntent()) {
|
||||||
|
Label("End", systemImage: "stop.fill")
|
||||||
|
.font(.caption).bold()
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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,6 +24,7 @@ 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
|
||||||
@@ -51,6 +52,15 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@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).
|
||||||
@@ -92,6 +102,14 @@ 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)
|
||||||
@@ -113,7 +131,62 @@ 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:
|
||||||
@@ -151,6 +224,9 @@ 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)
|
||||||
@@ -216,7 +292,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 3000 → Pairing) — no PIN needed. Or "
|
+ "device in the host's web console (port 47992 → 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
|
||||||
@@ -259,9 +335,62 @@ 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 3000 → Pairing). This device connects automatically once you "
|
+ "console (port 47992 → 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 {
|
||||||
@@ -610,6 +739,17 @@ 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,
|
||||||
@@ -621,7 +761,7 @@ struct ContentView: View {
|
|||||||
if let approvalReq { awaitingApproval = approvalReq }
|
if let approvalReq { awaitingApproval = approvalReq }
|
||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
width: scaledMode().width, height: scaledMode().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,
|
||||||
@@ -804,7 +944,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
width: scaledMode().width, height: scaledMode().height,
|
||||||
hz: UInt32(clamping: hz),
|
hz: UInt32(clamping: hz),
|
||||||
compositor: pref,
|
compositor: pref,
|
||||||
gamepad: pad,
|
gamepad: pad,
|
||||||
@@ -815,71 +955,3 @@ 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?
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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,6 +20,12 @@ 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
|
||||||
@@ -41,6 +47,9 @@ 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 {
|
||||||
@@ -96,6 +105,9 @@ 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)
|
||||||
@@ -147,6 +159,11 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// 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
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// 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
|
||||||
@@ -139,6 +139,18 @@ final class SessionModel: ObservableObject {
|
|||||||
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
|
||||||
@@ -148,6 +160,16 @@ 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`
|
||||||
@@ -188,7 +210,13 @@ 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)
|
||||||
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
// POTENTIAL, not current, headroom: `maximumExtendedDynamicRangeColorComponentValue`
|
||||||
|
// 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
|
||||||
@@ -242,14 +270,11 @@ final class SessionModel: ObservableObject {
|
|||||||
// PyroWave (wired LAN) is a pure opt-in: picking it in the codec setting both
|
// 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
|
// 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;
|
// 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 codec is 8-bit 4:2:0 SDR
|
// every M-series Mac and the ATV 4K gen 3 pass). The decoder self-configures from
|
||||||
// BT.709 by contract, so the opt-in also drops the HDR/10-bit/4:4:4 caps for this
|
// the per-frame sequence header (4:2:0/4:4:4, SDR/PQ — design/pyrowave-444-hdr.md),
|
||||||
// session — HDR sessions stay HEVC/AV1 (plan §4.7).
|
// so the session keeps the user's HDR/10-bit/4:4:4 caps exactly like HEVC/AV1.
|
||||||
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
||||||
videoCodecs |= PunktfunkConnection.codecPyroWave
|
videoCodecs |= PunktfunkConnection.codecPyroWave
|
||||||
videoCaps &= ~(PunktfunkConnection.videoCap10Bit
|
|
||||||
| PunktfunkConnection.videoCapHDR
|
|
||||||
| PunktfunkConnection.videoCap444)
|
|
||||||
}
|
}
|
||||||
let result = Result { try PunktfunkConnection(
|
let result = Result { try PunktfunkConnection(
|
||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
@@ -313,7 +338,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 3000 → Pairing), then "
|
+ "Approve it in the host's web console (port 47992 → 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
|
||||||
@@ -332,6 +357,48 @@ 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 }
|
||||||
@@ -349,6 +416,11 @@ 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
|
||||||
@@ -361,6 +433,12 @@ 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,
|
||||||
@@ -368,6 +446,9 @@ 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()
|
||||||
@@ -376,6 +457,9 @@ 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
|
||||||
@@ -450,6 +534,14 @@ 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() }
|
||||||
@@ -458,6 +550,40 @@ 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
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +64,23 @@ 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])
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
|
renderScaleRow
|
||||||
bitrateRows
|
bitrateRows
|
||||||
#endif
|
#endif
|
||||||
} header: {
|
} header: {
|
||||||
@@ -54,6 +55,50 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#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 {
|
||||||
|
Picker("Render scale", selection: $renderScale) {
|
||||||
|
ForEach(RenderScale.presets, id: \.self) { scale in
|
||||||
|
Text(RenderScale.label(scale)).tag(scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The concrete host resolution makes the cost legible. Only meaningful for the explicit mode
|
||||||
|
// (match-window derives the base from the live window, not these fields).
|
||||||
|
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 downscales it to your display.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
Picker("Modifier keys", selection: $modifierLayout) {
|
||||||
|
ForEach(ModifierLayout.allCases, id: \.self) { layout in
|
||||||
|
Text(layout.label).tag(layout.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toggle("Invert scroll direction", isOn: $invertScroll)
|
||||||
|
} header: {
|
||||||
|
Text("Keyboard & mouse")
|
||||||
|
} footer: {
|
||||||
|
Text((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail
|
||||||
|
+ " Invert scroll reverses the wheel/trackpad scroll direction sent to the host.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// MARK: - Stream mode (iOS wheel)
|
// MARK: - Stream mode (iOS wheel)
|
||||||
|
|
||||||
@@ -440,6 +485,34 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// iOS/iPadOS only: keep a backgrounded session alive (audio background mode). Empty elsewhere
|
||||||
|
/// (tvOS backgrounding semantics differ; macOS isn't gated by the mode) so the shared `.general`
|
||||||
|
/// detail can reference it unconditionally.
|
||||||
|
@ViewBuilder var keepAliveSection: some View {
|
||||||
|
#if os(iOS)
|
||||||
|
Section {
|
||||||
|
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
|
||||||
|
if backgroundKeepAlive {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Background")
|
||||||
|
} footer: {
|
||||||
|
Text("Off by default: backgrounding the app freezes the session. When on, audio keeps "
|
||||||
|
+ "playing and the connection stays live (video is dropped to save power) after you "
|
||||||
|
+ "switch away — and the session auto-disconnects after the time above so it can't "
|
||||||
|
+ "run down your battery. Returning to the app resumes video instantly.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder var experimentalSection: some View {
|
@ViewBuilder var experimentalSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Show game library", isOn: $libraryEnabled)
|
Toggle("Show game library", isOn: $libraryEnabled)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
// App settings. The host creates a virtual output at exactly the chosen size/refresh; the only
|
||||||
// there is no scaling anywhere in the pipeline.
|
// deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device
|
||||||
|
// downscales — supersampling for sharpness, or under-rendering for a lighter host/link).
|
||||||
//
|
//
|
||||||
// Navigation differs per platform, but all three group the same categories (General, Display,
|
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||||
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||||
@@ -25,6 +26,10 @@ 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
|
||||||
@@ -49,6 +54,14 @@ 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
|
||||||
@@ -110,6 +123,7 @@ struct SettingsView: View {
|
|||||||
TabView {
|
TabView {
|
||||||
Form {
|
Form {
|
||||||
streamModeSection
|
streamModeSection
|
||||||
|
inputSection
|
||||||
compositorSection
|
compositorSection
|
||||||
wakeSection
|
wakeSection
|
||||||
}
|
}
|
||||||
@@ -240,8 +254,10 @@ struct SettingsView: View {
|
|||||||
Form {
|
Form {
|
||||||
streamModeSection
|
streamModeSection
|
||||||
pointerSection
|
pointerSection
|
||||||
|
inputSection
|
||||||
compositorSection
|
compositorSection
|
||||||
wakeSection
|
wakeSection
|
||||||
|
keepAliveSection // iOS-only content; empty on tvOS
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("General")
|
.navigationTitle("General")
|
||||||
@@ -331,6 +347,10 @@ struct SettingsView: View {
|
|||||||
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),
|
||||||
|
|||||||
@@ -11,32 +11,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if canImport(WidgetKit)
|
||||||
|
import WidgetKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct StoredHost: Identifiable, Codable, Hashable {
|
// `StoredHost` (the model + its JSON codec) now lives in PunktfunkShared so the widget extension
|
||||||
var id = UUID()
|
// can read the same store; PunktfunkKit re-exports it. The discovery-join helpers below stay here
|
||||||
var name: String
|
// because they reference PunktfunkKit's `DiscoveredHost`/`HostDiscovery`.
|
||||||
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
|
||||||
@@ -86,8 +67,14 @@ 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() {
|
||||||
if let data = UserDefaults.standard.data(forKey: Self.key),
|
Self.migrateToAppGroupIfNeeded()
|
||||||
|
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 {
|
||||||
@@ -95,6 +82,20 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -112,7 +113,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()
|
hosts[i].lastConnected = Date() // didSet → persist() writes the shared suite + reloads widget
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently
|
/// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently
|
||||||
@@ -158,7 +159,17 @@ final class HostStore: ObservableObject {
|
|||||||
|
|
||||||
private func persist() {
|
private func persist() {
|
||||||
if let data = try? JSONEncoder().encode(hosts) {
|
if let data = try? JSONEncoder().encode(hosts) {
|
||||||
UserDefaults.standard.set(data, forKey: Self.key)
|
defaults.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 3000 →
|
// PIN pairing sheet. The host shows the pairing PIN in its web console (port 47992 →
|
||||||
// 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 "
|
||||||
+ "(http://<host>:3000 → Pairing). "
|
+ "(https://<host>:47992 → 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 "
|
||||||
+ "(http://<host>:3000 → Pairing). "
|
+ "(https://<host>:47992 → 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,7 +210,7 @@ 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 3000) "
|
errorText = "Wrong PIN — check the host's web console (port 47992) "
|
||||||
+ "and try again."
|
+ "and try again."
|
||||||
case .failure(PunktfunkClientError.rejected(let rejection)):
|
case .failure(PunktfunkClientError.rejected(let rejection)):
|
||||||
// The host answered and said why (not armed / rate-limited / armed for
|
// The host answered and said why (not armed / rate-limited / armed for
|
||||||
|
|||||||
@@ -180,6 +180,23 @@ 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) {
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
// 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
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
|
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
// `punktfunkDefaultMgmtPort` (and StoredHost/DefaultsKey) now live in PunktfunkShared so the
|
||||||
|
// dependency-free widget extension can share them; PunktfunkKit re-exports the module.
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
/// Cover art URLs (the public Steam CDN for Steam titles, user-supplied for custom entries).
|
/// Cover art URLs (the public Steam CDN for Steam titles, user-supplied for custom entries).
|
||||||
public struct Artwork: Codable, Hashable, Sendable {
|
public struct Artwork: Codable, Hashable, Sendable {
|
||||||
@@ -64,10 +67,6 @@ public enum LibraryError: LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The management API's default port — adjacent to the GameStream block; matches
|
|
||||||
/// `mgmt::DEFAULT_PORT` on the host.
|
|
||||||
public let punktfunkDefaultMgmtPort: UInt16 = 47990
|
|
||||||
|
|
||||||
/// Stateless fetcher for a host's library.
|
/// Stateless fetcher for a host's library.
|
||||||
public enum LibraryClient {
|
public enum LibraryClient {
|
||||||
/// `GET https://<address>:<port>/api/v1/library`, authenticated by **mTLS**: the client
|
/// `GET https://<address>:<port>/api/v1/library`, authenticated by **mTLS**: the client
|
||||||
|
|||||||
@@ -196,6 +196,10 @@ public final class PunktfunkConnection {
|
|||||||
/// Same role for the host-timing (0xCF) puller — its own plane in the core, drained
|
/// Same role for the host-timing (0xCF) puller — its own plane in the core, drained
|
||||||
/// non-blockingly by the app's 1 s stats tick (never contends with the blocking pullers).
|
/// non-blockingly by the app's 1 s stats tick (never contends with the blocking pullers).
|
||||||
private let statsLock = NSLock()
|
private let statsLock = NSLock()
|
||||||
|
/// Same role for the shared-clipboard drain thread (`nextClipboard` — its own plane in the
|
||||||
|
/// core). The clip *sends* (`clipControl`/`clipOffer`/`clipServe`…) share this lock too:
|
||||||
|
/// they're quick non-blocking enqueues, and a single lock keeps close() ordering simple.
|
||||||
|
private let clipboardLock = NSLock()
|
||||||
|
|
||||||
/// Negotiated session mode (host-confirmed).
|
/// Negotiated session mode (host-confirmed).
|
||||||
public private(set) var width: UInt32 = 0
|
public private(set) var width: UInt32 = 0
|
||||||
@@ -346,6 +350,16 @@ public final class PunktfunkConnection {
|
|||||||
/// parse-window size for `USER_FLAG_CHUNK_ALIGNED` PyroWave AUs (plan §4.4). Other codecs
|
/// parse-window size for `USER_FLAG_CHUNK_ALIGNED` PyroWave AUs (plan §4.4). Other codecs
|
||||||
/// never need it.
|
/// never need it.
|
||||||
public private(set) var shardPayload: UInt32 = 1408
|
public private(set) var shardPayload: UInt32 = 1408
|
||||||
|
|
||||||
|
/// The host capability bitfield (`Welcome.host_caps`): `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` /
|
||||||
|
/// `PUNKTFUNK_HOST_CAP_CLIPBOARD`. `0` for an older host that didn't say.
|
||||||
|
public private(set) var hostCaps: UInt8 = 0
|
||||||
|
/// Whether this host advertises the shared clipboard (`HOST_CAP_CLIPBOARD`) — the gate for
|
||||||
|
/// offering the clipboard toggle. Absent on an older host, or one whose operator policy
|
||||||
|
/// (`PUNKTFUNK_CLIPBOARD=off`) keeps the feature dark.
|
||||||
|
public var hostSupportsClipboard: Bool {
|
||||||
|
hostCaps & UInt8(PUNKTFUNK_HOST_CAP_CLIPBOARD) != 0
|
||||||
|
}
|
||||||
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing
|
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing
|
||||||
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
||||||
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
||||||
@@ -461,6 +475,9 @@ public final class PunktfunkConnection {
|
|||||||
var shard: UInt32 = 1408
|
var shard: UInt32 = 1408
|
||||||
_ = punktfunk_connection_shard_payload(handle, &shard)
|
_ = punktfunk_connection_shard_payload(handle, &shard)
|
||||||
shardPayload = shard
|
shardPayload = shard
|
||||||
|
var caps: UInt8 = 0
|
||||||
|
_ = punktfunk_connection_host_caps(handle, &caps)
|
||||||
|
hostCaps = caps
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||||
@@ -534,6 +551,23 @@ public final class PunktfunkConnection {
|
|||||||
_ = punktfunk_connection_request_keyframe(h)
|
_ = punktfunk_connection_request_keyframe(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Background-keep-alive video drop (opt-in). While true, both video pumps keep DRAINING
|
||||||
|
/// `nextAU()` (so QUIC flow control and host pacing stay healthy) but DISCARD each AU before any
|
||||||
|
/// VideoToolbox/Metal decode or render — the crash/jetsam-safe way to hold a backgrounded
|
||||||
|
/// session (audio keeps rendering; no GPU work off-screen). Set on `SessionModel.enterBackground`,
|
||||||
|
/// cleared on `exitBackground` (which then requests a fresh IDR; the pump's re-anchor gate
|
||||||
|
/// auto-arms on the resumed frame-index gap). Its own tiny lock — read on the pump thread every
|
||||||
|
/// iteration, written on the main actor; never contends the ABI/plane locks.
|
||||||
|
private let videoDropLock = NSLock()
|
||||||
|
private var videoDropped = false
|
||||||
|
public var isVideoDropped: Bool {
|
||||||
|
videoDropLock.lock(); defer { videoDropLock.unlock() }
|
||||||
|
return videoDropped
|
||||||
|
}
|
||||||
|
public func setVideoDropped(_ dropped: Bool) {
|
||||||
|
videoDropLock.lock(); videoDropped = dropped; videoDropLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
/// Feed each received AU's `frameIndex` (in receive order) so the client recovers from loss with a
|
/// Feed each received AU's `frameIndex` (in receive order) so the client recovers from loss with a
|
||||||
/// cheap reference-frame invalidation instead of always paying for a full IDR. On a forward gap —
|
/// cheap reference-frame invalidation instead of always paying for a full IDR. On a forward gap —
|
||||||
/// a `frameIndex` jump means the intervening frames were lost and the following AUs reference a
|
/// a `frameIndex` jump means the intervening frames were lost and the following AUs reference a
|
||||||
@@ -760,6 +794,34 @@ public final class PunktfunkConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pull the next EFFECTIVE rumble command from the core's shared rumble policy engine — the
|
||||||
|
/// uniform replacement for per-platform rumble policy. The engine owns every decision
|
||||||
|
/// (v2 lease expiry, legacy-host staleness at a uniform 1 s, connection-close drain zeros),
|
||||||
|
/// so apply commands verbatim: `(0, 0)` = stop now, non-zero = run at this level.
|
||||||
|
/// `backstopMs` is a safety-net duration for duration-parameterized platform APIs — the
|
||||||
|
/// CoreHaptics renderer ignores it (its finite segment ceiling is the equivalent net).
|
||||||
|
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
|
||||||
|
public func nextRumbleCommand(timeoutMs: UInt32 = 0) throws
|
||||||
|
-> (pad: UInt16, low: UInt16, high: UInt16, backstopMs: UInt32)?
|
||||||
|
{
|
||||||
|
feedbackLock.lock()
|
||||||
|
defer { feedbackLock.unlock() }
|
||||||
|
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||||
|
|
||||||
|
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, backstop: UInt32 = 0
|
||||||
|
let rc = punktfunk_connection_next_rumble_cmd(h, &pad, &low, &high, &backstop, timeoutMs)
|
||||||
|
switch rc {
|
||||||
|
case statusOK:
|
||||||
|
return (pad, low, high, backstop)
|
||||||
|
case statusNoFrame:
|
||||||
|
return nil
|
||||||
|
case statusClosed:
|
||||||
|
throw PunktfunkClientError.closed
|
||||||
|
default:
|
||||||
|
throw PunktfunkClientError.status(rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One DualSense feedback event a game wrote to the host's virtual pad — replay it on
|
/// One DualSense feedback event a game wrote to the host's virtual pad — replay it on
|
||||||
/// the real controller (GCDeviceLight, GCControllerPlayerIndex,
|
/// the real controller (GCDeviceLight, GCControllerPlayerIndex,
|
||||||
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these.
|
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these.
|
||||||
@@ -970,10 +1032,12 @@ public final class PunktfunkConnection {
|
|||||||
audioLock.lock()
|
audioLock.lock()
|
||||||
feedbackLock.lock()
|
feedbackLock.lock()
|
||||||
statsLock.lock()
|
statsLock.lock()
|
||||||
|
clipboardLock.lock()
|
||||||
abiLock.lock()
|
abiLock.lock()
|
||||||
let h = handle
|
let h = handle
|
||||||
handle = nil
|
handle = nil
|
||||||
abiLock.unlock()
|
abiLock.unlock()
|
||||||
|
clipboardLock.unlock()
|
||||||
statsLock.unlock()
|
statsLock.unlock()
|
||||||
feedbackLock.unlock()
|
feedbackLock.unlock()
|
||||||
audioLock.unlock()
|
audioLock.unlock()
|
||||||
@@ -1035,6 +1099,163 @@ public final class PunktfunkConnection {
|
|||||||
_ = punktfunk_connection_send_rich_input(h, &rich)
|
_ = punktfunk_connection_send_rich_input(h, &rich)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared clipboard (design/clipboard-and-file-transfer.md §5)
|
||||||
|
|
||||||
|
/// One advertised clipboard format in a lazy offer — the format list crosses the wire,
|
||||||
|
/// the bytes only on a fetch.
|
||||||
|
public struct ClipKind: Sendable, Equatable {
|
||||||
|
public let mime: String
|
||||||
|
/// Best-effort size in bytes; `0` = unknown.
|
||||||
|
public let sizeHint: UInt64
|
||||||
|
public init(mime: String, sizeHint: UInt64 = 0) {
|
||||||
|
self.mime = mime
|
||||||
|
self.sizeHint = sizeHint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shared-clipboard event from `nextClipboard`. The drain thread turns these into
|
||||||
|
/// NSPasteboard operations (`ClipboardSync`).
|
||||||
|
public enum ClipEvent: Sendable, Equatable {
|
||||||
|
/// The host copied: its lazy format list (empty = the host clipboard was cleared).
|
||||||
|
/// Fetch a format with `clipFetch(seq:mime:)` when a local app pastes.
|
||||||
|
case remoteOffer(seq: UInt32, kinds: [ClipKind])
|
||||||
|
/// Host ack / policy / backend update for `clipControl` (`CLIP_REASON_*`).
|
||||||
|
case state(enabled: Bool, policy: UInt8, reason: UInt8)
|
||||||
|
/// The host is pasting OUR offered data — answer with `clipServe(reqId:...)`.
|
||||||
|
case fetchRequest(reqId: UInt32, seq: UInt32, fileIndex: UInt32, mime: String)
|
||||||
|
/// Bytes for a fetch we started (`last` = final chunk).
|
||||||
|
case data(xferId: UInt32, chunk: Data, last: Bool)
|
||||||
|
/// A transfer was cancelled (either side).
|
||||||
|
case cancelled(id: UInt32)
|
||||||
|
/// A transfer failed (`status` = a PunktfunkStatus code).
|
||||||
|
case error(id: UInt32, status: Int32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable/disable the shared clipboard for this session. Opt-in: nothing is announced or
|
||||||
|
/// served until enabled. The host answers with a `.state` event carrying the resolved
|
||||||
|
/// outcome (its operator policy is authoritative). Best-effort — a dropped call on a
|
||||||
|
/// closing session is fine.
|
||||||
|
public func clipControl(enabled: Bool, flags: UInt8 = 0) {
|
||||||
|
clipboardLock.lock()
|
||||||
|
defer { clipboardLock.unlock() }
|
||||||
|
guard let h = liveHandle() else { return }
|
||||||
|
_ = punktfunk_connection_clipboard_control(h, enabled, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Announce that the local pasteboard changed — the lazy format-list offer (`seq` monotonic,
|
||||||
|
/// newest wins; empty `kinds` clears the host side). The bytes cross only if the host fetches.
|
||||||
|
public func clipOffer(seq: UInt32, kinds: [ClipKind]) {
|
||||||
|
clipboardLock.lock()
|
||||||
|
defer { clipboardLock.unlock() }
|
||||||
|
guard let h = liveHandle() else { return }
|
||||||
|
guard !kinds.isEmpty else {
|
||||||
|
_ = punktfunk_connection_clipboard_offer(h, seq, nil, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The C array borrows NUL-terminated strings for the duration of the call only.
|
||||||
|
let cStrings = kinds.map { strdup($0.mime) }
|
||||||
|
defer { cStrings.forEach { free($0) } }
|
||||||
|
let arr = zip(cStrings, kinds).map {
|
||||||
|
PunktfunkClipKind(mime: $0.map { UnsafePointer($0) }, size_hint: $1.sizeHint)
|
||||||
|
}
|
||||||
|
_ = arr.withUnsafeBufferPointer {
|
||||||
|
punktfunk_connection_clipboard_offer(h, seq, $0.baseAddress, UInt(arr.count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start pulling one format of the host's offer `seq` (a local app is pasting). Returns the
|
||||||
|
/// transfer id echoed on the resulting `.data`/`.error`/`.cancelled` events, or nil when the
|
||||||
|
/// session is closing.
|
||||||
|
public func clipFetch(seq: UInt32, mime: String, fileIndex: UInt32 = UInt32.max) -> UInt32? {
|
||||||
|
clipboardLock.lock()
|
||||||
|
defer { clipboardLock.unlock() }
|
||||||
|
guard let h = liveHandle() else { return nil }
|
||||||
|
var xfer: UInt32 = 0
|
||||||
|
let rc = mime.withCString {
|
||||||
|
punktfunk_connection_clipboard_fetch(h, seq, $0, fileIndex, &xfer)
|
||||||
|
}
|
||||||
|
return rc == statusOK ? xfer : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provide bytes answering a `.fetchRequest` (the host is pasting our offered data). Call
|
||||||
|
/// repeatedly to stream; `last = true` completes the transfer. An empty final chunk is fine.
|
||||||
|
public func clipServe(reqId: UInt32, data: Data, last: Bool) {
|
||||||
|
clipboardLock.lock()
|
||||||
|
defer { clipboardLock.unlock() }
|
||||||
|
guard let h = liveHandle() else { return }
|
||||||
|
if data.isEmpty {
|
||||||
|
_ = punktfunk_connection_clipboard_serve(h, reqId, nil, 0, last)
|
||||||
|
} else {
|
||||||
|
data.withUnsafeBytes { p in
|
||||||
|
_ = punktfunk_connection_clipboard_serve(
|
||||||
|
h, reqId, p.bindMemory(to: UInt8.self).baseAddress, UInt(data.count), last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel a clipboard transfer by id — an outbound fetch's `xferId` or an inbound
|
||||||
|
/// `.fetchRequest`'s `reqId`.
|
||||||
|
public func clipCancel(id: UInt32) {
|
||||||
|
clipboardLock.lock()
|
||||||
|
defer { clipboardLock.unlock() }
|
||||||
|
guard let h = liveHandle() else { return }
|
||||||
|
_ = punktfunk_connection_clipboard_cancel(h, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the next shared-clipboard event; nil on timeout, throws `.closed` once the session
|
||||||
|
/// ended. Drain from a single dedicated thread (`ClipboardSync`) — the event's borrowed
|
||||||
|
/// payload is copied into the returned `ClipEvent` before the next poll can overwrite it.
|
||||||
|
public func nextClipboard(timeoutMs: UInt32) throws -> ClipEvent? {
|
||||||
|
clipboardLock.lock()
|
||||||
|
defer { clipboardLock.unlock() }
|
||||||
|
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||||
|
var ev = PunktfunkClipEvent()
|
||||||
|
let rc = punktfunk_connection_next_clipboard(h, &ev, timeoutMs)
|
||||||
|
switch rc {
|
||||||
|
case statusOK:
|
||||||
|
return Self.decodeClipEvent(ev)
|
||||||
|
case statusNoFrame:
|
||||||
|
return nil
|
||||||
|
case statusClosed:
|
||||||
|
throw PunktfunkClientError.closed
|
||||||
|
default:
|
||||||
|
throw PunktfunkClientError.status(rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a raw C clip event (whose `data` borrows a per-connection slot) into an owned Swift
|
||||||
|
/// value. Unknown kinds (a newer core) decode to nil and are skipped by the drain.
|
||||||
|
private static func decodeClipEvent(_ ev: PunktfunkClipEvent) -> ClipEvent? {
|
||||||
|
let payload = ev.data.map { Data(bytes: $0, count: Int(ev.len)) } ?? Data()
|
||||||
|
switch Int32(ev.kind) {
|
||||||
|
case PUNKTFUNK_CLIP_REMOTE_OFFER:
|
||||||
|
// One `mime\tsize_hint\n` line per advertised format.
|
||||||
|
let kinds = String(decoding: payload, as: UTF8.self)
|
||||||
|
.split(separator: "\n")
|
||||||
|
.compactMap { line -> ClipKind? in
|
||||||
|
let parts = line.split(separator: "\t", maxSplits: 1)
|
||||||
|
guard let mime = parts.first, !mime.isEmpty else { return nil }
|
||||||
|
let hint = parts.count > 1 ? UInt64(parts[1]) ?? 0 : 0
|
||||||
|
return ClipKind(mime: String(mime), sizeHint: hint)
|
||||||
|
}
|
||||||
|
return .remoteOffer(seq: ev.transfer_id, kinds: kinds)
|
||||||
|
case PUNKTFUNK_CLIP_STATE:
|
||||||
|
return .state(enabled: ev.enabled != 0, policy: ev.policy, reason: ev.reason)
|
||||||
|
case PUNKTFUNK_CLIP_FETCH_REQUEST:
|
||||||
|
return .fetchRequest(
|
||||||
|
reqId: ev.transfer_id, seq: ev.seq, fileIndex: ev.file_index,
|
||||||
|
mime: String(decoding: payload, as: UTF8.self))
|
||||||
|
case PUNKTFUNK_CLIP_DATA:
|
||||||
|
return .data(xferId: ev.transfer_id, chunk: payload, last: ev.last != 0)
|
||||||
|
case PUNKTFUNK_CLIP_CANCELLED:
|
||||||
|
return .cancelled(id: ev.transfer_id)
|
||||||
|
case PUNKTFUNK_CLIP_ERROR:
|
||||||
|
return .error(id: ev.transfer_id, status: ev.status)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deinit { close() }
|
deinit { close() }
|
||||||
|
|
||||||
/// Snapshot the handle unless close is pending (callers hold their plane lock).
|
/// Snapshot the handle unless close is pending (callers hold their plane lock).
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import Combine
|
|||||||
import CoreHaptics
|
import CoreHaptics
|
||||||
import Foundation
|
import Foundation
|
||||||
import GameController
|
import GameController
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
public final class GamepadFeedback {
|
public final class GamepadFeedback {
|
||||||
private let connection: PunktfunkConnection
|
private let connection: PunktfunkConnection
|
||||||
@@ -154,21 +155,18 @@ public final class GamepadFeedback {
|
|||||||
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
|
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
|
||||||
// rumble/HID latency low while leaving the lock free between polls.
|
// rumble/HID latency low while leaving the lock free between polls.
|
||||||
//
|
//
|
||||||
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
|
// Rumble arrives as EFFECTIVE commands from the core's shared policy engine
|
||||||
// level PER PAD. The old one-datagram-per-cycle shape let a burst outpace the
|
// (design/rumble-root-fix.md §D): the engine owns leases, legacy staleness,
|
||||||
// ~125 Hz drain: levels rendered up to ~130 ms late through the core's 16-deep
|
// and close-drain zeros, and its per-pad mailbox already coalesces — a
|
||||||
// queue, and its drop-newest overflow could shed a stop while stale nonzero
|
// stalled drain wakes to ONE current-level command per pad, and a stop can
|
||||||
// states queued ahead of it — buzzing until the host's next 500 ms refresh.
|
// never be shed by a queue. Apply verbatim, in order.
|
||||||
var newestByPad: [UInt8: (low: UInt16, high: UInt16, ttl: UInt32)] = [:]
|
|
||||||
var rumbleBurst = 0
|
var rumbleBurst = 0
|
||||||
while rumbleBurst < 64, !flag.isStopped,
|
while rumbleBurst < 64, !flag.isStopped,
|
||||||
let r = try connection.nextRumble2(timeoutMs: 0) {
|
let c = try connection.nextRumbleCommand(timeoutMs: 0) {
|
||||||
newestByPad[UInt8(truncatingIfNeeded: r.pad)] = (r.low, r.high, r.ttlMs)
|
self?.routeRumble(
|
||||||
|
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high)
|
||||||
rumbleBurst += 1
|
rumbleBurst += 1
|
||||||
}
|
}
|
||||||
for (pad, n) in newestByPad {
|
|
||||||
self?.routeRumble(pad: pad, low: n.low, high: n.high, ttlMs: n.ttl)
|
|
||||||
}
|
|
||||||
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||||
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||||
var burst = 0
|
var burst = 0
|
||||||
@@ -217,15 +215,15 @@ public final class GamepadFeedback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Route one rumble envelope to its pad's renderer (drain thread). An update for a pad with no
|
/// Route one engine command to its pad's renderer (drain thread). A command for a pad with no
|
||||||
/// live renderer — one that just left the forwarded set — is dropped.
|
/// live renderer — one that just left the forwarded set — is dropped.
|
||||||
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
|
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16) {
|
||||||
let renderer = withRouting { rumbleByPad[pad] }
|
let renderer = withRouting { rumbleByPad[pad] }
|
||||||
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
|
renderer?.apply(low: low, high: high)
|
||||||
// The opt-in device mirror follows controller 1 unconditionally — the pads it exists for
|
// The opt-in device mirror follows controller 1 unconditionally — the pads it exists for
|
||||||
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
|
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
|
||||||
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
|
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
|
||||||
if pad == 0 { deviceRumble?.apply(low: low, high: high, ttlMs: ttlMs) }
|
if pad == 0 { deviceRumble?.apply(low: low, high: high) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func withRouting<R>(_ body: () -> R) -> R {
|
private func withRouting<R>(_ body: () -> R) -> R {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import GameController
|
import GameController
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class GamepadManager: ObservableObject {
|
public final class GamepadManager: ObservableObject {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
|
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
public enum GamepadUIEnvironment {
|
public enum GamepadUIEnvironment {
|
||||||
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
|
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
|
||||||
|
|||||||
@@ -23,23 +23,6 @@ enum RumbleTuning {
|
|||||||
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
|
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
|
||||||
/// zero is never throttled.
|
/// zero is never throttled.
|
||||||
static let minRebakeSeconds: TimeInterval = 0.025
|
static let minRebakeSeconds: TimeInterval = 0.025
|
||||||
/// Session watchdog: silence the motors when no wire command arrived for this long. This is
|
|
||||||
/// the **legacy-host fallback only** — an old host sends no self-termination lease, so its
|
|
||||||
/// periodic re-send (every 500 ms) is the sole liveness signal and 3 vanished refreshes means
|
|
||||||
/// the channel or host died while audible. A v2 host instead supplies a per-command TTL (see
|
|
||||||
/// [`leaseSeconds`]); that deadline supersedes this watchdog.
|
|
||||||
static let sessionStaleSeconds: TimeInterval = 1.6
|
|
||||||
|
|
||||||
/// The legacy no-lease sentinel a v2 `ttl_ms` carries for an old host (mirrors the C ABI's
|
|
||||||
/// `PUNKTFUNK_RUMBLE_NO_TTL`). `UInt32.max` by construction.
|
|
||||||
static let noTTL: UInt32 = .max
|
|
||||||
|
|
||||||
/// Interpret a wire TTL (ms) from a rumble update: `nil` for the legacy no-lease sentinel
|
|
||||||
/// ([`noTTL`]) — the renderer falls back to [`sessionStaleSeconds`] — else the self-termination
|
|
||||||
/// lease in seconds (render the level for at most this long unless the host renews it).
|
|
||||||
static func leaseSeconds(ttlMs: UInt32) -> TimeInterval? {
|
|
||||||
ttlMs == noTTL ? nil : TimeInterval(ttlMs) / 1000
|
|
||||||
}
|
|
||||||
/// Levels closer than this (≈0.4 % of full scale) are the same level — an identical host
|
/// Levels closer than this (≈0.4 % of full scale) are the same level — an identical host
|
||||||
/// refresh must never rebuild a player.
|
/// refresh must never rebuild a player.
|
||||||
static let levelEpsilon: Float = 1.0 / 256.0
|
static let levelEpsilon: Float = 1.0 / 256.0
|
||||||
@@ -110,13 +93,15 @@ enum RumbleTuning {
|
|||||||
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
||||||
/// `queue` closures — the serial queue is the synchronization.
|
/// `queue` closures — the serial queue is the synchronization.
|
||||||
final class RumbleRenderer: @unchecked Sendable {
|
final class RumbleRenderer: @unchecked Sendable {
|
||||||
/// What an un-refreshed nonzero target means. A live session ties motor life to wire
|
/// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's
|
||||||
/// liveness (the host refreshes state every 500 ms); the controller test panel holds a
|
/// commands verbatim — the engine (punktfunk-core `client/rumble.rs`) owns every lease,
|
||||||
/// slider level indefinitely.
|
/// staleness, and close decision and emits explicit zeros, so the renderer keeps NO
|
||||||
|
/// staleness policy of its own anymore. The controller test panel (`manual`) holds a slider
|
||||||
|
/// level indefinitely; both are identical renderer-side today, the distinction is kept for
|
||||||
|
/// the call sites' intent.
|
||||||
struct Policy {
|
struct Policy {
|
||||||
let staleAfter: TimeInterval?
|
static let session = Policy()
|
||||||
static let session = Policy(staleAfter: RumbleTuning.sessionStaleSeconds)
|
static let manual = Policy()
|
||||||
static let manual = Policy(staleAfter: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
||||||
@@ -160,13 +145,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
private var controller: GCController?
|
private var controller: GCController?
|
||||||
private var low: Motor?
|
private var low: Motor?
|
||||||
private var high: Motor?
|
private var high: Motor?
|
||||||
/// Wire-truth target (raw wire units) and when it was last confirmed by any command.
|
/// Wire-truth target (raw wire units) — the engine command's level, applied verbatim; the
|
||||||
|
/// core policy engine owns when it ends (explicit zero commands), so no deadline lives here.
|
||||||
private var target: (low: UInt16, high: UInt16) = (0, 0)
|
private var target: (low: UInt16, high: UInt16) = (0, 0)
|
||||||
private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
|
|
||||||
/// The v2 envelope lease: the active level is authorized until here unless the host renews it
|
|
||||||
/// (`tick` silences at the deadline). `nil` against a legacy host (no lease — the
|
|
||||||
/// `sessionStaleSeconds` watchdog is the backstop) and while silent.
|
|
||||||
private var envelopeDeadline: DispatchTime?
|
|
||||||
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
|
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
|
||||||
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
|
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
|
||||||
/// so an idle controller costs no timer wakeups and no radio traffic.
|
/// so an idle controller costs no timer wakeups and no radio traffic.
|
||||||
@@ -247,17 +228,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
/// against a legacy host (no lease → the staleness watchdog is the backstop). Renewals at an
|
/// against a legacy host (no lease → the staleness watchdog is the backstop). Renewals at an
|
||||||
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
|
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
|
||||||
/// lapses mid-effect.
|
/// lapses mid-effect.
|
||||||
func apply(low lowAmp: UInt16, high highAmp: UInt16, ttlMs: UInt32 = RumbleTuning.noTTL) {
|
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
||||||
queue.async {
|
queue.async {
|
||||||
self.lastCommand = .now()
|
|
||||||
let active = lowAmp != 0 || highAmp != 0
|
let active = lowAmp != 0 || highAmp != 0
|
||||||
// v2 lease: a nonzero level gets an explicit deadline; a stop or a legacy update clears
|
|
||||||
// it. Set BEFORE the idempotence guard so an identical renewal still extends the lease.
|
|
||||||
if let lease = RumbleTuning.leaseSeconds(ttlMs: ttlMs), active {
|
|
||||||
self.envelopeDeadline = .now() + lease
|
|
||||||
} else {
|
|
||||||
self.envelopeDeadline = nil
|
|
||||||
}
|
|
||||||
if active != self.wasActive {
|
if active != self.wasActive {
|
||||||
self.wasActive = active
|
self.wasActive = active
|
||||||
log.debug(
|
log.debug(
|
||||||
@@ -275,7 +248,6 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
self.ticker?.cancel()
|
self.ticker?.cancel()
|
||||||
self.ticker = nil
|
self.ticker = nil
|
||||||
self.target = (0, 0)
|
self.target = (0, 0)
|
||||||
self.envelopeDeadline = nil
|
|
||||||
self.wasActive = false
|
self.wasActive = false
|
||||||
self.teardown()
|
self.teardown()
|
||||||
self.closeHID()
|
self.closeHID()
|
||||||
@@ -331,25 +303,11 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
healthSink?(problem)
|
healthSink?(problem)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Watchdog + housekeeping heartbeat while audible.
|
/// Housekeeping heartbeat while audible: segment re-arm, HID keepalive, backoff retries.
|
||||||
|
/// Every liveness decision (lease expiry, legacy-host staleness, session close) lives in the
|
||||||
|
/// core policy engine now — it emits explicit zero commands, so the renderer never guesses
|
||||||
|
/// when a level should end.
|
||||||
private func tick() {
|
private func tick() {
|
||||||
if let deadline = envelopeDeadline {
|
|
||||||
// v2 host lease: silence the moment it lapses unrenewed. This firing in the wild is the
|
|
||||||
// observable signature of a host that stopped renewing (a dropped stop, or a dead host)
|
|
||||||
// — the whole point of the envelope model: the motor can't outlive the host's intent.
|
|
||||||
if target != (0, 0), DispatchTime.now() >= deadline {
|
|
||||||
log.warning("rumble: envelope expired unrenewed — silencing")
|
|
||||||
target = (0, 0)
|
|
||||||
envelopeDeadline = nil
|
|
||||||
}
|
|
||||||
} else if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
|
|
||||||
// Legacy host (no lease): it re-sends state every 500 ms, so this much silence means the
|
|
||||||
// channel (or host) died while a motor was on. A direct-connected pad would have been
|
|
||||||
// stopped by its game long ago — force the same outcome.
|
|
||||||
log.warning(
|
|
||||||
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
|
|
||||||
target = (0, 0)
|
|
||||||
}
|
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import UIKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import GameController
|
import GameController
|
||||||
import PunktfunkCore
|
import PunktfunkCore
|
||||||
|
import PunktfunkShared
|
||||||
import os
|
import os
|
||||||
|
|
||||||
/// Diagnostic logging for the input path. Off by default (input is high-rate); set
|
/// Diagnostic logging for the input path. Off by default (input is high-rate); set
|
||||||
@@ -125,6 +126,12 @@ public final class InputCapture {
|
|||||||
public var onDisconnect: (() -> Void)?
|
public var onDisconnect: (() -> Void)?
|
||||||
public var onCycleStats: (() -> Void)?
|
public var onCycleStats: (() -> Void)?
|
||||||
|
|
||||||
|
/// Fired on ⌃⌘F (macOS) — toggle the streaming window in/out of fullscreen. Detected in the
|
||||||
|
/// monitor only WHILE FORWARDING, for the same reason as the ⌃⌥⇧ combos: a captured stream view
|
||||||
|
/// swallows keys, so the Stream menu's identical ⌃⌘F equivalent never reaches it; released, the
|
||||||
|
/// menu handles it. Main queue.
|
||||||
|
public var onToggleFullscreen: (() -> Void)?
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
/// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides:
|
/// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides:
|
||||||
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
|
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
|
||||||
@@ -273,6 +280,14 @@ public final class InputCapture {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ⌃⌘F toggles the streaming window's fullscreen. Intercepted only while forwarding (the
|
||||||
|
// captured stream view swallows the menu's identical equivalent); the F is latched so its
|
||||||
|
// keyUp can't type into the host. keyCode 3 = kVK_ANSI_F (layout-independent).
|
||||||
|
if self.forwarding, flags == [.control, .command], event.keyCode == 3 /* F */ {
|
||||||
|
self.suppressedVK = 0x46 // VK_F — the same physical F is en route via GC
|
||||||
|
self.onToggleFullscreen?()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -318,7 +333,7 @@ public final class InputCapture {
|
|||||||
chordModifiersDown.removeAll()
|
chordModifiersDown.removeAll()
|
||||||
suppressedVK = nil
|
suppressedVK = nil
|
||||||
for vk in pressedVKs {
|
for vk in pressedVKs {
|
||||||
connection.send(.key(vk, down: false))
|
emitKey(vk, down: false)
|
||||||
}
|
}
|
||||||
for button in pressedButtons {
|
for button in pressedButtons {
|
||||||
connection.send(.mouseButton(button, down: false))
|
connection.send(.mouseButton(button, down: false))
|
||||||
@@ -331,6 +346,15 @@ public final class InputCapture {
|
|||||||
residualScrollY = 0
|
residualScrollY = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The single wire boundary for a key event. Every `.key` send funnels through here so the
|
||||||
|
/// active location-based modifier layout is applied in exactly one place while all internal
|
||||||
|
/// press/release bookkeeping (`pressedVKs`, `cmdKeysDown`, `resolveModifier`'s `isDown`) stays on
|
||||||
|
/// the physical VK. Read live from the setting so a mid-session change (rare) takes on the next
|
||||||
|
/// key without re-arming capture. Non-modifier VKs pass through untouched.
|
||||||
|
private func emitKey(_ vk: UInt32, down: Bool) {
|
||||||
|
connection.send(.key(Self.applyModifierLayout(vk, ModifierLayout.current), down: down))
|
||||||
|
}
|
||||||
|
|
||||||
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
|
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
|
||||||
/// the iPad pointer lock drops while a GCMouse button is held: by then the GCMouse release
|
/// the iPad pointer lock drops while a GCMouse button is held: by then the GCMouse release
|
||||||
/// handler is gated off (`gcMouseForwarding` is false), so it can't deliver the release
|
/// handler is gated off (`gcMouseForwarding` is false), so it can't deliver the release
|
||||||
@@ -399,7 +423,7 @@ public final class InputCapture {
|
|||||||
inputLog.debug(
|
inputLog.debug(
|
||||||
"key \(vk, privacy: .public) \(down ? "down" : "up", privacy: .public) sent")
|
"key \(vk, privacy: .public) \(down ? "down" : "up", privacy: .public) sent")
|
||||||
}
|
}
|
||||||
connection.send(.key(vk, down: down))
|
emitKey(vk, down: down)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp — they arrive
|
/// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp — they arrive
|
||||||
@@ -566,8 +590,15 @@ public final class InputCapture {
|
|||||||
/// Moonlight's convention). Fed by StreamLayerView.scrollWheel — the only delivery
|
/// Moonlight's convention). Fed by StreamLayerView.scrollWheel — the only delivery
|
||||||
/// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them).
|
/// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them).
|
||||||
/// Fractional remainders accumulate so slow two-finger scrolling isn't truncated away.
|
/// Fractional remainders accumulate so slow two-finger scrolling isn't truncated away.
|
||||||
public func sendScroll(dx: Float, dy: Float) {
|
public func sendScroll(dx rawDx: Float, dy rawDy: Float) {
|
||||||
guard forwarding else { return }
|
guard forwarding else { return }
|
||||||
|
// Optionally invert both axes (read live). This is the ONE scroll sink for every platform —
|
||||||
|
// the macOS wheel, the iOS trackpad pan, and a GCMouse wheel all land here — so the toggle
|
||||||
|
// flips them consistently. Residuals are accumulated AFTER inversion so a direction change
|
||||||
|
// between events doesn't strand a fractional remainder of the old sign.
|
||||||
|
let invert = UserDefaults.standard.bool(forKey: DefaultsKey.invertScroll)
|
||||||
|
let dx = invert ? -rawDx : rawDx
|
||||||
|
let dy = invert ? -rawDy : rawDy
|
||||||
let fy = dy + residualScrollY
|
let fy = dy + residualScrollY
|
||||||
let fx = dx + residualScrollX
|
let fx = dx + residualScrollX
|
||||||
let iy = fy.rounded(.towardZero)
|
let iy = fy.rounded(.towardZero)
|
||||||
@@ -643,7 +674,7 @@ public final class InputCapture {
|
|||||||
} else {
|
} else {
|
||||||
self.pressedVKs.remove(vk)
|
self.pressedVKs.remove(vk)
|
||||||
}
|
}
|
||||||
self.connection.send(.key(vk, down: pressed))
|
self.emitKey(vk, down: pressed)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
// InputCapture's static keymap tables: HID usage → Windows VK (the GCKeyboard path on all
|
// InputCapture's static keymap tables: HID usage → Windows VK (the GCKeyboard path on all
|
||||||
// platforms) and, on macOS, NSEvent.keyCode → Windows VK (the NSEvent key path).
|
// platforms) and, on macOS, NSEvent.keyCode → Windows VK (the NSEvent key path).
|
||||||
|
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
extension InputCapture {
|
extension InputCapture {
|
||||||
|
/// Remap one modifier VK for the active location-based [`ModifierLayout`] just before it goes on
|
||||||
|
/// the wire. `.mac` (and every non-modifier VK) is the identity. `.windows` swaps the Alt vs
|
||||||
|
/// Super/Windows ROLE between the Option and Command keys while KEEPING the side, so a Windows
|
||||||
|
/// user's `⌘ = Alt, ⌥ = Win` muscle memory lands correctly:
|
||||||
|
/// L Command 0x5B ↔ L Alt 0xA4 · R Command 0x5C ↔ R Alt 0xA5.
|
||||||
|
/// It's applied ONLY at the send boundary (`InputCapture.emitKey`) — all press/release
|
||||||
|
/// bookkeeping stays on the physical VK, so modifier direction tracking and the client-local
|
||||||
|
/// ⌘-based shortcuts are untouched. The swap is its own inverse: a key that went down remapped
|
||||||
|
/// goes up remapped, so the host never sees a stuck modifier.
|
||||||
|
static func applyModifierLayout(_ vk: UInt32, _ layout: ModifierLayout) -> UInt32 {
|
||||||
|
guard layout == .windows else { return vk }
|
||||||
|
switch vk {
|
||||||
|
case 0x5B: return 0xA4 // L Command → L Alt (VK_LWIN → VK_LMENU)
|
||||||
|
case 0x5C: return 0xA5 // R Command → R Alt (VK_RWIN → VK_RMENU)
|
||||||
|
case 0xA4: return 0x5B // L Option → L Win (VK_LMENU → VK_LWIN)
|
||||||
|
case 0xA5: return 0x5C // R Option → R Win (VK_RMENU → VK_RWIN)
|
||||||
|
default: return vk
|
||||||
|
}
|
||||||
|
}
|
||||||
/// HID usage (GCKeyCode raw) → Windows VK (the host maps VK → evdev; every VK emitted
|
/// HID usage (GCKeyCode raw) → Windows VK (the host maps VK → evdev; every VK emitted
|
||||||
/// here exists in punktfunk-host/src/inject.rs::vk_to_evdev — extend the two together).
|
/// here exists in punktfunk-host/src/inject.rs::vk_to_evdev — extend the two together).
|
||||||
static let hidToVK: [Int: UInt32] = {
|
static let hidToVK: [Int: UInt32] = {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import Foundation
|
import Foundation
|
||||||
import PunktfunkCore
|
import PunktfunkCore
|
||||||
|
import PunktfunkShared
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// How touchscreen fingers drive the host — persisted under `DefaultsKey.touchMode`, latched
|
/// How touchscreen fingers drive the host — persisted under `DefaultsKey.touchMode`, latched
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// PunktfunkShared holds what the app AND the widget extension both need — the stored-host model,
|
||||||
|
// the settings-key names, the App-Group constant, the deep-link grammar, and the Live Activity
|
||||||
|
// attributes — in a module that links neither the Rust core nor the presentation layer.
|
||||||
|
//
|
||||||
|
// Re-export it so every existing consumer of PunktfunkKit (`import PunktfunkKit`) keeps seeing
|
||||||
|
// `StoredHost`, `DefaultsKey`, `punktfunkDefaultMgmtPort`, `DeepLink`, etc. with no call-site churn.
|
||||||
|
// (Files INSIDE PunktfunkKit still `import PunktfunkShared` explicitly — Swift imports are
|
||||||
|
// file-scoped; the re-export only reaches downstream modules.)
|
||||||
|
@_exported import PunktfunkShared
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
// tap, InputCapture's captured-state ⌃⌥⇧S) cycle it directly.
|
// tap, InputCapture's captured-state ⌃⌥⇧S) cycle it directly.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
/// How much of the streaming statistics overlay to show. The raw values are stable on disk —
|
/// How much of the streaming statistics overlay to show. The raw values are stable on disk —
|
||||||
/// rename the cases freely, never the strings.
|
/// rename the cases freely, never the strings.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
// from looping request → rollback → request.
|
// from looping request → rollback → request.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
/// The pure, side-effect-free core of the Match-window trigger — so the normalize/skip discipline
|
/// The pure, side-effect-free core of the Match-window trigger — so the normalize/skip discipline
|
||||||
/// is unit-tested without a live connection or a UI (`MatchWindowTests`).
|
/// is unit-tested without a live connection or a UI (`MatchWindowTests`).
|
||||||
@@ -51,6 +52,11 @@ public final class MatchWindowFollower {
|
|||||||
private weak var connection: PunktfunkConnection?
|
private weak var connection: PunktfunkConnection?
|
||||||
private let debounce: TimeInterval
|
private let debounce: TimeInterval
|
||||||
private let minSpacing: TimeInterval
|
private let minSpacing: TimeInterval
|
||||||
|
/// Render-scale multiplier applied to the live window size before it's requested, so the
|
||||||
|
/// match-window path supersamples/undersamples exactly like the fixed-mode connect. 1.0 = the
|
||||||
|
/// window's native pixels (the prior behaviour). `maxDimension` is the codec's per-axis ceiling.
|
||||||
|
private let renderScale: Double
|
||||||
|
private let maxDimension: Int
|
||||||
private var enabled: Bool
|
private var enabled: Bool
|
||||||
|
|
||||||
private var work: DispatchWorkItem?
|
private var work: DispatchWorkItem?
|
||||||
@@ -74,15 +80,28 @@ public final class MatchWindowFollower {
|
|||||||
public init(
|
public init(
|
||||||
connection: PunktfunkConnection,
|
connection: PunktfunkConnection,
|
||||||
enabled: Bool,
|
enabled: Bool,
|
||||||
|
renderScale: Double = 1.0,
|
||||||
|
maxDimension: Int = 8192,
|
||||||
debounce: TimeInterval = 0.4,
|
debounce: TimeInterval = 0.4,
|
||||||
minSpacing: TimeInterval = 1.0
|
minSpacing: TimeInterval = 1.0
|
||||||
) {
|
) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
|
self.renderScale = RenderScale.sanitize(renderScale)
|
||||||
|
self.maxDimension = maxDimension
|
||||||
self.debounce = debounce
|
self.debounce = debounce
|
||||||
self.minSpacing = minSpacing
|
self.minSpacing = minSpacing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The host-valid mode for a live window size: the window's physical pixels × the render scale,
|
||||||
|
/// aspect-preserved, even, and clamped to the codec ceiling — the match-window twin of
|
||||||
|
/// ContentView's `scaledMode()`. Reduces to `MatchWindow.normalize` when the scale is 1.0.
|
||||||
|
private func targetMode(widthPx: Int, heightPx: Int) -> (width: UInt32, height: UInt32) {
|
||||||
|
RenderScale.apply(
|
||||||
|
baseWidth: widthPx, baseHeight: heightPx,
|
||||||
|
scale: renderScale, maxDimension: maxDimension)
|
||||||
|
}
|
||||||
|
|
||||||
/// Turn following on/off live (a mid-session settings change; off cancels a pending request).
|
/// Turn following on/off live (a mid-session settings change; off cancels a pending request).
|
||||||
public func setEnabled(_ on: Bool) {
|
public func setEnabled(_ on: Bool) {
|
||||||
enabled = on
|
enabled = on
|
||||||
@@ -109,7 +128,7 @@ public final class MatchWindowFollower {
|
|||||||
/// mode yet → nothing to compare against, skip.
|
/// mode yet → nothing to compare against, skip.
|
||||||
private func reportSteering(widthPx: Int, heightPx: Int) {
|
private func reportSteering(widthPx: Int, heightPx: Int) {
|
||||||
guard let connection else { return }
|
guard let connection else { return }
|
||||||
let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx)
|
let target = targetMode(widthPx: widthPx, heightPx: heightPx)
|
||||||
let mode = connection.currentMode()
|
let mode = connection.currentMode()
|
||||||
guard mode.width > 0, mode.height > 0 else { return }
|
guard mode.width > 0, mode.height > 0 else { return }
|
||||||
if target.width == mode.width, target.height == mode.height {
|
if target.width == mode.width, target.height == mode.height {
|
||||||
@@ -137,7 +156,7 @@ public final class MatchWindowFollower {
|
|||||||
schedule()
|
schedule()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let target = MatchWindow.normalize(widthPx: size.width, heightPx: size.height)
|
let target = targetMode(widthPx: size.width, heightPx: size.height)
|
||||||
let mode = connection.currentMode()
|
let mode = connection.currentMode()
|
||||||
pendingSize = nil
|
pendingSize = nil
|
||||||
guard let req = MatchWindow.request(
|
guard let req = MatchWindow.request(
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
#if canImport(Metal) && canImport(QuartzCore)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import CoreVideo
|
import CoreVideo
|
||||||
|
#if os(macOS)
|
||||||
|
import IOSurface
|
||||||
|
#endif
|
||||||
import Metal
|
import Metal
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
import os
|
import os
|
||||||
@@ -193,13 +196,11 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
|||||||
// display-referred SDR. (When the display IS in an HDR mode — requested per session via
|
// display-referred SDR. (When the display IS in an HDR mode — requested per session via
|
||||||
// AVDisplayManager, see StreamViewIOS — tvOS presents pf_frag_hdr's PQ passthrough instead:
|
// AVDisplayManager, see StreamViewIOS — tvOS presents pf_frag_hdr's PQ passthrough instead:
|
||||||
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
|
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
|
||||||
fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
// The shared PQ→display-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084
|
||||||
texture2d<float> lumaTex [[texture(0)]],
|
// EOTF → 203-nit-anchored scene light → BT.2020→709 primaries → extended-Reinhard rolloff →
|
||||||
texture2d<float> chromaTex [[texture(1)]],
|
// BT.709 OETF. Used by the tvOS biplanar tone-map and the planar (PyroWave) tone-map — the
|
||||||
constant CscUniform& csc [[buffer(0)]]) {
|
// latter also on macOS windowed sessions, whose IOSurface present path is BGRA8-only.
|
||||||
// Y′CbCr → full-range PQ R′G′B′ via the per-frame rows (as pf_frag_hdr).
|
static inline float3 pqToSdr(float3 pq) {
|
||||||
float3 pq = sampleRgb(lumaTex, chromaTex, in.uv, csc);
|
|
||||||
// ST 2084 EOTF: PQ code value → linear light, 1.0 = 10,000 nits.
|
|
||||||
const float m1 = 2610.0/16384.0;
|
const float m1 = 2610.0/16384.0;
|
||||||
const float m2 = 78.84375;
|
const float m2 = 78.84375;
|
||||||
const float c1 = 3424.0/4096.0;
|
const float c1 = 3424.0/4096.0;
|
||||||
@@ -207,20 +208,49 @@ fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
|||||||
const float c3 = 18.6875;
|
const float c3 = 18.6875;
|
||||||
float3 p = pow(pq, 1.0/m2);
|
float3 p = pow(pq, 1.0/m2);
|
||||||
float3 lin = pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0/m1);
|
float3 lin = pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0/m1);
|
||||||
// Scene-referred with diffuse white at 1.0 (the same 203-nit anchor the EDR path uses).
|
|
||||||
float3 t = lin * (10000.0/203.0);
|
float3 t = lin * (10000.0/203.0);
|
||||||
// BT.2020 → BT.709 primaries while still linear; negatives are out-of-gamut, floor them.
|
|
||||||
float3 t709 = float3(
|
float3 t709 = float3(
|
||||||
dot(t, float3( 1.6605, -0.5876, -0.0728)),
|
dot(t, float3( 1.6605, -0.5876, -0.0728)),
|
||||||
dot(t, float3(-0.1246, 1.1329, -0.0083)),
|
dot(t, float3(-0.1246, 1.1329, -0.0083)),
|
||||||
dot(t, float3(-0.0182, -0.1006, 1.1187)));
|
dot(t, float3(-0.0182, -0.1006, 1.1187)));
|
||||||
t709 = max(t709, 0.0);
|
t709 = max(t709, 0.0);
|
||||||
// Extended Reinhard: 1.0 stays put, the 1000-nit knee lands at display white, above rolls off.
|
|
||||||
const float w = 1000.0/203.0;
|
const float w = 1000.0/203.0;
|
||||||
float3 mapped = saturate(t709 * (1.0 + t709 / (w * w)) / (1.0 + t709));
|
float3 mapped = saturate(t709 * (1.0 + t709 / (w * w)) / (1.0 + t709));
|
||||||
// BT.709 OETF — the same encoding the SDR stream arrives in, so both paths present alike.
|
|
||||||
float3 e = select(1.099 * pow(mapped, 0.45) - 0.099, 4.5 * mapped, mapped < 0.018);
|
float3 e = select(1.099 * pow(mapped, 0.45) - 0.099, 4.5 * mapped, mapped < 0.018);
|
||||||
return float4(e, 1.0);
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
||||||
|
texture2d<float> lumaTex [[texture(0)]],
|
||||||
|
texture2d<float> chromaTex [[texture(1)]],
|
||||||
|
constant CscUniform& csc [[buffer(0)]]) {
|
||||||
|
// Y′CbCr → full-range PQ R′G′B′ via the per-frame rows (as pf_frag_hdr), then the tail.
|
||||||
|
return float4(pqToSdr(sampleRgb(lumaTex, chromaTex, in.uv, csc)), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PyroWave planar HDR tone-map: three separate R16 planes (P010-style studio codes; the rows
|
||||||
|
// fold in depth-10 MSB packing) → PQ R′G′B′ → the shared SDR tail. Used when a PQ pyrowave
|
||||||
|
// stream must land on an 8-bit surface: tvOS without HDR headroom, and macOS WINDOWED sessions
|
||||||
|
// (the IOSurface present path — the DCP-panic mitigation — is BGRA8). The passthrough planar
|
||||||
|
// HDR pipeline reuses pf_frag_planar itself on an rgba16Float drawable (identical math — the
|
||||||
|
// layer's itur_2100_PQ colour space + EDR metadata do the interpretation).
|
||||||
|
fragment float4 pf_frag_planar_tm(VOut in [[stage_in]],
|
||||||
|
texture2d<float> lumaTex [[texture(0)]],
|
||||||
|
texture2d<float> cbTex [[texture(1)]],
|
||||||
|
texture2d<float> crTex [[texture(2)]],
|
||||||
|
constant CscUniform& csc [[buffer(0)]]) {
|
||||||
|
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||||
|
#ifdef PF_BILINEAR_LUMA
|
||||||
|
float lumaY = lumaTex.sample(s, in.uv).r;
|
||||||
|
#else
|
||||||
|
float lumaY = catmullRomLuma(lumaTex, s, in.uv);
|
||||||
|
#endif
|
||||||
|
float2 cuv = chromaUV(lumaTex, cbTex, in.uv);
|
||||||
|
float3 yuv = float3(lumaY, cbTex.sample(s, cuv).r, crTex.sample(s, cuv).r);
|
||||||
|
float3 pq = saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
||||||
|
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
||||||
|
dot(csc.r2.xyz, yuv) + csc.r2.w));
|
||||||
|
return float4(pqToSdr(pq), 1.0);
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -228,6 +258,51 @@ public final class MetalVideoPresenter {
|
|||||||
/// The layer the hosting view installs (as a sublayer) and sizes to its bounds.
|
/// The layer the hosting view installs (as a sublayer) and sizes to its bounds.
|
||||||
public let layer: CAMetalLayer
|
public let layer: CAMetalLayer
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// The WINDOWED-mode PyroWave present target: a plain CALayer sized like `layer` (installed
|
||||||
|
/// as a sibling ABOVE it), fed IOSurfaces via `contents` inside ordinary CATransactions.
|
||||||
|
///
|
||||||
|
/// Why this exists — the macOS DCP KERNEL PANIC ("mismatched swapID's" @UnifiedPipeline.cpp,
|
||||||
|
/// WindowServer dies, machine reboots): out-of-band CAMetalLayer image-queue swaps into a
|
||||||
|
/// COMPOSITED (windowed) session race WindowServer's own swap submissions on high-refresh
|
||||||
|
/// displays, and the race survives glass pacing — a fully serialized one-in-flight present
|
||||||
|
/// stream still panicked a 240 Hz Mac Studio (2026-07-18, twice). So in windowed mode we stop
|
||||||
|
/// using the image queue entirely and present the way video players do: render the planar CSC
|
||||||
|
/// into an IOSurface pool and swap `contents` on main — WindowServer treats it as ordinary
|
||||||
|
/// damage on its own composite cadence, coalescing faster-than-refresh updates instead of
|
||||||
|
/// latching queue swaps mid-cycle. Fullscreen keeps the CAMetalLayer path (direct-scanout
|
||||||
|
/// promotion, no compositing, no panic reports). Contents updates are transparent to the
|
||||||
|
/// layer below when nil, so flipping modes just covers/uncovers the metal layer.
|
||||||
|
public let surfaceLayer: CALayer = {
|
||||||
|
let l = CALayer()
|
||||||
|
l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout
|
||||||
|
l.isOpaque = true
|
||||||
|
l.actions = ["contents": NSNull(), "bounds": NSNull(), "position": NSNull()]
|
||||||
|
return l
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// One IOSurface-backed render target of the windowed present pool. All pool state is
|
||||||
|
/// RENDER-THREAD confined; only the immutable surface refs cross to main (contents swap).
|
||||||
|
private struct SurfaceSlot {
|
||||||
|
let surface: IOSurfaceRef
|
||||||
|
let texture: MTLTexture
|
||||||
|
/// Monotonic use stamp — the reuse picker takes the least-recently-rendered free slot.
|
||||||
|
var seq: UInt64 = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private var surfacePool: [SurfaceSlot] = []
|
||||||
|
private var surfacePoolSize: CGSize = .zero
|
||||||
|
private var surfaceSeq: UInt64 = 0
|
||||||
|
/// Index of the slot most recently handed to the layer — never rewritten next, even if its
|
||||||
|
/// use count already dropped (the compositor may still be scanning out the previous frame).
|
||||||
|
private var lastHandedOff: Int?
|
||||||
|
/// Staged (under `stagingLock`, like every cross-thread input): the hosting view's windowed
|
||||||
|
/// vs fullscreen state, pushed from main via `setSurfacePresents`. Drained in `renderPlanar`.
|
||||||
|
private var surfacePresentsStaged = false
|
||||||
|
/// Render-thread copy, so pool teardown happens exactly once on a mode flip.
|
||||||
|
private var surfacePresentsActive = false
|
||||||
|
#endif
|
||||||
|
|
||||||
private let device: MTLDevice
|
private let device: MTLDevice
|
||||||
private let queue: MTLCommandQueue
|
private let queue: MTLCommandQueue
|
||||||
/// SDR (BT.709 8-bit → bgra8) and HDR (BT.2020 PQ 10-bit → rgba16Float) pipelines. Selected per
|
/// SDR (BT.709 8-bit → bgra8) and HDR (BT.2020 PQ 10-bit → rgba16Float) pipelines. Selected per
|
||||||
@@ -239,6 +314,11 @@ public final class MetalVideoPresenter {
|
|||||||
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||||
/// PyroWave's 3-plane SDR path (pf_frag_planar → bgra8) — see `renderPlanar`.
|
/// PyroWave's 3-plane SDR path (pf_frag_planar → bgra8) — see `renderPlanar`.
|
||||||
private let pipelinePlanar: MTLRenderPipelineState
|
private let pipelinePlanar: MTLRenderPipelineState
|
||||||
|
/// PyroWave planar HDR passthrough (pf_frag_planar → rgba16Float; the layer's PQ colour
|
||||||
|
/// space + EDR interpret the samples) and the planar PQ→SDR tone-map (pf_frag_planar_tm →
|
||||||
|
/// bgra8; tvOS without headroom + macOS windowed IOSurface presents).
|
||||||
|
private let pipelinePlanarHDR: MTLRenderPipelineState
|
||||||
|
private let pipelinePlanarToneMap: MTLRenderPipelineState
|
||||||
private var textureCache: CVMetalTextureCache?
|
private var textureCache: CVMetalTextureCache?
|
||||||
|
|
||||||
/// The PyroWave Metal decoder records on the presenter's device + queue: one device means
|
/// The PyroWave Metal decoder records on the presenter's device + queue: one device means
|
||||||
@@ -289,6 +369,8 @@ public final class MetalVideoPresenter {
|
|||||||
let pipelineHDR: MTLRenderPipelineState
|
let pipelineHDR: MTLRenderPipelineState
|
||||||
let pipelineHDRToneMap: MTLRenderPipelineState?
|
let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||||
let pipelinePlanar: MTLRenderPipelineState
|
let pipelinePlanar: MTLRenderPipelineState
|
||||||
|
let pipelinePlanarHDR: MTLRenderPipelineState
|
||||||
|
let pipelinePlanarToneMap: MTLRenderPipelineState
|
||||||
do {
|
do {
|
||||||
// DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF
|
// DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF
|
||||||
// (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is
|
// (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is
|
||||||
@@ -326,8 +408,18 @@ public final class MetalVideoPresenter {
|
|||||||
let planar = MTLRenderPipelineDescriptor()
|
let planar = MTLRenderPipelineDescriptor()
|
||||||
planar.vertexFunction = vtx
|
planar.vertexFunction = vtx
|
||||||
planar.fragmentFunction = library.makeFunction(name: "pf_frag_planar")
|
planar.fragmentFunction = library.makeFunction(name: "pf_frag_planar")
|
||||||
planar.colorAttachments[0].pixelFormat = .bgra8Unorm // PyroWave is 8-bit SDR
|
planar.colorAttachments[0].pixelFormat = .bgra8Unorm
|
||||||
pipelinePlanar = try device.makeRenderPipelineState(descriptor: planar)
|
pipelinePlanar = try device.makeRenderPipelineState(descriptor: planar)
|
||||||
|
let planarHdr = MTLRenderPipelineDescriptor()
|
||||||
|
planarHdr.vertexFunction = vtx
|
||||||
|
planarHdr.fragmentFunction = library.makeFunction(name: "pf_frag_planar")
|
||||||
|
planarHdr.colorAttachments[0].pixelFormat = .rgba16Float // PQ passthrough
|
||||||
|
pipelinePlanarHDR = try device.makeRenderPipelineState(descriptor: planarHdr)
|
||||||
|
let planarTm = MTLRenderPipelineDescriptor()
|
||||||
|
planarTm.vertexFunction = vtx
|
||||||
|
planarTm.fragmentFunction = library.makeFunction(name: "pf_frag_planar_tm")
|
||||||
|
planarTm.colorAttachments[0].pixelFormat = .bgra8Unorm
|
||||||
|
pipelinePlanarToneMap = try device.makeRenderPipelineState(descriptor: planarTm)
|
||||||
} catch {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -368,6 +460,7 @@ public final class MetalVideoPresenter {
|
|||||||
return MetalVideoPresenter(
|
return MetalVideoPresenter(
|
||||||
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||||
pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar,
|
pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar,
|
||||||
|
pipelinePlanarHDR: pipelinePlanarHDR, pipelinePlanarToneMap: pipelinePlanarToneMap,
|
||||||
textureCache: textureCache, layer: layer)
|
textureCache: textureCache, layer: layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,6 +468,8 @@ public final class MetalVideoPresenter {
|
|||||||
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||||
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
||||||
pipelinePlanar: MTLRenderPipelineState,
|
pipelinePlanar: MTLRenderPipelineState,
|
||||||
|
pipelinePlanarHDR: MTLRenderPipelineState,
|
||||||
|
pipelinePlanarToneMap: MTLRenderPipelineState,
|
||||||
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||||
) {
|
) {
|
||||||
self.device = device
|
self.device = device
|
||||||
@@ -383,6 +478,8 @@ public final class MetalVideoPresenter {
|
|||||||
self.pipelineHDR = pipelineHDR
|
self.pipelineHDR = pipelineHDR
|
||||||
self.pipelineHDRToneMap = pipelineHDRToneMap
|
self.pipelineHDRToneMap = pipelineHDRToneMap
|
||||||
self.pipelinePlanar = pipelinePlanar
|
self.pipelinePlanar = pipelinePlanar
|
||||||
|
self.pipelinePlanarHDR = pipelinePlanarHDR
|
||||||
|
self.pipelinePlanarToneMap = pipelinePlanarToneMap
|
||||||
self.textureCache = textureCache
|
self.textureCache = textureCache
|
||||||
self.layer = layer
|
self.layer = layer
|
||||||
}
|
}
|
||||||
@@ -493,6 +590,18 @@ public final class MetalVideoPresenter {
|
|||||||
stagingLock.unlock()
|
stagingLock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// Park the windowed-vs-fullscreen present routing (MAIN thread — the hosting view pushes its
|
||||||
|
/// window state on every layout). true = PyroWave frames present via `surfaceLayer` contents
|
||||||
|
/// (the DCP swapID-panic mitigation — see `surfaceLayer`); false = the CAMetalLayer path.
|
||||||
|
/// Applied by the render thread on the next frame, like every other staged value here.
|
||||||
|
public func setSurfacePresents(_ on: Bool) {
|
||||||
|
stagingLock.lock()
|
||||||
|
surfacePresentsStaged = on
|
||||||
|
stagingLock.unlock()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's;
|
/// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's;
|
||||||
/// `nextDrawable()` may block up to a frame — that wait belongs here, never on main). `isHDR`
|
/// `nextDrawable()` may block up to a frame — that wait belongs here, never on main). `isHDR`
|
||||||
/// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
|
/// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
|
||||||
@@ -588,12 +697,51 @@ public final class MetalVideoPresenter {
|
|||||||
) -> Bool {
|
) -> Bool {
|
||||||
stagingLock.lock()
|
stagingLock.lock()
|
||||||
let targetFromLayout = drawableTarget
|
let targetFromLayout = drawableTarget
|
||||||
|
#if os(macOS)
|
||||||
|
let surfaceMode = surfacePresentsStaged
|
||||||
|
#endif
|
||||||
stagingLock.unlock()
|
stagingLock.unlock()
|
||||||
configure(hdr: false)
|
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path;
|
||||||
|
// macOS WINDOWED sessions stay on the SDR layer (the IOSurface path tone-maps in-shader).
|
||||||
|
#if os(macOS)
|
||||||
|
configure(hdr: planes.pq && !surfaceMode)
|
||||||
|
#else
|
||||||
|
configure(hdr: planes.pq)
|
||||||
|
#endif
|
||||||
var csc = planes.csc
|
var csc = planes.csc
|
||||||
|
#if os(macOS)
|
||||||
|
if surfaceMode != surfacePresentsActive {
|
||||||
|
surfacePresentsActive = surfaceMode
|
||||||
|
presenterLog.info(
|
||||||
|
"stage2: windowed surface presents \(surfaceMode ? "ON" : "OFF", privacy: .public) (PyroWave DCP-panic mitigation)")
|
||||||
|
if !surfaceMode {
|
||||||
|
// Back to the metal path (fullscreen): drop the pool — at 5K it holds >100 MB,
|
||||||
|
// and re-entering windowed mode rebuilds it in one frame.
|
||||||
|
surfacePool.removeAll()
|
||||||
|
surfacePoolSize = .zero
|
||||||
|
lastHandedOff = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if surfaceMode {
|
||||||
|
return renderPlanarToSurface(
|
||||||
|
planes, targetFromLayout: targetFromLayout, csc: &csc, onPresented: onPresented)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
// PQ passthrough needs the HDR drawable; a PQ frame while the drawable is (still)
|
||||||
|
// 8-bit — tvOS without display headroom, or a not-yet-flipped layer — tone-maps
|
||||||
|
// in-shader instead (the pipeline must match the drawable's pixel format).
|
||||||
|
#if os(tvOS)
|
||||||
|
let planarPassthrough = hdrActive && hdrPassthroughActive
|
||||||
|
#else
|
||||||
|
let planarPassthrough = hdrActive
|
||||||
|
#endif
|
||||||
|
let planarPipeline: MTLRenderPipelineState =
|
||||||
|
planes.pq
|
||||||
|
? (planarPassthrough ? pipelinePlanarHDR : pipelinePlanarToneMap)
|
||||||
|
: pipelinePlanar
|
||||||
return encodePresent(
|
return encodePresent(
|
||||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||||
targetFromLayout: targetFromLayout, pipeline: pipelinePlanar,
|
targetFromLayout: targetFromLayout, pipeline: planarPipeline,
|
||||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||||
// The ring textures stay valid by ring depth; retaining them here also pins the
|
// The ring textures stay valid by ring depth; retaining them here also pins the
|
||||||
// slot's set until the sample completes (mirrors the biplanar keep-alive).
|
// slot's set until the sample completes (mirrors the biplanar keep-alive).
|
||||||
@@ -606,6 +754,118 @@ public final class MetalVideoPresenter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// The windowed-mode present tail (see `surfaceLayer` for why this path exists): render the
|
||||||
|
/// planar CSC into a pooled IOSurface and hand it to `surfaceLayer.contents` on MAIN inside a
|
||||||
|
/// plain CATransaction — an ordinary damaged-layer update on WindowServer's own composite
|
||||||
|
/// cadence, no CAMetalLayer image-queue swap anywhere. `presentAtMediaTime` doesn't apply
|
||||||
|
/// (the compositor paces); `onPresented` fires after the contents swap is committed, stamped
|
||||||
|
/// with CLOCK_REALTIME then — the closest observable analogue of "reached glass" here (the
|
||||||
|
/// composite follows within a refresh, so the meters' display stage reads slightly optimistic).
|
||||||
|
private func renderPlanarToSurface(
|
||||||
|
_ planes: WaveletPlanes, targetFromLayout: CGSize, csc: inout CscUniform,
|
||||||
|
onPresented: ((Int64?) -> Void)?
|
||||||
|
) -> Bool {
|
||||||
|
let decodedSize = CGSize(width: planes.width, height: planes.height)
|
||||||
|
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||||
|
? targetFromLayout : decodedSize
|
||||||
|
ensureSurfacePool(size: targetSize)
|
||||||
|
guard let slotIndex = takeSurfaceSlot(),
|
||||||
|
let commandBuffer = queue.makeCommandBuffer()
|
||||||
|
else { return false }
|
||||||
|
let slot = surfacePool[slotIndex]
|
||||||
|
|
||||||
|
let pass = MTLRenderPassDescriptor()
|
||||||
|
pass.colorAttachments[0].texture = slot.texture
|
||||||
|
pass.colorAttachments[0].loadAction = .clear
|
||||||
|
pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
|
||||||
|
pass.colorAttachments[0].storeAction = .store
|
||||||
|
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
encoder.setRenderPipelineState(planes.pq ? pipelinePlanarToneMap : pipelinePlanar)
|
||||||
|
encoder.setFragmentTexture(planes.y, index: 0)
|
||||||
|
encoder.setFragmentTexture(planes.cb, index: 1)
|
||||||
|
encoder.setFragmentTexture(planes.cr, index: 2)
|
||||||
|
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||||
|
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||||
|
encoder.endEncoding()
|
||||||
|
let surface = slot.surface
|
||||||
|
let surfaceLayer = surfaceLayer // captured directly — the handler must not retain self
|
||||||
|
let keepAlive: [Any] = [planes.y, planes.cb, planes.cr]
|
||||||
|
commandBuffer.addCompletedHandler { _ in
|
||||||
|
_ = keepAlive // ring textures pinned until the GPU finished sampling
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
CATransaction.begin()
|
||||||
|
CATransaction.setDisableActions(true)
|
||||||
|
surfaceLayer.contents = surface
|
||||||
|
CATransaction.commit()
|
||||||
|
onPresented?(
|
||||||
|
Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
commandBuffer.commit()
|
||||||
|
lastHandedOff = slotIndex
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (Re)build the pool at `size` — 4 BGRA8 IOSurface render targets (one on glass, one queued
|
||||||
|
/// in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the pool empty;
|
||||||
|
/// the caller returns false and the ring's putBack + display-link retry take over.
|
||||||
|
private func ensureSurfacePool(size: CGSize) {
|
||||||
|
guard size != surfacePoolSize else { return }
|
||||||
|
surfacePool.removeAll()
|
||||||
|
surfacePoolSize = size
|
||||||
|
lastHandedOff = nil
|
||||||
|
let w = Int(size.width)
|
||||||
|
let h = Int(size.height)
|
||||||
|
guard w > 0, h > 0 else { return }
|
||||||
|
// 256-byte row alignment satisfies both IOSurface and Metal linear-texture rules.
|
||||||
|
let bytesPerRow = ((w * 4) + 255) & ~255
|
||||||
|
let props: [String: Any] = [
|
||||||
|
kIOSurfaceWidth as String: w,
|
||||||
|
kIOSurfaceHeight as String: h,
|
||||||
|
kIOSurfaceBytesPerElement as String: 4,
|
||||||
|
kIOSurfaceBytesPerRow as String: bytesPerRow,
|
||||||
|
kIOSurfacePixelFormat as String: kCVPixelFormatType_32BGRA,
|
||||||
|
]
|
||||||
|
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||||
|
pixelFormat: .bgra8Unorm, width: w, height: h, mipmapped: false)
|
||||||
|
desc.usage = [.renderTarget]
|
||||||
|
desc.storageMode = .shared
|
||||||
|
for _ in 0..<4 {
|
||||||
|
guard let surface = IOSurfaceCreate(props as CFDictionary),
|
||||||
|
let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
|
||||||
|
else {
|
||||||
|
surfacePool.removeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
surfacePool.append(SurfaceSlot(surface: surface, texture: texture))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick the slot to render into: never the one just handed to the layer (the compositor may
|
||||||
|
/// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and
|
||||||
|
/// among those the least recently rendered. Falls back to the LRU busy slot rather than
|
||||||
|
/// stalling — a visible glitch at worst, never a queue-up. RENDER THREAD.
|
||||||
|
private func takeSurfaceSlot() -> Int? {
|
||||||
|
guard !surfacePool.isEmpty else { return nil }
|
||||||
|
var free: Int?
|
||||||
|
var busy: Int?
|
||||||
|
for i in surfacePool.indices where i != lastHandedOff {
|
||||||
|
if !IOSurfaceIsInUse(surfacePool[i].surface) {
|
||||||
|
if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i }
|
||||||
|
} else {
|
||||||
|
if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let pick = free ?? busy else { return nil }
|
||||||
|
surfaceSeq += 1
|
||||||
|
surfacePool[pick].seq = surfaceSeq
|
||||||
|
return pick
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||||
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||||
/// the present and the on-glass callback.
|
/// the present and the on-glass callback.
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ struct WaveletLayout {
|
|||||||
|
|
||||||
let width: Int
|
let width: Int
|
||||||
let height: Int
|
let height: Int
|
||||||
|
/// Full-res chroma (4:4:4): chroma components get the full band set including level 0,
|
||||||
|
/// exactly like luma — upstream `init_block_meta` with `Chroma444`.
|
||||||
|
let chroma444: Bool
|
||||||
let alignedWidth: Int
|
let alignedWidth: Int
|
||||||
let alignedHeight: Int
|
let alignedHeight: Int
|
||||||
/// blockMeta[component][level][band] = (blockOffset32x32, blockStride32x32); -1 offset =
|
/// blockMeta[component][level][band] = (blockOffset32x32, blockStride32x32); -1 offset =
|
||||||
@@ -59,9 +62,10 @@ struct WaveletLayout {
|
|||||||
func levelWidth(_ level: Int) -> Int { (alignedWidth / 2) >> level }
|
func levelWidth(_ level: Int) -> Int { (alignedWidth / 2) >> level }
|
||||||
func levelHeight(_ level: Int) -> Int { (alignedHeight / 2) >> level }
|
func levelHeight(_ level: Int) -> Int { (alignedHeight / 2) >> level }
|
||||||
|
|
||||||
init(width: Int, height: Int) {
|
init(width: Int, height: Int, chroma444: Bool) {
|
||||||
self.width = width
|
self.width = width
|
||||||
self.height = height
|
self.height = height
|
||||||
|
self.chroma444 = chroma444
|
||||||
let align = { (v: Int) in
|
let align = { (v: Int) in
|
||||||
max((v + Self.alignment - 1) & ~(Self.alignment - 1), Self.minimumImageSize)
|
max((v + Self.alignment - 1) & ~(Self.alignment - 1), Self.minimumImageSize)
|
||||||
}
|
}
|
||||||
@@ -78,7 +82,7 @@ struct WaveletLayout {
|
|||||||
let ah = alignedHeight
|
let ah = alignedHeight
|
||||||
for level in stride(from: Self.decompositionLevels - 1, through: 0, by: -1) {
|
for level in stride(from: Self.decompositionLevels - 1, through: 0, by: -1) {
|
||||||
for component in 0..<3 {
|
for component in 0..<3 {
|
||||||
if level == 0 && component != 0 { continue } // 4:2:0: no top-level chroma
|
if level == 0 && component != 0 && !chroma444 { continue } // 4:2:0: no top-level chroma
|
||||||
for band in (level == Self.decompositionLevels - 1 ? 0 : 1)..<4 {
|
for band in (level == Self.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||||
let levelW = (aw / 2) >> level
|
let levelW = (aw / 2) >> level
|
||||||
let levelH = (ah / 2) >> level
|
let levelH = (ah / 2) >> level
|
||||||
@@ -108,6 +112,9 @@ struct ParsedWaveletFrame {
|
|||||||
var decodedBlocks: Int
|
var decodedBlocks: Int
|
||||||
/// VUI bits from the sequence header (BitstreamSequenceHeader).
|
/// VUI bits from the sequence header (BitstreamSequenceHeader).
|
||||||
var bt2020: Bool
|
var bt2020: Bool
|
||||||
|
/// PQ transfer ⇒ HDR session: 16-bit studio-code planes + EDR present (the host stamps
|
||||||
|
/// this bit iff the session negotiated 10-bit — the depth is coupled to the transfer).
|
||||||
|
var pq: Bool
|
||||||
var fullRange: Bool
|
var fullRange: Bool
|
||||||
|
|
||||||
/// The frame's Y′CbCr→RGB signal for the presenter's planar CSC. PyroWave today is always
|
/// The frame's Y′CbCr→RGB signal for the presenter's planar CSC. PyroWave today is always
|
||||||
@@ -131,6 +138,12 @@ enum WaveletBitstream {
|
|||||||
/// decoding — upstream's `decoded_blocks > total/2` partial rule).
|
/// decoding — upstream's `decoded_blocks > total/2` partial rule).
|
||||||
static func parse(au: Data, chunkAligned: Bool, windowSize: Int) -> ParsedWaveletFrame? {
|
static func parse(au: Data, chunkAligned: Bool, windowSize: Int) -> ParsedWaveletFrame? {
|
||||||
var state = ParseState()
|
var state = ParseState()
|
||||||
|
// Reserve the coefficient buffer ONCE, up front. Every packet's payload is a slice of the
|
||||||
|
// AU, so `au.count / 4` words is a tight upper bound — reserving it here lets the per-packet
|
||||||
|
// appends stay amortized O(1). (Reserving per packet forces Swift to allocate the exact new
|
||||||
|
// size each time, turning the walk O(n²) — invisible on the tiny golden fixtures, but ~5 ms
|
||||||
|
// per 1.4 MB frame on a real 5120x1440 stream.)
|
||||||
|
state.payload.reserveCapacity(au.count / 4)
|
||||||
let ok = au.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Bool in
|
let ok = au.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Bool in
|
||||||
guard let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
guard let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
||||||
return false
|
return false
|
||||||
@@ -203,6 +216,7 @@ enum WaveletBitstream {
|
|||||||
var totalBlocks = 0
|
var totalBlocks = 0
|
||||||
var decodedBlocks = 0
|
var decodedBlocks = 0
|
||||||
var bt2020 = false
|
var bt2020 = false
|
||||||
|
var pq = false
|
||||||
var fullRange = false
|
var fullRange = false
|
||||||
var sawSOF = false
|
var sawSOF = false
|
||||||
|
|
||||||
@@ -220,22 +234,27 @@ enum WaveletBitstream {
|
|||||||
// siting[31].
|
// siting[31].
|
||||||
let code = (word1 >> 24) & 0x3
|
let code = (word1 >> 24) & 0x3
|
||||||
guard code == 0 else { return false } // only START_OF_FRAME is defined
|
guard code == 0 else { return false } // only START_OF_FRAME is defined
|
||||||
let chromaRes = (word1 >> 26) & 1
|
let chroma444 = (word1 >> 26) & 1 != 0
|
||||||
guard chromaRes == 0 else { return false } // host contract: 4:2:0
|
|
||||||
let w = Int(word0 & 0x3fff) + 1
|
let w = Int(word0 & 0x3fff) + 1
|
||||||
let h = Int((word0 >> 14) & 0x3fff) + 1
|
let h = Int((word0 >> 14) & 0x3fff) + 1
|
||||||
guard w >= 2, h >= 2, w % 2 == 0, h % 2 == 0 else { return false }
|
guard w >= 2, h >= 2, chroma444 || (w % 2 == 0 && h % 2 == 0) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if sawSOF {
|
if sawSOF {
|
||||||
// One frame, one geometry — a second SOF must agree.
|
// One frame, one geometry — a second SOF must agree.
|
||||||
guard layout?.width == w, layout?.height == h else { return false }
|
guard layout?.width == w, layout?.height == h,
|
||||||
|
layout?.chroma444 == chroma444
|
||||||
|
else { return false }
|
||||||
} else {
|
} else {
|
||||||
sawSOF = true
|
sawSOF = true
|
||||||
let l = WaveletLayout(width: w, height: h)
|
let l = WaveletLayout(width: w, height: h, chroma444: chroma444)
|
||||||
layout = l
|
layout = l
|
||||||
offsets = [UInt32](repeating: .max, count: l.blockCount32)
|
offsets = [UInt32](repeating: .max, count: l.blockCount32)
|
||||||
payload.reserveCapacity(64 * 1024 / 4)
|
|
||||||
totalBlocks = Int(word1 & 0xff_ffff)
|
totalBlocks = Int(word1 & 0xff_ffff)
|
||||||
bt2020 = (word1 >> 29) & 1 != 0
|
bt2020 = (word1 >> 29) & 1 != 0
|
||||||
|
// transfer_function bit: PQ ⇒ an HDR session (16-bit studio-code
|
||||||
|
// planes by the negotiated coupling — design/pyrowave-444-hdr.md).
|
||||||
|
pq = (word1 >> 28) & 1 != 0
|
||||||
fullRange = (word1 >> 30) & 1 == 0 // YCBCR_RANGE_FULL = 0
|
fullRange = (word1 >> 30) & 1 == 0 // YCBCR_RANGE_FULL = 0
|
||||||
}
|
}
|
||||||
pos += 8
|
pos += 8
|
||||||
@@ -252,9 +271,15 @@ enum WaveletBitstream {
|
|||||||
if offsets[blockIndex] == .max {
|
if offsets[blockIndex] == .max {
|
||||||
offsets[blockIndex] = UInt32(payload.count)
|
offsets[blockIndex] = UInt32(payload.count)
|
||||||
decodedBlocks += 1
|
decodedBlocks += 1
|
||||||
payload.reserveCapacity(payload.count + payloadWords)
|
// Bulk-copy the packet's coefficient words in one memcpy rather than
|
||||||
for w in 0..<payloadWords {
|
// word-by-word. All Apple platforms are little-endian, so the wire's LE
|
||||||
payload.append(loadWord(base, pos + w * 4))
|
// u32s land in the [UInt32] buffer verbatim; memcpy has no alignment
|
||||||
|
// requirement, so a non-word-aligned `base + pos` is fine. `reserveCapacity`
|
||||||
|
// up in `parse` keeps the grow amortized O(1).
|
||||||
|
let dstWord = payload.count
|
||||||
|
payload.append(contentsOf: repeatElement(0, count: payloadWords))
|
||||||
|
payload.withUnsafeMutableBytes { dst in
|
||||||
|
_ = memcpy(dst.baseAddress! + dstWord * 4, base + pos, payloadWords * 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if layout != nil {
|
} else if layout != nil {
|
||||||
@@ -280,7 +305,7 @@ enum WaveletBitstream {
|
|||||||
return ParsedWaveletFrame(
|
return ParsedWaveletFrame(
|
||||||
layout: layout, offsets: offsets, payload: payload,
|
layout: layout, offsets: offsets, payload: payload,
|
||||||
totalBlocks: totalBlocks, decodedBlocks: decodedBlocks,
|
totalBlocks: totalBlocks, decodedBlocks: decodedBlocks,
|
||||||
bt2020: bt2020, fullRange: fullRange)
|
bt2020: bt2020, pq: pq, fullRange: fullRange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,6 +318,8 @@ public struct WaveletPlanes: @unchecked Sendable {
|
|||||||
public let cb: MTLTexture
|
public let cb: MTLTexture
|
||||||
public let cr: MTLTexture
|
public let cr: MTLTexture
|
||||||
public let csc: CscUniform
|
public let csc: CscUniform
|
||||||
|
/// PQ (HDR) stream: the presenter picks the HDR/tone-map planar pipeline + EDR config.
|
||||||
|
public let pq: Bool
|
||||||
public var width: Int { y.width }
|
public var width: Int { y.width }
|
||||||
public var height: Int { y.height }
|
public var height: Int { y.height }
|
||||||
}
|
}
|
||||||
@@ -351,6 +378,8 @@ public final class MetalWaveletDecoder {
|
|||||||
|
|
||||||
private var slots: [Slot] = []
|
private var slots: [Slot] = []
|
||||||
private var nextSlot = 0
|
private var nextSlot = 0
|
||||||
|
/// The ring's plane format facts (from the last SOF): PQ ⇒ 16-bit UNORM planes.
|
||||||
|
private var hdr16 = false
|
||||||
|
|
||||||
/// The current geometry (from the last SOF that built the resources) — the pump reports
|
/// The current geometry (from the last SOF that built the resources) — the pump reports
|
||||||
/// decoded-size changes to the resize overlay from this. PUMP THREAD.
|
/// decoded-size changes to the resize overlay from this. PUMP THREAD.
|
||||||
@@ -409,8 +438,9 @@ public final class MetalWaveletDecoder {
|
|||||||
au: au, chunkAligned: chunkAligned, windowSize: windowSize)
|
au: au, chunkAligned: chunkAligned, windowSize: windowSize)
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
if layout?.width != frame.layout.width || layout?.height != frame.layout.height {
|
if layout?.width != frame.layout.width || layout?.height != frame.layout.height
|
||||||
guard rebuild(layout: frame.layout) else { return false }
|
|| layout?.chroma444 != frame.layout.chroma444 || hdr16 != frame.pq {
|
||||||
|
guard rebuild(layout: frame.layout, hdr16: frame.pq) else { return false }
|
||||||
}
|
}
|
||||||
guard let layout, !slots.isEmpty else { return false }
|
guard let layout, !slots.isEmpty else { return false }
|
||||||
|
|
||||||
@@ -450,7 +480,7 @@ public final class MetalWaveletDecoder {
|
|||||||
dequant.setBuffer(slot.payload, offset: 0, index: 1)
|
dequant.setBuffer(slot.payload, offset: 0, index: 1)
|
||||||
for level in 0..<WaveletLayout.decompositionLevels {
|
for level in 0..<WaveletLayout.decompositionLevels {
|
||||||
for component in 0..<3 {
|
for component in 0..<3 {
|
||||||
if level == 0 && component != 0 { continue } // 4:2:0
|
if level == 0 && component != 0 && !layout.chroma444 { continue } // 4:2:0
|
||||||
for band in (level == WaveletLayout.decompositionLevels - 1 ? 0 : 1)..<4 {
|
for band in (level == WaveletLayout.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||||
let meta = layout.blockMeta[component][level][band]
|
let meta = layout.blockMeta[component][level][band]
|
||||||
let w = layout.levelWidth(level)
|
let w = layout.levelWidth(level)
|
||||||
@@ -489,15 +519,20 @@ public final class MetalWaveletDecoder {
|
|||||||
let grid = MTLSize(width: (rx + 15) / 16, height: (ry + 15) / 16, depth: 1)
|
let grid = MTLSize(width: (rx + 15) / 16, height: (ry + 15) / 16, depth: 1)
|
||||||
let group = MTLSize(width: 64, height: 1, depth: 1)
|
let group = MTLSize(width: 64, height: 1, depth: 1)
|
||||||
if inputLevel == 0 {
|
if inputLevel == 0 {
|
||||||
// 4:2:0: the final full-res pass is luma only (chroma finished at level 1).
|
// Final full-res pass: luma only in 4:2:0 (chroma finished at level 1); all
|
||||||
|
// three components in 4:4:4 (chroma runs the full pyramid like luma).
|
||||||
idwt.setComputePipelineState(idwtShiftPipeline)
|
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||||
idwt.setTexture(coefficients[0][0], index: 0)
|
let components = layout.chroma444 ? 3 : 1
|
||||||
idwt.setTexture(slot.y, index: 1)
|
for component in 0..<components {
|
||||||
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
idwt.setTexture(coefficients[component][0], index: 0)
|
||||||
|
let out = component == 0 ? slot.y : (component == 1 ? slot.cb : slot.cr)
|
||||||
|
idwt.setTexture(out, index: 1)
|
||||||
|
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for component in 0..<3 {
|
for component in 0..<3 {
|
||||||
idwt.setTexture(coefficients[component][inputLevel], index: 0)
|
idwt.setTexture(coefficients[component][inputLevel], index: 0)
|
||||||
if component != 0 && inputLevel == 1 {
|
if component != 0 && inputLevel == 1 && !layout.chroma444 {
|
||||||
// 4:2:0 chroma emits its final half-res plane one level early.
|
// 4:2:0 chroma emits its final half-res plane one level early.
|
||||||
idwt.setComputePipelineState(idwtShiftPipeline)
|
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||||
idwt.setTexture(component == 1 ? slot.cb : slot.cr, index: 1)
|
idwt.setTexture(component == 1 ? slot.cb : slot.cr, index: 1)
|
||||||
@@ -513,7 +548,9 @@ public final class MetalWaveletDecoder {
|
|||||||
|
|
||||||
let planes = WaveletPlanes(
|
let planes = WaveletPlanes(
|
||||||
y: slot.y, cb: slot.cb, cr: slot.cr,
|
y: slot.y, cb: slot.cb, cr: slot.cr,
|
||||||
csc: CscRows.rows(frame.cscSignal, depth: 8, msbPacked: false))
|
csc: CscRows.rows(
|
||||||
|
frame.cscSignal, depth: frame.pq ? 10 : 8, msbPacked: frame.pq),
|
||||||
|
pq: frame.pq)
|
||||||
cmd.addCompletedHandler { buffer in
|
cmd.addCompletedHandler { buffer in
|
||||||
completion(buffer.error == nil ? planes : nil)
|
completion(buffer.error == nil ? planes : nil)
|
||||||
}
|
}
|
||||||
@@ -524,9 +561,9 @@ public final class MetalWaveletDecoder {
|
|||||||
|
|
||||||
/// (Re)allocate every size-dependent resource for `layout`'s geometry. Also the mid-stream
|
/// (Re)allocate every size-dependent resource for `layout`'s geometry. Also the mid-stream
|
||||||
/// resize path: a Reconfigure shows up here as new SOF dims.
|
/// resize path: a Reconfigure shows up here as new SOF dims.
|
||||||
private func rebuild(layout newLayout: WaveletLayout) -> Bool {
|
private func rebuild(layout newLayout: WaveletLayout, hdr16 newHdr16: Bool) -> Bool {
|
||||||
waveletLog.info(
|
waveletLog.info(
|
||||||
"pyrowave: building decoder \(newLayout.width)x\(newLayout.height) (aligned \(newLayout.alignedWidth)x\(newLayout.alignedHeight), \(newLayout.blockCount32) blocks)")
|
"pyrowave: building decoder \(newLayout.width)x\(newLayout.height) (aligned \(newLayout.alignedWidth)x\(newLayout.alignedHeight), \(newLayout.blockCount32) blocks, \(newLayout.chroma444 ? "4:4:4" : "4:2:0", privacy: .public)\(newHdr16 ? " HDR16" : "", privacy: .public))")
|
||||||
var coeff: [[MTLTexture]] = []
|
var coeff: [[MTLTexture]] = []
|
||||||
var lls: [[MTLTexture]] = []
|
var lls: [[MTLTexture]] = []
|
||||||
for component in 0..<3 {
|
for component in 0..<3 {
|
||||||
@@ -560,19 +597,22 @@ public final class MetalWaveletDecoder {
|
|||||||
|
|
||||||
var newSlots: [Slot] = []
|
var newSlots: [Slot] = []
|
||||||
for i in 0..<Self.ringDepth {
|
for i in 0..<Self.ringDepth {
|
||||||
|
let planeFormat: MTLPixelFormat = newHdr16 ? .r16Unorm : .r8Unorm
|
||||||
let plane = { (w: Int, h: Int, name: String) -> MTLTexture? in
|
let plane = { (w: Int, h: Int, name: String) -> MTLTexture? in
|
||||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||||
pixelFormat: .r8Unorm, width: w, height: h, mipmapped: false)
|
pixelFormat: planeFormat, width: w, height: h, mipmapped: false)
|
||||||
desc.usage = [.shaderRead, .shaderWrite]
|
desc.usage = [.shaderRead, .shaderWrite]
|
||||||
desc.storageMode = .private
|
desc.storageMode = .private
|
||||||
let t = self.device.makeTexture(descriptor: desc)
|
let t = self.device.makeTexture(descriptor: desc)
|
||||||
t?.label = name
|
t?.label = name
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
let cw = newLayout.chroma444 ? newLayout.width : newLayout.width / 2
|
||||||
|
let ch = newLayout.chroma444 ? newLayout.height : newLayout.height / 2
|
||||||
guard
|
guard
|
||||||
let y = plane(newLayout.width, newLayout.height, "pyrowave Y[\(i)]"),
|
let y = plane(newLayout.width, newLayout.height, "pyrowave Y[\(i)]"),
|
||||||
let cb = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cb[\(i)]"),
|
let cb = plane(cw, ch, "pyrowave Cb[\(i)]"),
|
||||||
let cr = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cr[\(i)]"),
|
let cr = plane(cw, ch, "pyrowave Cr[\(i)]"),
|
||||||
let offsets = device.makeBuffer(
|
let offsets = device.makeBuffer(
|
||||||
length: max(newLayout.blockCount32 * 4, 4), options: .storageModeShared),
|
length: max(newLayout.blockCount32 * 4, 4), options: .storageModeShared),
|
||||||
let payload = device.makeBuffer(length: 64 * 1024, options: .storageModeShared)
|
let payload = device.makeBuffer(length: 64 * 1024, options: .storageModeShared)
|
||||||
@@ -585,6 +625,7 @@ public final class MetalWaveletDecoder {
|
|||||||
slots = newSlots
|
slots = newSlots
|
||||||
nextSlot = 0
|
nextSlot = 0
|
||||||
layout = newLayout
|
layout = newLayout
|
||||||
|
hdr16 = newHdr16
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#if canImport(Metal) && canImport(QuartzCore)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PunktfunkShared
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
@@ -41,12 +42,20 @@ enum PresenterChoice: Equatable {
|
|||||||
/// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
|
/// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
|
||||||
/// freeze-prone fallback.
|
/// freeze-prone fallback.
|
||||||
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
|
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
|
||||||
|
explicit(setting: setting, env: env, allowStage1: allowStage1) ?? platformDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's EXPLICIT stage selection, nil when they haven't made one (unset/unknown values,
|
||||||
|
/// and a release build's gated "stage1"). Split from `resolve` so a codec-conditional default
|
||||||
|
/// (see `SessionPresenter.pacing`) can apply only when the user hasn't picked a stage — an
|
||||||
|
/// explicit "stage2" must stay a faithful A/B of arrival pacing.
|
||||||
|
static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? {
|
||||||
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
||||||
switch raw {
|
switch raw {
|
||||||
case "stage1": return allowStage1 ? .stage1 : platformDefault
|
case "stage1": return allowStage1 ? .stage1 : nil
|
||||||
case "stage2": return .stage2
|
case "stage2": return .stage2
|
||||||
case "stage3": return .stage3
|
case "stage3": return .stage3
|
||||||
default: return platformDefault
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,10 +74,41 @@ enum PresenterChoice: Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class SessionPresenter {
|
final class SessionPresenter {
|
||||||
|
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
|
||||||
|
/// default, macOS PyroWave sessions ALSO get glass gating — a kernel-panic mitigation, not a
|
||||||
|
/// latency tweak. macOS's DCP panics ("mismatched swapID's" @UnifiedPipeline.cpp, the whole
|
||||||
|
/// machine dies) when WindowServer's swap submissions race, and the reliable trigger is
|
||||||
|
/// out-of-band CAMetalLayer presents (displaySyncEnabled=false — mandatory for us, see
|
||||||
|
/// MetalVideoPresenter's init) arriving faster than the compositor latches them in a
|
||||||
|
/// COMPOSITED (windowed) session. Arrival pacing does exactly that with PyroWave: the wavelet
|
||||||
|
/// decode is near-instant Metal compute, so a network clump of frames presents within the
|
||||||
|
/// same millisecond, and PyroWave is the codec that sustains stream rates above the panel's
|
||||||
|
/// refresh. The glass gate admits one presented-but-undisplayed swap at a time (serialized on
|
||||||
|
/// the on-glass callback, 100 ms stale backstop), which removes the racing pattern outright;
|
||||||
|
/// frames the panel couldn't have shown anyway coalesce in the newest-wins ring. An explicit
|
||||||
|
/// stage-2 pick (setting/env) still forces arrival pacing — that A/B lever must stay honest.
|
||||||
|
/// VideoToolbox codecs keep arrival pacing: decode latency spaces their presents, and years
|
||||||
|
/// of stage-2 defaults there predate any panic report.
|
||||||
|
static func pacing(
|
||||||
|
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
|
||||||
|
) -> PresentPacing {
|
||||||
|
if choice == .stage3 { return .glass }
|
||||||
|
#if os(macOS)
|
||||||
|
if explicit == nil, codec == .pyrowave { return .glass }
|
||||||
|
#endif
|
||||||
|
return .arrival
|
||||||
|
}
|
||||||
|
|
||||||
private var pump: StreamPump?
|
private var pump: StreamPump?
|
||||||
private var stage2: Stage2Pipeline?
|
private var stage2: Stage2Pipeline?
|
||||||
private var stage2Link: CADisplayLink?
|
private var stage2Link: CADisplayLink?
|
||||||
private var metalLayer: CAMetalLayer?
|
private var metalLayer: CAMetalLayer?
|
||||||
|
#if os(macOS)
|
||||||
|
/// The windowed-mode PyroWave present target (sibling above `metalLayer`) and the last
|
||||||
|
/// routing pushed to the pipeline — see `setComposited`. Main-thread only, like all of this.
|
||||||
|
private var surfaceLayer: CALayer?
|
||||||
|
private var surfacePresentsActive = false
|
||||||
|
#endif
|
||||||
private var connection: PunktfunkConnection?
|
private var connection: PunktfunkConnection?
|
||||||
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||||
/// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
|
/// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
|
||||||
@@ -111,20 +151,29 @@ final class SessionPresenter {
|
|||||||
#else
|
#else
|
||||||
let allowStage1 = false
|
let allowStage1 = false
|
||||||
#endif
|
#endif
|
||||||
let choice = PresenterChoice.resolve(
|
let explicit = PresenterChoice.explicit(
|
||||||
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
|
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
|
||||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
||||||
allowStage1: allowStage1)
|
allowStage1: allowStage1)
|
||||||
|
let choice = explicit ?? PresenterChoice.platformDefault
|
||||||
if choice != .stage1,
|
if choice != .stage1,
|
||||||
let pipeline = Stage2Pipeline(
|
let pipeline = Stage2Pipeline(
|
||||||
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
pacing: choice == .stage3 ? .glass : .arrival) {
|
pacing: Self.pacing(
|
||||||
|
for: choice, explicit: explicit, codec: connection.videoCodec)) {
|
||||||
let metal = pipeline.layer
|
let metal = pipeline.layer
|
||||||
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
||||||
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
||||||
baseLayer.addSublayer(metal)
|
baseLayer.addSublayer(metal)
|
||||||
metalLayer = metal
|
metalLayer = metal
|
||||||
|
#if os(macOS)
|
||||||
|
// The windowed-PyroWave present target sits ABOVE the metal layer: transparent (nil
|
||||||
|
// contents) while the metal path presents, covering it while surface presents run.
|
||||||
|
baseLayer.addSublayer(pipeline.surfaceLayer)
|
||||||
|
surfaceLayer = pipeline.surfaceLayer
|
||||||
|
surfacePresentsActive = false
|
||||||
|
#endif
|
||||||
stage2 = pipeline
|
stage2 = pipeline
|
||||||
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
||||||
// (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the
|
// (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the
|
||||||
@@ -223,6 +272,12 @@ final class SessionPresenter {
|
|||||||
CATransaction.setDisableActions(true)
|
CATransaction.setDisableActions(true)
|
||||||
metalLayer.contentsScale = contentsScale
|
metalLayer.contentsScale = contentsScale
|
||||||
metalLayer.frame = snapped
|
metalLayer.frame = snapped
|
||||||
|
#if os(macOS)
|
||||||
|
// The surface present target mirrors the metal layer's geometry exactly — its IOSurfaces
|
||||||
|
// are sized to the same snapped pixel rect, so the contents composite is a 1:1 blit too.
|
||||||
|
surfaceLayer?.contentsScale = contentsScale
|
||||||
|
surfaceLayer?.frame = snapped
|
||||||
|
#endif
|
||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
||||||
// cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so
|
// cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so
|
||||||
@@ -250,6 +305,31 @@ final class SessionPresenter {
|
|||||||
contentSize = size
|
contentSize = size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// Route presents for the window's composited state (MAIN thread — the view pushes it on
|
||||||
|
/// every layout, which fullscreen transitions always trigger). PyroWave sessions in a
|
||||||
|
/// COMPOSITED (windowed) session present via `surfaceLayer` contents instead of the
|
||||||
|
/// CAMetalLayer image queue — the DCP "mismatched swapID's" kernel-panic mitigation (see
|
||||||
|
/// `MetalVideoPresenter.surfaceLayer`; the metal-swap race survives glass pacing, so pacing
|
||||||
|
/// alone was not enough). VT codecs keep the metal path: no panic reports there, and their
|
||||||
|
/// HDR/EDR presentation has no surface-contents equivalent wired.
|
||||||
|
func setComposited(_ composited: Bool) {
|
||||||
|
guard let stage2, let connection else { return }
|
||||||
|
let wantsSurface = composited && connection.videoCodec == .pyrowave
|
||||||
|
guard wantsSurface != surfacePresentsActive else { return }
|
||||||
|
surfacePresentsActive = wantsSurface
|
||||||
|
stage2.setSurfacePresents(wantsSurface)
|
||||||
|
if !wantsSurface {
|
||||||
|
// Uncover the metal layer NOW (its last drawable is still attached, so fullscreen
|
||||||
|
// entry shows the previous frame until the next present — no black flash).
|
||||||
|
CATransaction.begin()
|
||||||
|
CATransaction.setDisableActions(true)
|
||||||
|
surfaceLayer?.contents = nil
|
||||||
|
CATransaction.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
||||||
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
||||||
/// Idempotent.
|
/// Idempotent.
|
||||||
@@ -263,6 +343,11 @@ final class SessionPresenter {
|
|||||||
stage2 = nil
|
stage2 = nil
|
||||||
metalLayer?.removeFromSuperlayer()
|
metalLayer?.removeFromSuperlayer()
|
||||||
metalLayer = nil
|
metalLayer = nil
|
||||||
|
#if os(macOS)
|
||||||
|
surfaceLayer?.removeFromSuperlayer()
|
||||||
|
surfaceLayer = nil
|
||||||
|
surfacePresentsActive = false
|
||||||
|
#endif
|
||||||
connection = nil
|
connection = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Metal
|
import Metal
|
||||||
|
import PunktfunkShared
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
|
||||||
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
||||||
@@ -121,6 +122,11 @@ private final class VsyncClock: @unchecked Sendable {
|
|||||||
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
|
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
|
||||||
/// present instead of queueing them behind the display — the hidden queue latency becomes
|
/// present instead of queueing them behind the display — the hidden queue latency becomes
|
||||||
/// explicit, correct frame drops.
|
/// explicit, correct frame drops.
|
||||||
|
///
|
||||||
|
/// macOS PyroWave sessions default to `glass` even though the platform default is stage-2: burst
|
||||||
|
/// presents into a composited (windowed) layer are the trigger pattern for the macOS DCP
|
||||||
|
/// "mismatched swapID's" KERNEL PANIC, and the one-in-flight gate removes that pattern — see
|
||||||
|
/// `SessionPresenter.pacing` for the full rationale.
|
||||||
public enum PresentPacing: Sendable {
|
public enum PresentPacing: Sendable {
|
||||||
case arrival
|
case arrival
|
||||||
case glass
|
case glass
|
||||||
@@ -431,6 +437,15 @@ public final class Stage2Pipeline {
|
|||||||
while alive, !token.isStopped {
|
while alive, !token.isStopped {
|
||||||
alive = autoreleasepool { () -> Bool in
|
alive = autoreleasepool { () -> Bool in
|
||||||
do {
|
do {
|
||||||
|
// Background keep-alive: drain one AU (flow control + host pacing) and discard it
|
||||||
|
// BEFORE any VideoToolbox decode or Metal render — no GPU work off-screen. The
|
||||||
|
// decoder session is left intact; exitBackground requests a fresh IDR and the
|
||||||
|
// re-anchor gate arms on the resumed frame-index gap so concealed frames are
|
||||||
|
// withheld until it lands.
|
||||||
|
if connection.isVideoDropped {
|
||||||
|
_ = try connection.nextAU(timeoutMs: 100)
|
||||||
|
return true
|
||||||
|
}
|
||||||
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
||||||
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
||||||
// so key off the drop count climbing, then keep asking (awaitingIDR) until a fresh
|
// so key off the drop count climbing, then keep asking (awaitingIDR) until a fresh
|
||||||
@@ -615,6 +630,18 @@ public final class Stage2Pipeline {
|
|||||||
presenter.setDrawableTarget(size)
|
presenter.setDrawableTarget(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// The windowed-mode PyroWave present target (see `MetalVideoPresenter.surfaceLayer` — the
|
||||||
|
/// DCP swapID-panic mitigation). The hosting view installs it as a sibling above `layer`.
|
||||||
|
public var surfaceLayer: CALayer { presenter.surfaceLayer }
|
||||||
|
|
||||||
|
/// Forward the windowed-vs-fullscreen present routing (MAIN thread — see
|
||||||
|
/// `MetalVideoPresenter.setSurfacePresents`).
|
||||||
|
public func setSurfacePresents(_ on: Bool) {
|
||||||
|
presenter.setSurfacePresents(on)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen`
|
/// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen`
|
||||||
/// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on
|
/// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on
|
||||||
/// it; see `MetalVideoPresenter.setDisplayHeadroom`.
|
/// it; see `MetalVideoPresenter.setDisplayHeadroom`.
|
||||||
@@ -686,6 +713,13 @@ public final class Stage2Pipeline {
|
|||||||
while alive, !token.isStopped {
|
while alive, !token.isStopped {
|
||||||
alive = autoreleasepool { () -> Bool in
|
alive = autoreleasepool { () -> Bool in
|
||||||
do {
|
do {
|
||||||
|
// Background keep-alive: drain + discard before the Metal wavelet decode
|
||||||
|
// (PyroWave is all-intra, so the resumed frame heals on its own — no IDR
|
||||||
|
// request needed, just no GPU work off-screen).
|
||||||
|
if connection.isVideoDropped {
|
||||||
|
_ = try connection.nextAU(timeoutMs: 100)
|
||||||
|
return true
|
||||||
|
}
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let newest = newestIndex,
|
if let newest = newestIndex,
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ final class StreamPump {
|
|||||||
while alive, !token.isStopped {
|
while alive, !token.isStopped {
|
||||||
alive = autoreleasepool { () -> Bool in
|
alive = autoreleasepool { () -> Bool in
|
||||||
do {
|
do {
|
||||||
|
// Background keep-alive: drain one AU to keep QUIC flow control + host pacing
|
||||||
|
// healthy, then discard it BEFORE any decode/enqueue — no VideoToolbox/Metal work
|
||||||
|
// off-screen. Skips all recovery/gate bookkeeping too; exitBackground requests a
|
||||||
|
// fresh IDR and the re-anchor gate re-arms on the resumed frame-index gap.
|
||||||
|
if connection.isVideoDropped {
|
||||||
|
_ = try connection.nextAU(timeoutMs: 100)
|
||||||
|
return true
|
||||||
|
}
|
||||||
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||||
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||||
// (framesDropped); the decoder then *conceals* the reference-missing deltas — a
|
// (framesDropped); the decoder then *conceals* the reference-missing deltas — a
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
import PunktfunkShared
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -620,6 +621,12 @@ public final class StreamLayerView: NSView {
|
|||||||
guard let self, self.window?.isKeyWindow == true else { return }
|
guard let self, self.window?.isKeyWindow == true else { return }
|
||||||
self.onDisconnectRequest?()
|
self.onDisconnectRequest?()
|
||||||
}
|
}
|
||||||
|
capture.onToggleFullscreen = { [weak self] in
|
||||||
|
// App-level window action: post to the key window's FullscreenController (same routing as
|
||||||
|
// the Stream menu's ⌃⌘F item, so captured and released states hit one code path).
|
||||||
|
guard self?.window?.isKeyWindow == true else { return }
|
||||||
|
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
|
||||||
|
}
|
||||||
capture.onCycleStats = { [weak self] in
|
capture.onCycleStats = { [weak self] in
|
||||||
guard self?.window?.isKeyWindow == true else { return }
|
guard self?.window?.isKeyWindow == true else { return }
|
||||||
// Advance the shared tier setting directly — every @AppStorage reader (the HUD's
|
// Advance the shared tier setting directly — every @AppStorage reader (the HUD's
|
||||||
@@ -670,7 +677,10 @@ public final class StreamLayerView: NSView {
|
|||||||
// default keeps the explicit mode.
|
// default keeps the explicit mode.
|
||||||
let follower = MatchWindowFollower(
|
let follower = MatchWindowFollower(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
|
||||||
|
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
|
||||||
|
maxDimension: RenderScale.maxDimension(
|
||||||
|
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
|
||||||
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
||||||
matchFollower = follower
|
matchFollower = follower
|
||||||
layoutPresenter()
|
layoutPresenter()
|
||||||
@@ -682,6 +692,11 @@ public final class StreamLayerView: NSView {
|
|||||||
/// the view's physical-pixel size (bounds → backing), so a window resize / retina move follows.
|
/// the view's physical-pixel size (bounds → backing), so a window resize / retina move follows.
|
||||||
private func layoutPresenter() {
|
private func layoutPresenter() {
|
||||||
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
||||||
|
// Present routing tracks the window's composited state (fullscreen transitions always
|
||||||
|
// re-layout, so this stays current): windowed PyroWave presents via surface contents —
|
||||||
|
// the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited). A view
|
||||||
|
// not yet in a window counts as composited (the safe default).
|
||||||
|
presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false))
|
||||||
// Feed the follower only once in a window (backing scale is real then) and with real
|
// Feed the follower only once in a window (backing scale is real then) and with real
|
||||||
// bounds — a pre-window layout would report point-sized dimensions.
|
// bounds — a pre-window layout would report point-sized dimensions.
|
||||||
if window != nil, bounds.width > 0, bounds.height > 0 {
|
if window != nil, bounds.width > 0, bounds.height > 0 {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import GameController
|
import GameController
|
||||||
import PunktfunkCore
|
import PunktfunkCore
|
||||||
|
import PunktfunkShared
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import os
|
import os
|
||||||
@@ -384,7 +385,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
// default keeps the explicit mode.
|
// default keeps the explicit mode.
|
||||||
let follower = MatchWindowFollower(
|
let follower = MatchWindowFollower(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
|
||||||
|
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
|
||||||
|
maxDimension: RenderScale.maxDimension(
|
||||||
|
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
|
||||||
follower.onResizeTarget = onResizeTarget
|
follower.onResizeTarget = onResizeTarget
|
||||||
matchFollower = follower
|
matchFollower = follower
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// The App-Group foundation shared by the app and its extensions (Widgets / Live Activity).
|
||||||
|
//
|
||||||
|
// PunktfunkShared is deliberately dependency-free: it links NEITHER PunktfunkKit (which drags in
|
||||||
|
// the Rust staticlib + presentation layer) NOR any Apple UI framework. A widget process gets ~30 MB,
|
||||||
|
// so everything an extension needs — the stored-host model + its JSON codec, the settings-key names,
|
||||||
|
// the deep-link grammar, and (later) the Live Activity attributes — lives here and here only.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The one App-Group identifier, matched by `Config/*.entitlements`
|
||||||
|
/// (`com.apple.security.application-groups`). Registered on the developer portal for both the app
|
||||||
|
/// id (`io.unom.punktfunk`) and the widget extension id (`io.unom.punktfunk.widgets`).
|
||||||
|
public enum AppGroup {
|
||||||
|
public static let suiteName = "group.io.unom.punktfunk"
|
||||||
|
|
||||||
|
/// The shared defaults suite. Non-nil in a correctly-entitled process; falls back to
|
||||||
|
/// `.standard` if the group is somehow unavailable (unsigned `swift run`, a misprovisioned
|
||||||
|
/// build) so the app still functions single-process rather than crashing — the widget just
|
||||||
|
/// won't see the same store there.
|
||||||
|
public static var defaults: UserDefaults {
|
||||||
|
UserDefaults(suiteName: suiteName) ?? .standard
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// The `punktfunk://` deep-link grammar — the single builder/parser shared by the widget (which
|
||||||
|
// emits links via `widgetURL`/`Link`) and the app (`ContentView.onOpenURL`, which routes them into
|
||||||
|
// the existing connect path). Keeping both sides on one type means the wire format can't drift.
|
||||||
|
//
|
||||||
|
// Grammar (v1):
|
||||||
|
// punktfunk://connect/<host-uuid> — connect to a stored host
|
||||||
|
// punktfunk://connect/<host-uuid>?launch=<GameEntry.id> — connect and ask the host to launch it
|
||||||
|
//
|
||||||
|
// `launch` carries a `GameEntry.id` (e.g. "steam:570"); it is percent-encoded on build and decoded
|
||||||
|
// on parse, so ids with reserved characters survive the round trip.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum DeepLink: Equatable {
|
||||||
|
/// Connect to a saved host; `launchID` is a `GameEntry.id` to launch on arrival, if any.
|
||||||
|
case connect(host: UUID, launchID: String?)
|
||||||
|
|
||||||
|
public static let scheme = "punktfunk"
|
||||||
|
|
||||||
|
/// Build the canonical URL for a route. Non-optional: every route is representable.
|
||||||
|
public var url: URL {
|
||||||
|
switch self {
|
||||||
|
case let .connect(host, launchID):
|
||||||
|
var comps = URLComponents()
|
||||||
|
comps.scheme = Self.scheme
|
||||||
|
comps.host = "connect"
|
||||||
|
comps.path = "/\(host.uuidString)"
|
||||||
|
if let launchID, !launchID.isEmpty {
|
||||||
|
comps.queryItems = [URLQueryItem(name: "launch", value: launchID)]
|
||||||
|
}
|
||||||
|
// URLComponents percent-encodes the query value; force-unwrap is safe for a URL we
|
||||||
|
// fully control (scheme/host/path are all valid).
|
||||||
|
return comps.url!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an incoming URL, or nil if it isn't a recognized `punktfunk://` route. Tolerant of
|
||||||
|
/// case in the scheme and of a trailing slash on the path.
|
||||||
|
public init?(_ url: URL) {
|
||||||
|
guard url.scheme?.lowercased() == Self.scheme else { return nil }
|
||||||
|
guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
|
||||||
|
|
||||||
|
switch comps.host?.lowercased() {
|
||||||
|
case "connect":
|
||||||
|
// Path is "/<uuid>"; strip the leading slash and any trailing one.
|
||||||
|
let raw = comps.path
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
guard let host = UUID(uuidString: raw) else { return nil }
|
||||||
|
let launch = comps.queryItems?
|
||||||
|
.first(where: { $0.name == "launch" })?.value
|
||||||
|
.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
self = .connect(host: host, launchID: launch)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-1
@@ -1,7 +1,8 @@
|
|||||||
// One source of truth for the client's UserDefaults / @AppStorage keys. A magic-string key
|
// One source of truth for the client's UserDefaults / @AppStorage keys. A magic-string key
|
||||||
// duplicated across a setting's writer (a Settings @AppStorage) and reader (e.g. a stream view
|
// duplicated across a setting's writer (a Settings @AppStorage) and reader (e.g. a stream view
|
||||||
// reading UserDefaults) splits silently on a typo — the setting just stops taking effect. These
|
// reading UserDefaults) splits silently on a typo — the setting just stops taking effect. These
|
||||||
// live in PunktfunkKit because both the app and the kit's views read them.
|
// live in the dependency-free PunktfunkShared module (re-exported by PunktfunkKit) because the app,
|
||||||
|
// the kit's views, AND the widget extension all read them — the widget needs `DefaultsKey.hosts`.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -20,6 +21,14 @@ public enum DefaultsKey {
|
|||||||
/// is native either way, so this degenerates to Auto-native there). Read per session by the
|
/// is native either way, so this degenerates to Auto-native there). Read per session by the
|
||||||
/// stream views' `MatchWindowFollower`.
|
/// stream views' `MatchWindowFollower`.
|
||||||
public static let matchWindow = "punktfunk.matchWindow"
|
public static let matchWindow = "punktfunk.matchWindow"
|
||||||
|
/// Render-resolution multiplier (a `RenderScale` value, default 1.0): the client asks the host
|
||||||
|
/// to render/encode at `chosen resolution × scale`, then the presenter downscales the larger
|
||||||
|
/// decoded frame to this display in one Catmull-Rom pass. > 1 supersamples (sharper, at the cost
|
||||||
|
/// of more bandwidth AND client decode — both grow ∝ scale²); < 1 renders below native for a
|
||||||
|
/// weak host GPU / constrained link (the presenter upscales). Purely client-side — the host just
|
||||||
|
/// sees a normal (larger/smaller) `Mode`, and Automatic bitrate scales with it. Clamped even +
|
||||||
|
/// to the codec's max dimension at connect. Applies to the fixed mode and the match-window path.
|
||||||
|
public static let renderScale = "punktfunk.renderScale"
|
||||||
public static let compositor = "punktfunk.compositor"
|
public static let compositor = "punktfunk.compositor"
|
||||||
public static let gamepadType = "punktfunk.gamepadType"
|
public static let gamepadType = "punktfunk.gamepadType"
|
||||||
public static let gamepadID = "punktfunk.gamepadID"
|
public static let gamepadID = "punktfunk.gamepadID"
|
||||||
@@ -68,6 +77,21 @@ public enum DefaultsKey {
|
|||||||
public static let hosts = "punktfunk.hosts"
|
public static let hosts = "punktfunk.hosts"
|
||||||
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
||||||
public static let cursorMode = "punktfunk.cursorMode"
|
public static let cursorMode = "punktfunk.cursorMode"
|
||||||
|
/// Invert the scroll-wheel / two-finger-scroll direction sent to the host (both axes). Off by
|
||||||
|
/// default: the local (natural-scrolling) sign passes through untouched. When on, the sign is
|
||||||
|
/// negated at the single scroll sink (`InputCapture.sendScroll`), so it flips consistently across
|
||||||
|
/// the macOS wheel, the iOS trackpad pan, and a GCMouse wheel. For users whose host expects the
|
||||||
|
/// opposite convention from their local OS preference.
|
||||||
|
public static let invertScroll = "punktfunk.invertScroll"
|
||||||
|
/// Location-based modifier mapping (a `ModifierLayout` value, default `.mac`): which Windows VK
|
||||||
|
/// each PHYSICAL modifier position forwards to the host. `.mac` keeps ⌥ Option → Alt and
|
||||||
|
/// ⌘ Command → Super/Win (the Apple positions). `.windows` swaps the Alt/Super ROLE between the
|
||||||
|
/// Option and Command keys — preserving side (L/R) — so the key nearest the space bar acts as
|
||||||
|
/// Alt and the next one as the Windows key, matching a Windows keyboard's `Ctrl / ⊞ / Alt` row.
|
||||||
|
/// Only what's FORWARDED changes; client-local shortcuts (⌘⎋ &co.) stay on the physical ⌘ key.
|
||||||
|
/// Read live at the wire boundary by `InputCapture`. Control/Shift never move (same position on
|
||||||
|
/// both keyboards).
|
||||||
|
public static let modifierLayout = "punktfunk.modifierLayout"
|
||||||
/// iPad: capture the mouse/trackpad pointer (pointer lock → relative movement) for games,
|
/// iPad: capture the mouse/trackpad pointer (pointer lock → relative movement) for games,
|
||||||
/// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad
|
/// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad
|
||||||
/// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost
|
/// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost
|
||||||
@@ -111,6 +135,16 @@ public enum DefaultsKey {
|
|||||||
/// routed/VPN host), so connects go straight through instead of waiting out the wake timeout.
|
/// routed/VPN host), so connects go straight through instead of waiting out the wake timeout.
|
||||||
/// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession.
|
/// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession.
|
||||||
public static let autoWake = "punktfunk.autoWake"
|
public static let autoWake = "punktfunk.autoWake"
|
||||||
|
/// iOS/iPadOS: keep a streaming session ALIVE when the app is backgrounded (audio background
|
||||||
|
/// mode). Off by default (today's freeze-on-background is the default). When on, backgrounding a
|
||||||
|
/// live session keeps audio playing and the QUIC/pump live while DROPPING video decode, and a
|
||||||
|
/// bounded timer (`backgroundTimeoutMinutes`) auto-disconnects if the user doesn't return. Read
|
||||||
|
/// by ContentView's scenePhase driver. Hidden on tvOS/macOS.
|
||||||
|
public static let backgroundKeepAlive = "punktfunk.backgroundKeepAlive"
|
||||||
|
/// iOS/iPadOS: minutes a backgrounded keep-alive session runs before auto-disconnecting (a
|
||||||
|
/// battery/thermal/bandwidth backstop). Default 10; the UI offers 1/5/10/30. The auto-disconnect
|
||||||
|
/// is non-deliberate (host linger kept), so a late return reconnects fast. Read on enterBackground.
|
||||||
|
public static let backgroundTimeoutMinutes = "punktfunk.backgroundTimeoutMinutes"
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
@@ -120,4 +154,21 @@ extension Notification.Name {
|
|||||||
/// menus) — it exists so the menu item is honest whenever it CAN fire, and as the shortcut's
|
/// menus) — it exists so the menu item is honest whenever it CAN fire, and as the shortcut's
|
||||||
/// discoverable menu-bar surface.
|
/// discoverable menu-bar surface.
|
||||||
public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture")
|
public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture")
|
||||||
|
|
||||||
|
/// Posted by the app's Stream menu ("Toggle Fullscreen", ⌃⌘F) and by InputCapture's monitor
|
||||||
|
/// when the same combo fires while input is captured (the menu key-equivalent never reaches a
|
||||||
|
/// captured stream view). The key window's `FullscreenController` flips the window's fullscreen
|
||||||
|
/// state. macOS only.
|
||||||
|
public static let punktfunkToggleFullscreen = Notification.Name("io.unom.punktfunk.toggle-fullscreen")
|
||||||
|
|
||||||
|
/// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`,
|
||||||
|
/// which runs in the app's process): the app tears the active session down deliberately
|
||||||
|
/// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture` —
|
||||||
|
/// the intent lives in PunktfunkShared and can't reach the app's `SessionModel` directly.
|
||||||
|
public static let punktfunkEndActiveSession = Notification.Name("io.unom.punktfunk.end-active-session")
|
||||||
|
|
||||||
|
/// Posted by the Connect App Intent (Siri/Shortcuts) with a `punktfunk://` URL as `object`:
|
||||||
|
/// the app routes it through the SAME `.onOpenURL` handler a widget tap uses (one router, one
|
||||||
|
/// set of guards). The intent uses `openAppWhenRun`, so the app is foregrounded to receive it.
|
||||||
|
public static let punktfunkOpenDeepLink = Notification.Name("io.unom.punktfunk.open-deep-link")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// The saved-host as an App Intents entity — the parameter type for the Connect/Wake intents and
|
||||||
|
// the configurable single-host widget. Lives in the shared module (not the app) because widget
|
||||||
|
// *configuration* intents execute in the EXTENSION process, so the entity can't be app-only.
|
||||||
|
//
|
||||||
|
// AppIntents is genuinely available on macOS (13+), so this is gated on `canImport(AppIntents)`
|
||||||
|
// (unlike ActivityKit, whose macOS types are unavailable) — it compiles on every platform and the
|
||||||
|
// entity query reads the same shared App-Group store the widget does.
|
||||||
|
|
||||||
|
#if canImport(AppIntents)
|
||||||
|
import AppIntents
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct HostEntity: AppEntity, Identifiable {
|
||||||
|
public static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Host")
|
||||||
|
public static let defaultQuery = HostEntityQuery()
|
||||||
|
|
||||||
|
public let id: UUID
|
||||||
|
public let name: String
|
||||||
|
|
||||||
|
public init(id: UUID, name: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ host: StoredHost) {
|
||||||
|
self.id = host.id
|
||||||
|
self.name = host.displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
public var displayRepresentation: DisplayRepresentation {
|
||||||
|
DisplayRepresentation(title: "\(name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct HostEntityQuery: EntityQuery {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func entities(for identifiers: [UUID]) async throws -> [HostEntity] {
|
||||||
|
Self.loadHosts().filter { identifiers.contains($0.id) }.map(HostEntity.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sorted most-recent first — Siri/Shortcuts and the widget config picker suggest recent hosts.
|
||||||
|
public func suggestedEntities() async throws -> [HostEntity] {
|
||||||
|
Self.loadHosts().map(HostEntity.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Location-based modifier mapping — which Windows VK each PHYSICAL modifier position forwards to
|
||||||
|
// the host. A Mac keyboard's bottom row is `⌃ Control / ⌥ Option / ⌘ Command / space`; a Windows
|
||||||
|
// keyboard's is `Ctrl / ⊞ Super / Alt / space`. So the key NEAREST the space bar is Command on a
|
||||||
|
// Mac but Alt on Windows, and the next one out is Option on a Mac but the Windows key. A user who
|
||||||
|
// grew up on Windows reaches for Alt where the Mac has Command; this setting lets them get that
|
||||||
|
// muscle memory back WITHOUT relabelling keycaps — it remaps by physical position, not by a blunt
|
||||||
|
// "swap these two VKs" that would also drag Control/Shift or lose the left/right distinction.
|
||||||
|
//
|
||||||
|
// The model: keep the physical detection (which side, which key) exactly as the OS reports it, and
|
||||||
|
// swap only the Alt-vs-Super ROLE between the Option and Command keys, per side. So under `.windows`
|
||||||
|
// the ⌘ position emits Alt (VK_L/RMENU) and the ⌥ position emits the Windows key (VK_L/RWIN), while
|
||||||
|
// left stays left and right stays right. Control and Shift are in the same place on both keyboards,
|
||||||
|
// so they never move. Lives in PunktfunkShared because both the Settings UI (PunktfunkClient) and
|
||||||
|
// the wire-boundary remap (`InputCapture.applyModifierLayout`, PunktfunkKit) resolve it.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// How the physical ⌥ Option / ⌘ Command keys map to host modifiers. The raw values are stable on
|
||||||
|
/// disk — rename the cases freely, never the strings.
|
||||||
|
public enum ModifierLayout: String, CaseIterable, Sendable {
|
||||||
|
/// Apple positions (default): ⌥ Option → Alt, ⌘ Command → Super/Windows. The current behaviour.
|
||||||
|
case mac
|
||||||
|
/// Windows positions: the key nearest the space bar (⌘ Command) → Alt, the next one (⌥ Option)
|
||||||
|
/// → the Windows/Super key. Side (left/right) is preserved.
|
||||||
|
case windows
|
||||||
|
|
||||||
|
/// User-facing label (Settings picker).
|
||||||
|
public var label: String {
|
||||||
|
switch self {
|
||||||
|
case .mac: return "Mac (⌥ Alt · ⌘ Super)"
|
||||||
|
case .windows: return "Windows (⌘ Alt · ⌥ Super)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-line explanation for the setting's footer.
|
||||||
|
public var detail: String {
|
||||||
|
switch self {
|
||||||
|
case .mac:
|
||||||
|
return "The ⌥ Option key sends Alt and the ⌘ Command key sends the Windows key — the Apple layout."
|
||||||
|
case .windows:
|
||||||
|
return "The key nearest the space bar sends Alt and the next one sends the Windows key, matching a PC keyboard. Client shortcuts (⌘⎋ and friends) still use the physical ⌘ key."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted layout (default `.mac` when unset).
|
||||||
|
public static var current: ModifierLayout {
|
||||||
|
guard let raw = UserDefaults.standard.string(forKey: DefaultsKey.modifierLayout) else {
|
||||||
|
return .mac
|
||||||
|
}
|
||||||
|
return ModifierLayout(rawValue: raw) ?? .mac
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// The Live Activity's attributes — the ONE type that must be identical in the app (which starts
|
||||||
|
// and updates the Activity) and the widget extension (which renders it). Hence it lives in the
|
||||||
|
// dependency-free shared module.
|
||||||
|
//
|
||||||
|
// Gated on `os(iOS)`, NOT `canImport(ActivityKit)`: ActivityKit *imports* on macOS but its types
|
||||||
|
// are `@available(macOS, unavailable)`, so canImport would wrongly admit this on the macOS build.
|
||||||
|
// Live Activities are iPhone/iPad only (iPadOS reports os(iOS)).
|
||||||
|
//
|
||||||
|
// Naming/shape is a runtime contract: an Activity started by one build is decoded by the extension
|
||||||
|
// of the same build, so keep `ContentState` Codable-stable across releases the way `StoredHost` is.
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import ActivityKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct PunktfunkSessionAttributes: ActivityAttributes {
|
||||||
|
// Static for the Activity's whole life (set at request time).
|
||||||
|
public let hostID: UUID
|
||||||
|
public let hostName: String
|
||||||
|
/// The title of the launched game, if the session started from the library; nil for a plain
|
||||||
|
/// host connect (nothing tracks the live foreground app mid-session).
|
||||||
|
public let launchTitle: String?
|
||||||
|
|
||||||
|
public init(hostID: UUID, hostName: String, launchTitle: String?) {
|
||||||
|
self.hostID = hostID
|
||||||
|
self.hostName = hostName
|
||||||
|
self.launchTitle = launchTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ContentState: Codable, Hashable {
|
||||||
|
public enum Stage: String, Codable, Hashable {
|
||||||
|
case streaming // foreground, live
|
||||||
|
case background // backgrounded keep-alive (countdown running)
|
||||||
|
case reconnecting // post-loss re-anchor hold
|
||||||
|
case ending // torn down — final state before dismissal
|
||||||
|
}
|
||||||
|
|
||||||
|
public var stage: Stage
|
||||||
|
/// Session start — drives `Text(timerInterval:)` for a free client-side ticking clock (no
|
||||||
|
/// per-second push needed).
|
||||||
|
public var startedAt: Date
|
||||||
|
/// e.g. "2560×1440 @120 · HEVC · HDR". Updated only when it actually changes.
|
||||||
|
public var modeLine: String
|
||||||
|
/// Coarse, updated sparsely (every ~30 s) — never the 1 Hz stats firehose.
|
||||||
|
public var latencyMs: Int?
|
||||||
|
public var mbps: Double?
|
||||||
|
/// While backgrounded: when the keep-alive auto-disconnect fires — drives the countdown.
|
||||||
|
public var backgroundDeadline: Date?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
stage: Stage, startedAt: Date, modeLine: String,
|
||||||
|
latencyMs: Int? = nil, mbps: Double? = nil, backgroundDeadline: Date? = nil
|
||||||
|
) {
|
||||||
|
self.stage = stage
|
||||||
|
self.startedAt = startedAt
|
||||||
|
self.modeLine = modeLine
|
||||||
|
self.latencyMs = latencyMs
|
||||||
|
self.mbps = mbps
|
||||||
|
self.backgroundDeadline = backgroundDeadline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kind string for the Live Activity — kept next to the attributes so app + extension agree.
|
||||||
|
public enum PunktfunkActivity {
|
||||||
|
public static let kind = "PunktfunkSession"
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Render-resolution scaling — the pure geometry behind `DefaultsKey.renderScale`. The client asks
|
||||||
|
// the host to render/encode at `chosen resolution × scale` and lets the presenter downscale the
|
||||||
|
// larger decoded frame to the display (a Catmull-Rom minification, > 1 = supersampling for
|
||||||
|
// sharpness) or upscale a smaller one (< 1 = a performance mode for a weak host GPU / thin link).
|
||||||
|
//
|
||||||
|
// This is where the multiplier is turned into a host-valid `Mode` dimension: multiply, preserve the
|
||||||
|
// aspect ratio, floor to even (the host's `validate_dimensions` rejects odd sizes), and clamp to the
|
||||||
|
// codec's per-axis ceiling so the connect can't ask for something the encoder will reject. Kept
|
||||||
|
// dependency-free + side-effect-free so it's unit-tested (`RenderScaleTests`) and reused by both the
|
||||||
|
// fixed-mode connect and the match-window follower.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum RenderScale {
|
||||||
|
/// The supported multiplier range. Below 1 renders under native (upscaled on present); above 1
|
||||||
|
/// supersamples. The UI clamps its slider to this and the connect clamps the raw stored value.
|
||||||
|
public static let range: ClosedRange<Double> = 0.5...4.0
|
||||||
|
|
||||||
|
/// The multipliers the picker offers. 1.0 (Native) is the default; the rest are the round stops
|
||||||
|
/// users reason about.
|
||||||
|
public static let presets: [Double] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]
|
||||||
|
|
||||||
|
/// The encoder/host per-axis ceiling for a codec preference string (`DefaultsKey.codec`). H.264
|
||||||
|
/// tops out at 4096 px/axis; HEVC / AV1 / PyroWave (and "auto", which negotiates one of those in
|
||||||
|
/// practice) at 8192. The host enforces the same walls in `codec.rs::validate_dimensions`.
|
||||||
|
public static func maxDimension(codec: String) -> Int {
|
||||||
|
codec == "h264" ? 4096 : 8192
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compact user-facing label for a multiplier: "Native (1×)", "1.5×", "2× · supersample".
|
||||||
|
/// Shared by every platform's picker so the wording stays identical.
|
||||||
|
public static func label(_ scale: Double) -> String {
|
||||||
|
if scale == 1.0 { return "Native (1×)" }
|
||||||
|
let magnitude = String(format: "%g×", scale)
|
||||||
|
return scale > 1 ? "\(magnitude) · supersample" : magnitude
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp a raw stored multiplier into `range`, treating a missing/zero value as 1.0 (Native).
|
||||||
|
public static func sanitize(_ raw: Double) -> Double {
|
||||||
|
guard raw > 0 else { return 1.0 }
|
||||||
|
return min(max(raw, range.lowerBound), range.upperBound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply `scale` to a base pixel size, preserving aspect, even-flooring each axis, and clamping
|
||||||
|
/// uniformly so neither axis exceeds `maxDimension` (the larger axis lands on the cap, the ratio
|
||||||
|
/// is kept). Also floors each axis at `minWidth`/`minHeight` (the host never accepts < 320×200).
|
||||||
|
/// The result is a directly host-valid `Mode` width/height.
|
||||||
|
public static func apply(
|
||||||
|
baseWidth: Int,
|
||||||
|
baseHeight: Int,
|
||||||
|
scale rawScale: Double,
|
||||||
|
maxDimension: Int,
|
||||||
|
minWidth: Int = 320,
|
||||||
|
minHeight: Int = 200
|
||||||
|
) -> (width: UInt32, height: UInt32) {
|
||||||
|
let scale = sanitize(rawScale)
|
||||||
|
var w = Double(max(baseWidth, 1)) * scale
|
||||||
|
var h = Double(max(baseHeight, 1)) * scale
|
||||||
|
// Uniform down-clamp if either axis blew past the ceiling — keep the aspect ratio intact.
|
||||||
|
let cap = Double(maxDimension)
|
||||||
|
let over = max(w / cap, h / cap)
|
||||||
|
if over > 1 {
|
||||||
|
w /= over
|
||||||
|
h /= over
|
||||||
|
}
|
||||||
|
let evenFloor: (Double, Int) -> UInt32 = { value, minimum in
|
||||||
|
let clamped = max(Int(value.rounded(.down)), minimum)
|
||||||
|
return UInt32(clamped / 2 * 2)
|
||||||
|
}
|
||||||
|
return (evenFloor(w, minWidth), evenFloor(h, minHeight))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// App Intents that must compile into BOTH the app and the widget extension live here in the shared
|
||||||
|
// module. Today that's `EndStreamIntent` — the Live Activity's "End stream" button (a
|
||||||
|
// LiveActivityIntent runs in the APP's process) which M4 also surfaces to Siri/Shortcuts.
|
||||||
|
//
|
||||||
|
// Gated on os(iOS): LiveActivityIntent is part of ActivityKit's world (iPhone/iPad only). The M4
|
||||||
|
// Connect/Wake intents that need the app's router live in the app target, not here.
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import AppIntents
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Ends the active streaming session. Backs the Live Activity's End button and the Shortcuts /
|
||||||
|
/// Siri "End the Punktfunk stream" phrase. `perform()` runs in the app's process (LiveActivityIntent)
|
||||||
|
/// — it posts `.punktfunkEndActiveSession`, which the app's SessionModel owner observes and turns
|
||||||
|
/// into `disconnect(deliberate: true)` (the user explicitly ended it → quit-close the host).
|
||||||
|
@available(iOS 17.0, *)
|
||||||
|
public struct EndStreamIntent: LiveActivityIntent {
|
||||||
|
public static let title: LocalizedStringResource = "End Punktfunk Stream"
|
||||||
|
public static let description = IntentDescription("Ends the active Punktfunk streaming session.")
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func perform() async throws -> some IntentResult {
|
||||||
|
await MainActor.run {
|
||||||
|
NotificationCenter.default.post(name: .punktfunkEndActiveSession, object: nil)
|
||||||
|
}
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// The saved-host model + its on-disk JSON wire format — the widget/extension depends on BOTH, so
|
||||||
|
// they live in the dependency-free shared module. The `ObservableObject` store that wraps them
|
||||||
|
// (`HostStore`, with add/remove/pin/reachability) stays in the app target; discovery-join helpers
|
||||||
|
// (`matches`, `advertises`) stay there too because they reference PunktfunkKit's `DiscoveredHost`.
|
||||||
|
//
|
||||||
|
// Wire-format stability: the JSON encoding of `StoredHost` is now a shared contract between the app
|
||||||
|
// (writer) and the widget (reader). The `PunktfunkSharedTests` codec round-trip pins it — do not
|
||||||
|
// rename the coding keys or make a stored `Optional` non-optional (older saved JSON must still
|
||||||
|
// decode; synthesized Decodable treats a missing Optional as nil).
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The management-API port default (distinct from the data-plane `port`). Lives here (not in
|
||||||
|
/// PunktfunkKit's LibraryClient, which re-exports it) so `StoredHost.effectiveMgmtPort` can resolve
|
||||||
|
/// it without the shared module taking a dependency on the kit.
|
||||||
|
public let punktfunkDefaultMgmtPort: UInt16 = 47990
|
||||||
|
|
||||||
|
public struct StoredHost: Identifiable, Codable, Hashable {
|
||||||
|
public var id = UUID()
|
||||||
|
public var name: String
|
||||||
|
public var address: String
|
||||||
|
public var port: UInt16 = 9777
|
||||||
|
/// SHA-256 of the host's certificate, set after the user explicitly trusted it.
|
||||||
|
public var pinnedSHA256: Data?
|
||||||
|
/// Last time a streaming session actually started (nil until the first one).
|
||||||
|
public 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.)
|
||||||
|
public 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.
|
||||||
|
public var macAddresses: [String]?
|
||||||
|
/// Share the clipboard with this host (macOS sessions; design/clipboard-and-file-transfer.md
|
||||||
|
/// §5.3). Opt-in per host: nil/false = off (nil also keeps older saved JSON decoding — same
|
||||||
|
/// forward-compat reason as `mgmtPort`). Honored only when the host advertises
|
||||||
|
/// `HOST_CAP_CLIPBOARD`.
|
||||||
|
public var clipboardSync: Bool?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: UUID = UUID(), name: String, address: String, port: UInt16 = 9777,
|
||||||
|
pinnedSHA256: Data? = nil, lastConnected: Date? = nil, mgmtPort: UInt16? = nil,
|
||||||
|
macAddresses: [String]? = nil, clipboardSync: Bool? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.address = address
|
||||||
|
self.port = port
|
||||||
|
self.pinnedSHA256 = pinnedSHA256
|
||||||
|
self.lastConnected = lastConnected
|
||||||
|
self.mgmtPort = mgmtPort
|
||||||
|
self.macAddresses = macAddresses
|
||||||
|
self.clipboardSync = clipboardSync
|
||||||
|
}
|
||||||
|
|
||||||
|
public var displayName: String { name.isEmpty ? address : name }
|
||||||
|
public var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
|
||||||
|
/// Wake-capable, in a form the wake helper accepts (empty when none learned yet).
|
||||||
|
public var wakeMacs: [String] { macAddresses ?? [] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
import PunktfunkShared
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
/// Pins the location-based modifier remap (`InputCapture.applyModifierLayout`) — the wire-boundary
|
||||||
|
/// swap that relocates the Alt/Super role between the physical ⌥/⌘ keys per side, without touching
|
||||||
|
/// Control/Shift or the physical detection upstream.
|
||||||
|
final class ModifierLayoutMappingTests: XCTestCase {
|
||||||
|
// The four physical modifier VKs the remap can touch, and everything else it must not.
|
||||||
|
private let lWin: UInt32 = 0x5B, rWin: UInt32 = 0x5C
|
||||||
|
private let lAlt: UInt32 = 0xA4, rAlt: UInt32 = 0xA5
|
||||||
|
|
||||||
|
func testMacLayoutIsIdentity() {
|
||||||
|
for vk: UInt32 in [lWin, rWin, lAlt, rAlt, 0xA0, 0xA2, 0x41, 0x1B] {
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(vk, .mac), vk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWindowsLayoutSwapsAltAndSuperPerSide() {
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(lWin, .windows), lAlt) // L ⌘ → L Alt
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(rWin, .windows), rAlt) // R ⌘ → R Alt
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(lAlt, .windows), lWin) // L ⌥ → L Win
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(rAlt, .windows), rWin) // R ⌥ → R Win
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWindowsLayoutKeepsSideNeverCrossesLeftRight() {
|
||||||
|
// A left key never becomes a right VK or vice-versa.
|
||||||
|
XCTAssertNotEqual(InputCapture.applyModifierLayout(lWin, .windows), rAlt)
|
||||||
|
XCTAssertNotEqual(InputCapture.applyModifierLayout(rWin, .windows), lAlt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testControlShiftAndRegularKeysNeverMove() {
|
||||||
|
for vk: UInt32 in [
|
||||||
|
0xA0, 0xA1, // L/R Shift
|
||||||
|
0xA2, 0xA3, // L/R Control
|
||||||
|
0x41, 0x5A, // A, Z
|
||||||
|
0x1B, 0x0D, // Esc, Return
|
||||||
|
] {
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(vk, .windows), vk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWindowsRemapIsItsOwnInverse() {
|
||||||
|
// Down-remapped then the same key up-remapped land on the same host VK — no stuck modifier.
|
||||||
|
for vk: UInt32 in [lWin, rWin, lAlt, rAlt] {
|
||||||
|
let once = InputCapture.applyModifierLayout(vk, .windows)
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(once, .windows), vk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,5 +79,44 @@ final class PresentPacingTests: XCTestCase {
|
|||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2)
|
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `explicit` is nil exactly when `resolve` would fall back to the platform default — the
|
||||||
|
/// distinction the codec-conditional pacing default rides on.
|
||||||
|
func testPresenterChoiceExplicitIsNilWithoutASelection() {
|
||||||
|
XCTAssertNil(PresenterChoice.explicit(setting: nil, env: nil, allowStage1: true))
|
||||||
|
XCTAssertNil(PresenterChoice.explicit(setting: "garbage", env: nil, allowStage1: true))
|
||||||
|
XCTAssertNil(PresenterChoice.explicit(setting: "stage1", env: nil, allowStage1: false))
|
||||||
|
XCTAssertEqual(
|
||||||
|
PresenterChoice.explicit(setting: "stage2", env: nil, allowStage1: true), .stage2)
|
||||||
|
XCTAssertEqual(
|
||||||
|
PresenterChoice.explicit(setting: nil, env: "stage3", allowStage1: true), .stage3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session pacing (the macOS PyroWave swapID-panic mitigation)
|
||||||
|
|
||||||
|
/// macOS PyroWave sessions under the DEFAULT stage-2 choice must get glass pacing (the
|
||||||
|
/// one-in-flight gate is the "mismatched swapID's" kernel-panic mitigation); an EXPLICIT
|
||||||
|
/// stage-2 pick must stay a faithful arrival-pacing A/B. Elsewhere the default is unchanged.
|
||||||
|
func testPacingDefaultsPyroWaveToGlassOnMacOS() {
|
||||||
|
#if os(macOS)
|
||||||
|
XCTAssertEqual(
|
||||||
|
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .pyrowave), .glass,
|
||||||
|
"defaulted macOS PyroWave must serialize presents (swapID-panic mitigation)")
|
||||||
|
XCTAssertEqual(
|
||||||
|
SessionPresenter.pacing(for: .stage2, explicit: .stage2, codec: .pyrowave), .arrival,
|
||||||
|
"an explicit stage-2 pick must keep arrival pacing (honest A/B)")
|
||||||
|
#else
|
||||||
|
XCTAssertEqual(
|
||||||
|
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .pyrowave), .arrival)
|
||||||
|
#endif
|
||||||
|
// Non-PyroWave defaults keep arrival pacing under stage-2 everywhere.
|
||||||
|
XCTAssertEqual(
|
||||||
|
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .hevc), .arrival)
|
||||||
|
// Stage-3 means glass regardless of codec or how it was chosen.
|
||||||
|
XCTAssertEqual(
|
||||||
|
SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass)
|
||||||
|
XCTAssertEqual(
|
||||||
|
SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ final class PyroWaveParserTests: XCTestCase {
|
|||||||
func testLayoutMatchesUpstreamBlockSpace() {
|
func testLayoutMatchesUpstreamBlockSpace() {
|
||||||
// init_block_meta's walk for 256x144 (aligned 256x160): level extents halve from
|
// init_block_meta's walk for 256x144 (aligned 256x160): level extents halve from
|
||||||
// 128x80; per (comp,level,band) count32 = ceil(ceil(w/8)/4) * ceil(ceil(h/8)/4).
|
// 128x80; per (comp,level,band) count32 = ceil(ceil(w/8)/4) * ceil(ceil(h/8)/4).
|
||||||
let layout = WaveletLayout(width: width, height: height)
|
let layout = WaveletLayout(width: width, height: height, chroma444: false)
|
||||||
XCTAssertEqual(layout.alignedWidth, 256)
|
XCTAssertEqual(layout.alignedWidth, 256)
|
||||||
XCTAssertEqual(layout.alignedHeight, 160)
|
XCTAssertEqual(layout.alignedHeight, 160)
|
||||||
XCTAssertEqual(layout.levelWidth(0), 128)
|
XCTAssertEqual(layout.levelWidth(0), 128)
|
||||||
@@ -85,7 +85,7 @@ final class PyroWaveParserTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testDenseParseFillsOffsetsAndCountsBlocks() throws {
|
func testDenseParseFillsOffsetsAndCountsBlocks() throws {
|
||||||
let layout = WaveletLayout(width: width, height: height)
|
let layout = WaveletLayout(width: width, height: height, chroma444: false)
|
||||||
var au = sof(totalBlocks: 4)
|
var au = sof(totalBlocks: 4)
|
||||||
au += packet(blockIndex: 0)
|
au += packet(blockIndex: 0)
|
||||||
au += packet(blockIndex: 3)
|
au += packet(blockIndex: 3)
|
||||||
@@ -273,6 +273,17 @@ final class PyroWaveGoldenTests: XCTestCase {
|
|||||||
try assertMatchesReference(decoded, prefix: "ref-chunked")
|
try assertMatchesReference(decoded, prefix: "ref-chunked")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 4:4:4: the chroma components run the full pyramid like luma (no level-0 skip, no
|
||||||
|
/// early half-res emit) — the layout + dispatch structure Phase 4 added
|
||||||
|
/// (design/pyrowave-444-hdr.md). The fixture comes from the 4:4:4 host encoder; the
|
||||||
|
/// reference is upstream's own 4:4:4 decode (full-res chroma planes).
|
||||||
|
func testDense444GoldenFrame() throws {
|
||||||
|
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||||||
|
let au = try fixture("au-dense444")
|
||||||
|
let decoded = try decode(au: au, chunkAligned: false, windowSize: 0)
|
||||||
|
try assertMatchesReference(decoded, prefix: "ref-dense444")
|
||||||
|
}
|
||||||
|
|
||||||
/// Phase-4 partial delivery: zero a mid-AU window (a lost shard) — the frame must still
|
/// Phase-4 partial delivery: zero a mid-AU window (a lost shard) — the frame must still
|
||||||
/// decode (blocks > half) and stay recognizably the same picture (holes reconstruct as
|
/// decode (blocks > half) and stay recognizably the same picture (holes reconstruct as
|
||||||
/// localized blur, not garbage).
|
/// localized blur, not garbage).
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
|
/// Pins the render-scale geometry (`RenderScale`) — the multiply → aspect-preserve → even-floor →
|
||||||
|
/// codec-clamp that turns the `renderScale` setting into a host-valid `Mode`, plus the label/clamp
|
||||||
|
/// helpers the UI and the connect share.
|
||||||
|
final class RenderScaleTests: XCTestCase {
|
||||||
|
func testSanitizeClampsToRangeAndDefaultsMissing() {
|
||||||
|
XCTAssertEqual(RenderScale.sanitize(0), 1.0) // absent / zero → Native
|
||||||
|
XCTAssertEqual(RenderScale.sanitize(-2), 1.0) // nonsense → Native
|
||||||
|
XCTAssertEqual(RenderScale.sanitize(0.1), 0.5) // below the floor
|
||||||
|
XCTAssertEqual(RenderScale.sanitize(9), 4.0) // above the ceiling
|
||||||
|
XCTAssertEqual(RenderScale.sanitize(1.5), 1.5) // in range, untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMaxDimensionIsCodecAware() {
|
||||||
|
XCTAssertEqual(RenderScale.maxDimension(codec: "h264"), 4096)
|
||||||
|
XCTAssertEqual(RenderScale.maxDimension(codec: "hevc"), 8192)
|
||||||
|
XCTAssertEqual(RenderScale.maxDimension(codec: "av1"), 8192)
|
||||||
|
XCTAssertEqual(RenderScale.maxDimension(codec: "auto"), 8192)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNativeScaleIsIdentity() {
|
||||||
|
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 1.0, maxDimension: 8192)
|
||||||
|
XCTAssertEqual(m.width, 1920)
|
||||||
|
XCTAssertEqual(m.height, 1080)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupersampleDoubles() {
|
||||||
|
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 2.0, maxDimension: 8192)
|
||||||
|
XCTAssertEqual(m.width, 3840)
|
||||||
|
XCTAssertEqual(m.height, 2160)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnderRenderHalves() {
|
||||||
|
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 0.5, maxDimension: 8192)
|
||||||
|
XCTAssertEqual(m.width, 960)
|
||||||
|
XCTAssertEqual(m.height, 540)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResultsAreAlwaysEven() {
|
||||||
|
// 1280×720 × 1.5 = 1920×1080 (already even); 1366×768 × 1.5 = 2049×1152 → 2048×1152.
|
||||||
|
let m = RenderScale.apply(baseWidth: 1366, baseHeight: 768, scale: 1.5, maxDimension: 8192)
|
||||||
|
XCTAssertEqual(m.width % 2, 0)
|
||||||
|
XCTAssertEqual(m.height % 2, 0)
|
||||||
|
XCTAssertEqual(m.width, 2048)
|
||||||
|
XCTAssertEqual(m.height, 1152)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOverCeilingClampsUniformlyPreservingAspect() {
|
||||||
|
// 4K × 4 would be 15360×8640; both axes exceed 8192, so the LARGER (width) lands on the cap
|
||||||
|
// and the ratio is kept (16:9 → 8192×4608, even).
|
||||||
|
let m = RenderScale.apply(baseWidth: 3840, baseHeight: 2160, scale: 4.0, maxDimension: 8192)
|
||||||
|
XCTAssertLessThanOrEqual(m.width, 8192)
|
||||||
|
XCTAssertLessThanOrEqual(m.height, 8192)
|
||||||
|
XCTAssertEqual(m.width, 8192)
|
||||||
|
XCTAssertEqual(m.height, 4608)
|
||||||
|
// 16:9 preserved to within the even-floor rounding.
|
||||||
|
XCTAssertEqual(Double(m.width) / Double(m.height), 16.0 / 9.0, accuracy: 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testH264CeilingIsTighter() {
|
||||||
|
// 1080p × 4 = 7680×4320; under H.264's 4096 wall the width clamps to 4096, aspect kept.
|
||||||
|
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 4.0, maxDimension: 4096)
|
||||||
|
XCTAssertEqual(m.width, 4096)
|
||||||
|
XCTAssertEqual(m.height, 2304)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMinimumFloorHonoured() {
|
||||||
|
// A tiny base under a small scale can't fall below 320×200.
|
||||||
|
let m = RenderScale.apply(baseWidth: 400, baseHeight: 300, scale: 0.5, maxDimension: 8192)
|
||||||
|
XCTAssertGreaterThanOrEqual(m.width, 320)
|
||||||
|
XCTAssertGreaterThanOrEqual(m.height, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLabels() {
|
||||||
|
XCTAssertEqual(RenderScale.label(1.0), "Native (1×)")
|
||||||
|
XCTAssertEqual(RenderScale.label(2.0), "2× · supersample")
|
||||||
|
XCTAssertEqual(RenderScale.label(0.5), "0.5×")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,13 +52,6 @@ final class RumbleTuningTests: XCTestCase {
|
|||||||
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5)
|
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPolicies() {
|
|
||||||
// The session policy ties motor life to wire liveness; the manual (test-panel) policy
|
|
||||||
// holds a level indefinitely.
|
|
||||||
XCTAssertNotNil(RumbleRenderer.Policy.session.staleAfter)
|
|
||||||
XCTAssertNil(RumbleRenderer.Policy.manual.staleAfter)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call
|
/// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call
|
||||||
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
||||||
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
||||||
@@ -75,45 +68,22 @@ final class RumbleTuningTests: XCTestCase {
|
|||||||
renderer.stop()
|
renderer.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLeaseSecondsInterpretsWireTTL() {
|
/// A zero command must silence promptly — the engine (punktfunk-core) emits explicit zeros at
|
||||||
// The legacy no-lease sentinel → nil (fall back to the staleness watchdog).
|
/// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only
|
||||||
XCTAssertNil(RumbleTuning.leaseSeconds(ttlMs: RumbleTuning.noTTL))
|
/// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge.
|
||||||
XCTAssertEqual(RumbleTuning.noTTL, UInt32.max)
|
func testZeroCommandSilencesAndTeardownDoesNotDeadlock() {
|
||||||
// A real lease → its duration in seconds (non-nil for any ttl != noTTL).
|
|
||||||
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 400) ?? .nan, 0.4, accuracy: 1e-9)
|
|
||||||
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 0) ?? .nan, 0, accuracy: 1e-9)
|
|
||||||
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 150) ?? .nan, 0.15, accuracy: 1e-9)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testEnvelopeLeaseBoundsMotorLifeTighterThanTheLegacyWatchdog() {
|
|
||||||
// The whole point of v2: a host-supplied lease silences the motor faster than the
|
|
||||||
// legacy staleness watchdog ever could (which needs sessionStaleSeconds of silence). The
|
|
||||||
// default 400 ms TTL is well under that, on every platform.
|
|
||||||
let defaultTTL = RumbleTuning.leaseSeconds(ttlMs: 400)
|
|
||||||
XCTAssertNotNil(defaultTTL)
|
|
||||||
XCTAssertLessThan(defaultTTL!, RumbleTuning.sessionStaleSeconds)
|
|
||||||
// The ticker must be able to observe an expired lease promptly (well within one TTL).
|
|
||||||
XCTAssertLessThan(RumbleTuning.tickSeconds, defaultTTL!)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A v2 envelope with a short TTL, left unrenewed, must self-silence — the renderer's core
|
|
||||||
/// promise. Drive the real queue/ticker (no physical pad) and confirm it doesn't wedge.
|
|
||||||
func testEnvelopeExpiresWhenUnrenewed() {
|
|
||||||
let renderer = RumbleRenderer(policy: .session)
|
let renderer = RumbleRenderer(policy: .session)
|
||||||
renderer.retarget(nil)
|
renderer.retarget(nil)
|
||||||
// A 100 ms lease, then no renewal — the ticker (50 ms) must silence it on its own.
|
renderer.apply(low: 0x8000, high: 0x8000)
|
||||||
renderer.apply(low: 0x8000, high: 0x8000, ttlMs: 100)
|
Thread.sleep(forTimeInterval: 0.1)
|
||||||
Thread.sleep(forTimeInterval: 0.3)
|
renderer.apply(low: 0, high: 0)
|
||||||
// No assertion on private state; this exercises the expiry path + serial-queue teardown
|
Thread.sleep(forTimeInterval: 0.1)
|
||||||
|
// No assertion on private state; this exercises the stop path + serial-queue teardown
|
||||||
// without deadlock (the ticker fires on the same queue stop() sync-hops onto).
|
// without deadlock (the ticker fires on the same queue stop() sync-hops onto).
|
||||||
renderer.stop()
|
renderer.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTuningRelationsTheDesignDependsOn() {
|
func testTuningRelationsTheDesignDependsOn() {
|
||||||
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
|
|
||||||
// but trip well before a stuck rumble reads as "still going".
|
|
||||||
XCTAssertGreaterThan(RumbleTuning.sessionStaleSeconds, 2 * 0.5)
|
|
||||||
XCTAssertLessThanOrEqual(RumbleTuning.sessionStaleSeconds, 2.5)
|
|
||||||
// Re-arm headroom must clear several ticker periods, or a steady rumble could miss the
|
// Re-arm headroom must clear several ticker periods, or a steady rumble could miss the
|
||||||
// segment boundary and gap.
|
// segment boundary and gap.
|
||||||
XCTAssertGreaterThanOrEqual(
|
XCTAssertGreaterThanOrEqual(
|
||||||
@@ -123,9 +93,8 @@ final class RumbleTuningTests: XCTestCase {
|
|||||||
// The rebake throttle must be far under the host refresh period, or refreshed level
|
// The rebake throttle must be far under the host refresh period, or refreshed level
|
||||||
// changes would queue behind it; and under a frame at 30 fps so ramps stay smooth.
|
// changes would queue behind it; and under a frame at 30 fps so ramps stay smooth.
|
||||||
XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30)
|
XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30)
|
||||||
// The ticker (which lands throttled levels) must outpace the HID keepalive and the
|
// The ticker (which lands throttled levels) must outpace the HID keepalive, or its
|
||||||
// watchdog, or those deadlines could be overshot by a full period.
|
// deadline could be overshot by a full period.
|
||||||
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
|
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
|
||||||
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.sessionStaleSeconds)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// PunktfunkShared is now a wire contract between the app (writer) and the widget extension +
|
||||||
|
// deep-link senders (readers). These pin the two formats that cross that boundary:
|
||||||
|
// • the `StoredHost` JSON codec — the widget decodes the exact bytes the app persisted, and
|
||||||
|
// older saved JSON (missing `mgmtPort` / `macAddresses`) must still decode;
|
||||||
|
// • the `punktfunk://` deep-link grammar — the widget builds URLs the app parses.
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
|
final class SharedFoundationTests: XCTestCase {
|
||||||
|
// MARK: - StoredHost JSON codec
|
||||||
|
|
||||||
|
func testStoredHostRoundTrips() throws {
|
||||||
|
let host = StoredHost(
|
||||||
|
id: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!,
|
||||||
|
name: "Tower", address: "192.168.1.173", port: 9777,
|
||||||
|
pinnedSHA256: Data([0xDE, 0xAD, 0xBE, 0xEF]),
|
||||||
|
lastConnected: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"], clipboardSync: true)
|
||||||
|
|
||||||
|
let data = try JSONEncoder().encode(host)
|
||||||
|
let decoded = try JSONDecoder().decode(StoredHost.self, from: data)
|
||||||
|
XCTAssertEqual(decoded, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Older saved hosts predate `mgmtPort`/`macAddresses` — a missing key must decode to nil, not
|
||||||
|
/// throw (synthesized Decodable treats a missing Optional as nil). This is the forward-compat
|
||||||
|
/// guarantee the widget depends on when reading a store written by any prior build.
|
||||||
|
func testStoredHostDecodesLegacyJSONWithoutOptionalKeys() throws {
|
||||||
|
let json = """
|
||||||
|
{"id":"11111111-2222-3333-4444-555555555555","name":"Old","address":"10.0.0.5","port":9777}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let decoded = try JSONDecoder().decode(StoredHost.self, from: json)
|
||||||
|
XCTAssertEqual(decoded.name, "Old")
|
||||||
|
XCTAssertNil(decoded.mgmtPort)
|
||||||
|
XCTAssertNil(decoded.macAddresses)
|
||||||
|
XCTAssertNil(decoded.pinnedSHA256)
|
||||||
|
XCTAssertNil(decoded.lastConnected)
|
||||||
|
XCTAssertNil(decoded.clipboardSync)
|
||||||
|
// Resolvers fall back cleanly.
|
||||||
|
XCTAssertEqual(decoded.effectiveMgmtPort, punktfunkDefaultMgmtPort)
|
||||||
|
XCTAssertEqual(decoded.wakeMacs, [])
|
||||||
|
XCTAssertEqual(decoded.displayName, "Old")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStoredHostDisplayNameFallsBackToAddress() {
|
||||||
|
let host = StoredHost(name: "", address: "10.0.0.9")
|
||||||
|
XCTAssertEqual(host.displayName, "10.0.0.9")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DeepLink grammar
|
||||||
|
|
||||||
|
func testDeepLinkConnectRoundTrips() {
|
||||||
|
let id = UUID()
|
||||||
|
let link = DeepLink.connect(host: id, launchID: nil)
|
||||||
|
let parsed = DeepLink(link.url)
|
||||||
|
XCTAssertEqual(parsed, link)
|
||||||
|
XCTAssertEqual(parsed, .connect(host: id, launchID: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeepLinkConnectWithLaunchRoundTrips() {
|
||||||
|
let id = UUID()
|
||||||
|
// A store-qualified GameEntry.id with a reserved char must survive percent-encoding.
|
||||||
|
let launch = "steam:570"
|
||||||
|
let link = DeepLink.connect(host: id, launchID: launch)
|
||||||
|
XCTAssertEqual(DeepLink(link.url), .connect(host: id, launchID: launch))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeepLinkParsesCanonicalString() throws {
|
||||||
|
let id = UUID()
|
||||||
|
let url = try XCTUnwrap(URL(string: "punktfunk://connect/\(id.uuidString)"))
|
||||||
|
XCTAssertEqual(DeepLink(url), .connect(host: id, launchID: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeepLinkRejectsForeignSchemeAndBadHost() throws {
|
||||||
|
let id = UUID()
|
||||||
|
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "https://connect/\(id.uuidString)"))))
|
||||||
|
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "punktfunk://connect/not-a-uuid"))))
|
||||||
|
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "punktfunk://bogus/\(id.uuidString)"))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeepLinkSchemeIsCaseInsensitive() throws {
|
||||||
|
let id = UUID()
|
||||||
|
let url = try XCTUnwrap(URL(string: "PUNKTFUNK://connect/\(id.uuidString)"))
|
||||||
|
XCTAssertEqual(DeepLink(url), .connect(host: id, launchID: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -896,8 +896,8 @@ class Plugin:
|
|||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
# The client's own defaults (native display, host-default bitrate, auto pad).
|
# The client's own defaults (native display, host-default bitrate, auto pad).
|
||||||
return {
|
return {
|
||||||
"width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0,
|
"width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
|
||||||
"codec": "auto", "gamepad": "auto", "compositor": "auto",
|
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto",
|
||||||
"inhibit_shortcuts": True, "mic_enabled": False,
|
"inhibit_shortcuts": True, "mic_enabled": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user