Compare commits
232 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b22d0da75b | |||
| d398e2296f | |||
| f9668b16a1 | |||
| 04e4394ee0 | |||
| 9296e1bed7 | |||
| c2bba13405 | |||
| 940a260506 | |||
| 7084ebf78d | |||
| 40c2f68231 | |||
| 8fe90a8a4b | |||
| cac23b7a05 | |||
| 2508b72062 | |||
| aa45757a72 | |||
| cab6350723 | |||
| a042dc2850 | |||
| 67d2964db9 | |||
| 344618e1f0 | |||
| 51f7ff1b3f | |||
| 7a1809547b | |||
| 72583b272e | |||
| 1ef0229bd9 | |||
| fecbec2daf | |||
| ba1caf0281 | |||
| 677a31acd7 | |||
| beb7a4dae9 | |||
| a65c47874d | |||
| b781b38b77 | |||
| 0b8e8312a5 | |||
| f0c511c8fa | |||
| f250db96f4 | |||
| 62af85eaea | |||
| 6fc32ee355 | |||
| ffa7ebb3db | |||
| 8a40e46706 | |||
| 17302ee811 | |||
| 70fd8ac0d3 | |||
| c66acebc41 | |||
| 8a71ed3fd1 | |||
| ba977095f7 | |||
| ab2bcc8e68 | |||
| d2daeacc60 | |||
| 1d587a259e | |||
| 1436c0b9b4 | |||
| fb8deb31a5 | |||
| 40030e90c8 | |||
| 2149673f89 | |||
| c4d6e6a877 | |||
| 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
|
||||||
@@ -42,11 +42,12 @@ jobs:
|
|||||||
- name: Install build + runtime-dev deps
|
- name: Install build + runtime-dev deps
|
||||||
run: |
|
run: |
|
||||||
pacman -Syu --noconfirm --needed \
|
pacman -Syu --noconfirm --needed \
|
||||||
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
|
git nodejs rust clang cmake ninja 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:
|
||||||
|
|||||||
@@ -104,8 +104,9 @@ jobs:
|
|||||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
||||||
|
|
||||||
# Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
|
# All three client binaries — the shell spawns punktfunk-session.exe (a package
|
||||||
# for every stream. --no-default-features on ARM64 is a no-op for the shell.
|
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's
|
||||||
|
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
|
||||||
- name: Build (release)
|
- name: Build (release)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||||
|
|||||||
@@ -38,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.15.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2248,6 +2262,14 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libvpl-sys"
|
||||||
|
version = "0.15.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.15.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.15.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.15.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.15.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.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2799,18 +2869,106 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-encode"
|
||||||
|
version = "0.15.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.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-frame"
|
||||||
|
version = "0.15.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.15.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.15.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-inject"
|
||||||
|
version = "0.15.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.15.0"
|
||||||
|
dependencies = [
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.12.0"
|
version = "0.15.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.15.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.15.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.15.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.15.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.15.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.15.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.15.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.15.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.15.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.15.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.15.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.15.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.15.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"
|
||||||
|
|||||||
+1483
-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 */; };
|
||||||
|
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
E29556A7300948BA009F939C /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = AA000000000000000000000D /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = E2955696300948B9009F939C;
|
||||||
|
remoteInfo = PunktfunkWidgetsExtension;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
E29556AA300948BA009F939C /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
AA0000000000000000000001 /* Punktfunk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Punktfunk.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AA0000000000000000000001 /* Punktfunk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Punktfunk.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
BB0000000000000000000001 /* Punktfunk-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
BB0000000000000000000001 /* Punktfunk-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
CC0000000000000000000001 /* Punktfunk-tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
CC0000000000000000000001 /* Punktfunk-tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PunktfunkWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
E2955699300948B9009F939C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||||
|
E295569B300948B9009F939C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||||
|
E295577B30094CE5009F939C /* PunktfunkWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PunktfunkWidgetsExtension.entitlements; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
E29556AD300948BA009F939C /* Exceptions for "PunktfunkWidgets" folder in "PunktfunkWidgetsExtension" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = E2955696300948B9009F939C /* PunktfunkWidgetsExtension */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
AA0000000000000000000002 /* App */ = {
|
AA0000000000000000000002 /* App */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
@@ -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;
|
||||||
@@ -379,6 +504,7 @@
|
|||||||
MARKETING_VERSION = 0.9.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
REGISTER_APP_GROUPS = YES;
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -415,6 +541,7 @@
|
|||||||
MARKETING_VERSION = 0.9.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
REGISTER_APP_GROUPS = YES;
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -564,6 +691,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 = 0.9.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
E29556AC300948BA009F939C /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = PunktfunkWidgetsExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 0.9.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -603,6 +821,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 */
|
||||||
@@ -641,6 +868,10 @@
|
|||||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||||
productName = SwiftUINavigationTransitions;
|
productName = SwiftUINavigationTransitions;
|
||||||
};
|
};
|
||||||
|
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = PunktfunkShared;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = AA000000000000000000000D /* Project object */;
|
rootObject = AA000000000000000000000D /* Project object */;
|
||||||
|
|||||||
@@ -55,6 +55,11 @@
|
|||||||
value = "1"
|
value = "1"
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "MTL_HUD_ENABLED"
|
||||||
|
value = "1"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
shouldAutocreateTestPlan = "YES">
|
shouldAutocreateTestPlan = "YES">
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "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,230 @@
|
|||||||
|
// Home-Screen / Lock-Screen quick-launch widget (kind "PunktfunkHosts"). Reads the saved-host
|
||||||
|
// store from the shared App-Group suite, sorts most-recent-first, and deep-links each host into a
|
||||||
|
// session via `punktfunk://connect/<uuid>` — the app's onOpenURL routes it through the normal
|
||||||
|
// connect path (trust policy / WoL / approval all apply).
|
||||||
|
//
|
||||||
|
// No reachability probing in v1 (a UDP check has no place in a timeline build; WoL handles offline
|
||||||
|
// hosts on tap). Timeline is a single `.never` entry — the app pushes reloads on store changes
|
||||||
|
// (HostStore → WidgetCenter.reloadTimelines).
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
|
// MARK: - Timeline
|
||||||
|
|
||||||
|
struct HostsEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let hosts: [StoredHost]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HostsProvider: TimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> HostsEntry {
|
||||||
|
HostsEntry(date: .now, hosts: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSnapshot(in context: Context, completion: @escaping (HostsEntry) -> Void) {
|
||||||
|
completion(HostsEntry(date: .now, hosts: Self.loadHosts()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeline(in context: Context, completion: @escaping (Timeline<HostsEntry>) -> Void) {
|
||||||
|
// Single entry, never auto-refresh: the app reloads this timeline whenever the store
|
||||||
|
// changes (a new host, a fresh connect reordering by recency).
|
||||||
|
let entry = HostsEntry(date: .now, hosts: Self.loadHosts())
|
||||||
|
completion(Timeline(entries: [entry], policy: .never))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the shared-suite host JSON (same wire format the app writes), most-recent first.
|
||||||
|
static func loadHosts() -> [StoredHost] {
|
||||||
|
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
||||||
|
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
return hosts.sorted {
|
||||||
|
($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget
|
||||||
|
|
||||||
|
struct HostsWidget: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: "PunktfunkHosts", provider: HostsProvider()) { entry in
|
||||||
|
HostsWidgetView(entry: entry)
|
||||||
|
.containerBackground(.fill.tertiary, for: .widget)
|
||||||
|
}
|
||||||
|
.configurationDisplayName("Punktfunk Hosts")
|
||||||
|
.description("Quick-launch your recent streaming hosts.")
|
||||||
|
.supportedFamilies([
|
||||||
|
.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Views
|
||||||
|
|
||||||
|
struct HostsWidgetView: View {
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
let entry: HostsEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .systemMedium:
|
||||||
|
MediumHostsView(hosts: entry.hosts)
|
||||||
|
case .accessoryCircular:
|
||||||
|
CircularHostView(host: entry.hosts.first)
|
||||||
|
case .accessoryRectangular:
|
||||||
|
RectangularHostView(host: entry.hosts.first)
|
||||||
|
default: // systemSmall + fallback
|
||||||
|
SmallHostView(host: entry.hosts.first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deep link that connects to a stored host.
|
||||||
|
private func connectURL(_ host: StoredHost) -> URL {
|
||||||
|
DeepLink.connect(host: host.id, launchID: nil).url
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SmallHostView: View {
|
||||||
|
let host: StoredHost?
|
||||||
|
var body: some View {
|
||||||
|
if let host {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Image(systemName: "play.tv.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.brand)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(host.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(2)
|
||||||
|
if let last = host.lastConnected {
|
||||||
|
Text(last, format: .relative(presentation: .named))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.widgetURL(connectURL(host))
|
||||||
|
} else {
|
||||||
|
EmptyHostView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MediumHostsView: View {
|
||||||
|
let hosts: [StoredHost]
|
||||||
|
var body: some View {
|
||||||
|
if hosts.isEmpty {
|
||||||
|
EmptyHostView()
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Punktfunk")
|
||||||
|
.font(.caption).bold()
|
||||||
|
.foregroundStyle(Color.brand)
|
||||||
|
ForEach(hosts.prefix(4)) { host in
|
||||||
|
Link(destination: connectURL(host)) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "play.tv.fill")
|
||||||
|
.foregroundStyle(Color.brand)
|
||||||
|
Text(host.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
if let last = host.lastConnected {
|
||||||
|
Text(last, format: .relative(presentation: .named))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CircularHostView: View {
|
||||||
|
let host: StoredHost?
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
Image(systemName: "play.tv.fill")
|
||||||
|
}
|
||||||
|
.widgetURL(host.map(connectURL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RectangularHostView: View {
|
||||||
|
let host: StoredHost?
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "play.tv.fill")
|
||||||
|
Text(host?.displayName ?? "Punktfunk")
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.widgetURL(host.map(connectURL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EmptyHostView: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: "play.tv")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Open Punktfunk to add a host.")
|
||||||
|
.font(.caption)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews (Xcode canvas)
|
||||||
|
//
|
||||||
|
// Select the PunktfunkWidgetsExtension scheme and open the canvas (⌥⌘↩). The widget
|
||||||
|
// `#Preview(as:widget:timeline:)` form feeds sample entries directly — the App-Group store is
|
||||||
|
// never read, so the canvas works without a paired device or saved hosts. The small preview's
|
||||||
|
// second entry shows the empty state one timeline click away.
|
||||||
|
|
||||||
|
private let previewHosts: [StoredHost] = [
|
||||||
|
StoredHost(
|
||||||
|
name: "Studio", address: "192.168.1.20",
|
||||||
|
lastConnected: .now.addingTimeInterval(-40 * 60)),
|
||||||
|
StoredHost(
|
||||||
|
name: "Living Room", address: "192.168.1.30",
|
||||||
|
lastConnected: .now.addingTimeInterval(-26 * 3600)),
|
||||||
|
StoredHost(
|
||||||
|
name: "Workstation", address: "10.0.0.5",
|
||||||
|
lastConnected: .now.addingTimeInterval(-6 * 86400)),
|
||||||
|
]
|
||||||
|
|
||||||
|
#Preview("Small", as: .systemSmall) {
|
||||||
|
HostsWidget()
|
||||||
|
} timeline: {
|
||||||
|
HostsEntry(date: .now, hosts: previewHosts)
|
||||||
|
HostsEntry(date: .now, hosts: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Medium", as: .systemMedium) {
|
||||||
|
HostsWidget()
|
||||||
|
} timeline: {
|
||||||
|
HostsEntry(date: .now, hosts: previewHosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Lock Screen circular", as: .accessoryCircular) {
|
||||||
|
HostsWidget()
|
||||||
|
} timeline: {
|
||||||
|
HostsEntry(date: .now, hosts: previewHosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Lock Screen rectangular", as: .accessoryRectangular) {
|
||||||
|
HostsWidget()
|
||||||
|
} timeline: {
|
||||||
|
HostsEntry(date: .now, hosts: previewHosts)
|
||||||
|
}
|
||||||
@@ -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,356 @@
|
|||||||
|
// The Live Activity UI (Lock Screen banner + Dynamic Island) for a running session. The app owns
|
||||||
|
// the Activity's lifecycle (SessionActivityController); this is only its presentation, rendered in
|
||||||
|
// the widget-extension process from the shared `PunktfunkSessionAttributes`.
|
||||||
|
//
|
||||||
|
// The End button runs `EndStreamIntent` (a LiveActivityIntent) IN THE APP's process, which posts
|
||||||
|
// .punktfunkEndActiveSession → the app disconnects. Elapsed time ticks client-side via
|
||||||
|
// Text(timerInterval:) — no per-second push.
|
||||||
|
|
||||||
|
import ActivityKit
|
||||||
|
import AppIntents
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
|
struct PunktfunkSessionLiveActivity: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
ActivityConfiguration(for: PunktfunkSessionAttributes.self) { context in
|
||||||
|
LockScreenView(context: context)
|
||||||
|
.activitySystemActionForegroundColor(.white)
|
||||||
|
} dynamicIsland: { context in
|
||||||
|
// Island layout (2026-07 rebuild): the EXPANDED island leads with identity (brand
|
||||||
|
// glyph + host), keeps the elapsed clock at the trailing edge, and gives the bottom
|
||||||
|
// region one purposeful row — session status + the live numbers on the left, the
|
||||||
|
// End action on the right. COMPACT shows the one number worth a glance: live
|
||||||
|
// latency while streaming (the sparse ~30 s pushes), the disconnect countdown while
|
||||||
|
// backgrounded, a state glyph otherwise. Brand purple is the identity accent
|
||||||
|
// everywhere `.tint` used to leak system blue.
|
||||||
|
DynamicIsland {
|
||||||
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "play.tv.fill")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.brand)
|
||||||
|
Text(context.attributes.hostName)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(.leading, 4)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
|
ElapsedClock(startedAt: context.state.startedAt)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: 64, alignment: .trailing)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.center) {
|
||||||
|
if let title = context.attributes.launchTitle {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
|
// The expanded island's height is there to be used: one info row (status
|
||||||
|
// leading, live numbers trailing — the mode string stays on the Lock
|
||||||
|
// Screen), then the platform-conventional LARGE full-width action button.
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
StatusLine(state: context.state)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
StatsLine(state: context.state, showMode: false)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0) // any slack height goes here — button hugs the bottom
|
||||||
|
EndButton(fullWidth: true)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
} compactLeading: {
|
||||||
|
Image(systemName: "play.tv.fill")
|
||||||
|
.foregroundStyle(Color.brand)
|
||||||
|
} compactTrailing: {
|
||||||
|
CompactReadout(state: context.state)
|
||||||
|
} minimal: {
|
||||||
|
Image(systemName: "play.tv.fill")
|
||||||
|
.foregroundStyle(Color.brand)
|
||||||
|
}
|
||||||
|
.keylineTint(Color.brand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock Screen banner
|
||||||
|
|
||||||
|
private struct LockScreenView: View {
|
||||||
|
let context: ActivityViewContext<PunktfunkSessionAttributes>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Image(systemName: "play.tv.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.brand)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
ElapsedClock(startedAt: context.state.startedAt)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let title = context.attributes.launchTitle {
|
||||||
|
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
}
|
||||||
|
StatusLine(state: context.state)
|
||||||
|
StatsLine(state: context.state)
|
||||||
|
}
|
||||||
|
if context.state.stage == .background {
|
||||||
|
EndButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared pieces
|
||||||
|
|
||||||
|
/// The ticking elapsed-session clock — client-side via `timerInterval`, no per-second push.
|
||||||
|
private struct ElapsedClock: View {
|
||||||
|
let startedAt: Date
|
||||||
|
var body: some View {
|
||||||
|
Text(timerInterval: startedAt...Date.distantFuture, countsDown: false)
|
||||||
|
.monospacedDigit()
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session's state as a colored dot + label; while backgrounded with a deadline, the label
|
||||||
|
/// IS the countdown. One shared truth for the island bottom and the Lock Screen banner.
|
||||||
|
private struct StatusLine: View {
|
||||||
|
let state: PunktfunkSessionAttributes.ContentState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
label
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var color: Color {
|
||||||
|
switch state.stage {
|
||||||
|
case .streaming: return .green
|
||||||
|
case .background: return .orange
|
||||||
|
case .reconnecting: return .yellow
|
||||||
|
case .ending: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var label: some View {
|
||||||
|
switch state.stage {
|
||||||
|
case .streaming:
|
||||||
|
Text("Streaming")
|
||||||
|
case .background:
|
||||||
|
if let deadline = state.backgroundDeadline {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Text("Background · ends in")
|
||||||
|
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Running in background")
|
||||||
|
}
|
||||||
|
case .reconnecting:
|
||||||
|
Text("Reconnecting…")
|
||||||
|
case .ending:
|
||||||
|
Text("Session ended")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live numbers, one quiet line: latency and bitrate (the sparse ~30 s pushes) ahead of the
|
||||||
|
/// mode (`showMode` false on the island, where the row shares space with the status line).
|
||||||
|
/// Anything unreported simply doesn't appear.
|
||||||
|
private struct StatsLine: View {
|
||||||
|
let state: PunktfunkSessionAttributes.ContentState
|
||||||
|
var showMode = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if let ms = state.latencyMs {
|
||||||
|
stat("bolt.fill", "\(ms) ms")
|
||||||
|
}
|
||||||
|
if let mbps = state.mbps {
|
||||||
|
stat("arrow.down", String(format: "%.0f Mb/s", mbps))
|
||||||
|
}
|
||||||
|
if showMode {
|
||||||
|
Text(state.modeLine)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stat(_ icon: String, _ text: String) -> some View {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 8, weight: .semibold))
|
||||||
|
Text(text)
|
||||||
|
.font(.caption2)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The compact trailing readout — the island's one glanceable number. Streaming: the live
|
||||||
|
/// latency once the app has reported one, the elapsed clock until then. Backgrounded: the
|
||||||
|
/// auto-disconnect countdown. Off-nominal stages show a state glyph instead of a number.
|
||||||
|
private struct CompactReadout: View {
|
||||||
|
let state: PunktfunkSessionAttributes.ContentState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch state.stage {
|
||||||
|
case .streaming:
|
||||||
|
if let ms = state.latencyMs {
|
||||||
|
Text("\(ms) ms")
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
ElapsedClock(startedAt: state.startedAt)
|
||||||
|
.font(.caption2)
|
||||||
|
.frame(maxWidth: 48)
|
||||||
|
}
|
||||||
|
case .background:
|
||||||
|
if let deadline = state.backgroundDeadline {
|
||||||
|
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||||
|
.font(.caption2)
|
||||||
|
.monospacedDigit()
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(maxWidth: 48)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "moon.fill").foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
case .reconnecting:
|
||||||
|
Image(systemName: "wifi.exclamationmark").foregroundStyle(.yellow)
|
||||||
|
case .ending:
|
||||||
|
Image(systemName: "stop.fill").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
||||||
|
/// `fullWidth` is the expanded island's large bottom action (the platform convention there);
|
||||||
|
/// the compact form remains the Lock Screen banner's trailing button while backgrounded.
|
||||||
|
private struct EndButton: View {
|
||||||
|
var fullWidth = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if fullWidth {
|
||||||
|
Button(intent: EndStreamIntent()) {
|
||||||
|
Label("End Session", systemImage: "stop.fill")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
} else {
|
||||||
|
Button(intent: EndStreamIntent()) {
|
||||||
|
Label("End", systemImage: "stop.fill")
|
||||||
|
.font(.caption).bold()
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews (Xcode canvas)
|
||||||
|
//
|
||||||
|
// Select the PunktfunkWidgetsExtension scheme and open the canvas (⌥⌘↩) — the activity
|
||||||
|
// `#Preview(as:using:)` form renders every surface WITHOUT running the app or starting a real
|
||||||
|
// Activity: `.content` is the Lock Screen banner, `.dynamicIsland(.expanded/.compact/.minimal)`
|
||||||
|
// the island states (canvas device must be a Dynamic Island phone for those). Each listed
|
||||||
|
// content state becomes a frame in the canvas timeline strip, so all four session stages are one
|
||||||
|
// click apart. Sample state lives here (fileprivate), never in PunktfunkShared.
|
||||||
|
|
||||||
|
extension PunktfunkSessionAttributes {
|
||||||
|
fileprivate static var preview: PunktfunkSessionAttributes {
|
||||||
|
PunktfunkSessionAttributes(hostID: UUID(), hostName: "Studio", launchTitle: "Hades II")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PunktfunkSessionAttributes.ContentState {
|
||||||
|
fileprivate static var streaming: Self {
|
||||||
|
.init(
|
||||||
|
stage: .streaming, startedAt: .now.addingTimeInterval(-754),
|
||||||
|
modeLine: "2752×2064 @120 · HEVC · HDR", latencyMs: 8, mbps: 84.2)
|
||||||
|
}
|
||||||
|
fileprivate static var backgrounded: Self {
|
||||||
|
.init(
|
||||||
|
stage: .background, startedAt: .now.addingTimeInterval(-1975),
|
||||||
|
modeLine: "2752×2064 @120 · HEVC · HDR",
|
||||||
|
backgroundDeadline: .now.addingTimeInterval(9 * 60))
|
||||||
|
}
|
||||||
|
fileprivate static var reconnecting: Self {
|
||||||
|
.init(
|
||||||
|
stage: .reconnecting, startedAt: .now.addingTimeInterval(-754),
|
||||||
|
modeLine: "2752×2064 @120 · HEVC · HDR")
|
||||||
|
}
|
||||||
|
fileprivate static var ended: Self {
|
||||||
|
.init(
|
||||||
|
stage: .ending, startedAt: .now.addingTimeInterval(-3541),
|
||||||
|
modeLine: "2752×2064 @120 · HEVC · HDR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Lock Screen", as: .content, using: PunktfunkSessionAttributes.preview) {
|
||||||
|
PunktfunkSessionLiveActivity()
|
||||||
|
} contentStates: {
|
||||||
|
PunktfunkSessionAttributes.ContentState.streaming
|
||||||
|
PunktfunkSessionAttributes.ContentState.backgrounded
|
||||||
|
PunktfunkSessionAttributes.ContentState.reconnecting
|
||||||
|
PunktfunkSessionAttributes.ContentState.ended
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview(
|
||||||
|
"Island expanded", as: .dynamicIsland(.expanded),
|
||||||
|
using: PunktfunkSessionAttributes.preview
|
||||||
|
) {
|
||||||
|
PunktfunkSessionLiveActivity()
|
||||||
|
} contentStates: {
|
||||||
|
PunktfunkSessionAttributes.ContentState.streaming
|
||||||
|
PunktfunkSessionAttributes.ContentState.backgrounded
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview(
|
||||||
|
"Island compact", as: .dynamicIsland(.compact),
|
||||||
|
using: PunktfunkSessionAttributes.preview
|
||||||
|
) {
|
||||||
|
PunktfunkSessionLiveActivity()
|
||||||
|
} contentStates: {
|
||||||
|
PunktfunkSessionAttributes.ContentState.streaming
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview(
|
||||||
|
"Island minimal", as: .dynamicIsland(.minimal),
|
||||||
|
using: PunktfunkSessionAttributes.preview
|
||||||
|
) {
|
||||||
|
PunktfunkSessionLiveActivity()
|
||||||
|
} contentStates: {
|
||||||
|
PunktfunkSessionAttributes.ContentState.streaming
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -79,6 +89,11 @@ struct ContentView: View {
|
|||||||
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
||||||
@State private var showShortcutHint = false
|
@State private var showShortcutHint = false
|
||||||
#endif
|
#endif
|
||||||
|
#if os(iOS)
|
||||||
|
/// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)`
|
||||||
|
/// — the disc must LEAVE the hierarchy so nothing composites over the metal layer).
|
||||||
|
@State private var showTouchExit = false
|
||||||
|
#endif
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
#endif
|
#endif
|
||||||
@@ -92,6 +107,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,13 +136,71 @@ struct ContentView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
seedDefaultModeIfNeeded()
|
seedDefaultModeIfNeeded()
|
||||||
autoConnectIfAsked()
|
autoConnectIfAsked()
|
||||||
|
#if os(iOS)
|
||||||
|
SessionActivityController.sweepOrphans() // end any Activity a prior killed launch left
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
// Deep links (widget quick-launch, Siri/Shortcuts): route into the SAME connect path a card
|
||||||
|
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
|
||||||
|
// parallel session — this drives the one `model` ContentView owns.
|
||||||
|
.onOpenURL { handleDeepLink($0) }
|
||||||
|
#if os(iOS)
|
||||||
|
// Background keep-alive driver (opt-in). Only .background/.active matter; .inactive (a
|
||||||
|
// transient peek) is ignored so the disconnect timer never starts for a Control-Center pull.
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
switch phase {
|
||||||
|
case .background:
|
||||||
|
if backgroundKeepAlive, model.phase == .streaming {
|
||||||
|
model.enterBackground(timeoutMinutes: backgroundTimeoutMinutes)
|
||||||
|
}
|
||||||
|
case .active:
|
||||||
|
model.exitBackground()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Live Activity lifecycle, driven from the model's published state.
|
||||||
|
.onChange(of: model.phase) { _, phase in
|
||||||
|
switch phase {
|
||||||
|
case .streaming:
|
||||||
|
if let host = model.activeHost {
|
||||||
|
liveActivity.begin(
|
||||||
|
hostID: host.id, hostName: host.displayName,
|
||||||
|
launchTitle: nil, // no live foreground-app title mid-session (v1)
|
||||||
|
modeLine: currentModeLine(), startedAt: Date())
|
||||||
|
}
|
||||||
|
case .idle:
|
||||||
|
liveActivity.end()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: model.isBackgrounded) { _, backgrounded in
|
||||||
|
liveActivity.update {
|
||||||
|
$0.stage = backgrounded ? .background : .streaming
|
||||||
|
$0.backgroundDeadline = model.backgroundDeadline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The Live Activity's / Shortcuts' End button runs EndStreamIntent in-process, which posts
|
||||||
|
// this — tear the session down deliberately (quit-close the host).
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .punktfunkEndActiveSession)) { _ in
|
||||||
|
model.disconnect(deliberate: true)
|
||||||
|
}
|
||||||
|
// Connect App Intent (Siri/Shortcuts): route its punktfunk:// URL through the same handler
|
||||||
|
// as a widget tap.
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .punktfunkOpenDeepLink)) { note in
|
||||||
|
if let url = note.object as? URL { handleDeepLink(url) }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
.onChange(of: model.phase) { _, phase in
|
.onChange(of: model.phase) { _, phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .streaming:
|
case .streaming:
|
||||||
#if os(macOS) || os(tvOS)
|
#if os(macOS) || os(tvOS)
|
||||||
showShortcutHint = true // the 6 s shortcut banner, per session start
|
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||||||
#endif
|
#endif
|
||||||
|
#if os(iOS)
|
||||||
|
showTouchExit = true // the off-tier exit disc's 8 s window, per session start
|
||||||
|
#endif
|
||||||
// A session actually started — remember it on the card ("Connected … ago"
|
// A session actually started — remember it on the card ("Connected … ago"
|
||||||
// plus the accent ring on the most recent host).
|
// plus the accent ring on the most recent host).
|
||||||
guard let host = model.activeHost else { break }
|
guard let host = model.activeHost else { break }
|
||||||
@@ -151,6 +232,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 +300,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 +343,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 {
|
||||||
@@ -363,12 +500,19 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
// The resize spinner rides over the (blurred) stream; suppressed under the trust
|
// The resize spinner rides over the (blurred) stream; suppressed under the trust
|
||||||
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
|
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
|
||||||
// steering and the next click still reaches the stream.
|
// steering and the next click still reaches the stream. Mounted ONLY while a
|
||||||
|
// resize is live: resident structure above the CAMetalLayer is what the stage-4
|
||||||
|
// direct-to-display hunt is eliminating — composited presents reach glass a full
|
||||||
|
// refresh later. The enter/exit fade rides the call-site transition + the
|
||||||
|
// .animation(value: resizing) below (the view's internal `if active` fade can't
|
||||||
|
// run when the whole view unmounts).
|
||||||
.overlay {
|
.overlay {
|
||||||
if pendingFingerprint == nil {
|
if pendingFingerprint == nil, model.resizing {
|
||||||
ResizeIndicatorView(active: model.resizing)
|
ResizeIndicatorView(active: true)
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.92)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.22), value: model.resizing)
|
||||||
if let fp = pendingFingerprint {
|
if let fp = pendingFingerprint {
|
||||||
TrustCardView(
|
TrustCardView(
|
||||||
fingerprint: fp,
|
fingerprint: fp,
|
||||||
@@ -458,7 +602,8 @@ struct ContentView: View {
|
|||||||
},
|
},
|
||||||
endToEndMeter: model.endToEnd,
|
endToEndMeter: model.endToEnd,
|
||||||
decodeMeter: model.decodeStage,
|
decodeMeter: model.decodeStage,
|
||||||
displayMeter: model.displayStage
|
displayMeter: model.displayStage,
|
||||||
|
presentFloorMeter: model.presentFloor
|
||||||
)
|
)
|
||||||
.overlay(alignment: placement.alignment) {
|
.overlay(alignment: placement.alignment) {
|
||||||
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
||||||
@@ -507,18 +652,27 @@ struct ContentView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||||
// screen — the overlay off, or the compact pill (which carries no button) —
|
// screen — the overlay off, or the compact pill (which carries no button) —
|
||||||
// keep a minimal always-reachable exit in a corner. It rides a material disc
|
// keep a minimal touch exit in a corner. It rides a material disc (like the
|
||||||
// (like the HUD) so the glyph stays legible over a bright frame — this is the
|
// HUD) so the glyph stays legible over a bright frame.
|
||||||
// sole touch disconnect path in those tiers.
|
//
|
||||||
|
// In the OFF tier the disc shows for the first 8 s of a session, then leaves
|
||||||
|
// the hierarchy ENTIRELY (the shortcut-banner pattern): any composited overlay
|
||||||
|
// above the stream — a glass one doubly so, its blur SAMPLES the video layer —
|
||||||
|
// forces the CAMetalLayer through the compositor, costing ~a refresh of display
|
||||||
|
// latency and blocking direct-to-display promotion. Off is the immersive/
|
||||||
|
// measurement tier; after the fade, touch-only exits are backgrounding the app
|
||||||
|
// or re-enabling the stats overlay. Compact keeps its disc permanently — that
|
||||||
|
// tier composites a HUD pill anyway, so hiding the exit there wins nothing.
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
|
if captureEnabled,
|
||||||
|
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
|
||||||
Button { model.disconnect() } label: {
|
Button { model.disconnect() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.headline.weight(.semibold))
|
.font(.headline.weight(.semibold))
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
// Sole touch exit in the off/compact tiers — a floating glass disc
|
// Floating glass disc over the frame (26+, material fallback).
|
||||||
// over the frame (26+, material fallback). interactive: the disc
|
// interactive: the disc IS the tap target, so the glass reacts
|
||||||
// IS the tap target, so the glass reacts to press.
|
// to press.
|
||||||
.glassBackground(Circle(), interactive: true)
|
.glassBackground(Circle(), interactive: true)
|
||||||
// Match the hit region to the visible disc so every tap also
|
// Match the hit region to the visible disc so every tap also
|
||||||
// triggers the interactive-glass press highlight.
|
// triggers the interactive-glass press highlight.
|
||||||
@@ -527,6 +681,12 @@ struct ContentView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.accessibilityLabel("Disconnect")
|
.accessibilityLabel("Disconnect")
|
||||||
|
.transition(.opacity)
|
||||||
|
.task {
|
||||||
|
guard statsVerbosity == .off else { return }
|
||||||
|
try? await Task.sleep(for: .seconds(8))
|
||||||
|
withAnimation(.easeOut(duration: 0.6)) { showTouchExit = false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -610,6 +770,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 +792,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 +975,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 +986,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
|
||||||
@@ -102,6 +102,20 @@ final class SessionModel: ObservableObject {
|
|||||||
@Published var decodeValid = false
|
@Published var decodeValid = false
|
||||||
@Published var displayP50Ms = 0.0
|
@Published var displayP50Ms = 0.0
|
||||||
@Published var displayValid = false
|
@Published var displayValid = false
|
||||||
|
/// The measured OS present floor (design/apple-presentation-rebuild.md): the deadline
|
||||||
|
/// engine's vend→glass pipeline depth — an OS property no client can pace under (~2 refresh
|
||||||
|
/// intervals composited; would read ~1 under direct-to-display). The HUD subtracts it from
|
||||||
|
/// the shown display/e2e so the numbers describe Punktfunk's own pipeline; raw values stay
|
||||||
|
/// in the detailed tier + the stats log. Invalid (0) on macOS arrival (sync-off ≈ no floor)
|
||||||
|
/// and under stage-1.
|
||||||
|
@Published var osFloorP50Ms = 0.0
|
||||||
|
@Published var osFloorValid = false
|
||||||
|
|
||||||
|
/// The floor-shaved values every HUD tier displays (raw − floor, never below 0). Identical
|
||||||
|
/// to the raw values whenever no floor is measured.
|
||||||
|
var displayAdjP50Ms: Double { max(0, displayP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
||||||
|
var endToEndAdjP50Ms: Double { max(0, endToEndP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
||||||
|
var endToEndAdjP95Ms: Double { max(0, endToEndP95Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
||||||
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
|
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
|
||||||
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
|
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
|
||||||
@Published var lostFrames = 0
|
@Published var lostFrames = 0
|
||||||
@@ -133,12 +147,27 @@ final class SessionModel: ObservableObject {
|
|||||||
let endToEnd = LatencyMeter()
|
let endToEnd = LatencyMeter()
|
||||||
let decodeStage = LatencyMeter()
|
let decodeStage = LatencyMeter()
|
||||||
let displayStage = LatencyMeter()
|
let displayStage = LatencyMeter()
|
||||||
|
/// The OS present floor sampler (see `osFloorP50Ms`) — fed one sample per display-link
|
||||||
|
/// update by the deadline engine, drained by the same 1 s tick as the stage meters.
|
||||||
|
let presentFloor = LatencyMeter()
|
||||||
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
|
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
|
||||||
private var lastFramesDropped: UInt64 = 0
|
private var lastFramesDropped: UInt64 = 0
|
||||||
private var statsTimer: Timer?
|
private var statsTimer: Timer?
|
||||||
private var audio: SessionAudio?
|
private var audio: SessionAudio?
|
||||||
private var gamepadCapture: GamepadCapture?
|
private var gamepadCapture: GamepadCapture?
|
||||||
private var gamepadFeedback: GamepadFeedback?
|
private var gamepadFeedback: GamepadFeedback?
|
||||||
|
#if os(macOS)
|
||||||
|
/// The live session's clipboard bridge (design/clipboard-and-file-transfer.md §5) — created
|
||||||
|
/// by `beginStreaming` when the per-host toggle is on and the host advertises
|
||||||
|
/// `HOST_CAP_CLIPBOARD`; stopped (off-main, drain joined) in `disconnect`.
|
||||||
|
private var clipboardSync: ClipboardSync?
|
||||||
|
#endif
|
||||||
|
/// Whether clipboard sync is live (host-acked `ClipState.enabled`) — drives the Stream menu
|
||||||
|
/// item's title and the settings footnote. Always false off-macOS.
|
||||||
|
@Published private(set) var clipboardEnabled = false
|
||||||
|
/// The host's last `ClipState.reason` (`CLIP_REASON_*`) — why an enable was refused
|
||||||
|
/// (backend unavailable / policy disabled / …); 0 = OK.
|
||||||
|
@Published private(set) var clipboardReason: UInt8 = 0
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
/// Siri Remote → host pointer while streaming (touch surface moves, press = left click,
|
/// Siri Remote → host pointer while streaming (touch surface moves, press = left click,
|
||||||
/// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See
|
/// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See
|
||||||
@@ -148,6 +177,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 +227,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 +287,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 +355,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 +374,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 +433,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 +450,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 +463,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 +474,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
|
||||||
@@ -388,6 +489,7 @@ final class SessionModel: ObservableObject {
|
|||||||
endToEndValid = false
|
endToEndValid = false
|
||||||
decodeValid = false
|
decodeValid = false
|
||||||
displayValid = false
|
displayValid = false
|
||||||
|
osFloorValid = false
|
||||||
lostFrames = 0
|
lostFrames = 0
|
||||||
lostPct = 0
|
lostPct = 0
|
||||||
mouseCaptured = false
|
mouseCaptured = false
|
||||||
@@ -450,6 +552,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 +568,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
|
||||||
@@ -529,15 +673,25 @@ final class SessionModel: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.displayValid = false
|
self.displayValid = false
|
||||||
}
|
}
|
||||||
|
if let f = self.presentFloor.drain() {
|
||||||
|
self.osFloorP50Ms = f.p50Ms
|
||||||
|
self.osFloorValid = true
|
||||||
|
} else {
|
||||||
|
self.osFloorValid = false
|
||||||
|
}
|
||||||
// Mirror the window to the unified log (see statsLog) — one line per second,
|
// Mirror the window to the unified log (see statsLog) — one line per second,
|
||||||
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
||||||
// `presents` counts frames that reached glass (the display meter's sample count)
|
// `presents` counts frames that reached glass (the display meter's sample count)
|
||||||
// — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is
|
// — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is
|
||||||
// upstream (host capture/encode or the network).
|
// upstream (host capture/encode or the network).
|
||||||
if frames > 0 {
|
if frames > 0 {
|
||||||
|
// The classic fields stay RAW (cross-session comparability with every log
|
||||||
|
// captured before the 2026-07 floor policy); the appended trio carries the
|
||||||
|
// measured OS present floor and the floor-shaved values the HUD displays.
|
||||||
let line = String(
|
let line = String(
|
||||||
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||||
+ "decode_p50=%.1f display_p50=%.1f lost=%d",
|
+ "decode_p50=%.1f display_p50=%.1f lost=%d "
|
||||||
|
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f",
|
||||||
frames,
|
frames,
|
||||||
displayWindow?.count ?? 0,
|
displayWindow?.count ?? 0,
|
||||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||||
@@ -545,7 +699,10 @@ final class SessionModel: ObservableObject {
|
|||||||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
||||||
self.decodeValid ? self.decodeP50Ms : -1,
|
self.decodeValid ? self.decodeP50Ms : -1,
|
||||||
self.displayValid ? self.displayP50Ms : -1,
|
self.displayValid ? self.displayP50Ms : -1,
|
||||||
lost)
|
lost,
|
||||||
|
self.osFloorValid ? self.osFloorP50Ms : -1,
|
||||||
|
self.displayValid ? self.displayAdjP50Ms : -1,
|
||||||
|
self.endToEndValid ? self.endToEndAdjP50Ms : -1)
|
||||||
statsLog.info("\(line, privacy: .public)")
|
statsLog.info("\(line, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ struct StreamHUDView: View {
|
|||||||
private var compactLine: String {
|
private var compactLine: String {
|
||||||
var parts = ["\(model.fps) fps"]
|
var parts = ["\(model.fps) fps"]
|
||||||
if model.endToEndValid {
|
if model.endToEndValid {
|
||||||
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
|
// Floor-shaved (design/apple-presentation-rebuild.md): the OS present pipeline's
|
||||||
|
// fixed depth is excluded, so the headline describes Punktfunk's own latency.
|
||||||
|
parts.append(String(format: "%.1f ms", model.endToEndAdjP50Ms))
|
||||||
} else if model.hostNetworkValid {
|
} else if model.hostNetworkValid {
|
||||||
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
||||||
}
|
}
|
||||||
@@ -86,24 +88,36 @@ struct StreamHUDView: View {
|
|||||||
}
|
}
|
||||||
if model.endToEndValid {
|
if model.endToEndValid {
|
||||||
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
||||||
// corrected) — "(same-host clock)" when the host didn't answer the skew handshake.
|
// corrected) — "(same-host clock)" when the host didn't answer the skew
|
||||||
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
// handshake. FLOOR-SHAVED (design/apple-presentation-rebuild.md): the OS present
|
||||||
|
// pipeline's fixed depth is excluded so the number describes Punktfunk's own
|
||||||
|
// latency; the detailed tier shows the excluded floor as its own line, and the
|
||||||
|
// stats log keeps the raw values.
|
||||||
|
Text("end-to-end \(model.endToEndAdjP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndAdjP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
// The equation (detailed tier only): the stages tiling the headline interval
|
// The equation (detailed tier only): the stages tiling the headline interval
|
||||||
// (per-window p50s — they only approximately sum to the directly-measured
|
// (per-window p50s — they only approximately sum to the directly-measured
|
||||||
// total). With a host that reports per-AU timings (0xCF) the first term splits
|
// total). With a host that reports per-AU timings (0xCF) the first term splits
|
||||||
// into host + network (phase 2); an old host keeps the combined term.
|
// into host + network (phase 2); an old host keeps the combined term. The
|
||||||
|
// display term is floor-shaved like the headline, so the equation still sums.
|
||||||
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||||
if model.splitValid {
|
if model.splitValid {
|
||||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
if model.osFloorValid {
|
||||||
|
// The excluded OS term, kept visible for honesty: display-pipeline
|
||||||
|
// minimum no client can pace under (~2 refresh intervals composited).
|
||||||
|
Text("os present +\(model.osFloorP50Ms, specifier: "%.1f") excluded (display pipeline minimum)")
|
||||||
|
.font(.system(.caption2, design: .monospaced))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if model.hostNetworkValid {
|
} else if model.hostNetworkValid {
|
||||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||||
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
||||||
|
SettingsOptions.presentPriorityDefault
|
||||||
|
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||||
#endif
|
#endif
|
||||||
@@ -288,11 +290,19 @@ struct GamepadSettingsView: View {
|
|||||||
+ "hardware decode.",
|
+ "hardware decode.",
|
||||||
value: $enable444),
|
value: $enable444),
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "presenter", icon: "rectangle.stack", label: "Presenter",
|
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
|
||||||
detail: "Stage 3 paces presents to the display — lowest display latency. "
|
detail: "Lowest latency shows each frame the moment the display can take it; "
|
||||||
+ "Stage 2 shows each frame on arrival. Applies from the next session.",
|
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
|
||||||
options: SettingsOptions.presenters, current: presenter
|
+ "from the next session.",
|
||||||
) { presenter = $0 },
|
options: SettingsOptions.presentPriorities, current: presentPriority
|
||||||
|
) { presentPriority = $0 },
|
||||||
|
choiceRow(
|
||||||
|
id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer",
|
||||||
|
detail: "How many frames Smoothness holds — each adds about a refresh of "
|
||||||
|
+ "display latency and absorbs about a refresh of jitter. Only applies "
|
||||||
|
+ "when prioritizing smoothness.",
|
||||||
|
options: SettingsOptions.smoothBuffers(refreshHz: hz), current: smoothBuffer
|
||||||
|
) { smoothBuffer = $0 },
|
||||||
|
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import SwiftUI
|
|||||||
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
|
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
|
||||||
/// private) so the screenshot harness can open SettingsView on a specific category.
|
/// private) so the screenshot harness can open SettingsView on a specific category.
|
||||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||||
case general, display, audio, controllers, advanced, about
|
// The 2026-07 revamp's map: General = session/app behavior, Display = everything about the
|
||||||
|
// picture (resolution, quality, presentation, host output), Input = touch/keyboard/mouse.
|
||||||
|
// The old Advanced tab dissolved (its lone game-library toggle lives in General now).
|
||||||
|
case general, display, input, audio, controllers, about
|
||||||
|
|
||||||
var id: Self { self }
|
var id: Self { self }
|
||||||
|
|
||||||
@@ -16,9 +19,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .general: return "General"
|
case .general: return "General"
|
||||||
case .display: return "Display"
|
case .display: return "Display"
|
||||||
|
case .input: return "Input"
|
||||||
case .audio: return "Audio"
|
case .audio: return "Audio"
|
||||||
case .controllers: return "Controllers"
|
case .controllers: return "Controllers"
|
||||||
case .advanced: return "Advanced"
|
|
||||||
case .about: return "About"
|
case .about: return "About"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,9 +30,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .general: return "gearshape"
|
case .general: return "gearshape"
|
||||||
case .display: return "display"
|
case .display: return "display"
|
||||||
|
case .input: return "keyboard"
|
||||||
case .audio: return "speaker.wave.2"
|
case .audio: return "speaker.wave.2"
|
||||||
case .controllers: return "gamecontroller"
|
case .controllers: return "gamecontroller"
|
||||||
case .advanced: return "slider.horizontal.3"
|
|
||||||
case .about: return "info.circle"
|
case .about: return "info.circle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,28 +37,31 @@ enum SettingsOptions {
|
|||||||
static let hudPlacements: [(label: String, tag: String)] =
|
static let hudPlacements: [(label: String, tag: String)] =
|
||||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||||
|
|
||||||
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` — see SessionPresenter's
|
/// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced
|
||||||
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
|
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
|
||||||
static var presenters: [(label: String, tag: String)] {
|
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
|
||||||
var options: [(label: String, tag: String)] = [
|
/// PUNKTFUNK_PRESENTER debug env lever.
|
||||||
("Stage 2", "stage2"),
|
static let presentPriorities: [(label: String, tag: String)] = [
|
||||||
("Stage 3", "stage3"),
|
("Lowest latency", "latency"),
|
||||||
]
|
("Smoothness", "smooth"),
|
||||||
#if DEBUG
|
]
|
||||||
options.append(("Stage 1 (debug)", "stage1"))
|
static let presentPriorityDefault = "latency"
|
||||||
#endif
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The platform's presenter default (mirrors SessionPresenter's platformDefault — tvOS runs
|
/// Smoothness's jitter-buffer sizes (`DefaultsKey.smoothBuffer`; 0 = Automatic, currently 2
|
||||||
/// glass pacing, everything else arrival). Views seed their @AppStorage display from this so
|
/// frames). The ms hints derive from the chosen refresh setting — each buffered frame costs
|
||||||
/// an untouched picker shows what actually runs.
|
/// about one refresh interval of display latency and absorbs about one interval of arrival
|
||||||
static var presenterDefault: String {
|
/// jitter.
|
||||||
#if os(tvOS)
|
static func smoothBuffers(refreshHz: Int) -> [(label: String, tag: Int)] {
|
||||||
"stage3"
|
let periodMs = 1000.0 / Double(max(24, refreshHz))
|
||||||
#else
|
func hint(_ frames: Int) -> String {
|
||||||
"stage2"
|
String(format: "+%.0f ms", Double(frames) * periodMs)
|
||||||
#endif
|
}
|
||||||
|
return [
|
||||||
|
("Automatic", 0),
|
||||||
|
("1 frame (\(hint(1)))", 1),
|
||||||
|
("2 frames (\(hint(2)))", 2),
|
||||||
|
("3 frames (\(hint(3)))", 3),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
||||||
// composed by the per-platform bodies in SettingsView.swift.
|
// composed by the per-platform bodies in SettingsView.swift.
|
||||||
|
//
|
||||||
|
// 2026-07 settings revamp: every field carries its explanation DIRECTLY under it in the same
|
||||||
|
// cell (the `described` helper in SettingsView+Support) — the old per-section footer paragraphs
|
||||||
|
// collected several fields' explanations into one blob nobody could match back to its row.
|
||||||
|
// Where a picker's meaning depends on the selection (touch mode, modifier layout, prioritize),
|
||||||
|
// the description is DYNAMIC — it explains the current choice. The only footers left are the
|
||||||
|
// one-line "Applies from the next session." form notes.
|
||||||
|
//
|
||||||
|
// Category map (SettingsCategory): General = session/app behavior, Display = everything about
|
||||||
|
// the picture (resolution lives HERE), Input = touch/keyboard/mouse, Audio, Controllers, About.
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import CoreHaptics
|
import CoreHaptics
|
||||||
@@ -8,18 +18,23 @@ import PunktfunkKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension SettingsView {
|
extension SettingsView {
|
||||||
// MARK: - Sections (shared)
|
// MARK: - Display: Resolution
|
||||||
|
|
||||||
// NOTE: the Section content is deliberately split into the small named builders below — as one
|
// NOTE: the Section content is deliberately split into the small named builders below — as one
|
||||||
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
|
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
|
||||||
// type-checker budget ("unable to type-check this expression in reasonable time"), which
|
// type-checker budget ("unable to type-check this expression in reasonable time"), which
|
||||||
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
||||||
@ViewBuilder var streamModeSection: some View {
|
@ViewBuilder var resolutionSection: some View {
|
||||||
Section {
|
Section("Resolution") {
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS)
|
||||||
// Match-window (design/midstream-resolution-resize.md D1): follow the session
|
// Match-window (design/midstream-resolution-resize.md D1): follow the session
|
||||||
// window/scene, renegotiating the host mode on a resize. Off → the explicit mode below.
|
// window/scene, renegotiating the host mode on a resize. Off → the explicit mode below.
|
||||||
Toggle("Match window", isOn: $matchWindow)
|
described(matchWindow
|
||||||
|
? "The host resizes its output to follow this window — the picture stays "
|
||||||
|
+ "pixel-exact (1:1) through every resize."
|
||||||
|
: "Stream at the fixed mode below; a window at a different size shows it scaled.") {
|
||||||
|
Toggle("Match window", isOn: $matchWindow)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
iosResolutionWheel
|
iosResolutionWheel
|
||||||
@@ -32,30 +47,19 @@ extension SettingsView {
|
|||||||
TextField("", value: $height, format: .number.grouping(.never))
|
TextField("", value: $height, format: .number.grouping(.never))
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
described("The host drives a real virtual output at exactly this size and refresh — "
|
||||||
|
+ "true pixels, no scaling.") {
|
||||||
|
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
||||||
|
}
|
||||||
LabeledContent("") {
|
LabeledContent("") {
|
||||||
Button("Use this display's mode") { fillFromMainScreen() }
|
Button("Use this display's mode") { fillFromMainScreen() }
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
|
||||||
bitrateRows
|
|
||||||
#endif
|
|
||||||
} header: {
|
|
||||||
Text("Stream mode")
|
|
||||||
} footer: {
|
|
||||||
Text(matchWindow
|
|
||||||
? "The stream follows this window — the host resizes its virtual output to match "
|
|
||||||
+ "as you resize, so the picture stays pixel-exact (1:1) with no scaling. "
|
|
||||||
+ "\(Self.bitrateFooter)"
|
|
||||||
: "The host creates a virtual output at exactly this mode — native resolution, but "
|
|
||||||
+ "a window that isn't this size is scaled to fit. \(Self.bitrateFooter)")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// MARK: - Stream mode (iOS wheel)
|
// MARK: - Display: Resolution (iOS wheel)
|
||||||
|
|
||||||
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) — the
|
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) — the
|
||||||
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
|
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
|
||||||
@@ -74,6 +78,11 @@ extension SettingsView {
|
|||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.wheel)
|
.pickerStyle(.wheel)
|
||||||
.frame(maxHeight: 140)
|
.frame(maxHeight: 140)
|
||||||
|
Text("The host drives a real output at exactly this mode — true pixels, no scaling.")
|
||||||
|
.font(.geist(13, relativeTo: .footnote))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: 360, alignment: .leading) // match the described-row caption cap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,10 +174,69 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Display: Quality
|
||||||
|
|
||||||
|
@ViewBuilder var qualitySection: some View {
|
||||||
|
Section("Quality") {
|
||||||
|
#if !os(tvOS)
|
||||||
|
renderScaleRow
|
||||||
|
bitrateRows
|
||||||
|
#endif
|
||||||
|
described("A preference — the host falls back if it can't encode it.") {
|
||||||
|
Picker("Video codec", selection: $codec) {
|
||||||
|
ForEach(SettingsOptions.codecs, id: \.tag) { option in
|
||||||
|
Text(option.label).tag(option.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
described("HDR10, when the host has HDR content and this display supports it. "
|
||||||
|
+ "HEVC only; otherwise the stream stays SDR.") {
|
||||||
|
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
||||||
|
}
|
||||||
|
described("Sharper text and UI for desktop work, at more bandwidth. For games the "
|
||||||
|
+ "bits are better spent at 4:2:0. HEVC only.") {
|
||||||
|
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
|
/// Render-scale picker + the resulting host resolution. > 1 supersamples (sharper, at more
|
||||||
|
/// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the
|
||||||
|
/// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives.
|
||||||
|
@ViewBuilder var renderScaleRow: some View {
|
||||||
|
described(renderScaleDescription) {
|
||||||
|
Picker("Render scale", selection: $renderScale) {
|
||||||
|
ForEach(RenderScale.presets, id: \.self) { scale in
|
||||||
|
Text(RenderScale.label(scale)).tag(scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render scale explained, with the CONCRETE host resolution when it applies — the cost made
|
||||||
|
/// legible. Only the explicit mode can show it (match-window derives the base from the live
|
||||||
|
/// window, not these fields).
|
||||||
|
private var renderScaleDescription: String {
|
||||||
|
var text = "Above native supersamples for sharpness; below renders lighter on the host "
|
||||||
|
+ "and the link."
|
||||||
|
if renderScale != 1.0, !matchWindow {
|
||||||
|
let mode = RenderScale.apply(
|
||||||
|
baseWidth: width, baseHeight: height,
|
||||||
|
scale: renderScale,
|
||||||
|
maxDimension: RenderScale.maxDimension(codec: codec))
|
||||||
|
text += " Host renders \(Int(mode.width))×\(Int(mode.height)); this device scales "
|
||||||
|
+ "it to your display."
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
||||||
@ViewBuilder private var bitrateRows: some View {
|
@ViewBuilder private var bitrateRows: some View {
|
||||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
described("The host's default 20 Mbps, clamped to what it supports. Turn off to set a "
|
||||||
|
+ "fixed rate — a host card's context menu has a network speed test.") {
|
||||||
|
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||||
|
}
|
||||||
if bitrateKbps != 0 {
|
if bitrateKbps != 0 {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Slider(value: bitrateSlider, in: 0...1) {
|
Slider(value: bitrateSlider, in: 0...1) {
|
||||||
@@ -188,26 +256,228 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@ViewBuilder var audioSection: some View {
|
// MARK: - Display: Presentation
|
||||||
Section {
|
|
||||||
Picker("Audio channels", selection: $audioChannels) {
|
// The presentation intent (design/apple-presentation-rebuild.md — replaced the visible
|
||||||
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
// stage picker): latency (newest-wins, zero queue) vs smoothness (a small deliberate jitter
|
||||||
Text(option.label).tag(option.tag)
|
// buffer). The stage ladder survives only as the hidden PUNKTFUNK_PRESENTER debug env lever.
|
||||||
|
@ViewBuilder var presentationSection: some View {
|
||||||
|
Section("Presentation") {
|
||||||
|
described(presentPriority == "smooth"
|
||||||
|
? "A small frame buffer evens out network hiccups, at the buffer's worth of "
|
||||||
|
+ "added display latency."
|
||||||
|
: "Every frame shows the moment the display can take it — a network hiccup is "
|
||||||
|
+ "an occasional repeated or skipped frame.") {
|
||||||
|
Picker("Prioritize", selection: $presentPriority) {
|
||||||
|
ForEach(SettingsOptions.presentPriorities, id: \.tag) { option in
|
||||||
|
Text(option.tag == SettingsOptions.presentPriorityDefault
|
||||||
|
? "\(option.label) (default)" : option.label)
|
||||||
|
.tag(option.tag)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
if presentPriority == "smooth" {
|
||||||
Picker("Speaker", selection: $speakerUID) {
|
described("Frames held back — each absorbs about one refresh of jitter and "
|
||||||
Text("System default").tag("")
|
+ "adds one refresh of delay.") {
|
||||||
ForEach(outputDevices) { device in
|
Picker("Buffer", selection: $smoothBuffer) {
|
||||||
Text(device.name).tag(device.uid)
|
ForEach(SettingsOptions.smoothBuffers(refreshHz: hz), id: \.tag) { option in
|
||||||
|
Text(option.label).tag(option.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !speakerUID.isEmpty,
|
}
|
||||||
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh.
|
||||||
Text("Unavailable device").tag(speakerUID)
|
#if !os(tvOS)
|
||||||
|
described("A ProMotion or adaptive-sync display follows the stream's rate — "
|
||||||
|
+ "smoother motion. No effect on fixed-refresh displays.") {
|
||||||
|
Toggle("Allow VRR", isOn: $allowVRR)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice
|
||||||
|
// only exists on the Mac (the layer's own sync stays off — see MetalVideoPresenter).
|
||||||
|
#if os(macOS)
|
||||||
|
described("Flips align to the display's refresh — even pacing, up to one refresh "
|
||||||
|
+ "of added latency. Off shows frames as soon as they're ready.") {
|
||||||
|
Toggle("V-Sync", isOn: $vsync)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Display: Host output
|
||||||
|
|
||||||
|
@ViewBuilder var hostOutputSection: some View {
|
||||||
|
Section {
|
||||||
|
described("The backend the host uses for its virtual output. A specific choice "
|
||||||
|
+ "falls back to auto-detection when that backend isn't available.") {
|
||||||
|
Picker("Compositor", selection: $compositor) {
|
||||||
|
ForEach(SettingsOptions.compositors, id: \.tag) { option in
|
||||||
|
Text(option.label).tag(option.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Host output")
|
||||||
|
} footer: {
|
||||||
|
// The one form-level note (deliberately not repeated on every row above).
|
||||||
|
Text("Display changes apply from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - General: Session
|
||||||
|
|
||||||
|
@ViewBuilder var sessionSection: some View {
|
||||||
|
Section("Session") {
|
||||||
|
#if os(macOS)
|
||||||
|
described("Go fullscreen when a session starts; return to a window on the host "
|
||||||
|
+ "list.") {
|
||||||
|
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
described("Connecting to a saved host that's offline sends Wake-on-LAN and waits "
|
||||||
|
+ "for it to boot. Turn off if hosts behind a VPN look offline when they "
|
||||||
|
+ "aren't.") {
|
||||||
|
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
described("Audio and the connection stay live after you switch away; video pauses "
|
||||||
|
+ "to save power and resumes instantly when you return. Off, backgrounding "
|
||||||
|
+ "freezes the session.") {
|
||||||
|
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
|
||||||
|
}
|
||||||
|
if backgroundKeepAlive {
|
||||||
|
described("Ends a backgrounded session so it can't run down the battery.") {
|
||||||
|
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
|
||||||
|
Text("1 minute").tag(1)
|
||||||
|
Text("5 minutes").tag(5)
|
||||||
|
Text("10 minutes").tag(10)
|
||||||
|
Text("30 minutes").tag(30)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
Toggle("Send microphone to the host", isOn: $micEnabled)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - General: Statistics overlay
|
||||||
|
|
||||||
|
@ViewBuilder var overlaySection: some View {
|
||||||
|
Section("Statistics") {
|
||||||
|
described(Self.statisticsDescription) {
|
||||||
|
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
||||||
|
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
||||||
|
Text(tier.label).tag(tier.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Picker("Position", selection: $hudPlacement) {
|
||||||
|
ForEach(HUDPlacement.allCases) { placement in
|
||||||
|
Text(placement.label).tag(placement.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - General: Library
|
||||||
|
|
||||||
|
@ViewBuilder var librarySection: some View {
|
||||||
|
Section("Library") {
|
||||||
|
described("Adds “Browse Library…” to paired hosts — list their Steam and custom "
|
||||||
|
+ "games and launch one directly. No extra host setup.") {
|
||||||
|
Toggle("Show game library", isOn: $libraryEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Input
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
|
||||||
|
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
|
||||||
|
@ViewBuilder var pointerSection: some View {
|
||||||
|
Section("Touch & pointer") {
|
||||||
|
described(touchModeDescription) {
|
||||||
|
Picker("Touch input", selection: $touchMode) {
|
||||||
|
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
|
||||||
|
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
|
||||||
|
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||||
|
described("Locks a hardware mouse for relative mouse-look in games; off sends "
|
||||||
|
+ "absolute positions. Needs the stream fullscreen and frontmost.") {
|
||||||
|
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SELECTED touch mode explained — dynamic, so the caption always describes what the
|
||||||
|
/// picker currently does instead of narrating all three modes at once.
|
||||||
|
private var touchModeDescription: String {
|
||||||
|
switch TouchInputMode(rawValue: touchMode) ?? .trackpad {
|
||||||
|
case .trackpad:
|
||||||
|
return "Your finger drives the host cursor like a laptop trackpad — tap to click, "
|
||||||
|
+ "two-finger tap right-clicks, two-finger drag scrolls, tap-and-drag holds."
|
||||||
|
case .pointer:
|
||||||
|
return "The host cursor jumps to wherever you touch — tap is a click at that spot."
|
||||||
|
case .touch:
|
||||||
|
return "Real multi-touch reaches the host — for touch-native apps and games."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
/// Keyboard & mouse forwarding — applies wherever a hardware keyboard/mouse drives the stream
|
||||||
|
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
|
||||||
|
@ViewBuilder var inputSection: some View {
|
||||||
|
Section("Keyboard & mouse") {
|
||||||
|
described((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail) {
|
||||||
|
Picker("Modifier keys", selection: $modifierLayout) {
|
||||||
|
ForEach(ModifierLayout.allCases, id: \.self) { layout in
|
||||||
|
Text(layout.label).tag(layout.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
described("Reverses the wheel and trackpad scroll direction sent to the host.") {
|
||||||
|
Toggle("Invert scroll direction", isOn: $invertScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Audio
|
||||||
|
|
||||||
|
@ViewBuilder var audioSection: some View {
|
||||||
|
Section {
|
||||||
|
described("The speaker layout requested from the host.") {
|
||||||
|
Picker("Audio channels", selection: $audioChannels) {
|
||||||
|
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
||||||
|
Text(option.label).tag(option.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
described("Host audio plays through this device; System default follows your "
|
||||||
|
+ "Mac's output changes.") {
|
||||||
|
Picker("Speaker", selection: $speakerUID) {
|
||||||
|
Text("System default").tag("")
|
||||||
|
ForEach(outputDevices) { device in
|
||||||
|
Text(device.name).tag(device.uid)
|
||||||
|
}
|
||||||
|
if !speakerUID.isEmpty,
|
||||||
|
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
||||||
|
Text("Unavailable device").tag(speakerUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
described("This device's microphone feeds the host's virtual mic.") {
|
||||||
|
Toggle("Send microphone to the host", isOn: $micEnabled)
|
||||||
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Picker("Microphone", selection: $micUID) {
|
Picker("Microphone", selection: $micUID) {
|
||||||
Text("System default").tag("")
|
Text("System default").tag("")
|
||||||
@@ -223,236 +493,27 @@ extension SettingsView {
|
|||||||
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
|
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
|
||||||
// pick it. Auto sums every channel (a lone hot mic still passes at full level).
|
// pick it. Auto sums every channel (a lone hot mic still passes at full level).
|
||||||
if micChannelCount > 1 {
|
if micChannelCount > 1 {
|
||||||
Picker("Microphone channel", selection: $micChannel) {
|
described("Pick the input your mic is on; Auto sums every channel.") {
|
||||||
Text("Auto (all channels)").tag(0)
|
Picker("Microphone channel", selection: $micChannel) {
|
||||||
ForEach(1...micChannelCount, id: \.self) { ch in
|
Text("Auto (all channels)").tag(0)
|
||||||
Text("Channel \(ch)").tag(ch)
|
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||||
|
Text("Channel \(ch)").tag(ch)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(!micEnabled)
|
||||||
}
|
}
|
||||||
.disabled(!micEnabled)
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
} header: {
|
} header: {
|
||||||
Text("Audio")
|
Text("Audio")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(Self.audioFooter)
|
Text("Applies from the next session.")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
// MARK: - Controllers
|
||||||
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
|
|
||||||
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
|
|
||||||
@ViewBuilder var pointerSection: some View {
|
|
||||||
Section {
|
|
||||||
Picker("Touch input", selection: $touchMode) {
|
|
||||||
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
|
|
||||||
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
|
|
||||||
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
|
|
||||||
}
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
|
||||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Touch & pointer")
|
|
||||||
} footer: {
|
|
||||||
Text(pointerFooterText)
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Footer copy for `pointerSection`, built in plain `+=` statements. Deliberately NOT one big
|
|
||||||
/// `+` chain (with a ternary) inside the ViewBuilder — that single expression blew Swift's
|
|
||||||
/// type-checker budget and was what actually broke the iOS archive.
|
|
||||||
private var pointerFooterText: String {
|
|
||||||
var text = "Trackpad: your finger moves the host cursor like a laptop touchpad — tap "
|
|
||||||
text += "to click, two-finger tap to right-click, two-finger drag to scroll, "
|
|
||||||
text += "tap-and-drag to hold, three-finger tap for the stats overlay. Direct pointer: "
|
|
||||||
text += "the cursor jumps to your finger. Touch passthrough: real multi-touch reaches "
|
|
||||||
text += "the host. Applies from the next touch."
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
|
||||||
text += " Pointer capture locks a hardware mouse for relative mouse-look; off sends "
|
|
||||||
text += "absolute positions. Needs the stream full-screen and frontmost."
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@ViewBuilder var compositorSection: some View {
|
|
||||||
Section {
|
|
||||||
Picker("Compositor", selection: $compositor) {
|
|
||||||
ForEach(SettingsOptions.compositors, id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Host compositor")
|
|
||||||
} footer: {
|
|
||||||
Text("Which compositor drives the virtual output on the host. A specific "
|
|
||||||
+ "choice is honored only if that backend is available there — "
|
|
||||||
+ "otherwise the host falls back to auto-detection.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Auto-wake on connect — fire Wake-on-LAN + wait for a sleeping saved host to come back before
|
|
||||||
/// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted).
|
|
||||||
@ViewBuilder var wakeSection: some View {
|
|
||||||
Section {
|
|
||||||
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
|
||||||
} header: {
|
|
||||||
Text("Wake-on-LAN")
|
|
||||||
} footer: {
|
|
||||||
Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN "
|
|
||||||
+ "packet and waits for it to come back before streaming. Turn off if a host that's "
|
|
||||||
+ "already on just isn't visible here (e.g. over a VPN), so connects go straight "
|
|
||||||
+ "through instead of waiting out the wake. A host's “Wake” action still works either "
|
|
||||||
+ "way.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder var windowSection: some View {
|
|
||||||
#if os(macOS)
|
|
||||||
Section {
|
|
||||||
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
|
|
||||||
} header: {
|
|
||||||
Text("Window")
|
|
||||||
} footer: {
|
|
||||||
Text("Take the window fullscreen when a session starts and restore it on the host "
|
|
||||||
+ "list, so only the stream is fullscreen — not the picker.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh to allow.
|
|
||||||
@ViewBuilder var vrrSection: some View {
|
|
||||||
#if !os(tvOS)
|
|
||||||
Section {
|
|
||||||
Toggle("Allow VRR", isOn: $allowVRR)
|
|
||||||
} header: {
|
|
||||||
Text("Variable refresh rate")
|
|
||||||
} footer: {
|
|
||||||
Text("Let a ProMotion or adaptive-sync display vary its refresh rate to match the "
|
|
||||||
+ "stream — smoother motion without tearing. No effect on fixed-refresh displays. "
|
|
||||||
+ "Applies from the next session.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice only
|
|
||||||
// exists on the Mac (where the layer's own sync must stay off — see MetalVideoPresenter).
|
|
||||||
@ViewBuilder var vsyncSection: some View {
|
|
||||||
#if os(macOS)
|
|
||||||
Section {
|
|
||||||
Toggle("V-Sync", isOn: $vsync)
|
|
||||||
} header: {
|
|
||||||
Text("Presentation")
|
|
||||||
} footer: {
|
|
||||||
Text("Off (default): each frame is shown as soon as it's ready — lowest latency, "
|
|
||||||
+ "but frame timing can look uneven and fullscreen may tear. On: frames flip "
|
|
||||||
+ "in step with the display's refresh — evenly paced, up to one refresh of "
|
|
||||||
+ "added latency. Applies from the next session.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage-2 (Metal/VTDecompressionSession, present on frame arrival) is the proven default;
|
|
||||||
// stage-3 is the same pipeline with glass-gated present pacing — a user-visible A/B while the
|
|
||||||
// pacing work settles (see Stage2Pipeline's PresentPacing for the queue-saturation rationale).
|
|
||||||
// Stage-1 (compressed video straight to the system layer) stays a DEBUG-only diagnostic — it
|
|
||||||
// freezes hard on a lost HEVC reference.
|
|
||||||
@ViewBuilder var presenterSection: some View {
|
|
||||||
Section {
|
|
||||||
Picker("Presenter", selection: $presenter) {
|
|
||||||
Text("Stage 2 (default)").tag("stage2")
|
|
||||||
Text("Stage 3 (experimental)").tag("stage3")
|
|
||||||
#if DEBUG
|
|
||||||
Text("Stage 1 (debug)").tag("stage1")
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Video presenter")
|
|
||||||
} footer: {
|
|
||||||
Text("Stage 2: each frame is shown the moment it's decoded — proven, but on displays "
|
|
||||||
+ "running near the stream's frame rate, queued frames can add two to three "
|
|
||||||
+ "refreshes of display latency that never drains. Stage 3: presents are paced "
|
|
||||||
+ "to the display — at most one undisplayed frame in flight, always the freshest, "
|
|
||||||
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
|
|
||||||
+ "display time to compare. Applies from the next session.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder var hdrSection: some View {
|
|
||||||
Section {
|
|
||||||
Picker("Video codec", selection: $codec) {
|
|
||||||
ForEach(SettingsOptions.codecs, id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
|
||||||
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
|
||||||
} header: {
|
|
||||||
Text("Video quality")
|
|
||||||
} footer: {
|
|
||||||
Text("Codec is a preference; the host falls back if it can't encode your choice. "
|
|
||||||
+ "HDR (HDR10) and full chroma (4:4:4) are HEVC-only, and each engages only when "
|
|
||||||
+ "both this device and the host support it — otherwise the stream stays 8-bit "
|
|
||||||
+ "4:2:0 SDR. 4:4:4 (off by default) sharpens text and UI — best for desktop "
|
|
||||||
+ "work; for games the bits are better spent at 4:2:0. Applies from the next "
|
|
||||||
+ "session.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder var statisticsSection: some View {
|
|
||||||
Section {
|
|
||||||
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
|
||||||
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
|
||||||
Text(tier.label).tag(tier.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Picker("Position", selection: $hudPlacement) {
|
|
||||||
ForEach(HUDPlacement.allCases) { placement in
|
|
||||||
Text(placement.label).tag(placement.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
|
||||||
} header: {
|
|
||||||
Text("Statistics")
|
|
||||||
} footer: {
|
|
||||||
Text(Self.statisticsFooter)
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder var experimentalSection: some View {
|
|
||||||
Section {
|
|
||||||
Toggle("Show game library", isOn: $libraryEnabled)
|
|
||||||
} header: {
|
|
||||||
Text("Experimental")
|
|
||||||
} footer: {
|
|
||||||
Text("Adds a “Browse Library…” action to each host that lists its games "
|
|
||||||
+ "(Steam + custom); tap a title to launch it. Works once you've paired — no "
|
|
||||||
+ "extra host setup.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder var controllersSection: some View {
|
@ViewBuilder var controllersSection: some View {
|
||||||
Section {
|
Section {
|
||||||
@@ -464,24 +525,37 @@ extension SettingsView {
|
|||||||
controllerRow(controller)
|
controllerRow(controller)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
described("One controller is forwarded as player 1 — Automatic picks the most "
|
||||||
ForEach(controllerOptions, id: \.tag) { option in
|
+ "recently connected.") {
|
||||||
Text(option.label).tag(option.tag)
|
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||||
|
ForEach(controllerOptions, id: \.tag) { option in
|
||||||
|
Text(option.label).tag(option.tag)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Picker("Controller type", selection: $gamepadType) {
|
described("The virtual pad created on the host. Automatic matches your controller "
|
||||||
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
+ "— a DualSense keeps adaptive triggers, lightbar, touchpad and motion.") {
|
||||||
Text(option.label).tag(option.tag)
|
Picker("Controller type", selection: $gamepadType) {
|
||||||
|
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
||||||
|
Text(option.label).tag(option.tag)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||||
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
described("Plays player 1's rumble on the phone's own Taptic Engine — for "
|
||||||
|
+ "clip-on controllers without motors of their own.") {
|
||||||
|
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
described("With a controller connected, the host list and library switch to a "
|
||||||
|
+ "controller-friendly layout — larger focus targets, a swipeable cover "
|
||||||
|
+ "browser.") {
|
||||||
|
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
Button("Test Controller…") { showControllerTest = true }
|
Button("Test Controller…") { showControllerTest = true }
|
||||||
@@ -491,22 +565,9 @@ extension SettingsView {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Controllers")
|
Text("Controllers")
|
||||||
} footer: {
|
} footer: {
|
||||||
// The gamepad-UI blurb is appended here, not merged into the shared
|
Text("Applies from the next session.")
|
||||||
// `controllersFooter` constant — tvOS's `tvBody` reuses that exact string (line ~348)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
// for its own footer and has no such toggle to describe.
|
.foregroundStyle(.secondary)
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text(Self.controllersFooter)
|
|
||||||
#if os(iOS)
|
|
||||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
|
||||||
Text(Self.deviceRumbleFooter)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#if !os(tvOS)
|
|
||||||
Text(Self.gamepadUIFooter)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,31 @@ import PunktfunkKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension SettingsView {
|
extension SettingsView {
|
||||||
|
// MARK: - Described rows (the 2026-07 revamp's field idiom)
|
||||||
|
|
||||||
|
/// A control with its explanation attached to the SAME cell: the field, then a tight caption
|
||||||
|
/// directly under it. This replaced the per-section footer paragraphs — a description the eye
|
||||||
|
/// can't match to its field is one nobody reads. Keep captions to one or two sentences; when
|
||||||
|
/// a picker's meaning depends on the selection, pass a DYNAMIC string describing the current
|
||||||
|
/// choice.
|
||||||
|
@ViewBuilder
|
||||||
|
func described<Content: View>(
|
||||||
|
_ caption: String, @ViewBuilder content: () -> Content
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
content()
|
||||||
|
Text(caption)
|
||||||
|
.font(.geist(13, relativeTo: .footnote))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true) // wrap, never truncate, in Form cells
|
||||||
|
// Cap the caption's line length well short of the cell: a full-width caption runs
|
||||||
|
// its text right up to the control column (toggles especially), reading as one
|
||||||
|
// colliding block. ~46 chars/line also just measures better.
|
||||||
|
.frame(maxWidth: 360, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Bitrate
|
// MARK: - Bitrate
|
||||||
|
|
||||||
/// Slider domain, log-scale: the useful range spans three orders of magnitude
|
/// Slider domain, log-scale: the useful range spans three orders of magnitude
|
||||||
@@ -17,6 +42,7 @@ extension SettingsView {
|
|||||||
private static let minSliderKbps = 2_000.0
|
private static let minSliderKbps = 2_000.0
|
||||||
private static let maxSliderKbps = 3_000_000.0
|
private static let maxSliderKbps = 3_000_000.0
|
||||||
|
|
||||||
|
/// tvOS's cluster caption (the touch/desktop forms describe bitrate per-row instead).
|
||||||
static let bitrateFooter =
|
static let bitrateFooter =
|
||||||
"Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice "
|
"Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice "
|
||||||
+ "to its supported range. Run a speed test from a host card's context menu to "
|
+ "to its supported range. Run a speed test from a host card's context menu to "
|
||||||
@@ -53,55 +79,27 @@ extension SettingsView {
|
|||||||
|
|
||||||
// MARK: - Statistics
|
// MARK: - Statistics
|
||||||
|
|
||||||
static var statisticsFooter: String {
|
static var statisticsDescription: String {
|
||||||
let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
|
let base = "Live session stats in a corner overlay — Compact is a one-line pill, "
|
||||||
+ "pill, Normal adds resolution and latency, Detailed adds the latency stage "
|
+ "Detailed adds the latency stage breakdown."
|
||||||
+ "breakdown."
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
|
return base + " ⌃⌥⇧S cycles the tiers any time."
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
|
return base + " ⌃⌥⇧S or a three-finger tap cycles the tiers any time."
|
||||||
+ "any time."
|
|
||||||
#else
|
#else
|
||||||
return base
|
return base
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio
|
|
||||||
|
|
||||||
static var audioFooter: String {
|
|
||||||
#if os(macOS)
|
|
||||||
return "Host audio plays through the chosen speaker; your microphone feeds the host's "
|
|
||||||
+ "virtual mic. System default follows your Mac's device changes. Applies from the "
|
|
||||||
+ "next session."
|
|
||||||
#else
|
|
||||||
return "Host audio plays locally; your microphone feeds the host's virtual mic. "
|
|
||||||
+ "Applies from the next session."
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Controllers
|
// MARK: - Controllers
|
||||||
|
|
||||||
|
/// tvOS's cluster caption (the touch/desktop form describes each row inline instead).
|
||||||
static let controllersFooter =
|
static let controllersFooter =
|
||||||
"One controller is forwarded as player 1 — Automatic picks the most recently "
|
"One controller is forwarded as player 1 — Automatic picks the most recently "
|
||||||
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
|
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
|
||||||
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||||
+ "Applies from the next session."
|
+ "Applies from the next session."
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static let deviceRumbleFooter =
|
|
||||||
"Rumble on this iPhone plays player 1's rumble on the phone's own Taptic Engine as "
|
|
||||||
+ "well — for clip-on controllers that have no rumble motors of their own. Applies "
|
|
||||||
+ "from the next session."
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
static let gamepadUIFooter =
|
|
||||||
"When a controller connects, the host list and library switch to a controller-"
|
|
||||||
+ "friendly layout — larger focus targets and a swipeable cover browser. Turn this "
|
|
||||||
+ "off to always use the standard layout."
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
|
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
|
||||||
var controllerOptions: [(label: String, tag: String)] {
|
var controllerOptions: [(label: String, tag: String)] {
|
||||||
SettingsOptions.controllerOptions(gamepads)
|
SettingsOptions.controllerOptions(gamepads)
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
// 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 follow the same category map (General =
|
||||||
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
// session/app behavior, Display = everything about the picture, Input, Audio, Controllers,
|
||||||
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
// About — see SettingsCategory): macOS uses a tabbed preferences window; iOS/iPadOS uses an
|
||||||
|
// adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||||||
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
||||||
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
|
// focus-native pushed-picker layout in the same order. The individual sections
|
||||||
// `audioSection`, …) are shared across all three so a setting is defined exactly once — they
|
// (`resolutionSection`, `audioSection`, …) are shared across all three so a setting is defined
|
||||||
// live in SettingsView+Sections.swift, with their helpers in SettingsView+Support.swift.
|
// exactly once — they live in SettingsView+Sections.swift, with their helpers (including the
|
||||||
|
// per-field `described` caption idiom) in SettingsView+Support.swift.
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -25,10 +28,16 @@ struct SettingsView: View {
|
|||||||
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
|
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
|
||||||
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
|
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
|
||||||
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
||||||
|
// Render-resolution multiplier: the host renders/encodes at chosen-resolution × this, and the
|
||||||
|
// presenter downscales (> 1 = supersampling for sharpness) or upscales (< 1 = a lighter host /
|
||||||
|
// link). 1.0 = Native (the prior behaviour).
|
||||||
|
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
|
||||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||||
@AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
|
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
|
||||||
|
SettingsOptions.presentPriorityDefault
|
||||||
|
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||||
#endif
|
#endif
|
||||||
@@ -49,6 +58,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
|
||||||
@@ -107,26 +124,32 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private var macBody: some View {
|
private var macBody: some View {
|
||||||
|
// Tab map mirrors SettingsCategory: General = session/app behavior, Display = the whole
|
||||||
|
// picture (resolution lives here), Input = keyboard & mouse.
|
||||||
TabView {
|
TabView {
|
||||||
Form {
|
Form {
|
||||||
streamModeSection
|
sessionSection
|
||||||
compositorSection
|
overlaySection
|
||||||
wakeSection
|
librarySection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tabItem { Label("General", systemImage: "gearshape") }
|
.tabItem { Label("General", systemImage: "gearshape") }
|
||||||
|
|
||||||
Form {
|
Form {
|
||||||
presenterSection
|
resolutionSection
|
||||||
hdrSection
|
qualitySection
|
||||||
vrrSection
|
presentationSection
|
||||||
vsyncSection
|
hostOutputSection
|
||||||
windowSection
|
|
||||||
statisticsSection
|
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tabItem { Label("Display", systemImage: "display") }
|
.tabItem { Label("Display", systemImage: "display") }
|
||||||
|
|
||||||
|
Form {
|
||||||
|
inputSection
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.tabItem { Label("Input", systemImage: "keyboard") }
|
||||||
|
|
||||||
Form {
|
Form {
|
||||||
audioSection
|
audioSection
|
||||||
}
|
}
|
||||||
@@ -154,16 +177,10 @@ struct SettingsView: View {
|
|||||||
.onDisappear { gamepads.stopDiscovery() }
|
.onDisappear { gamepads.stopDiscovery() }
|
||||||
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
||||||
|
|
||||||
Form {
|
|
||||||
experimentalSection
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
|
||||||
|
|
||||||
AcknowledgementsView()
|
AcknowledgementsView()
|
||||||
.tabItem { Label("About", systemImage: "info.circle") }
|
.tabItem { Label("About", systemImage: "info.circle") }
|
||||||
}
|
}
|
||||||
.frame(width: 480, height: 460)
|
.frame(width: 500, height: 520)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -238,24 +255,31 @@ struct SettingsView: View {
|
|||||||
switch category {
|
switch category {
|
||||||
case .general:
|
case .general:
|
||||||
Form {
|
Form {
|
||||||
streamModeSection
|
sessionSection
|
||||||
pointerSection
|
overlaySection
|
||||||
compositorSection
|
librarySection
|
||||||
wakeSection
|
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("General")
|
.navigationTitle("General")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
case .display:
|
case .display:
|
||||||
Form {
|
Form {
|
||||||
presenterSection
|
resolutionSection
|
||||||
hdrSection
|
qualitySection
|
||||||
vrrSection
|
presentationSection
|
||||||
statisticsSection
|
hostOutputSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("Display")
|
.navigationTitle("Display")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
case .input:
|
||||||
|
Form {
|
||||||
|
pointerSection
|
||||||
|
inputSection
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle("Input")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
case .audio:
|
case .audio:
|
||||||
Form { audioSection }
|
Form { audioSection }
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
@@ -266,11 +290,6 @@ struct SettingsView: View {
|
|||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("Controllers")
|
.navigationTitle("Controllers")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
case .advanced:
|
|
||||||
Form { experimentalSection }
|
|
||||||
.formStyle(.grouped)
|
|
||||||
.navigationTitle("Advanced")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
case .about:
|
case .about:
|
||||||
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
||||||
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
||||||
@@ -316,6 +335,16 @@ struct SettingsView: View {
|
|||||||
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
|
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One cluster caption, TV-legible — the 10-foot analogue of the touch/desktop per-row
|
||||||
|
/// `described` captions (per-row text doesn't scale to TV type sizes).
|
||||||
|
private func tvCaption(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.geist(20, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
private var tvBody: some View {
|
private var tvBody: some View {
|
||||||
let currentTag = "\(width)x\(height)x\(hz)"
|
let currentTag = "\(width)x\(height)x\(hz)"
|
||||||
let bounds = UIScreen.main.nativeBounds
|
let bounds = UIScreen.main.nativeBounds
|
||||||
@@ -328,44 +357,55 @@ struct SettingsView: View {
|
|||||||
if !options.contains(where: { $0.tag == currentTag }) {
|
if !options.contains(where: { $0.tag == currentTag }) {
|
||||||
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
|
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
|
||||||
}
|
}
|
||||||
|
// Row order mirrors the touch/desktop category map: Display (mode → quality →
|
||||||
|
// presentation → host output), then Audio, General, Statistics, Controllers — with one
|
||||||
|
// short caption per cluster (per-row captions don't scale to 10-foot type sizes).
|
||||||
return ScrollView {
|
return ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Render scale",
|
||||||
|
options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) },
|
||||||
|
selection: $renderScale)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Bitrate",
|
title: "Bitrate",
|
||||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||||
selection: $bitrateKbps)
|
selection: $bitrateKbps)
|
||||||
TVSelectionRow(
|
|
||||||
title: "Audio channels",
|
|
||||||
options: SettingsOptions.audioChannels,
|
|
||||||
selection: $audioChannels)
|
|
||||||
if bitrateKbps > 1_000_000 {
|
if bitrateKbps > 1_000_000 {
|
||||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
TVSelectionRow(
|
|
||||||
title: "Compositor", options: SettingsOptions.compositors,
|
|
||||||
selection: $compositor)
|
|
||||||
TVSelectionRow(
|
|
||||||
title: "Presenter",
|
|
||||||
options: SettingsOptions.presenters,
|
|
||||||
selection: $presenter)
|
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "10-bit HDR",
|
title: "10-bit HDR",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Prioritize",
|
||||||
|
options: SettingsOptions.presentPriorities,
|
||||||
|
selection: $presentPriority)
|
||||||
|
if presentPriority == "smooth" {
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Smoothness buffer",
|
||||||
|
options: SettingsOptions.smoothBuffers(refreshHz: hz),
|
||||||
|
selection: $smoothBuffer)
|
||||||
|
}
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Compositor", options: SettingsOptions.compositors,
|
||||||
|
selection: $compositor)
|
||||||
|
tvCaption("The host drives a real output at exactly the chosen mode. "
|
||||||
|
+ "\(Self.bitrateFooter) Lowest latency shows frames immediately; "
|
||||||
|
+ "Smoothness buffers a few to even out network hiccups. A specific "
|
||||||
|
+ "compositor is honored only if available on the host.")
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Audio channels",
|
||||||
|
options: SettingsOptions.audioChannels,
|
||||||
|
selection: $audioChannels)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Auto-wake on connect",
|
title: "Auto-wake on connect",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
||||||
Text("The host creates a virtual output at exactly this mode — native "
|
tvCaption("Auto-wake sends Wake-on-LAN to a sleeping saved host and waits for "
|
||||||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
+ "it before streaming.")
|
||||||
+ "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a "
|
|
||||||
+ "sleeping saved host and waits for it before streaming.")
|
|
||||||
.font(.geist(20, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.top, 8)
|
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Statistics overlay",
|
title: "Statistics overlay",
|
||||||
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
||||||
@@ -385,11 +425,7 @@ struct SettingsView: View {
|
|||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Gamepad-optimized browsing",
|
title: "Gamepad-optimized browsing",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||||
Text(Self.controllersFooter)
|
tvCaption(Self.controllersFooter)
|
||||||
.font(.geist(20, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.top, 8)
|
|
||||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,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
|
||||||
@@ -57,30 +57,8 @@ public enum BrandFont {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Color {
|
// Color.brand lives in PunktfunkShared/BrandColor.swift (re-exported here): the widget
|
||||||
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
|
// extension links Shared alone and must render the same purple.
|
||||||
/// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and
|
|
||||||
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
|
|
||||||
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
|
|
||||||
static let brand: Color = {
|
|
||||||
#if canImport(UIKit)
|
|
||||||
return Color(UIColor { traits in
|
|
||||||
traits.userInterfaceStyle == .dark
|
|
||||||
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
|
||||||
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
|
||||||
})
|
|
||||||
#elseif canImport(AppKit)
|
|
||||||
return Color(NSColor(name: nil) { appearance in
|
|
||||||
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
|
||||||
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
|
||||||
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
|
||||||
})
|
|
||||||
#else
|
|
||||||
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
|
|
||||||
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
|
|
||||||
#endif
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension Font {
|
public extension Font {
|
||||||
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
||||||
|
|||||||
@@ -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,48 @@ 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
|
||||||
|
|
||||||
|
/// Deadline pacing only, RENDER THREAD: reconcile the layer with a decoded frame BEFORE a
|
||||||
|
/// drawable exists. The link vends from the layer's CURRENT config, and the layer starts
|
||||||
|
/// with `drawableSize` 0 (it never tracks bounds once set explicitly, and the sublayer's
|
||||||
|
/// frame isn't even laid out when the link spins up) — so leaving all reconciliation to the
|
||||||
|
/// render path (which needs a frame AND a vended drawable) deadlocks at session start:
|
||||||
|
/// every vend fails allocation at 0×0, the stash stays empty, no pair ever completes, and
|
||||||
|
/// the size is never set. The 2026-07-19 iPad black screen ("[CAMetalLayer nextDrawable]
|
||||||
|
/// returning nil because allocation failed" every refresh). Called on EVERY frame arrival:
|
||||||
|
/// drains the same staging the render path drains (both are idempotent about it) and
|
||||||
|
/// applies size + HDR config, so the next vend always matches the frame about to present —
|
||||||
|
/// this also makes a mid-session HDR flip cost at most one skipped vend instead of waiting
|
||||||
|
/// for a paired present to retag the layer.
|
||||||
|
func reconcileLayer(decodedSize: CGSize, isHDR: Bool) {
|
||||||
|
stagingLock.lock()
|
||||||
|
let targetFromLayout = drawableTarget
|
||||||
|
let newHdrMeta = pendingHdrMeta
|
||||||
|
pendingHdrMeta = nil
|
||||||
|
stagingLock.unlock()
|
||||||
|
configure(hdr: isHDR)
|
||||||
|
if let newHdrMeta {
|
||||||
|
self.lastHdrMeta = newHdrMeta
|
||||||
|
#if !os(tvOS)
|
||||||
|
if hdrActive { layer.edrMetadata = makeEDR(newHdrMeta) }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||||
|
? targetFromLayout : decodedSize
|
||||||
|
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
||||||
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -508,10 +647,15 @@ public final class MetalVideoPresenter {
|
|||||||
/// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which
|
/// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which
|
||||||
/// is the "frametimes are off with the stats HUD closed" report. nil presents immediately
|
/// is the "frametimes are off with the stats HUD closed" report. nil presents immediately
|
||||||
/// (`PUNKTFUNK_PRESENT_MODE=immediate` — the pre-fix behavior, kept as a diagnostic A/B).
|
/// (`PUNKTFUNK_PRESENT_MODE=immediate` — the pre-fix behavior, kept as a diagnostic A/B).
|
||||||
|
///
|
||||||
|
/// `into drawable` (deadline pacing) supplies the CAMetalDisplayLink-vended drawable to
|
||||||
|
/// render into instead of calling `nextDrawable()` — see `encodePresent` for the format
|
||||||
|
/// guard that skips a vend the layer's config outran.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func render(
|
public func render(
|
||||||
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
|
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
|
||||||
presentAtMediaTime: CFTimeInterval? = nil,
|
presentAtMediaTime: CFTimeInterval? = nil,
|
||||||
|
into drawable: CAMetalDrawable? = nil,
|
||||||
onPresented: ((Int64?) -> Void)? = nil
|
onPresented: ((Int64?) -> Void)? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
|
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
|
||||||
@@ -565,7 +709,8 @@ public final class MetalVideoPresenter {
|
|||||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||||
return encodePresent(
|
return encodePresent(
|
||||||
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
|
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
|
||||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
|
||||||
|
onPresented: onPresented,
|
||||||
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU
|
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU
|
||||||
// finishes sampling — releasing them at scope exit could free the backing mid-read.
|
// finishes sampling — releasing them at scope exit could free the backing mid-read.
|
||||||
keepAlive: [luma, chroma, pixelBuffer]
|
keepAlive: [luma, chroma, pixelBuffer]
|
||||||
@@ -584,17 +729,58 @@ public final class MetalVideoPresenter {
|
|||||||
func renderPlanar(
|
func renderPlanar(
|
||||||
_ planes: WaveletPlanes,
|
_ planes: WaveletPlanes,
|
||||||
presentAtMediaTime: CFTimeInterval? = nil,
|
presentAtMediaTime: CFTimeInterval? = nil,
|
||||||
|
into drawable: CAMetalDrawable? = nil,
|
||||||
onPresented: ((Int64?) -> Void)? = nil
|
onPresented: ((Int64?) -> Void)? = nil
|
||||||
) -> 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, providedDrawable: drawable,
|
||||||
|
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).
|
||||||
keepAlive: [planes.y, planes.cb, planes.cr]
|
keepAlive: [planes.y, planes.cb, planes.cr]
|
||||||
@@ -606,12 +792,132 @@ 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.
|
||||||
|
///
|
||||||
|
/// `providedDrawable` (deadline pacing) is the CAMetalDisplayLink-vended drawable to render
|
||||||
|
/// into instead of `nextDrawable()`. It was vended against the layer's config at vend time,
|
||||||
|
/// so after a mid-session reconfigure (HDR flip: `configure` above already retagged the
|
||||||
|
/// layer) its pixel format can lag the pipeline's attachment format — encoding would be a
|
||||||
|
/// Metal validation failure. The guard returns false instead: the drawable drops back to
|
||||||
|
/// the pool, the caller re-rings the frame, and the link's next vend carries the new format.
|
||||||
private func encodePresent(
|
private func encodePresent(
|
||||||
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
|
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
|
||||||
presentAtMediaTime: CFTimeInterval?, onPresented: ((Int64?) -> Void)?,
|
presentAtMediaTime: CFTimeInterval?, providedDrawable: CAMetalDrawable? = nil,
|
||||||
|
onPresented: ((Int64?) -> Void)?,
|
||||||
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
||||||
@@ -624,11 +930,17 @@ public final class MetalVideoPresenter {
|
|||||||
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
||||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||||
? targetFromLayout : decodedSize
|
? targetFromLayout : decodedSize
|
||||||
|
// Under a provided (link-vended) drawable this sizes the NEXT vend — the one in hand
|
||||||
|
// keeps its size, and a live-resize transient composites via contentsGravity as ever.
|
||||||
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
||||||
#endif
|
#endif
|
||||||
guard let drawable = layer.nextDrawable(),
|
if let providedDrawable,
|
||||||
|
providedDrawable.texture.pixelFormat != layer.pixelFormat {
|
||||||
|
return false // config outran the vend (HDR flip) — next vend has the new format
|
||||||
|
}
|
||||||
|
guard let drawable = providedDrawable ?? layer.nextDrawable(),
|
||||||
let commandBuffer = queue.makeCommandBuffer()
|
let commandBuffer = queue.makeCommandBuffer()
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: stage-2 (explicit
|
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: the Metal pipeline
|
||||||
// VTDecompressionSession decode → CAMetalLayer, driven by the hosting view's CADisplayLink) is the
|
// (explicit VTDecompressionSession decode → CAMetalLayer) is the default — deadline-paced
|
||||||
// default; stage-1 (StreamPump → AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG
|
// stage-4 on iOS/tvOS, arrival-paced stage-2 on macOS (see PresenterChoice.platformDefault);
|
||||||
// fallback. The views own the platform bits — capture, window/scale tracking, and constructing the
|
// the user-facing choice is the INTENT (PresentPriority: latency vs smoothness+buffer — the
|
||||||
// display link — and delegate the shared presenter lifecycle here.
|
// 2026-07 rebuild, design/apple-presentation-rebuild.md), the stage ladder is env-only debug.
|
||||||
|
// Stage-1 (StreamPump → AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG fallback.
|
||||||
|
// The views own the platform bits — capture, window/scale tracking, and constructing the
|
||||||
|
// display link (arrival/glass pacing only; deadline pacing runs its own CAMetalDisplayLink) —
|
||||||
|
// and delegate the shared presenter lifecycle here.
|
||||||
//
|
//
|
||||||
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
|
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
|
||||||
|
|
||||||
#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
|
||||||
@@ -25,15 +30,17 @@ public final class DisplayLinkProxy: NSObject {
|
|||||||
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
|
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
|
/// Which presenter a session runs. Stage-2/3/4 are the same Metal pipeline with different present
|
||||||
/// glass-gated present pacing (`PresentPacing` — see Stage2Pipeline for the tradeoff, and why
|
/// pacing (`PresentPacing` — see Stage2Pipeline for the full tradeoff): stage-2 presents on frame
|
||||||
/// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
|
/// arrival, stage-3 gates presents on the on-glass callback, stage-4 presents into
|
||||||
/// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
|
/// CAMetalDisplayLink-vended drawables (deadline pacing — iOS/tvOS only; see `PresentPacing`'s
|
||||||
/// DEBUG-only diagnostic. Internal (not private) for unit tests.
|
/// doc for why the vsync-latching platforms need it). Stage-1 (compressed video straight to the
|
||||||
|
/// system layer) is a DEBUG-only diagnostic. Internal (not private) for unit tests.
|
||||||
enum PresenterChoice: Equatable {
|
enum PresenterChoice: Equatable {
|
||||||
case stage1
|
case stage1
|
||||||
case stage2
|
case stage2
|
||||||
case stage3
|
case stage3
|
||||||
|
case stage4
|
||||||
|
|
||||||
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
|
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
|
||||||
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
|
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
|
||||||
@@ -41,34 +48,159 @@ 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. "stage4" resolves only on
|
||||||
|
/// iOS/tvOS: macOS's present path is entangled with the sync-off/DCP-panic saga (see
|
||||||
|
/// MetalVideoPresenter's init) and stays on its proven pacings until deadline presents are
|
||||||
|
/// deliberately validated there — a synced "stage4" value maps back to the platform default.
|
||||||
|
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
|
case "stage4":
|
||||||
|
#if os(macOS)
|
||||||
|
return nil
|
||||||
|
#else
|
||||||
|
return .stage4
|
||||||
|
#endif
|
||||||
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// tvOS defaults to GLASS pacing: an Apple TV is the sticky-FIFO worst case by construction —
|
/// iOS/iPadOS/tvOS default to DEADLINE pacing (stage-4), macOS to arrival (stage-2).
|
||||||
/// a fixed 60 Hz panel fed a 60 fps stream, where arrival pacing pins the layer's image queue
|
///
|
||||||
/// at ~3 drawables and every frame rides ~50 ms of queue (the measured display stage there).
|
/// The iOS/tvOS layers ALWAYS vsync-latch presents into a FIFO image queue
|
||||||
/// The Settings picker can still force stage-2 for an A/B. Everything else keeps stage-2 (the
|
/// (`displaySyncEnabled` is macOS-only API), and at stream rate ≈ panel rate — an Apple TV's
|
||||||
/// proven default; ProMotion/desktop panels out-tick the stream often enough to drain).
|
/// fixed 60 Hz by construction; an iPhone/iPad with VRR (default on, preferred = stream rate)
|
||||||
|
/// steering the panel to the stream — that queue's depth is STICKY: one burst fills it and,
|
||||||
|
/// with arrivals and latches then running at the same rate, it NEVER drains. Every queued
|
||||||
|
/// present costs a full refresh, forever: the 2026-07 iPad Pro (2752×2064@120) field ladder
|
||||||
|
/// read ~30 ms display on arrival (~3 refreshes of queue), 22–28 ms glass-gated at depth 2
|
||||||
|
/// (a standing queue of 2 — the depth-2 experiment's post-mortem), 14 ms at depth 1. Glass
|
||||||
|
/// pacing (stage-3) bounds the queue but presents still serialize on the on-glass callback;
|
||||||
|
/// deadline pacing (stage-4) is the fix for the remainder: one CAMetalDisplayLink-vended
|
||||||
|
/// drawable per refresh, presented the moment a frame decodes — the queue cannot exist and
|
||||||
|
/// nothing waits on callbacks (see `PresentPacing.deadline`).
|
||||||
|
///
|
||||||
|
/// tvOS joined iOS on the deadline engine in the 2026-07 presentation rebuild
|
||||||
|
/// (design/apple-presentation-rebuild.md — the engine is field-proven on iOS and strictly
|
||||||
|
/// simpler than the glass gate it replaces; `PUNKTFUNK_PRESENTER=stage3` remains the
|
||||||
|
/// fallback lever if a TV-specific issue surfaces). macOS keeps stage-2: with the layer's
|
||||||
|
/// sync off, presents are out-of-band flips that don't queue, so arrival is genuinely
|
||||||
|
/// lowest-latency there.
|
||||||
static var platformDefault: PresenterChoice {
|
static var platformDefault: PresenterChoice {
|
||||||
#if os(tvOS)
|
#if os(iOS) || os(tvOS)
|
||||||
.stage3
|
.stage4
|
||||||
#else
|
#else
|
||||||
.stage2
|
.stage2
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user's presentation INTENT — what replaced the visible stage picker in the 2026-07
|
||||||
|
/// rebuild (design/apple-presentation-rebuild.md). Two intents, one engine per platform:
|
||||||
|
///
|
||||||
|
/// - `.latency` (the default): every frame shows as soon as the display can take it — the
|
||||||
|
/// newest-wins zero-queue store; network/decode jitter appears as the occasional repeat or
|
||||||
|
/// drop. This is the configuration the whole 2026-07 pacing saga optimized.
|
||||||
|
/// - `.smooth(buffer:)`: a small deliberate jitter buffer (`FrameStore.fifo`) evens the present
|
||||||
|
/// cadence at the cost of `buffer` refresh intervals of added display latency — which the HUD
|
||||||
|
/// SHOWS (only the OS floor is shaved, never the user's chosen buffer). `buffer` ∈ 1…3;
|
||||||
|
/// the "Automatic" setting (stored 0) currently maps to 2.
|
||||||
|
///
|
||||||
|
/// Mechanism stays internal: intents map onto `PresentPacing`/`FrameStore.Policy` per platform
|
||||||
|
/// in `SessionPresenter.start`; the stage ladder survives only as the PUNKTFUNK_PRESENTER debug
|
||||||
|
/// env lever. Internal (not private) for unit tests.
|
||||||
|
enum PresentPriority: Equatable {
|
||||||
|
case latency
|
||||||
|
case smooth(buffer: Int)
|
||||||
|
|
||||||
|
/// Resolve from the persisted settings: `DefaultsKey.presentPriority` ("latency" default;
|
||||||
|
/// anything but "smooth" — unset, garbage, a synced unknown future value — falls back to
|
||||||
|
/// latency) and `DefaultsKey.smoothBuffer` (0/out-of-range = Automatic = 2).
|
||||||
|
static func resolve(setting: String?, bufferSetting: Int?) -> PresentPriority {
|
||||||
|
guard setting == "smooth" else { return .latency }
|
||||||
|
let raw = bufferSetting ?? 0
|
||||||
|
return .smooth(buffer: (1...3).contains(raw) ? raw : 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frame hand-off policy this intent runs (see `FrameStore`).
|
||||||
|
var storePolicy: FrameStorePolicy {
|
||||||
|
switch self {
|
||||||
|
case .latency: return .newestWins
|
||||||
|
case .smooth(let buffer): return .fifo(capacity: buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 == .stage4 { return .deadline }
|
||||||
|
if choice == .stage3 { return .glass }
|
||||||
|
#if os(macOS)
|
||||||
|
if explicit == nil, codec == .pyrowave { return .glass }
|
||||||
|
#endif
|
||||||
|
return .arrival
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The glass gate's in-flight present budget (`PresentGate` capacity): 1 everywhere.
|
||||||
|
///
|
||||||
|
/// Depth 1 is the only depth that works. The 2026-07 depth-2 experiment (one flip scanning
|
||||||
|
/// out + one queued, predicted ~5–8 ms at 120 Hz) REGRESSED the iPad Pro's display stage to
|
||||||
|
/// 22–28 ms vs depth 1's 14: any second gate slot becomes a STANDING queue — a burst fills
|
||||||
|
/// it, and with presents and latches then running at the same rate the occupancy never
|
||||||
|
/// returns to zero, so every frame permanently rides one extra refresh per slot. A bounded
|
||||||
|
/// FIFO can cap the queue but nothing ever drains it; the prediction assumed an idle queue
|
||||||
|
/// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing
|
||||||
|
/// that can't queue at all — that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
|
||||||
|
///
|
||||||
|
/// `PUNKTFUNK_GATE_DEPTH` (1…3) still overrides on iOS/tvOS so the standing-queue ladder
|
||||||
|
/// stays reproducible on-device; macOS is pinned to 1, env ignored — glass pacing exists
|
||||||
|
/// there as the DCP swapID kernel-panic mitigation (see `pacing`), and STRICT present
|
||||||
|
/// serialization is its point. Internal (not private) for unit tests.
|
||||||
|
static func gateDepth(env: String?) -> Int {
|
||||||
|
#if os(macOS)
|
||||||
|
return 1
|
||||||
|
#else
|
||||||
|
if let env, let depth = Int(env), (1...3).contains(depth) { return depth }
|
||||||
|
return 1
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -92,6 +224,7 @@ final class SessionPresenter {
|
|||||||
endToEndMeter: LatencyMeter?,
|
endToEndMeter: LatencyMeter?,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil,
|
||||||
|
presentFloorMeter: LatencyMeter? = nil,
|
||||||
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
onSessionEnd: (@Sendable () -> Void)?,
|
onSessionEnd: (@Sendable () -> Void)?,
|
||||||
@@ -100,43 +233,79 @@ final class SessionPresenter {
|
|||||||
stop()
|
stop()
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
|
|
||||||
// Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
|
// Presentation resolution (design/apple-presentation-rebuild.md). The Metal pipeline is
|
||||||
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
|
// the DEFAULT (explicit VTDecompressionSession decode + a CAMetalLayer present): it can
|
||||||
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-3 is
|
// detect + recover a wedged decoder where stage-1's AVSampleBufferDisplayLayer freezes
|
||||||
// the same pipeline with glass-gated present pacing (the settings picker's live A/B — see
|
// hard on a lost HEVC reference. The MECHANISM (pacing) is per-platform via
|
||||||
// PresentPacing). Stage-1 is reachable only via the DEBUG presenter value; release maps it
|
// PresenterChoice.platformDefault — deadline on iOS/tvOS, arrival on macOS — overridable
|
||||||
// back to stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
// only by the hidden PUNKTFUNK_PRESENTER debug env (the legacy persisted stage picker
|
||||||
|
// value is deliberately ignored). The user-facing choice is the INTENT
|
||||||
|
// (PresentPriority): latency (newest-wins zero-queue store) vs smoothness (a FIFO jitter
|
||||||
|
// buffer; on macOS it additionally paces presents onto the vsync grid so the buffer
|
||||||
|
// drains on display cadence). Stage-1 is reachable only via env in DEBUG; release maps
|
||||||
|
// it back to the default (the stage-1 pump below stays the automatic Metal-missing
|
||||||
|
// fallback).
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let allowStage1 = true
|
let allowStage1 = true
|
||||||
#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: nil, // the legacy DefaultsKey.presenter picker value is no longer read
|
||||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
||||||
allowStage1: allowStage1)
|
allowStage1: allowStage1)
|
||||||
|
let choice = explicit ?? PresenterChoice.platformDefault
|
||||||
|
let pacing = Self.pacing(for: choice, explicit: explicit, codec: connection.videoCodec)
|
||||||
|
let priority = PresentPriority.resolve(
|
||||||
|
setting: UserDefaults.standard.string(forKey: DefaultsKey.presentPriority),
|
||||||
|
bufferSetting: UserDefaults.standard.object(forKey: DefaultsKey.smoothBuffer) as? Int)
|
||||||
|
// macOS smoothness rides arrival pacing + forced vsync scheduling; under a glass-paced
|
||||||
|
// macOS session (the PyroWave DCP mitigation) the gate already serializes on the
|
||||||
|
// display, so the FIFO alone provides the buffering.
|
||||||
|
#if os(macOS)
|
||||||
|
let vsyncPaced = priority != .latency && pacing == .arrival
|
||||||
|
#else
|
||||||
|
let vsyncPaced = false
|
||||||
|
#endif
|
||||||
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) {
|
presentFloorMeter: presentFloorMeter,
|
||||||
|
pacing: pacing,
|
||||||
|
gateDepth: Self.gateDepth(
|
||||||
|
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"]),
|
||||||
|
storePolicy: priority.storePolicy,
|
||||||
|
vsyncPaced: vsyncPaced) {
|
||||||
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
|
||||||
// link's own report of the current refresh period (tracks VRR rate changes).
|
// link's own report of the current refresh period (tracks VRR rate changes).
|
||||||
let proxy = DisplayLinkProxy { [weak self] link in
|
// DEADLINE pacing needs neither: its CAMetalDisplayLink (pipeline-owned) is the vsync
|
||||||
self?.stage2?.renderTick(
|
// clock, and every one of its updates re-checks the ring, which IS the retry tick —
|
||||||
targetMediaTime: link.targetTimestamp,
|
// a second link would only fight it over the frame-rate hint.
|
||||||
period: link.targetTimestamp - link.timestamp)
|
if pacing != .deadline {
|
||||||
|
let proxy = DisplayLinkProxy { [weak self] link in
|
||||||
|
self?.stage2?.renderTick(
|
||||||
|
targetMediaTime: link.targetTimestamp,
|
||||||
|
period: link.targetTimestamp - link.timestamp)
|
||||||
|
}
|
||||||
|
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
|
||||||
|
link.add(to: .main, forMode: .common)
|
||||||
|
stage2Link = link
|
||||||
}
|
}
|
||||||
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
|
|
||||||
link.add(to: .main, forMode: .common)
|
|
||||||
stage2Link = link
|
|
||||||
syncFrameRate(hz: connection.currentMode().refreshHz)
|
syncFrameRate(hz: connection.currentMode().refreshHz)
|
||||||
pipeline.start(
|
pipeline.start(
|
||||||
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
|
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
|
||||||
@@ -164,7 +333,12 @@ final class SessionPresenter {
|
|||||||
/// rate (it already tracks the display and must NOT be capped to the stream rate).
|
/// rate (it already tracks the display and must NOT be capped to the stream rate).
|
||||||
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
|
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
|
||||||
private func syncFrameRate(hz: UInt32) {
|
private func syncFrameRate(hz: UInt32) {
|
||||||
guard hz > 0, let link = stage2Link else { return }
|
guard hz > 0 else { return }
|
||||||
|
// Deadline pacing: the hint goes to the pipeline's CAMetalDisplayLink instead (staged;
|
||||||
|
// applied from the link's own thread — see Stage2Pipeline.setFrameRateHint). A no-op
|
||||||
|
// under arrival/glass pacing, where the CADisplayLink below is the one hinted link.
|
||||||
|
stage2?.setFrameRateHint(hz: Float(hz))
|
||||||
|
guard let link = stage2Link else { return }
|
||||||
let hzF = Float(hz)
|
let hzF = Float(hz)
|
||||||
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
|
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -223,6 +397,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 +430,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 +468,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,14 @@
|
|||||||
// – V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
|
// – V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
|
||||||
// period ahead by construction, falling back to immediate when the link data is stale — a
|
// period ahead by construction, falling back to immediate when the link data is stale — a
|
||||||
// schedule can never sit far in the future holding drawables hostage.
|
// schedule can never sit far in the future holding drawables hostage.
|
||||||
// • Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
|
// • Present PACING is the stage-2/3/4 presenter split (`PresentPacing`, chosen per session by
|
||||||
// by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
// SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
||||||
// frame arrival; stage-3 additionally gates presents to ONE undisplayed drawable so the layer's
|
// frame arrival; stage-3 additionally gates presents on the on-glass callback (`PresentGate`)
|
||||||
// FIFO image queue can never saturate — see PresentPacing's doc for the full rationale.
|
// so the layer's FIFO image queue can never saturate; stage-4 (iOS/tvOS) presents into
|
||||||
|
// CAMetalDisplayLink-vended drawables the moment a frame decodes — deadline pacing, where the
|
||||||
|
// queue cannot exist at all — see PresentPacing's doc for the full rationale. Under deadline
|
||||||
|
// pacing the render thread below is fed by BOTH the decoder callback and the link's per-refresh
|
||||||
|
// updates (which vend the drawable), and the V-Sync policy/vsync clock don't apply.
|
||||||
// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
|
// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
|
||||||
//
|
//
|
||||||
// The render thread also stamps the unified latency stages (end-to-end capture→on-glass + decode and
|
// The render thread also stamps the unified latency stages (end-to-end capture→on-glass + decode and
|
||||||
@@ -38,7 +42,9 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Metal
|
import Metal
|
||||||
|
import PunktfunkShared
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
import os
|
||||||
|
|
||||||
/// 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
|
||||||
/// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call — for
|
/// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call — for
|
||||||
@@ -46,33 +52,127 @@ import QuartzCore
|
|||||||
/// stdout is the cheapest reliable capture channel.
|
/// stdout is the cheapest reliable capture channel.
|
||||||
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
|
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
|
||||||
|
|
||||||
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame — lowest
|
/// The pf-present line's os_log mirror (subsystem io.unom.punktfunk, category "present") — the
|
||||||
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
|
/// SessionModel "stats" mirror's sibling, so DEADLINE sessions stream their pacing decomposition
|
||||||
private final class ReadyRing: @unchecked Sendable {
|
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
|
||||||
|
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
|
||||||
|
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
|
||||||
|
|
||||||
|
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
|
||||||
|
/// user's presentation intent (design/apple-presentation-rebuild.md — the 2026-07 rebuild that
|
||||||
|
/// replaced the visible stage picker):
|
||||||
|
///
|
||||||
|
/// - `.newestWins` (Prioritize lowest latency, the default): a 1-slot ring — the decoder
|
||||||
|
/// overwrites (drops the older undisplayed frame), the render thread takes-and-clears. Zero
|
||||||
|
/// store by construction: any deeper app-held buffer ahead of a latch-paced display becomes a
|
||||||
|
/// STANDING queue costing one full refresh per slot, forever (the depth-2 gate post-mortem —
|
||||||
|
/// see SessionPresenter.gateDepth).
|
||||||
|
/// - `.fifo(capacity: K)` (Prioritize smoothness): a small deliberate jitter buffer. The
|
||||||
|
/// decoder appends; overflow drops the OLDEST (bounded added latency — the newest keeps
|
||||||
|
/// flowing); the render thread pops the oldest ONE per present opportunity, so the cadence is
|
||||||
|
/// the display's. `take` withholds frames until the buffer has PREROLLED to capacity once —
|
||||||
|
/// without preroll a steady stream drains every frame on arrival and headroom never builds —
|
||||||
|
/// and re-arms preroll when it runs dry (an underflow: the previous frame persists on glass,
|
||||||
|
/// a repeat by omission, while headroom rebuilds). Each buffered frame ≈ one refresh interval
|
||||||
|
/// of jitter absorbed for one interval of added display latency, which the metrics SHOW —
|
||||||
|
/// only the OS present floor is shaved from the HUD, never the user's chosen buffer.
|
||||||
|
///
|
||||||
|
/// Sendable; lock-guarded — decoder callbacks and the render thread cross here.
|
||||||
|
public enum FrameStorePolicy: Sendable, Equatable {
|
||||||
|
case newestWins
|
||||||
|
case fifo(capacity: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class FrameStore<Frame>: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var frame: ReadyFrame?
|
private let capacity: Int // 1 = newest-wins semantics
|
||||||
/// Ring submissions since the last `drainSubmitted` — the decode rate for the
|
private let isFifo: Bool
|
||||||
/// PUNKTFUNK_PRESENT_DEBUG stat line.
|
private var frames: [Frame] = []
|
||||||
|
private var prerolled = false
|
||||||
|
/// Submissions since the last `drainSubmitted` — the decode rate for the pf-present line.
|
||||||
private var submitted = 0
|
private var submitted = 0
|
||||||
func submit(_ f: ReadyFrame) {
|
/// Smoothness accounting for the pf-present line: frames dropped by a full buffer, and
|
||||||
lock.lock(); frame = f; submitted += 1; lock.unlock()
|
/// runs-dry that re-armed preroll.
|
||||||
|
private var overflowDrops = 0
|
||||||
|
private var underflows = 0
|
||||||
|
|
||||||
|
public init(policy: FrameStorePolicy) {
|
||||||
|
switch policy {
|
||||||
|
case .newestWins:
|
||||||
|
capacity = 1
|
||||||
|
isFifo = false
|
||||||
|
case .fifo(let k):
|
||||||
|
capacity = max(1, k)
|
||||||
|
isFifo = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
func drainSubmitted() -> Int {
|
|
||||||
lock.lock(); defer { lock.unlock() }
|
func submit(_ f: Frame) {
|
||||||
let n = submitted; submitted = 0; return n
|
|
||||||
}
|
|
||||||
func take() -> ReadyFrame? {
|
|
||||||
lock.lock(); defer { lock.unlock() }
|
|
||||||
let f = frame; frame = nil; return f
|
|
||||||
}
|
|
||||||
/// Return a frame the display link took but could not present (a transient `nextDrawable`
|
|
||||||
/// failure). Kept only while the slot is still empty — a newer decoded frame wins, so
|
|
||||||
/// newest-ready ordering is preserved. Without this, a failed render silently LOSES the
|
|
||||||
/// frame, and under the host's infinite GOP a static scene sends no replacement until the
|
|
||||||
/// next damage — the stale picture would persist.
|
|
||||||
func putBack(_ f: ReadyFrame) {
|
|
||||||
lock.lock()
|
lock.lock()
|
||||||
if frame == nil { frame = f }
|
if isFifo {
|
||||||
|
frames.append(f)
|
||||||
|
if frames.count > capacity {
|
||||||
|
frames.removeFirst() // oldest goes — bounded latency, the newest keeps flowing
|
||||||
|
overflowDrops += 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
frames = [f] // newest wins; the replaced frame is the intended drop point
|
||||||
|
}
|
||||||
|
submitted += 1
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func drainSubmitted() -> Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
let n = submitted
|
||||||
|
submitted = 0
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take-and-reset the smoothness counters (the pf-present `qDrop`/`qDry` stats).
|
||||||
|
func drainSmoothing() -> (overflowDrops: Int, underflows: Int) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
let out = (overflowDrops, underflows)
|
||||||
|
overflowDrops = 0
|
||||||
|
underflows = 0
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func take() -> Frame? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if isFifo {
|
||||||
|
if !prerolled {
|
||||||
|
guard frames.count >= capacity else { return nil } // still building headroom
|
||||||
|
prerolled = true
|
||||||
|
}
|
||||||
|
guard !frames.isEmpty else {
|
||||||
|
underflows += 1 // ran dry — repeat by omission, rebuild headroom
|
||||||
|
prerolled = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return frames.removeFirst()
|
||||||
|
}
|
||||||
|
let f = frames.first
|
||||||
|
frames.removeAll(keepingCapacity: true)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a frame the render thread took but could not present (no drawable yet, or a
|
||||||
|
/// transient render failure). Newest-wins keeps it only while the slot is still empty — a
|
||||||
|
/// newer decoded frame wins; FIFO reinserts it at the FRONT (it is the oldest; a transient
|
||||||
|
/// capacity+1 is trimmed by the next submit). Without this, a failed present silently LOSES
|
||||||
|
/// the frame, and under the host's infinite GOP a static scene sends no replacement until
|
||||||
|
/// the next damage — the stale picture would persist.
|
||||||
|
func putBack(_ f: Frame) {
|
||||||
|
lock.lock()
|
||||||
|
if isFifo {
|
||||||
|
frames.insert(f, at: 0)
|
||||||
|
} else if frames.isEmpty {
|
||||||
|
frames = [f]
|
||||||
|
}
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,63 +206,211 @@ private final class VsyncClock: @unchecked Sendable {
|
|||||||
/// When a ready frame is pushed to the layer — the stage-2 vs stage-3 presenter split. Same decode
|
/// When a ready frame is pushed to the layer — the stage-2 vs stage-3 presenter split. Same decode
|
||||||
/// half, same newest-wins ring; only the present cadence differs.
|
/// half, same newest-wins ring; only the present cadence differs.
|
||||||
///
|
///
|
||||||
/// - `arrival` (stage-2, the default): present the moment a frame is decoded. Lowest latency while
|
/// - `arrival` (stage-2, the macOS default): present the moment a frame is decoded. Lowest latency
|
||||||
/// the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable per
|
/// while the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable
|
||||||
/// refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
|
/// per refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
|
||||||
/// presents the same way when composited), so at stream rate ≈ refresh rate its depth is STICKY:
|
/// presents the same way when composited), so at stream rate ≈ refresh rate its depth is STICKY:
|
||||||
/// one early burst (session start, a Wi-Fi clump) fills it to `maximumDrawableCount` and — with
|
/// one early burst (session start, a Wi-Fi clump) fills it to `maximumDrawableCount` and — with
|
||||||
/// arrivals and latches then running at the same rate — it never drains. Every later frame rides
|
/// arrivals and latches then running at the same rate — it never drains. Every later frame rides
|
||||||
/// ~2–3 refreshes of queue (the measured 29–30 ms display stage on 120 Hz ProMotion panels), and
|
/// ~2–3 refreshes of queue (the measured 23–30 ms display stage on 120 Hz ProMotion panels), and
|
||||||
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
||||||
/// "fixed-interval" jitter reports).
|
/// "fixed-interval" jitter reports).
|
||||||
/// - `glass` (stage-3, experimental): at most ONE presented-but-undisplayed drawable in flight
|
/// - `glass` (stage-3, the tvOS default): at most a small BOUNDED number of presented-but-
|
||||||
/// (`PresentGate`). The render thread presents only when the previous flip reached glass (the
|
/// undisplayed drawables in flight (`PresentGate`; depth 1 — see `SessionPresenter.gateDepth`
|
||||||
/// drawable's presented handler reopens the gate and re-signals); frames decoded meanwhile
|
/// for why deeper is a regression). The render thread presents only while a gate slot is free
|
||||||
|
/// (a drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
|
||||||
/// 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. The residual cost: presents serialize on the on-glass
|
||||||
|
/// callback, whose own delivery latency pushes each present ~a refresh past the frame's decode
|
||||||
|
/// (the field-measured 14 ms display stage at 120 Hz vs the ~half-refresh floor).
|
||||||
|
/// - `deadline` (stage-4, the iOS/iPadOS default; iOS/tvOS only — see
|
||||||
|
/// `PresenterChoice.explicit`): a CAMetalDisplayLink vends ONE drawable per refresh
|
||||||
|
/// (`preferredFrameLatency` 1) into a newest-wins hand-off slot, and the render thread pairs
|
||||||
|
/// it with the newest decoded frame THE MOMENT either half arrives — usually the frame, into
|
||||||
|
/// an already-vended drawable. The image queue cannot exist (one vended drawable in flight,
|
||||||
|
/// ever), nothing serializes on the on-glass callback (the link's next vend is the pace), and
|
||||||
|
/// the present is deadline-timed by the system to latch the upcoming refresh. This is the only
|
||||||
|
/// pacing whose steady state can reach the sub-refresh display floor on the always-vsync-latch
|
||||||
|
/// platforms; `arrival`/`glass` remain the on-device A/B rungs.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
case deadline
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stage-3's present gate: admits one in-flight (presented, not yet on glass) drawable. The render
|
/// Newest-wins 1-slot hand-off box (the generic sibling of `ReadyRing`): deadline pacing's
|
||||||
/// thread `tryAcquire`s before taking a frame; the drawable's presented handler `release`s and
|
/// drawable stash — the link thread `put`s each update's vended drawable (replacing an
|
||||||
/// re-signals the render thread. `staleAfter` is insurance against a present whose handler never
|
/// unpresented older one, which just returns to the layer's pool), the render thread `take`s.
|
||||||
/// fires (the macOS "out-of-band presents aren't damage" hazard class — see MetalVideoPresenter's
|
/// `putBack` returns a taken value only while the slot is still empty, so a fresher `put` from
|
||||||
/// init post-mortem): rather than freezing the stream, a stuck gate force-opens after 100 ms, a
|
/// the other thread is never clobbered by a stale return. Internal (not private) for unit tests.
|
||||||
/// visible ~10 fps degradation that PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0
|
/// Sendable; lock-guarded.
|
||||||
/// on healthy systems). Internal (not private) for unit tests. Sendable; lock-guarded — the
|
final class LatestBox<T>: @unchecked Sendable {
|
||||||
/// releaser runs on a Metal callback thread.
|
private let lock = NSLock()
|
||||||
|
private var value: T?
|
||||||
|
func put(_ v: T) { lock.lock(); value = v; lock.unlock() }
|
||||||
|
func putBack(_ v: T) {
|
||||||
|
lock.lock()
|
||||||
|
if value == nil { value = v }
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
func take() -> T? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
let v = value
|
||||||
|
value = nil
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deadline pacing's staged frame-rate hint. SessionPresenter pushes the stream rate from the
|
||||||
|
/// MAIN thread (session start + every layout/Reconfigure); the link's own thread drains and
|
||||||
|
/// applies it, so the CAMetalDisplayLink is only ever touched from the thread that runs it. The
|
||||||
|
/// floor is PINNED at the stream rate — no idle ramp-down: with a low floor the link idles toward
|
||||||
|
/// it on a static scene (infinite GOP ⇒ no frames), and the first damage frame after idle would
|
||||||
|
/// wait out a slow tick before it could present. Empty wakes at stream rate are near-free; the
|
||||||
|
/// PANEL still idles via VRR because no presents happen. Sendable; lock-guarded.
|
||||||
|
private final class FrameRateHint: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var pending: CAFrameRateRange?
|
||||||
|
func stage(hz: Float) {
|
||||||
|
guard hz > 0 else { return }
|
||||||
|
lock.lock()
|
||||||
|
pending = CAFrameRateRange(minimum: hz, maximum: max(hz, 120), preferred: hz)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
func drain() -> CAFrameRateRange? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
let p = pending
|
||||||
|
pending = nil
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CAMetalDisplayLink delegate for deadline pacing: each per-refresh update stashes its
|
||||||
|
/// vended drawable (newest wins) and nudges the render thread — which also wakes on decoder
|
||||||
|
/// arrivals, so whichever half completes the (frame, drawable) pair triggers the present. Also
|
||||||
|
/// applies the staged frame-rate hint from the link's own thread. Retained by the link thread's
|
||||||
|
/// closure (the link holds it weak); captures only the shared boxes, never the pipeline — the
|
||||||
|
/// same no-self-capture rule as the pump/render threads.
|
||||||
|
private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
||||||
|
private let stash: LatestBox<CAMetalDrawable>
|
||||||
|
private let renderSignal: DispatchSemaphore
|
||||||
|
private let hint: FrameRateHint
|
||||||
|
private let stats: PresentDebugStats?
|
||||||
|
/// The OS-floor sampler (design/apple-presentation-rebuild.md): every update's vend→glass
|
||||||
|
/// lead is recorded so its p50 becomes the "OS present floor" the HUD subtracts from the
|
||||||
|
/// shown display/e2e numbers. Self-adapting — reads ~2 refresh periods composited today,
|
||||||
|
/// would read ~1 under direct-to-display, tracks VRR rate changes.
|
||||||
|
private let floorMeter: LatencyMeter?
|
||||||
|
/// One-shot: log the link's EFFECTIVE preferredFrameLatency after the first re-assert —
|
||||||
|
/// reads 1 while vendLeadMs sits at ~2 periods ⇒ the scheduler ignores the request while
|
||||||
|
/// the layer is composited (the promotion hunt); reads 2 ⇒ the system clamped it outright.
|
||||||
|
private var loggedEffective = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
|
||||||
|
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?
|
||||||
|
) {
|
||||||
|
self.stash = stash
|
||||||
|
self.renderSignal = renderSignal
|
||||||
|
self.hint = hint
|
||||||
|
self.stats = stats
|
||||||
|
self.floorMeter = floorMeter
|
||||||
|
}
|
||||||
|
|
||||||
|
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
|
||||||
|
if let range = hint.drain(), link.preferredFrameRateRange != range {
|
||||||
|
link.preferredFrameRateRange = range
|
||||||
|
}
|
||||||
|
// Re-assert the minimum-latency request every update (cheap compare): it was set once
|
||||||
|
// before add(to:), and whether a pre-add set survives scheduling is exactly the kind of
|
||||||
|
// thing the vendLeadMs stat exists to catch — belt and braces.
|
||||||
|
if link.preferredFrameLatency != 1 { link.preferredFrameLatency = 1 }
|
||||||
|
if !loggedEffective {
|
||||||
|
loggedEffective = true
|
||||||
|
let range = link.preferredFrameRateRange
|
||||||
|
let msg = String(
|
||||||
|
format: "deadline link up: effective preferredFrameLatency=%.2f "
|
||||||
|
+ "range=%.0f-%.0f preferred=%.0f",
|
||||||
|
link.preferredFrameLatency, range.minimum, range.maximum, range.preferred ?? 0)
|
||||||
|
presentLog.info("\(msg, privacy: .public)")
|
||||||
|
}
|
||||||
|
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
|
||||||
|
let leadS = update.targetPresentationTimestamp - CACurrentMediaTime()
|
||||||
|
stats?.vendLead(ms: leadS * 1000)
|
||||||
|
// Same measurement into the floor meter (as a LatencyMeter sample: end = now, start =
|
||||||
|
// now − lead) — its 1 s p50 is the OS present floor SessionModel shaves off.
|
||||||
|
if leadS > 0, let floorMeter {
|
||||||
|
var ts = timespec()
|
||||||
|
clock_gettime(CLOCK_REALTIME, &ts)
|
||||||
|
let nowNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||||
|
floorMeter.record(
|
||||||
|
ptsNs: UInt64(nowNs - Int64(leadS * 1_000_000_000)), atNs: nowNs, offsetNs: 0)
|
||||||
|
}
|
||||||
|
stash.put(update.drawable)
|
||||||
|
renderSignal.signal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage-3's present gate: admits `capacity` in-flight (presented, not yet on glass) drawables.
|
||||||
|
/// The render thread `tryAcquire`s before taking a frame; the drawable's presented handler
|
||||||
|
/// `release`s and re-signals the render thread. Depth 1 fully serializes presents on the on-glass
|
||||||
|
/// callback — which costs a refresh whenever the callback's own latency pushes the next present
|
||||||
|
/// past a vsync; depth 2 keeps one flip queued behind the one scanning out, so a decoded frame
|
||||||
|
/// presents immediately and latches the very next vsync while the queue still can't build (see
|
||||||
|
/// `SessionPresenter.gateDepth` for the per-platform choice). `staleAfter` is insurance against a
|
||||||
|
/// present whose handler never fires (the macOS "out-of-band presents aren't damage" hazard class
|
||||||
|
/// — see MetalVideoPresenter's init post-mortem): rather than freezing the stream, a full gate
|
||||||
|
/// force-opens a slot 100 ms after its oldest present, a visible ~10 fps degradation that
|
||||||
|
/// PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0 on healthy systems). Internal
|
||||||
|
/// (not private) for unit tests. Sendable; lock-guarded — the releaser runs on a Metal callback
|
||||||
|
/// thread.
|
||||||
final class PresentGate: @unchecked Sendable {
|
final class PresentGate: @unchecked Sendable {
|
||||||
/// How long one pending present may hold the gate before it's presumed lost.
|
/// How long one pending present may hold its slot before it's presumed lost.
|
||||||
static let staleAfter: CFTimeInterval = 0.1
|
static let staleAfter: CFTimeInterval = 0.1
|
||||||
|
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var pending = false
|
private let capacity: Int
|
||||||
private var armedAt: CFTimeInterval = 0
|
/// Arm instants of the in-flight presents, oldest first (≤ `capacity` entries).
|
||||||
|
private var armed: [CFTimeInterval] = []
|
||||||
private var forced = 0
|
private var forced = 0
|
||||||
|
|
||||||
/// Arm the gate for one present. False = a present is already in flight (and not stale) —
|
/// `capacity` = the in-flight present budget (clamped to ≥ 1) — see the type doc.
|
||||||
/// leave the frame in the ring; the presented handler's release/re-signal (or the next
|
init(capacity: Int = 1) {
|
||||||
|
self.capacity = max(1, capacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arm the gate for one present. False = the gate is full of live presents (none stale) —
|
||||||
|
/// leave the frame in the ring; a presented handler's release/re-signal (or the next
|
||||||
/// display-link tick) retries with the freshest frame then.
|
/// display-link tick) retries with the freshest frame then.
|
||||||
func tryAcquire(now: CFTimeInterval) -> Bool {
|
func tryAcquire(now: CFTimeInterval) -> Bool {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
if pending {
|
if armed.count >= capacity {
|
||||||
guard now - armedAt > Self.staleAfter else { return false }
|
// Full: reopen only by presuming the OLDEST in-flight present lost (its handler
|
||||||
forced += 1 // presumed-lost present — reopen rather than stall the stream
|
// never fired) rather than stalling the stream.
|
||||||
|
guard let oldest = armed.first, now - oldest > Self.staleAfter else { return false }
|
||||||
|
armed.removeFirst()
|
||||||
|
forced += 1
|
||||||
}
|
}
|
||||||
pending = true
|
armed.append(now)
|
||||||
armedAt = now
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The in-flight present reached glass (or was dropped, or its render failed before a present
|
/// One in-flight present reached glass (or was dropped, or its render failed before a present
|
||||||
/// was registered) — reopen. Idempotent: a late stale-path double-release is harmless.
|
/// was registered) — free the oldest slot. A release with nothing in flight is a no-op; a
|
||||||
|
/// lost present's handler firing late after its stale force-open can transiently over-admit
|
||||||
|
/// one flip, which the next glass callback corrects.
|
||||||
func release() {
|
func release() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
pending = false
|
if !armed.isEmpty { armed.removeFirst() }
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,13 +431,23 @@ final class PresentGate: @unchecked Sendable {
|
|||||||
private final class PresentDebugStats: @unchecked Sendable {
|
private final class PresentDebugStats: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var last = CACurrentMediaTime()
|
private var last = CACurrentMediaTime()
|
||||||
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0
|
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0, noDrawable = 0
|
||||||
private var maxRenderMs = 0.0
|
private var maxRenderMs = 0.0
|
||||||
private var lastGlassNs: Int64 = 0
|
private var lastGlassNs: Int64 = 0
|
||||||
private var glassDeltasMs: [Double] = []
|
private var glassDeltasMs: [Double] = []
|
||||||
|
/// Present-issue → on-glass delay per frame (system presentedTime minus the render call's
|
||||||
|
/// start) — the DIRECT decomposition of the display stage: ring/pairing wait lives upstream
|
||||||
|
/// of it, queue + present-pipeline cost inside it. Standing queue reads as ~n×period here;
|
||||||
|
/// a healthy latch reads under one period.
|
||||||
|
private var latchMs: [Double] = []
|
||||||
|
/// Deadline pacing: the link's own pipeline depth — `targetPresentationTimestamp - now` at
|
||||||
|
/// each update. ~1 period means preferredFrameLatency=1 is honored (a vended drawable can
|
||||||
|
/// reach glass at the NEXT refresh); ~2 periods means the system is running a frame ahead
|
||||||
|
/// and one whole refresh of the display stage lives INSIDE the link, not in our pairing.
|
||||||
|
private var vendLeadMs: [Double] = []
|
||||||
/// Presented-but-not-yet-on-glass drawables right now / the window's peak — the direct
|
/// Presented-but-not-yet-on-glass drawables right now / the window's peak — the direct
|
||||||
/// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a
|
/// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a
|
||||||
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 should peg it at 1).
|
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
|
||||||
private var inFlight = 0
|
private var inFlight = 0
|
||||||
private var maxInFlight = 0
|
private var maxInFlight = 0
|
||||||
|
|
||||||
@@ -200,6 +458,14 @@ private final class PresentDebugStats: @unchecked Sendable {
|
|||||||
/// is normal, it just shows the gate working.
|
/// is normal, it just shows the gate working.
|
||||||
func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
|
func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
|
||||||
|
|
||||||
|
/// Deadline pacing: a decoded frame is waiting but the link hasn't vended this interval's
|
||||||
|
/// drawable yet — the frame presents on the link's next update. A high count just means
|
||||||
|
/// decode outruns the link's phase; the wait is bounded by one refresh.
|
||||||
|
func noDrawableWake() { lock.lock(); noDrawable += 1; lock.unlock() }
|
||||||
|
|
||||||
|
/// Deadline pacing, LINK thread: one update's vend-to-target distance (see `vendLeadMs`).
|
||||||
|
func vendLead(ms: Double) { lock.lock(); vendLeadMs.append(ms); lock.unlock() }
|
||||||
|
|
||||||
func renderReturned(ok rendered: Bool, tookMs: Double) {
|
func renderReturned(ok rendered: Bool, tookMs: Double) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
if rendered {
|
if rendered {
|
||||||
@@ -213,40 +479,59 @@ private final class PresentDebugStats: @unchecked Sendable {
|
|||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func presented(atNs: Int64?) {
|
func presented(atNs: Int64?, issuedNs: Int64) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
|
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
|
||||||
if let atNs {
|
if let atNs {
|
||||||
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
||||||
lastGlassNs = atNs
|
lastGlassNs = atNs
|
||||||
|
latchMs.append(Double(atNs - issuedNs) / 1e6)
|
||||||
} else {
|
} else {
|
||||||
dropped += 1
|
dropped += 1
|
||||||
}
|
}
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
|
func flushIfDue(ring: FrameStore<ReadyFrame>, gate: PresentGate?) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
let now = CACurrentMediaTime()
|
let now = CACurrentMediaTime()
|
||||||
guard now - last >= 1 else { lock.unlock(); return }
|
guard now - last >= 1 else { lock.unlock(); return }
|
||||||
last = now
|
last = now
|
||||||
let decoded = ring.drainSubmitted()
|
let decoded = ring.drainSubmitted()
|
||||||
|
let smoothing = ring.drainSmoothing()
|
||||||
let deltas = glassDeltasMs.sorted()
|
let deltas = glassDeltasMs.sorted()
|
||||||
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
||||||
let dMax = deltas.last ?? 0
|
let dMax = deltas.last ?? 0
|
||||||
|
let latches = latchMs.sorted()
|
||||||
|
let latchP50 = latches.isEmpty ? 0 : latches[latches.count / 2]
|
||||||
|
let latchMax = latches.last ?? 0
|
||||||
|
let vends = vendLeadMs.sorted()
|
||||||
|
let vendP50 = vends.isEmpty ? 0 : vends[vends.count / 2]
|
||||||
|
let vendMax = vends.last ?? 0
|
||||||
let inflightMax = maxInFlight
|
let inflightMax = maxInFlight
|
||||||
let line = String(
|
let line = String(
|
||||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d dropped=%d "
|
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d "
|
||||||
+ "maxRenderMs=%.1f inflightMax=%d forced=%d glassDeltaMs p50=%.2f max=%.2f n=%d",
|
+ "dropped=%d qDrop=%d qDry=%d maxRenderMs=%.1f inflightMax=%d forced=%d "
|
||||||
decoded, ok, failed, empty, gated, dropped, maxRenderMs, inflightMax,
|
+ "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f "
|
||||||
gate?.drainForced() ?? 0, p50, dMax, deltas.count)
|
+ "vendLeadMs p50=%.2f max=%.2f",
|
||||||
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0
|
decoded, ok, failed, empty, gated, noDrawable, dropped,
|
||||||
|
smoothing.overflowDrops, smoothing.underflows, maxRenderMs, inflightMax,
|
||||||
|
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
|
||||||
|
vendP50, vendMax)
|
||||||
|
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
|
||||||
maxRenderMs = 0
|
maxRenderMs = 0
|
||||||
maxInFlight = inFlight // the window peak restarts from the live depth
|
maxInFlight = inFlight // the window peak restarts from the live depth
|
||||||
glassDeltasMs.removeAll(keepingCapacity: true)
|
glassDeltasMs.removeAll(keepingCapacity: true)
|
||||||
|
latchMs.removeAll(keepingCapacity: true)
|
||||||
|
vendLeadMs.removeAll(keepingCapacity: true)
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
print(line)
|
// Console.app first (the on-device readout — see presentLog); stdout only under the env
|
||||||
fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows
|
// lever (the CLI client's capture channel).
|
||||||
|
presentLog.info("\(line, privacy: .public)")
|
||||||
|
if presentDebug {
|
||||||
|
print(line)
|
||||||
|
fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,15 +558,27 @@ private final class DecodeReport: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public final class Stage2Pipeline {
|
public final class Stage2Pipeline {
|
||||||
private let ring = ReadyRing()
|
private let ring: FrameStore<ReadyFrame>
|
||||||
private let presenter: MetalVideoPresenter
|
private let presenter: MetalVideoPresenter
|
||||||
private let decoder: VideoDecoder
|
private let decoder: VideoDecoder
|
||||||
/// Present cadence — `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
|
/// Present cadence — `.arrival` (stage-2), `.glass` (stage-3, the present gate) or
|
||||||
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
/// `.deadline` (stage-4, the CAMetalDisplayLink engine). Fixed for the pipeline's lifetime;
|
||||||
|
/// SessionPresenter resolves it per session (see PresentPacing).
|
||||||
private let pacing: PresentPacing
|
private let pacing: PresentPacing
|
||||||
|
/// The glass gate's in-flight present budget (`PresentGate` capacity) — meaningful only under
|
||||||
|
/// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`).
|
||||||
|
private let gateDepth: Int
|
||||||
|
/// macOS smoothness: pace presents onto the vsync grid (`present(at:)` via the VsyncClock),
|
||||||
|
/// at most one per vsync, so the FIFO store drains on the display's cadence rather than on
|
||||||
|
/// arrival. Ignored under `.deadline` (the link IS the cadence there).
|
||||||
|
private let vsyncPaced: Bool
|
||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
|
/// The measured OS present floor (deadline pacing only): each link update's vend→glass lead
|
||||||
|
/// is recorded here, and its p50 is what SessionModel subtracts from the shown display/e2e
|
||||||
|
/// numbers — the pipeline-depth cost no client controls (design/apple-presentation-rebuild.md).
|
||||||
|
private let presentFloorMeter: LatencyMeter?
|
||||||
private let recovery = KeyframeRecovery()
|
private let recovery = KeyframeRecovery()
|
||||||
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
||||||
/// binds the live connection + arming flag (see DecodeReport).
|
/// binds the live connection + arming flag (see DecodeReport).
|
||||||
@@ -312,6 +609,9 @@ public final class Stage2Pipeline {
|
|||||||
private let vsyncClock = VsyncClock()
|
private let vsyncClock = VsyncClock()
|
||||||
private let renderStopped = DispatchSemaphore(value: 0)
|
private let renderStopped = DispatchSemaphore(value: 0)
|
||||||
private var renderJoinable = false
|
private var renderJoinable = false
|
||||||
|
/// Deadline pacing's staged CAMetalDisplayLink frame-rate hint (see `FrameRateHint`).
|
||||||
|
/// Created unconditionally (cheap); only the deadline link thread drains it.
|
||||||
|
private let frameRateHint = FrameRateHint()
|
||||||
|
|
||||||
/// The Metal layer the hosting view installs + sizes.
|
/// The Metal layer the hosting view installs + sizes.
|
||||||
public var layer: CAMetalLayer { presenter.layer }
|
public var layer: CAMetalLayer { presenter.layer }
|
||||||
@@ -322,19 +622,28 @@ public final class Stage2Pipeline {
|
|||||||
/// render + vsync — the tail stage-2 exists to shorten). All optional: metering never gates
|
/// render + vsync — the tail stage-2 exists to shorten). All optional: metering never gates
|
||||||
/// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) — caller
|
/// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) — caller
|
||||||
/// falls back to the stage-1 presenter. `pacing` selects the stage-2 (arrival) vs stage-3
|
/// falls back to the stage-1 presenter. `pacing` selects the stage-2 (arrival) vs stage-3
|
||||||
/// (glass-gated) present cadence — see PresentPacing.
|
/// (glass-gated) present cadence — see PresentPacing; `gateDepth` is the glass gate's
|
||||||
|
/// in-flight present budget (see `SessionPresenter.gateDepth`).
|
||||||
public init?(
|
public init?(
|
||||||
endToEndMeter: LatencyMeter?,
|
endToEndMeter: LatencyMeter?,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil,
|
||||||
pacing: PresentPacing = .arrival
|
presentFloorMeter: LatencyMeter? = nil,
|
||||||
|
pacing: PresentPacing = .arrival,
|
||||||
|
gateDepth: Int = 1,
|
||||||
|
storePolicy: FrameStorePolicy = .newestWins,
|
||||||
|
vsyncPaced: Bool = false
|
||||||
) {
|
) {
|
||||||
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||||
self.presenter = presenter
|
self.presenter = presenter
|
||||||
self.pacing = pacing
|
self.pacing = pacing
|
||||||
|
self.gateDepth = gateDepth
|
||||||
|
self.vsyncPaced = vsyncPaced
|
||||||
|
self.ring = FrameStore(policy: storePolicy)
|
||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
|
self.presentFloorMeter = presentFloorMeter
|
||||||
let ring = ring
|
let ring = ring
|
||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
let renderSignal = renderSignal
|
let renderSignal = renderSignal
|
||||||
@@ -431,6 +740,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
|
||||||
@@ -500,6 +818,17 @@ public final class Stage2Pipeline {
|
|||||||
pumpJoinable = true
|
pumpJoinable = true
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
// The present half. Deadline pacing (stage-4) swaps it wholesale: a CAMetalDisplayLink
|
||||||
|
// vends the drawables and its per-refresh updates co-drive the render thread — see
|
||||||
|
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
|
||||||
|
// times every present). Deadline sessions ALWAYS carry the stats (their pf-present line
|
||||||
|
// streams to Console.app via presentLog — the on-device pacing decomposition).
|
||||||
|
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
|
||||||
|
if pacing == .deadline {
|
||||||
|
startDeadlinePresenter(debugStats: debugStats)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// The render thread: one present per display-link signal. It owns every layer format/colour/
|
// The render thread: one present per display-link signal. It owns every layer format/colour/
|
||||||
// drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on,
|
// drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on,
|
||||||
// nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is
|
// nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is
|
||||||
@@ -514,16 +843,21 @@ public final class Stage2Pipeline {
|
|||||||
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
|
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
|
||||||
// Resolved once per session.
|
// Resolved once per session.
|
||||||
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
|
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
|
||||||
let vsyncEnabled = presentMode == "vsync"
|
// `vsyncPaced` (macOS smoothness) FORCES vsync scheduling — the FIFO store must drain
|
||||||
|
// on the display cadence, one frame per vsync, or the buffer degenerates to arrival.
|
||||||
|
let vsyncPaced = vsyncPaced
|
||||||
|
let vsyncEnabled = vsyncPaced || presentMode == "vsync"
|
||||||
|| (presentMode != "immediate"
|
|| (presentMode != "immediate"
|
||||||
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
||||||
let debugStats = presentDebug ? PresentDebugStats() : nil
|
|
||||||
let vsyncClock = vsyncClock
|
let vsyncClock = vsyncClock
|
||||||
// Stage-3's one-in-flight present gate; nil = stage-2's present-on-arrival. A local (like
|
// Stage-3's bounded in-flight present gate; nil = stage-2's present-on-arrival. A local
|
||||||
// the ring) so neither the render thread nor the presented handlers capture `self`.
|
// (like the ring) so neither the render thread nor the presented handlers capture `self`.
|
||||||
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
|
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
|
||||||
let renderThread = Thread {
|
let renderThread = Thread {
|
||||||
defer { renderStopped.signal() }
|
defer { renderStopped.signal() }
|
||||||
|
// macOS smoothness: the vsync this thread last presented onto — at most ONE present
|
||||||
|
// per vsync so the FIFO drains on the display's cadence. Thread-confined.
|
||||||
|
var lastPresentTarget: CFTimeInterval = 0
|
||||||
// Every iteration drains its own autorelease pool (`return` = the old `continue`):
|
// Every iteration drains its own autorelease pool (`return` = the old `continue`):
|
||||||
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable —
|
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable —
|
||||||
// without a per-iteration pool every presented frame's drawable object (plus its
|
// without a per-iteration pool every presented frame's drawable object (plus its
|
||||||
@@ -533,6 +867,15 @@ public final class Stage2Pipeline {
|
|||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Smoothness pacing: this vsync's present slot already taken — the frame stays
|
||||||
|
// in the store, and the next display-link tick re-signals. (Tolerance well under
|
||||||
|
// any refresh period; a stale clock ⇒ nil target ⇒ no dedup, present flows.)
|
||||||
|
if vsyncPaced, let t = vsyncClock.nextVsync(after: CACurrentMediaTime()),
|
||||||
|
abs(t - lastPresentTarget) < 0.002 {
|
||||||
|
debugStats?.gatedWake()
|
||||||
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Stage-3: while a present is in flight, don't take from the ring at all — frames
|
// Stage-3: while a present is in flight, don't take from the ring at all — frames
|
||||||
// keep coalescing there (newest wins, the intended drop point) and the presented
|
// keep coalescing there (newest wins, the intended drop point) and the presented
|
||||||
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
|
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
|
||||||
@@ -553,6 +896,7 @@ public final class Stage2Pipeline {
|
|||||||
let presentAt = vsyncEnabled
|
let presentAt = vsyncEnabled
|
||||||
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
||||||
let renderStarted = CACurrentMediaTime()
|
let renderStarted = CACurrentMediaTime()
|
||||||
|
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
|
||||||
let onGlass: (Int64?) -> Void = { presentedNs in
|
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||||
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
||||||
// then re-signal so the freshest waiting ring frame goes out immediately.
|
// then re-signal so the freshest waiting ring frame goes out immediately.
|
||||||
@@ -570,7 +914,7 @@ public final class Stage2Pipeline {
|
|||||||
// Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME,
|
// Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME,
|
||||||
// so no skew offset applies.
|
// so no skew offset applies.
|
||||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||||
debugStats?.presented(atNs: presentedNs)
|
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
|
||||||
}
|
}
|
||||||
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the
|
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the
|
||||||
// PyroWave Metal planes — the ring, pacing and meters are agnostic to which.
|
// PyroWave Metal planes — the ring, pacing and meters are agnostic to which.
|
||||||
@@ -589,6 +933,8 @@ public final class Stage2Pipeline {
|
|||||||
if !rendered {
|
if !rendered {
|
||||||
gate?.release() // no present registered — its handler will never fire
|
gate?.release() // no present registered — its handler will never fire
|
||||||
ring.putBack(frame)
|
ring.putBack(frame)
|
||||||
|
} else if vsyncPaced, let presentAt {
|
||||||
|
lastPresentTarget = presentAt // this vsync's slot is now taken
|
||||||
}
|
}
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
} }
|
} }
|
||||||
@@ -599,22 +945,186 @@ public final class Stage2Pipeline {
|
|||||||
renderThread.start()
|
renderThread.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deadline pacing's present half (stage-4 — see `PresentPacing.deadline`): a
|
||||||
|
/// CAMetalDisplayLink on its own runloop thread vends ONE drawable per refresh into the
|
||||||
|
/// newest-wins stash, and the render thread pairs it with the newest decoded frame the
|
||||||
|
/// moment either half completes the pair — the common case is a decoded frame presenting
|
||||||
|
/// instantly into an already-vended drawable, which the system then latches at the upcoming
|
||||||
|
/// refresh (`preferredFrameLatency` 1). No image queue can form (one vended drawable in
|
||||||
|
/// flight, ever) and nothing serializes on the on-glass callback. An unpresented stashed
|
||||||
|
/// drawable is simply replaced by the next update (back to the layer's pool), so the stash
|
||||||
|
/// is never stale by more than a refresh while the link runs.
|
||||||
|
///
|
||||||
|
/// Threading mirrors the arrival/glass half: neither thread captures `self`; the link is
|
||||||
|
/// created, driven and invalidated entirely on its own thread (CAMetalDisplayLink is only
|
||||||
|
/// ever touched there — the frame-rate hint crosses via `FrameRateHint`); the link thread's
|
||||||
|
/// runloop iterations each drain an autorelease pool (a vended CAMetalDrawable is
|
||||||
|
/// autoreleased like a `nextDrawable()` one — see the render loop's identical rule); the
|
||||||
|
/// 100 ms runloop horizon is the stop-flag poll, so teardown is bounded without a join.
|
||||||
|
private func startDeadlinePresenter(debugStats: PresentDebugStats?) {
|
||||||
|
let token = token
|
||||||
|
let ring = ring
|
||||||
|
let renderSignal = renderSignal
|
||||||
|
let renderStopped = renderStopped
|
||||||
|
let presenter = presenter
|
||||||
|
let endToEndMeter = endToEndMeter
|
||||||
|
let displayMeter = displayMeter
|
||||||
|
let offsetNs = offsetNs
|
||||||
|
let hint = frameRateHint
|
||||||
|
let layer = presenter.layer
|
||||||
|
let stash = LatestBox<CAMetalDrawable>()
|
||||||
|
|
||||||
|
let floorMeter = presentFloorMeter
|
||||||
|
// The link starts LAZILY — the render thread triggers this after the FIRST decoded
|
||||||
|
// frame's reconcileLayer. Started eagerly it vends into the layer's initial 0×0
|
||||||
|
// drawableSize for the whole connect window: every vend fails allocation and the system
|
||||||
|
// logs "[CAMetalLayer nextDrawable] returning nil because allocation failed" once per
|
||||||
|
// refresh until the first frame arrives. Before that frame there is nothing to present
|
||||||
|
// anyway, and the first frame waits at most one refresh for the first vend.
|
||||||
|
let startLink: () -> Void = {
|
||||||
|
let linkThread = Thread {
|
||||||
|
let delegate = DeadlineLinkDelegate(
|
||||||
|
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
|
||||||
|
floorMeter: floorMeter)
|
||||||
|
let link = CAMetalDisplayLink(metalLayer: layer)
|
||||||
|
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
|
||||||
|
if let range = hint.drain() { link.preferredFrameRateRange = range }
|
||||||
|
link.delegate = delegate // weak — this closure is the strong ref
|
||||||
|
link.add(to: RunLoop.current, forMode: .default)
|
||||||
|
while !token.isStopped {
|
||||||
|
autoreleasepool {
|
||||||
|
_ = RunLoop.current.run(
|
||||||
|
mode: .default, before: Date(timeIntervalSinceNow: 0.1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
link.invalidate()
|
||||||
|
}
|
||||||
|
linkThread.name = "punktfunk-stage4-link"
|
||||||
|
linkThread.qualityOfService = .userInteractive
|
||||||
|
linkThread.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderThread = Thread {
|
||||||
|
defer { renderStopped.signal() }
|
||||||
|
// Whether startLink ran — render-thread confined (only this thread triggers it).
|
||||||
|
var linkLive = false
|
||||||
|
// Per-iteration autorelease pool — same contract as the arrival/glass loop (the
|
||||||
|
// vended drawable and its retinue are autoreleased objects on a runloop-less thread).
|
||||||
|
while !token.isStopped { autoreleasepool {
|
||||||
|
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
|
||||||
|
debugStats?.flushIfDue(ring: ring, gate: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Present needs the PAIR — frame first. The frame drives the layer reconcile,
|
||||||
|
// which must run even when NO drawable is vended yet: the link vends from the
|
||||||
|
// layer's CURRENT config, so drawableSize/format have to be right before a vend
|
||||||
|
// can succeed at all (see reconcileLayer — the session-start bootstrap, where
|
||||||
|
// the layer still has its initial 0×0 size and every vend fails allocation).
|
||||||
|
guard !token.isStopped, let frame = ring.take() else {
|
||||||
|
debugStats?.emptyWake()
|
||||||
|
debugStats?.flushIfDue(ring: ring, gate: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch frame.image {
|
||||||
|
case .video(let pixelBuffer, let isHDR):
|
||||||
|
presenter.reconcileLayer(
|
||||||
|
decodedSize: CGSize(
|
||||||
|
width: CVPixelBufferGetWidth(pixelBuffer),
|
||||||
|
height: CVPixelBufferGetHeight(pixelBuffer)),
|
||||||
|
isHDR: isHDR)
|
||||||
|
case .planar(let planes):
|
||||||
|
presenter.reconcileLayer(
|
||||||
|
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||||
|
isHDR: planes.pq)
|
||||||
|
}
|
||||||
|
// First frame: the layer now has a real config — start vending (see startLink).
|
||||||
|
if !linkLive {
|
||||||
|
linkLive = true
|
||||||
|
startLink()
|
||||||
|
}
|
||||||
|
guard let drawable = stash.take() else {
|
||||||
|
// No vend yet (session start: the reconcile above just unblocked the
|
||||||
|
// allocator, the link's next update delivers; steady state: decode beat the
|
||||||
|
// link's phase). putBack keeps newest-wins — a fresher decode replaces this
|
||||||
|
// frame while it waits, and the update's signal retries the pairing.
|
||||||
|
ring.putBack(frame)
|
||||||
|
debugStats?.noDrawableWake()
|
||||||
|
debugStats?.flushIfDue(ring: ring, gate: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let renderStarted = CACurrentMediaTime()
|
||||||
|
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
|
||||||
|
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||||
|
let atNs = presentedNs
|
||||||
|
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
|
||||||
|
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
|
||||||
|
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||||
|
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
|
||||||
|
}
|
||||||
|
let rendered: Bool
|
||||||
|
switch frame.image {
|
||||||
|
case .video(let pixelBuffer, let isHDR):
|
||||||
|
rendered = presenter.render(
|
||||||
|
pixelBuffer, isHDR: isHDR, into: drawable, onPresented: onGlass)
|
||||||
|
case .planar(let planes):
|
||||||
|
rendered = presenter.renderPlanar(
|
||||||
|
planes, into: drawable, onPresented: onGlass)
|
||||||
|
}
|
||||||
|
debugStats?.renderReturned(
|
||||||
|
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
||||||
|
if !rendered {
|
||||||
|
// The vended drawable is spent either way (an unused/mismatched one drops
|
||||||
|
// back to the pool); the frame retries on the link's next vend. A format
|
||||||
|
// mismatch (mid-session HDR flip caught between the layer reconfigure and
|
||||||
|
// the next vend) self-heals the same way — see encodePresent's guard.
|
||||||
|
ring.putBack(frame)
|
||||||
|
}
|
||||||
|
debugStats?.flushIfDue(ring: ring, gate: nil)
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
renderThread.name = "punktfunk-stage2-render"
|
||||||
|
renderThread.qualityOfService = .userInteractive
|
||||||
|
renderJoinable = true
|
||||||
|
renderThread.start()
|
||||||
|
}
|
||||||
|
|
||||||
/// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling)
|
/// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling)
|
||||||
/// and nudge the render thread. The nudge is NOT the presentation trigger — frame arrival is
|
/// and nudge the render thread. The nudge is NOT the presentation trigger — frame arrival is
|
||||||
/// (see the header) — it only retries a frame a transient `nextDrawable` failure put back into
|
/// (see the header) — it only retries a frame a transient `nextDrawable` failure put back into
|
||||||
/// the ring, which matters under the host's infinite GOP where a static scene sends no
|
/// the ring, which matters under the host's infinite GOP where a static scene sends no
|
||||||
/// replacement frame.
|
/// replacement frame. Arrival/glass pacing only — deadline sessions have no CADisplayLink
|
||||||
|
/// (their CAMetalDisplayLink's updates are both clock and retry).
|
||||||
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
|
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
|
||||||
vsyncClock.set(target: targetMediaTime, period: period)
|
vsyncClock.set(target: targetMediaTime, period: period)
|
||||||
renderSignal.signal()
|
renderSignal.signal()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MAIN thread (SessionPresenter — session start + every layout/Reconfigure): hint the
|
||||||
|
/// deadline link with the stream cadence. Staged; the link's own thread applies it (see
|
||||||
|
/// `FrameRateHint`). No-op under arrival/glass pacing, where the hosting view's CADisplayLink
|
||||||
|
/// is the hinted link.
|
||||||
|
public func setFrameRateHint(hz: Float) {
|
||||||
|
frameRateHint.stage(hz: hz)
|
||||||
|
}
|
||||||
|
|
||||||
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread — see
|
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread — see
|
||||||
/// `MetalVideoPresenter.setDrawableTarget`).
|
/// `MetalVideoPresenter.setDrawableTarget`).
|
||||||
public func setDrawableTarget(_ size: CGSize) {
|
public func setDrawableTarget(_ size: CGSize) {
|
||||||
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`.
|
||||||
@@ -659,7 +1169,7 @@ public final class Stage2Pipeline {
|
|||||||
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
|
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
|
||||||
private static func makePyroWavePump(
|
private static func makePyroWavePump(
|
||||||
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
||||||
ring: ReadyRing, renderSignal: DispatchSemaphore,
|
ring: FrameStore<ReadyFrame>, renderSignal: DispatchSemaphore,
|
||||||
device: MTLDevice, queue: MTLCommandQueue,
|
device: MTLDevice, queue: MTLCommandQueue,
|
||||||
decodeMeter: LatencyMeter?,
|
decodeMeter: LatencyMeter?,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
@@ -686,6 +1196,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,
|
||||||
|
|||||||
@@ -65,19 +65,21 @@ public enum Stage444Probe {
|
|||||||
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
|
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
|
||||||
|
|
||||||
var produced: OSType = 0
|
var produced: OSType = 0
|
||||||
let done = DispatchSemaphore(value: 0)
|
// SYNCHRONOUS decode — no `._EnableAsynchronousDecompression`, so the output callback
|
||||||
|
// runs on THIS thread before DecodeFrame returns. The async flag + semaphore wait it
|
||||||
|
// replaced tripped the Thread Performance Checker on every first connect: VideoToolbox's
|
||||||
|
// callback thread carries no QoS class, and the userInteractive connect Task blocked on
|
||||||
|
// it through the semaphore (a priority inversion). A one-shot 256×256 probe gains
|
||||||
|
// nothing from decode parallelism; the lazy statics still cache the result.
|
||||||
let status = VTDecompressionSessionDecodeFrame(
|
let status = VTDecompressionSessionDecodeFrame(
|
||||||
session, sampleBuffer: sample,
|
session, sampleBuffer: sample,
|
||||||
flags: [._EnableAsynchronousDecompression], infoFlagsOut: nil
|
flags: [], infoFlagsOut: nil
|
||||||
) { status, _, imageBuffer, _, _ in
|
) { status, _, imageBuffer, _, _ in
|
||||||
if status == noErr, let imageBuffer {
|
if status == noErr, let imageBuffer {
|
||||||
produced = CVPixelBufferGetPixelFormatType(imageBuffer)
|
produced = CVPixelBufferGetPixelFormatType(imageBuffer)
|
||||||
}
|
}
|
||||||
done.signal()
|
|
||||||
}
|
}
|
||||||
guard status == noErr else { return false }
|
guard status == noErr else { return false }
|
||||||
VTDecompressionSessionWaitForAsynchronousFrames(session)
|
|
||||||
_ = done.wait(timeout: .now() + 1.0)
|
|
||||||
return produced == want || produced == fullRangeSibling
|
return produced == want || produced == fullRangeSibling
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -92,6 +93,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
|
private let presentFloorMeter: LatencyMeter?
|
||||||
|
|
||||||
/// `onFrame`/`onSessionEnd` fire on the pump thread — hop to the main actor for UI.
|
/// `onFrame`/`onSessionEnd` fire on the pump thread — hop to the main actor for UI.
|
||||||
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
|
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
|
||||||
@@ -114,7 +116,8 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil
|
displayMeter: LatencyMeter? = nil,
|
||||||
|
presentFloorMeter: LatencyMeter? = nil
|
||||||
) {
|
) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.captureEnabled = captureEnabled
|
self.captureEnabled = captureEnabled
|
||||||
@@ -127,6 +130,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
|
self.presentFloorMeter = presentFloorMeter
|
||||||
}
|
}
|
||||||
|
|
||||||
public func makeNSView(context: Context) -> StreamLayerView {
|
public func makeNSView(context: Context) -> StreamLayerView {
|
||||||
@@ -137,6 +141,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
view.displayMeter = displayMeter
|
view.displayMeter = displayMeter
|
||||||
|
view.presentFloorMeter = presentFloorMeter
|
||||||
view.onResizeTarget = onResizeTarget
|
view.onResizeTarget = onResizeTarget
|
||||||
view.onDecodedSize = onDecodedSize
|
view.onDecodedSize = onDecodedSize
|
||||||
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -150,6 +155,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
view.displayMeter = displayMeter
|
view.displayMeter = displayMeter
|
||||||
|
view.presentFloorMeter = presentFloorMeter
|
||||||
view.onResizeTarget = onResizeTarget
|
view.onResizeTarget = onResizeTarget
|
||||||
view.onDecodedSize = onDecodedSize
|
view.onDecodedSize = onDecodedSize
|
||||||
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
||||||
@@ -171,6 +177,7 @@ public final class StreamLayerView: NSView {
|
|||||||
var endToEndMeter: LatencyMeter?
|
var endToEndMeter: LatencyMeter?
|
||||||
var decodeMeter: LatencyMeter?
|
var decodeMeter: LatencyMeter?
|
||||||
var displayMeter: LatencyMeter?
|
var displayMeter: LatencyMeter?
|
||||||
|
var presentFloorMeter: LatencyMeter?
|
||||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||||
private let presenter = SessionPresenter()
|
private let presenter = SessionPresenter()
|
||||||
@@ -620,6 +627,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
|
||||||
@@ -654,6 +667,7 @@ public final class StreamLayerView: NSView {
|
|||||||
endToEndMeter: endToEndMeter,
|
endToEndMeter: endToEndMeter,
|
||||||
decodeMeter: decodeMeter,
|
decodeMeter: decodeMeter,
|
||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
|
presentFloorMeter: presentFloorMeter,
|
||||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
onFrame: onFrame,
|
||||||
onSessionEnd: onSessionEnd,
|
onSessionEnd: onSessionEnd,
|
||||||
@@ -670,7 +684,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 +699,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
|
||||||
@@ -60,6 +61,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
|
private let presentFloorMeter: LatencyMeter?
|
||||||
|
|
||||||
/// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the
|
/// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the
|
||||||
/// captured-state ⌃⌥⇧D combo is detected by the macOS NSEvent monitor only); on iOS a
|
/// captured-state ⌃⌥⇧D combo is detected by the macOS NSEvent monitor only); on iOS a
|
||||||
@@ -76,7 +78,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil
|
displayMeter: LatencyMeter? = nil,
|
||||||
|
presentFloorMeter: LatencyMeter? = nil
|
||||||
) {
|
) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.captureEnabled = captureEnabled
|
self.captureEnabled = captureEnabled
|
||||||
@@ -88,6 +91,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
|
self.presentFloorMeter = presentFloorMeter
|
||||||
}
|
}
|
||||||
|
|
||||||
public func makeUIViewController(context: Context) -> StreamViewController {
|
public func makeUIViewController(context: Context) -> StreamViewController {
|
||||||
@@ -97,6 +101,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
controller.endToEndMeter = endToEndMeter
|
controller.endToEndMeter = endToEndMeter
|
||||||
controller.decodeMeter = decodeMeter
|
controller.decodeMeter = decodeMeter
|
||||||
controller.displayMeter = displayMeter
|
controller.displayMeter = displayMeter
|
||||||
|
controller.presentFloorMeter = presentFloorMeter
|
||||||
controller.onResizeTarget = onResizeTarget
|
controller.onResizeTarget = onResizeTarget
|
||||||
controller.onDecodedSize = onDecodedSize
|
controller.onDecodedSize = onDecodedSize
|
||||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -109,6 +114,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
controller.endToEndMeter = endToEndMeter
|
controller.endToEndMeter = endToEndMeter
|
||||||
controller.decodeMeter = decodeMeter
|
controller.decodeMeter = decodeMeter
|
||||||
controller.displayMeter = displayMeter
|
controller.displayMeter = displayMeter
|
||||||
|
controller.presentFloorMeter = presentFloorMeter
|
||||||
controller.onResizeTarget = onResizeTarget
|
controller.onResizeTarget = onResizeTarget
|
||||||
controller.onDecodedSize = onDecodedSize
|
controller.onDecodedSize = onDecodedSize
|
||||||
if controller.connection !== connection {
|
if controller.connection !== connection {
|
||||||
@@ -144,6 +150,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
var endToEndMeter: LatencyMeter?
|
var endToEndMeter: LatencyMeter?
|
||||||
var decodeMeter: LatencyMeter?
|
var decodeMeter: LatencyMeter?
|
||||||
var displayMeter: LatencyMeter?
|
var displayMeter: LatencyMeter?
|
||||||
|
var presentFloorMeter: LatencyMeter?
|
||||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||||
private let presenter = SessionPresenter()
|
private let presenter = SessionPresenter()
|
||||||
@@ -384,7 +391,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
|
||||||
@@ -402,6 +412,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
endToEndMeter: endToEndMeter,
|
endToEndMeter: endToEndMeter,
|
||||||
decodeMeter: decodeMeter,
|
decodeMeter: decodeMeter,
|
||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
|
presentFloorMeter: presentFloorMeter,
|
||||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
onFrame: onFrame,
|
||||||
onSessionEnd: onSessionEnd,
|
onSessionEnd: onSessionEnd,
|
||||||
|
|||||||
@@ -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,30 @@
|
|||||||
|
// The brand color, in the dependency-free foundation so EVERY process can use it — the widget
|
||||||
|
// extension links PunktfunkShared alone (never PunktfunkKit's Rust staticlib), and before this
|
||||||
|
// moved here the Live Activity / widgets fell back to `.tint` = system blue.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public extension Color {
|
||||||
|
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
|
||||||
|
/// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and
|
||||||
|
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
|
||||||
|
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
|
||||||
|
static let brand: Color = {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
return Color(UIColor { traits in
|
||||||
|
traits.userInterfaceStyle == .dark
|
||||||
|
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||||
|
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||||
|
})
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
return Color(NSColor(name: nil) { appearance in
|
||||||
|
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
||||||
|
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||||
|
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||||
|
})
|
||||||
|
#else
|
||||||
|
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
|
||||||
|
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
|
||||||
|
#endif
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-6
@@ -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"
|
||||||
@@ -41,12 +50,21 @@ public enum DefaultsKey {
|
|||||||
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
||||||
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
||||||
public static let micChannel = "punktfunk.micChannel"
|
public static let micChannel = "punktfunk.micChannel"
|
||||||
/// Which presenter runs a session: "stage2" (default — explicit decode + Metal present on
|
/// LEGACY (2026-07 presentation rebuild — design/apple-presentation-rebuild.md): the old
|
||||||
/// frame arrival), "stage3" (same pipeline, glass-gated present pacing — the experimental
|
/// user-visible stage picker's key. No longer read — the presenter is resolved from
|
||||||
/// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
|
/// `presentPriority` below; the stage ladder survives only as the
|
||||||
/// system-layer fallback). Resolved once per session by SessionPresenter;
|
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3|stage4 debug env lever. Kept so a synced old
|
||||||
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
|
/// value is documented, not mysterious.
|
||||||
public static let presenter = "punktfunk.presenter"
|
public static let presenter = "punktfunk.presenter"
|
||||||
|
/// The user's presentation intent: "latency" (default — every frame shows as soon as the
|
||||||
|
/// display can; jitter appears as the occasional repeat/drop) or "smooth" (a small client
|
||||||
|
/// jitter buffer evens the cadence at the cost of added, visible display latency).
|
||||||
|
/// Resolved once per session by SessionPresenter — see PresentPriority.
|
||||||
|
public static let presentPriority = "punktfunk.presentPriority"
|
||||||
|
/// Smoothness's jitter-buffer capacity in frames: 0 = Automatic (currently 2), or 1…3.
|
||||||
|
/// Each buffered frame adds ~one refresh interval of display latency and absorbs ~one
|
||||||
|
/// interval of arrival jitter. Only meaningful when `presentPriority` is "smooth".
|
||||||
|
public static let smoothBuffer = "punktfunk.smoothBuffer"
|
||||||
/// macOS: V-Sync the stream's presents — each decoded frame flips on the next display vsync
|
/// macOS: V-Sync the stream's presents — each decoded frame flips on the next display vsync
|
||||||
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
||||||
/// (lowest latency — the default, OFF). Resolved once per session;
|
/// (lowest latency — the default, OFF). Resolved once per session;
|
||||||
@@ -68,6 +86,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 +144,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 +163,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user