Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e243c6c93 | ||
|
|
b5cace3a00 | ||
|
|
1e5dca4c25 | ||
|
|
b2146f33fe | ||
|
|
1ac6c9bf3d | ||
|
|
36e133ae66 | ||
|
|
d7e66fafe1 | ||
|
|
a4210024dc | ||
|
|
ed935ed31c | ||
|
|
daabb85373 | ||
|
|
9af894a374 | ||
|
|
52df9c59af | ||
|
|
b21b2f6ce9 | ||
|
|
791dedd62a | ||
|
|
35f940a3bb | ||
|
|
aa53f1e5ef | ||
|
|
80061fbf6b | ||
|
|
026dbe6153 | ||
|
|
3cc8fa7ee0 | ||
|
|
99eb679c07 | ||
|
|
bb78117504 | ||
|
|
4676d20dc1 | ||
|
|
be0030f953 | ||
|
|
cc70c64797 | ||
|
|
8ee963b2b0 | ||
|
|
2d15548e38 | ||
|
|
6eb5edaff4 | ||
|
|
9dde564835 | ||
|
|
b79ff45bd1 | ||
|
|
b66bcef528 | ||
|
|
42848c56b7 | ||
|
|
39b9e9e276 | ||
|
|
79114891df | ||
|
|
d0a3eca7b8 | ||
|
|
f033d3f5df | ||
|
|
bf741f8693 | ||
|
|
e3443da108 | ||
|
|
5a4dd7423e | ||
|
|
0a468c96da | ||
|
|
ea5afbaa8c | ||
|
|
832a5ffd8d | ||
|
|
76c677a8f8 | ||
|
|
7cb70bf6ea | ||
|
|
b6b3c10cb5 | ||
|
|
1a8fa2282f | ||
|
|
d669064dc0 | ||
|
|
d4ad8be6bf | ||
|
|
e0c10bad85 | ||
|
|
1b28a7f7f1 | ||
|
|
ceb081f045 | ||
|
|
0870f81148 | ||
|
|
f8361f3e6f | ||
|
|
e8bc10bf0c | ||
|
|
4499313749 | ||
|
|
784f880fbf | ||
|
|
8ca4c6eb0e | ||
|
|
13aa59c575 | ||
|
|
652de8b5e0 | ||
|
|
ec36597058 | ||
|
|
e5c0d6b4eb | ||
|
|
0bba8d7f8c | ||
|
|
0ead084838 | ||
|
|
0f9ccfa8b6 | ||
|
|
8d60f1cec0 | ||
|
|
6dd4add11b | ||
|
|
b6cc76c472 | ||
|
|
e2239fd964 | ||
|
|
f17fe1fc2b | ||
|
|
a3a6444e6e | ||
|
|
38631d43df | ||
|
|
4caf2b76e8 | ||
|
|
0f64551c56 | ||
|
|
8fe5acf7f2 | ||
|
|
fb33555059 | ||
|
|
81022bcc80 | ||
|
|
1b167f8e35 | ||
|
|
082c65755f | ||
|
|
9c2c8d1643 | ||
|
|
c591b7b4af | ||
|
|
3ac4548cf8 | ||
|
|
90d13de81e | ||
|
|
1abf5c91b9 | ||
|
|
6fd5769b3b | ||
|
|
ed8c080603 | ||
|
|
a190fdb1ae | ||
|
|
94f049ba70 | ||
|
|
c29e720324 | ||
|
|
e057bd60f4 | ||
|
|
96278eebb5 | ||
|
|
fdf48fcaa1 | ||
|
|
05a08b9804 | ||
|
|
99c245520c | ||
|
|
4ab6a399e6 | ||
|
|
8216f1d92d | ||
|
|
1677d1c0c2 | ||
|
|
9a52d725d5 | ||
|
|
fbbfce9b0e | ||
|
|
c12476736d | ||
|
|
5bcee83c34 | ||
|
|
4ebe7d1185 | ||
|
|
3f738a9989 | ||
|
|
7df321f459 | ||
|
|
c7d0fd2e03 | ||
|
|
9c13335089 | ||
|
|
92db66514b | ||
|
|
86cbbea020 | ||
|
|
3d5d8e2d76 | ||
|
|
8f6eb1494d | ||
|
|
362d532d25 | ||
|
|
b2a9b281f0 | ||
|
|
e568513f74 | ||
|
|
7403450a8d | ||
|
|
0a53457cb7 | ||
|
|
e7af5a5274 |
@@ -6,7 +6,7 @@
|
||||
# android.yml would mean an `if:` on all ten of its build steps.
|
||||
#
|
||||
# What it is for:
|
||||
# * promote a tested build up a track (alpha -> production)
|
||||
# * promote a tested build up a track (beta -> production)
|
||||
# * roll production back by re-pointing it at an older versionCode (to_track=production,
|
||||
# version_code=<the good one>, from_track blank)
|
||||
# * halt a rollout (status=halted)
|
||||
@@ -36,7 +36,7 @@ on:
|
||||
from_track:
|
||||
description: 'track to verify it is on, then clear (blank = touch nothing else)'
|
||||
required: false
|
||||
default: 'alpha'
|
||||
default: 'beta'
|
||||
notes_tag:
|
||||
description: "tag whose docs/releases/whatsnew/<tag>.txt to attach, e.g. v0.23.0 (blank = none)"
|
||||
required: false
|
||||
|
||||
@@ -36,8 +36,13 @@ on:
|
||||
- '.gitea/workflows/android.yml'
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at
|
||||
# 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary
|
||||
# (Play `internal`). Production access was granted 2026-08-01; before that a tag could only
|
||||
# reach `alpha` and someone had to promote it by hand in the Console.
|
||||
# (Play `beta` = open testing: public opt-in, no tester list — but unlike the previous
|
||||
# `internal` target, every canary now passes Google review before testers see it, so a
|
||||
# canary lands in hours/days, not minutes). The same canary versionCode is also assigned
|
||||
# to `alpha` (closed testing) in the same Play edit, so the pre-production-access closed
|
||||
# testers keep receiving builds without re-opting-in. Production access was granted
|
||||
# 2026-08-01; before that a tag could only reach `alpha` and someone had to promote it
|
||||
# by hand in the Console.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -94,8 +99,9 @@ jobs:
|
||||
# store listing. Failing here also means a missing file cannot leave a half-published
|
||||
# release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play.
|
||||
#
|
||||
# Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal
|
||||
# testers costs nothing.
|
||||
# Canary is exempt on purpose: it has no curated notes. Open-testing users therefore see
|
||||
# the previous release's text on a canary — cosmetic, and cheaper than gating every main
|
||||
# push on a notes file.
|
||||
- name: Play release notes gate (tags only)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
@@ -226,11 +232,12 @@ jobs:
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;;
|
||||
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production"; ALSO="" ;;
|
||||
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="beta"; ALSO="alpha" ;;
|
||||
esac
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
echo "PLAY_ALSO_TRACK=$ALSO" >> "$GITHUB_ENV"
|
||||
# Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB).
|
||||
# On a tag the gate step above already proved this exists, so the else branch is only
|
||||
# ever the canary path. See docs/releases/README.md.
|
||||
@@ -240,7 +247,7 @@ jobs:
|
||||
else
|
||||
echo "no Play release notes at $NOTES (canary — Play keeps the previous text)"
|
||||
fi
|
||||
echo "android version $VN -> Play track '$TRACK'"
|
||||
echo "android version $VN -> Play track '$TRACK'${ALSO:+ (+ '$ALSO')}"
|
||||
|
||||
- name: Build Release (signed AAB + universal APK)
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
@@ -312,7 +319,8 @@ jobs:
|
||||
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
|
||||
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
|
||||
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `production` at 100% (`completed`).
|
||||
# Track: canary main -> `beta` (open testing) + the same versionCode on `alpha` (closed
|
||||
# testing) in the same Play edit; a vX.Y.Z release -> `production` at 100% (`completed`).
|
||||
#
|
||||
# A tag therefore ships to real users with no further click. Two things keep that honest:
|
||||
# the tag is only pushed once every platform is green, and Play reviews each production
|
||||
@@ -324,9 +332,10 @@ jobs:
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
echo "uploading to Play track '$PLAY_TRACK'"
|
||||
echo "uploading to Play track '$PLAY_TRACK'${PLAY_ALSO_TRACK:+ (+ '$PLAY_ALSO_TRACK')}"
|
||||
set -- --package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
if [ -n "${PLAY_ALSO_TRACK:-}" ]; then set -- "$@" --also-track "$PLAY_ALSO_TRACK"; fi
|
||||
if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi
|
||||
python3 clients/android/ci/play-upload.py "$@"
|
||||
|
||||
+21
-10
@@ -676,20 +676,23 @@ jobs:
|
||||
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
|
||||
# first, and is a separate job so a capture hiccup can never red the core signal.
|
||||
#
|
||||
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13"), captured on the Simulator
|
||||
# (`simctl io screenshot`, no Screen Recording grant needed). macOS and tvOS are deliberately
|
||||
# NOT in CI: the self-hosted runner is headless (no window-server session), so the mac window
|
||||
# capture can't run there; tvOS needs the Tier-3 build-std slice. Generate those two locally on
|
||||
# a GUI Mac with `clients/apple/tools/screenshots.sh macos tvos`.
|
||||
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13") + Apple TV (1920×1080), captured
|
||||
# on the Simulator (`simctl io screenshot`, no Screen Recording grant needed). The tvOS slice is
|
||||
# Tier-3 (nightly -Zbuild-std, same as the distribute job — slow cold, cached on the self-hosted
|
||||
# runner). The tvOS scene list is explicit: the gamepad-console scenes are iOS/macOS-only, and an
|
||||
# unknown scene name falls back to a NORMAL app launch — the capture would silently be of the
|
||||
# real empty app. macOS stays deliberately NOT in CI: the runner is headless (no window-server
|
||||
# session), so the mac window capture can't run there — generate it locally on a GUI Mac with
|
||||
# `clients/apple/tools/screenshots.sh macos`.
|
||||
screenshots:
|
||||
needs: swift
|
||||
if: gitea.event_name != 'pull_request'
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 75
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rust toolchain + iOS Simulator targets
|
||||
- name: Rust toolchain + iOS Simulator targets (+ nightly for the tvOS slices)
|
||||
run: |
|
||||
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
@@ -699,6 +702,10 @@ jobs:
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
|
||||
# nightly + -Zbuild-std, so ensure nightly + rust-src are present (see the swift job).
|
||||
"$RUSTUP" toolchain install nightly --profile minimal
|
||||
"$RUSTUP" component add rust-src --toolchain nightly
|
||||
|
||||
# Shared compile cache. The script handles the macOS side (user-prefix install +
|
||||
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
|
||||
@@ -735,10 +742,10 @@ jobs:
|
||||
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
|
||||
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS slices)
|
||||
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
- name: Capture screenshots (iPhone 6.9" + iPad 13"; auto-creates the Simulators)
|
||||
- name: Capture screenshots (iPhone 6.9" + iPad 13" + Apple TV; auto-creates the Simulators)
|
||||
working-directory: clients/apple
|
||||
env:
|
||||
SETTLE: "8" # Simulators settle slower than a local run
|
||||
@@ -746,6 +753,10 @@ jobs:
|
||||
# Independent invocations: one platform failing skips it, not the other.
|
||||
bash tools/screenshots.sh ios || echo "::warning::iOS (iPhone 6.9\") screenshots skipped"
|
||||
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
|
||||
# tvOS shoots only the scenes that exist there — the 06–09 gamepad-console scenes are
|
||||
# compiled out on tvOS (native focus engine), and an unknown name = a normal app launch.
|
||||
SCENES="01-stream 02-hosts 11-library 05-settings 03-pair" \
|
||||
bash tools/screenshots.sh tvos || echo "::warning::Apple TV screenshots skipped"
|
||||
echo "Produced:"; ls -la screenshots || true
|
||||
|
||||
- name: Shut the Simulators down (leaked booted sims once piled up 846 deep)
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
# login gate, session sealing, mgmt bearer token), sdk (@punktfunk/host),
|
||||
# plugin-kit (@punktfunk/plugin-kit).
|
||||
# * pnpm audit → clients/decky (the Steam Deck plugin).
|
||||
# * docs-site → scanned NON-blocking (continue-on-error): known transitive advisories ride in
|
||||
# via the CMS/UI chain (@unom/ui → payload → dompurify/monaco) and the nitropack
|
||||
# build chain (node-tar, brace-expansion); clearing them needs coordinated bumps
|
||||
# verified against the LIVE site (the docs don't build standalone) — tracked in
|
||||
# punktfunk-planning design/cra-readiness.md. Flip to blocking once clean.
|
||||
# * docs-site → scanned NON-blocking (continue-on-error). 2026-08-14: docs-site's own deps
|
||||
# are current (fumadocs/tanstack/react bumped; build + tsc + serve verified),
|
||||
# but every remaining advisory is pinned INSIDE @unom/ui 0.9.2's dependency
|
||||
# tree (@payloadcms/* → fast-uri/image-size/sharp, next 16.x, sass→immutable) —
|
||||
# nothing bumpable from this lockfile, and overrides would fork what the CMS
|
||||
# actually ships. The fix belongs in the @unom/ui package repo; flip this to
|
||||
# blocking after a ui release with a clean payload chain lands here.
|
||||
# * cargo-about → license-allowlist gate over the host + driver workspaces (about.toml `accepted`);
|
||||
# fails if any crate carries a license outside the allowlist — the regression
|
||||
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
|
||||
|
||||
@@ -517,15 +517,17 @@ jobs:
|
||||
bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)" \
|
||||
--extra-fallback libdisplay-info
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
# The WHOLE staged tree, not just the binary: it also carries the Vulkan WSI layer built
|
||||
# beside the compositor, which is the only route to an HDR10 swapchain for a nested game.
|
||||
mkdir -p gs-cache && cp -a gs-stage/. gs-cache/
|
||||
|
||||
# The binary must RUN, not merely link: `--version` is what the old job used as its ship
|
||||
# gate, and it is the cheapest proof that the static-libstdc++ trick and the vendored wlroots
|
||||
# actually produced a working compositor.
|
||||
- name: Build the .deb
|
||||
run: |
|
||||
gs-cache/punktfunk-gamescope --version
|
||||
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||
gs-cache/usr/bin/punktfunk-gamescope --version
|
||||
bash packaging/debian/build-gamescope-deb.sh --stage gs-cache
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
|
||||
@@ -293,7 +293,11 @@ jobs:
|
||||
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
|
||||
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
# The WHOLE staged tree, not just the binary: it also carries the Vulkan WSI layer built
|
||||
# beside the compositor, which is the only thing that can give a nested game an HDR10
|
||||
# swapchain. Caching the tree rather than a file per artifact is what keeps the next
|
||||
# addition from needing a change here as well as in every packaging script.
|
||||
mkdir -p gs-cache && cp -a gs-stage/. gs-cache/
|
||||
else
|
||||
# Warn only, even on a tag — the hard gate is the LAST step of this job. Failing here
|
||||
# would skip the sysext build, the sysext feed, AND the release attach below, so a
|
||||
@@ -311,9 +315,9 @@ jobs:
|
||||
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
|
||||
- name: Package punktfunk-gamescope as an RPM
|
||||
run: |
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
if [ -x gs-cache/usr/bin/punktfunk-gamescope ] && gs-cache/usr/bin/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/gamescope/build-gamescope-rpm.sh \
|
||||
--binary gs-cache/punktfunk-gamescope \
|
||||
--stage gs-cache \
|
||||
--release "$PF_RELEASE"
|
||||
else
|
||||
# Warn only — see the note on the build step. The gate is the last step of this job.
|
||||
@@ -372,9 +376,9 @@ jobs:
|
||||
# whose runtime libs are missing from this container must cost the image its HDR, not the
|
||||
# image itself.
|
||||
gs=()
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
gs=(--gamescope gs-cache/punktfunk-gamescope)
|
||||
echo "folding in $(gs-cache/punktfunk-gamescope --version 2>&1 | head -1)"
|
||||
if [ -x gs-cache/usr/bin/punktfunk-gamescope ] && gs-cache/usr/bin/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
gs=(--gamescope-stage gs-cache)
|
||||
echo "folding in $(gs-cache/usr/bin/punktfunk-gamescope --version 2>&1 | head -1)"
|
||||
else
|
||||
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
@@ -257,6 +257,19 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
env:
|
||||
# Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_*
|
||||
# when all three are set. Not secret: an account/profile name and a regional endpoint,
|
||||
# inert without the credentials below. The profile's verified subject is also the MSIX
|
||||
# manifest Publisher; pack-msix.ps1 reads the signature back and fails on a mismatch.
|
||||
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
|
||||
AZURE_CODESIGNING_ACCOUNT: unomsigning
|
||||
AZURE_CODESIGNING_PROFILE: unom-io
|
||||
# Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate
|
||||
# Profile Signer role, scoped to the unom-io profile — it can sign and nothing else.
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
# Legacy self-signed path, kept as the fallback for builds without Azure access.
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
@@ -275,10 +288,13 @@ jobs:
|
||||
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
|
||||
$aliasNames = @{
|
||||
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
|
||||
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
|
||||
}
|
||||
# Under Azure signing there is no .cer, so MSIX_CER_PATH is unset. The quotes below are
|
||||
# load-bearing: "$($env:UNSET)" interpolates to an empty string (a legal key), whereas a
|
||||
# BARE $env:UNSET is $null and a null key is a hard error in a hash literal — which is
|
||||
# exactly how windows-host.yml's publish step broke. Added explicitly rather than relying
|
||||
# on that accident, so removing the quotes can't silently reintroduce it.
|
||||
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
|
||||
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
|
||||
@@ -20,12 +20,18 @@
|
||||
# main push / dispatch -> <next-minor>.<run_number> (canary; `canary/` alias; base one minor
|
||||
# ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs).
|
||||
#
|
||||
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
|
||||
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
||||
# (import once to LocalMachine\TrustedPublisher). That fallback is for canary/CI ONLY — on a v* tag
|
||||
# Signing goes through Azure Artifact Signing (account `unomsigning`, profile `unom-io`) — a publicly
|
||||
# trusted CA, so there is no .cer for users to import and no SmartScreen "unknown publisher" prompt.
|
||||
# It falls back to the old MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD self-signed cert, and then to an
|
||||
# ephemeral one, for builds without Azure access. Those fallbacks are for canary/CI ONLY — on a v* tag
|
||||
# the pack script FAILS CLOSED rather than ship a release signed by a per-build throwaway cert.
|
||||
# See packaging/windows/pack-host-installer.ps1.
|
||||
#
|
||||
# The bundled DRIVERS are NOT signed by Azure — they keep their own DRIVER_CERT_* cert and are still
|
||||
# trusted by planting that cert in the machine Root store at install time. Independent by design:
|
||||
# Windows checks the installer's signature via SmartScreen/UAC and driver catalogs via PnP, and never
|
||||
# requires a common signer. See packaging/windows/README.md for why that root-plant is still there.
|
||||
#
|
||||
# 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
|
||||
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
|
||||
@@ -415,12 +421,26 @@ jobs:
|
||||
- name: Pack + sign installer
|
||||
shell: pwsh
|
||||
env:
|
||||
# Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_*
|
||||
# when all three of these are set. Not secret: an account/profile name and a regional
|
||||
# endpoint, all inert without the credentials below, so they live here where a reviewer
|
||||
# can see which profile a release was signed by.
|
||||
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
|
||||
AZURE_CODESIGNING_ACCOUNT: unomsigning
|
||||
AZURE_CODESIGNING_PROFILE: unom-io
|
||||
# Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate
|
||||
# Profile Signer role, scoped to the unom-io profile — it can sign and nothing else.
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
# Legacy self-signed path, kept as the fallback for builds without Azure access.
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
# The DRIVER cert is separate from the host/MSIX one and reaches the two driver build
|
||||
# scripts through the environment (pack-host-installer.ps1 invokes them, they read
|
||||
# $env:DRIVER_CERT_PFX_B64 themselves). Without it they sign with a per-build throwaway,
|
||||
# which the installer then trusts as a machine root — see packaging/windows/README.md.
|
||||
# NOT moved to Azure: driver catalogs are a separate track, see that README.
|
||||
DRIVER_CERT_PFX_B64: ${{ secrets.DRIVER_CERT_PFX_B64 }}
|
||||
DRIVER_CERT_PASSWORD: ${{ secrets.DRIVER_CERT_PASSWORD }}
|
||||
run: |
|
||||
@@ -452,7 +472,13 @@ jobs:
|
||||
# Refresh the channel alias (delete-then-reupload, like flatpak.yml/decky.yml) for a
|
||||
# predictable download URL: stable release -> `latest/`, canary main build -> `canary/`.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
|
||||
# Build this incrementally, NOT as one literal: under Azure signing there is no .cer, so
|
||||
# HOST_CER_PATH is unset — and an unset $env: var is $null, which is a HARD ERROR as a hash
|
||||
# literal key ("A null key is not allowed in a hash literal"), not the empty-string key it
|
||||
# looks like it should be. The $files guard above filters the missing .cer out just fine;
|
||||
# this line ran before anything could use it and failed the whole publish step.
|
||||
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe' }
|
||||
if ($env:HOST_CER_PATH) { $aliasNames[$env:HOST_CER_PATH] = 'punktfunk-host-windows.cer' }
|
||||
foreach ($f in $files) {
|
||||
$an = $aliasNames[$f]; if (-not $an) { continue }
|
||||
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
|
||||
|
||||
+1232
-2
File diff suppressed because it is too large
Load Diff
Generated
+38
-129
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -1353,18 +1353,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastbloom"
|
||||
version = "0.17.0"
|
||||
@@ -1999,32 +1987,11 @@ dependencies = [
|
||||
"zerocopy 0.8.56",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -2195,7 +2162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@@ -2376,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2477,20 +2444,9 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.38.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2519,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -3011,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -3019,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3040,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3075,7 +3031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3093,7 +3049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3115,7 +3071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3125,7 +3081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3151,7 +3107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3159,11 +3115,12 @@ dependencies = [
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3177,11 +3134,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3210,14 +3167,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3232,7 +3189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3240,7 +3197,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3252,7 +3209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3261,7 +3218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3294,7 +3251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3305,7 +3262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3316,7 +3273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3528,7 +3485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3538,7 +3495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3556,7 +3513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3573,7 +3530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
@@ -3587,7 +3544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3605,7 +3562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3637,7 +3594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3646,7 +3603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3688,9 +3645,7 @@ dependencies = [
|
||||
"quinn",
|
||||
"rand 0.9.5",
|
||||
"rcgen",
|
||||
"roxmltree",
|
||||
"rsa",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"rusty_enet",
|
||||
"semver",
|
||||
@@ -3718,7 +3673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3732,7 +3687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3755,7 +3710,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -4137,15 +4092,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpkg-config"
|
||||
version = "0.1.2"
|
||||
@@ -4173,31 +4119,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.40.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -4769,18 +4690,6 @@ dependencies = [
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.27.0"
|
||||
version = "0.28.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+5
-1
@@ -5,13 +5,17 @@ machine, so we take security reports seriously and appreciate responsible disclo
|
||||
|
||||
## Supported versions
|
||||
|
||||
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag; the current line is **0.22.x**) and
|
||||
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag) and
|
||||
**canary** (built from `main`). Fixes ship as a new release on those tracks; in practice
|
||||
we don't backport to older minor versions, so the supported versions are the latest stable release
|
||||
and the current canary build. If you're on an older build, please check that the issue still
|
||||
reproduces on the latest stable before reporting it. See
|
||||
[Release Channels](https://docs.punktfunk.unom.io/docs/channels).
|
||||
|
||||
Security fixes are **free of charge**, ship **without undue delay**, and are **separated from
|
||||
feature updates where feasible**: on the stable track they arrive as patch releases (`vX.Y.Z+1`)
|
||||
that carry the fix rather than waiting on the next feature release.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please report security issues privately by email to security@punktfunk.com.**
|
||||
|
||||
+108
-12
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.27.0"
|
||||
"version": "0.28.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -45,6 +45,36 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"clients"
|
||||
],
|
||||
"summary": "Unpair every client",
|
||||
"description": "The collection form of [`unpair_client`]: empties the pairing store in ONE persisted write,\ncarrying the same revocation guarantees across the whole set. A LIVE GameStream session is\nended (its owning certificate is necessarily one of those just removed), and the ENet control\nport (UDP 47999) closes, because no pairing is left to hold it open.\n\nIdempotent, and so a 200 rather than the single unpair's 204/404 pair: \"unpair everything\" is\nsatisfied by an already-empty store, and the operator still wants to know whether that meant\nthree devices or none.",
|
||||
"operationId": "unpairAllClients",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Every client unpaired (possibly none)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnpairAllResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/clients/{fingerprint}": {
|
||||
@@ -997,7 +1027,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "List the game library",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
|
||||
"description": "Every title this host knows about, sorted by title: the entries each installed library plugin\nhas synced (Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …) plus the user's\nown custom entries. Artwork fields are URLs the client fetches directly, except local files on\nthe host, which are rewritten to this API's own art proxy. `?provider=` narrows to the entries a\ngiven external provider owns; `?platform=` to one platform (case-insensitive — whatever the\nsource authored, conventionally `PC` for desktop stores).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
|
||||
"operationId": "getLibrary",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1052,7 +1082,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Fetch one cover-art image for a library entry",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file; anything else 404s and\nthe client falls through to its next art candidate.\n\nThe host fetches nothing here. Art a plugin published as an `http(s)` URL is fetched by the\nclient directly — this proxy exists for the *local* files a plugin finds on the host's own disk\n(a launcher's cover cache), which a client has no way to read.",
|
||||
"operationId": "getLibraryArt",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1380,7 +1410,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Replace a provider's library entries (declarative reconcile)",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what let a library plugin reproduce the entries the in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches, and is why\nremoving those scanners changed nothing downstream. One provider per store; a second claimant\ngets 409. The claim is released by `DELETE`, not by an empty reconcile (a store can legitimately\nhave zero installed titles).",
|
||||
"operationId": "reconcileProviderEntries",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1538,8 +1568,8 @@
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "List the library scanners",
|
||||
"description": "The installed-store scanners this host supports — the list is platform-dependent (Steam\neverywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console\nrenders a toggle only for scanners that can do anything here. Scanners default to enabled;\ndisabling one hides its titles from every library surface from the next read. The user-curated\ncustom store is not a scanner and is always on.",
|
||||
"summary": "List the library sources",
|
||||
"description": "Every game source on this host with its enable state — one row per installed library plugin\n(Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …), so the list reflects what\nthe operator has actually installed rather than what this build happens to support. Sources\ndefault to enabled; disabling one hides its titles from every library surface from the next\nread. The user-curated custom store is not a source and is always on.\n\nOlder hosts (≤ v0.27.x) also listed the six scanners built into the host binary, with\n`origin: \"builtin\"`. Those are gone; every row now reports `origin: \"plugin\"`.",
|
||||
"operationId": "listLibraryScanners",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -1573,8 +1603,8 @@
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Enable or disable a library scanner",
|
||||
"description": "Persists the toggle and applies it from the next library read (no restart). Disabling a scanner\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits\n`library.changed` with the scanner id as `source` when the state changed.",
|
||||
"summary": "Enable or disable a library source",
|
||||
"description": "Persists the toggle and applies it from the next library read (no restart). Disabling a source\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back. Nothing is deleted: the plugin may keep reconciling\nwhile its source is off, and those entries simply aren't surfaced. Emits `library.changed` with\nthe source id as `source` when the state changed.",
|
||||
"operationId": "setLibraryScanner",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1767,6 +1797,56 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"native"
|
||||
],
|
||||
"summary": "Unpair every native client",
|
||||
"description": "The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE\npersisted write (not a loop of them — a failure partway would leave a half-emptied store), and\nends every live native session the removed clients own.\n\nIdempotent, hence a 200 rather than the single unpair's 204/404: an already-empty store\nsatisfies the request, and the count still tells the operator what it meant.",
|
||||
"operationId": "unpairAllNativeClients",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Every native client unpaired (possibly none)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnpairAllResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the trust store",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Native host not enabled",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/native/clients/{fingerprint}": {
|
||||
@@ -5397,7 +5477,7 @@
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
|
||||
"description": "The external provider owning this entry (entries synced by a provider plugin, RFC §8) —\n`None` only for the manual entries the operator typed in. The console uses it for\nattribution; `GET /library?provider=` filters on it."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
@@ -7143,7 +7223,7 @@
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/components/schemas/SourceOrigin",
|
||||
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
|
||||
"description": "Where the source comes from. Always `plugin` from this host build onward — see\n[`SourceOrigin`]."
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
@@ -7163,7 +7243,7 @@
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the scanner should run on this host."
|
||||
"description": "Whether this source should contribute titles on this host."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7687,6 +7767,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UnpairAllResult": {
|
||||
"type": "object",
|
||||
"description": "What a bulk unpair removed. Shared by the two collection DELETEs (`/clients` and\n`/native/clients`) so the console sees one schema across both pairing planes.\n\nA count rather than 204: \"unpair everything\" is idempotent, so an empty store is a success, and\nthe operator still wants to be told whether that meant three devices or none.",
|
||||
"required": [
|
||||
"unpaired"
|
||||
],
|
||||
"properties": {
|
||||
"unpaired": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Clients removed from the trust store — 0 when nothing was paired.",
|
||||
"example": 3,
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateJobInfo": {
|
||||
"type": "object",
|
||||
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
|
||||
@@ -7958,7 +8054,7 @@
|
||||
},
|
||||
{
|
||||
"name": "library",
|
||||
"description": "Game library: installed-store titles (Steam) plus user-curated custom entries"
|
||||
"description": "Game library: the titles each installed library plugin syncs, plus user-curated custom entries"
|
||||
},
|
||||
{
|
||||
"name": "stats",
|
||||
|
||||
@@ -51,6 +51,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
|
||||
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
|
||||
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
|
||||
# x11-xcb is needed by the VULKAN WSI LAYER (layer/meson.build), not by the compositor — so it
|
||||
# was not missed until v0.28.1 started building the layer beside the binary. Debian is the only
|
||||
# channel that needs it named: Arch's libx11 and Fedora's libX11-devel both carry x11-xcb.pc
|
||||
# themselves, while Debian splits it into its own -dev package.
|
||||
libx11-xcb-dev \
|
||||
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
|
||||
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
|
||||
libvulkan-dev libxcb1-dev libxcb-composite0-dev libxcb-xfixes0-dev libxcb-res0-dev \
|
||||
@@ -66,3 +71,13 @@ RUN set -eux; \
|
||||
pkg-config --atleast-version=1.23.1 wayland-server \
|
||||
|| { echo "wayland-server $have < 1.23.1 — the vendored wlroots will not configure" >&2; exit 1; }; \
|
||||
echo "wayland-server $have — OK"
|
||||
|
||||
# The layer's own floor, asserted for the same reason: a missing x11-xcb does not fail the
|
||||
# COMPOSITOR build, it fails `layer/meson.build` — and the layer is the only route to an HDR10
|
||||
# swapchain for a nested game, so losing it silently ships a package that looks healthy and denies
|
||||
# every game HDR. This is exactly how v0.28.1's deb leg broke, one release after the layer was
|
||||
# added; assert it here so the next dep the layer grows fails at image build, not mid-release.
|
||||
RUN set -eux; \
|
||||
pkg-config --exists x11-xcb \
|
||||
|| { echo "x11-xcb absent — the Vulkan WSI layer will not configure (need libx11-xcb-dev)" >&2; exit 1; }; \
|
||||
echo "x11-xcb $(pkg-config --modversion x11-xcb) — OK"
|
||||
|
||||
@@ -22,7 +22,8 @@ Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "n
|
||||
|
||||
## Get it
|
||||
|
||||
Published to **Google Play (Internal Testing)** — join the beta via the
|
||||
Published to **Google Play (Open Testing)** — join via the
|
||||
[public opt-in link](https://play.google.com/apps/testing/io.unom.punktfunk) or the
|
||||
[Discord](https://discord.gg/kaPNvzMuGU). Per-device setup and pairing:
|
||||
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
|
||||
|
||||
|
||||
@@ -142,6 +142,10 @@ dependencies {
|
||||
// job runs `:app:testDebugUnitTest -PskipRustBuild` (see kit/build.gradle.kts). ---
|
||||
testImplementation(composeBom)
|
||||
testImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
// Deterministic cover art for the library scene: FakeImageLoaderEngine answers the coverflow's
|
||||
// AsyncImage synchronously with generated posters — no network, no async race under the frozen
|
||||
// animation clock.
|
||||
testImplementation("io.coil-kt:coil-test:2.7.0")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub
|
||||
|
||||
@@ -243,6 +243,15 @@ fun ConnectScreen(
|
||||
knownHostStore.learnOs(dh.host, dh.port, dh.os)
|
||||
any = true
|
||||
}
|
||||
// And the mgmt port, so a host that moved off 47990 keeps its library once this
|
||||
// device can no longer see the advert (VPN, routed subnet, multicast-dead Wi-Fi).
|
||||
val mgmt = dh.mgmtPort
|
||||
if (mgmt != null &&
|
||||
knownHostStore.get(dh.host, dh.port)?.let { it.mgmtPort != mgmt } == true
|
||||
) {
|
||||
knownHostStore.learnMgmtPort(dh.host, dh.port, mgmt)
|
||||
any = true
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
@@ -313,13 +322,24 @@ fun ConnectScreen(
|
||||
// What the stream screen is handed: the settings this connect actually used, plus the HOST's
|
||||
// clipboard decision (a property of the record, not a global). A host we never saved — a
|
||||
// connect that failed to pin — falls back to the on default the setting always had.
|
||||
fun session(handle: Long, record: KnownHost?, profile: StreamProfile?) = ActiveSession(
|
||||
handle,
|
||||
settings.effectiveFor(profile),
|
||||
clipboardSync = record?.clipboardSync ?: true,
|
||||
profileName = profile?.name,
|
||||
hostId = record?.id,
|
||||
)
|
||||
fun session(handle: Long, record: KnownHost?, profile: StreamProfile?): ActiveSession {
|
||||
// The session's own Welcome carries where this host serves its library. Save it now: this
|
||||
// is the only source that does not need an mDNS advert, so it is what makes a host that
|
||||
// moved off 47990 browsable over a VPN or when it was added by address. 0 = not
|
||||
// advertised, and learnMgmtPort ignores it.
|
||||
if (record != null) {
|
||||
NativeBridge.nativeHostMgmtPort(handle).takeIf { it > 0 }?.let {
|
||||
knownHostStore.learnMgmtPort(record.address, record.port, it)
|
||||
}
|
||||
}
|
||||
return ActiveSession(
|
||||
handle,
|
||||
settings.effectiveFor(profile),
|
||||
clipboardSync = record?.clipboardSync ?: true,
|
||||
profileName = profile?.name,
|
||||
hostId = record?.id,
|
||||
)
|
||||
}
|
||||
|
||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||
|
||||
@@ -69,7 +69,7 @@ import kotlinx.coroutines.delay
|
||||
* to be the same one whichever interface asked.
|
||||
*/
|
||||
@Composable
|
||||
fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List<PadInfo>? = null) {
|
||||
BackHandler(onBack = onBack)
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
ControllersBody(
|
||||
@@ -77,6 +77,7 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
scroll = rememberScrollState(),
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// The touch screen holds the probes for its whole life: events are OBSERVED (not consumed)
|
||||
// while the test is off, which is what keeps the "Last input" line live while browsing.
|
||||
// Nothing else here wants the pad, so there is no one to hand them to.
|
||||
@@ -99,7 +100,12 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
* drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive: Boolean = true) {
|
||||
internal fun ConsoleControllersScreen(
|
||||
gamepadSetting: Int,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
@@ -139,6 +145,7 @@ fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive:
|
||||
scroll = scroll,
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// Only while testing: the rest of the time the screen's own nav holds the
|
||||
// probes, so the "Last input" line is a test-time readout here rather than
|
||||
// an always-on one. A pad that reaches this screen at all has already
|
||||
@@ -200,14 +207,17 @@ private fun ControllersBody(
|
||||
onTestingChange: (Boolean) -> Unit,
|
||||
observeInput: Boolean,
|
||||
contentPadding: PaddingValues,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
heading: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
|
||||
// Device list, re-read on every hot-plug event.
|
||||
// Device list, re-read on every hot-plug event. [padsOverride] replaces it wholesale: the
|
||||
// screenshot harness runs where no InputDevice can exist, and the connected-pad card is the
|
||||
// point of that shot.
|
||||
var generation by remember { mutableIntStateOf(0) }
|
||||
val pads = remember(generation) { Gamepad.pads() }
|
||||
val pads = padsOverride ?: remember(generation) { Gamepad.pads() }.map(::padInfoOf)
|
||||
val others = remember(generation) {
|
||||
InputDevice.getDeviceIds()
|
||||
.toList()
|
||||
@@ -392,8 +402,8 @@ private fun ControllersBody(
|
||||
// Every real controller is forwarded now (Automatic forwards them all, each on its own
|
||||
// wire pad index) — not just the first. A joystick-only device Android doesn't classify as
|
||||
// a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it.
|
||||
pads.forEach { dev ->
|
||||
PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting)
|
||||
pads.forEach { info ->
|
||||
PadRow(info, gamepadSetting = gamepadSetting)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,19 +685,19 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
|
||||
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
||||
@Composable
|
||||
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
private fun PadRow(info: PadInfo, gamepadSetting: Int) {
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
if (forwarded) {
|
||||
Text(info.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
if (info.forwarded) {
|
||||
// Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad
|
||||
// user can tell which physical pad is which. The stream's wire pad index is
|
||||
// assigned separately (lowest-free per device) once streaming starts.
|
||||
val number = dev.controllerNumber
|
||||
val number = info.controllerNumber
|
||||
Text(
|
||||
if (number > 0) "forwarded · player $number" else "forwarded to host",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -696,11 +706,11 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
}
|
||||
}
|
||||
Text(
|
||||
deviceDetail(dev),
|
||||
info.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val resolved = Gamepad.prefFor(dev)
|
||||
val resolved = info.resolvedPref
|
||||
Text(
|
||||
if (gamepadSetting == Gamepad.PREF_AUTO) {
|
||||
"Streams as: ${prefLabel(resolved)} (automatic)"
|
||||
@@ -711,9 +721,8 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val canRumble = deviceHasVibrator(dev)
|
||||
if (canRumble) {
|
||||
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
|
||||
if (info.canRumble) {
|
||||
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
|
||||
} else {
|
||||
Text(
|
||||
"No rumble motors reported — host rumble will be silent",
|
||||
@@ -794,6 +803,32 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
private fun isForwarded(dev: InputDevice): Boolean =
|
||||
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||
|
||||
/**
|
||||
* Everything [PadRow] renders, decoupled from [InputDevice] so the screenshot harness can compose
|
||||
* the connected-pad card at all — Robolectric enumerates no input devices, and a marketing shot of
|
||||
* "no controller detected" sells nothing. Production always maps a real device via [padInfoOf];
|
||||
* [dev] powers the rumble test and is absent only in the harness (the button then no-ops).
|
||||
*/
|
||||
internal data class PadInfo(
|
||||
val name: String,
|
||||
val detail: String,
|
||||
val forwarded: Boolean,
|
||||
val controllerNumber: Int,
|
||||
val resolvedPref: Int,
|
||||
val canRumble: Boolean,
|
||||
val dev: InputDevice? = null,
|
||||
)
|
||||
|
||||
internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
|
||||
name = dev.name,
|
||||
detail = deviceDetail(dev),
|
||||
forwarded = isForwarded(dev),
|
||||
controllerNumber = dev.controllerNumber,
|
||||
resolvedPref = Gamepad.prefFor(dev),
|
||||
canRumble = deviceHasVibrator(dev),
|
||||
dev = dev,
|
||||
)
|
||||
|
||||
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
||||
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
|
||||
@@ -59,7 +59,6 @@ import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import io.unom.punktfunk.components.launcherIcon
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.library.LibraryClient
|
||||
import io.unom.punktfunk.kit.library.LibraryResult
|
||||
@@ -120,14 +119,16 @@ fun LibraryScreen(
|
||||
}
|
||||
val streamSettings = remember(settings, profile) { settings.effectiveFor(profile) }
|
||||
|
||||
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||
// Keyed on the mgmt port too: a discovery tick can learn it after this screen is composed, and
|
||||
// the fetch must redo itself against the real port rather than stay on a stale 47990 failure.
|
||||
LaunchedEffect(host.address, host.port, host.fpHex, host.effectiveMgmtPort) {
|
||||
state = LibState.Loading
|
||||
state = withContext(Dispatchers.IO) {
|
||||
val id = runCatching { obtainIdentity(IdentityStore(context)) }.getOrNull()
|
||||
?: return@withContext LibState.Message("Identity unavailable — re-pair may be required.")
|
||||
when (val res = LibraryClient.fetch(
|
||||
address = host.address,
|
||||
mgmtPort = DEFAULT_MGMT_PORT,
|
||||
mgmtPort = host.effectiveMgmtPort,
|
||||
certPem = id.certPem,
|
||||
keyPem = id.privateKeyPem,
|
||||
fpHex = host.fpHex,
|
||||
@@ -254,8 +255,10 @@ private fun MessageState(text: String) {
|
||||
)
|
||||
}
|
||||
|
||||
// Internal (not private): the screenshot harness composes the real coverflow with mock games —
|
||||
// the library screen itself can't be shot, its state comes off the network.
|
||||
@Composable
|
||||
private fun Coverflow(
|
||||
internal fun Coverflow(
|
||||
games: List<GameEntry>,
|
||||
loader: ImageLoader,
|
||||
navActive: Boolean,
|
||||
|
||||
@@ -526,10 +526,25 @@ class MainActivity : ComponentActivity() {
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val handle = streamHandle
|
||||
if (handle != 0L) {
|
||||
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
|
||||
// Resolved before the gamepad and remote-pointer hooks so neither can claim them as
|
||||
// its own BACK. See [mouseSideButton] for how a mouse's BACK is told from a pad's or
|
||||
// a remote's; it answers null for every device that cannot be a mouse, so asking it
|
||||
// first re-routes nothing else.
|
||||
mouseSideButton(event)?.let { back ->
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN ->
|
||||
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
|
||||
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Gamepad buttons (incl. DPAD only when truly from a gamepad — else KEYCODE_DPAD_* are
|
||||
// keyboard arrows and belong to the VK path below).
|
||||
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
|
||||
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
|
||||
// why this asks it rather than `buttonBit`).
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
val bit = Gamepad.buttonBit(event.keyCode)
|
||||
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
|
||||
if (bit != 0) {
|
||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||
@@ -540,17 +555,6 @@ class MainActivity : ComponentActivity() {
|
||||
return true // consumed
|
||||
}
|
||||
}
|
||||
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
|
||||
// Resolved before the remote-pointer hook so pointer mode can't eat them as its own
|
||||
// BACK. See [mouseSideButton] for how a mouse's BACK is told from a remote's.
|
||||
mouseSideButton(event)?.let { back ->
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN ->
|
||||
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
|
||||
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
|
||||
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
|
||||
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
@@ -567,12 +571,13 @@ class MainActivity : ComponentActivity() {
|
||||
return true
|
||||
}
|
||||
when (event.keyCode) {
|
||||
// Whatever [mouseSideButton] didn't claim. A view-level FALLBACK BACK appears when
|
||||
// a BUTTON_* press goes unconsumed, and an air-mouse remote stamps its own BACK
|
||||
// SOURCE_MOUSE; both are duplicates of something already handled, and letting
|
||||
// either through doubles as Android navigation and yanks the user out of the
|
||||
// stream. A remote/keyboard BACK is never mouse-sourced, so it still falls through
|
||||
// to the BackHandler and exits.
|
||||
// Whatever [mouseSideButton] and the pad branch didn't claim. A view-level FALLBACK
|
||||
// BACK appears when a BUTTON_* press goes unconsumed, and an air-mouse remote stamps
|
||||
// its own BACK SOURCE_MOUSE; both are duplicates of something already handled, and
|
||||
// letting either through doubles as Android navigation and yanks the user out of the
|
||||
// stream. A remote/keyboard BACK is never mouse-sourced and never gamepad-sourced,
|
||||
// so it still falls through to the BackHandler and exits — which for a device with
|
||||
// no pad on it is the documented way out.
|
||||
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD ->
|
||||
if (event.isFromSource(InputDevice.SOURCE_MOUSE) ||
|
||||
event.flags and KeyEvent.FLAG_FALLBACK != 0
|
||||
|
||||
@@ -32,20 +32,12 @@ import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -57,7 +49,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -130,12 +121,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// it, and survives the same recreate because the composition outlives the surface.
|
||||
var micMuted by remember(handle) { mutableStateOf(false) }
|
||||
// Whether a capture is actually RUNNING, not merely wanted — set from surfaceCreated on what
|
||||
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute control
|
||||
// rather than one that lies about a mic being heard.
|
||||
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute chord and
|
||||
// no chord line in the start banner, rather than an offer to mute a mic nobody is hearing.
|
||||
var micRunning by remember(handle) { mutableStateOf(false) }
|
||||
// Transient confirmation of a mic-chord toggle (null = nothing showing). Only the gamepad path
|
||||
// needs it: the touch button confirms itself by changing under the finger, but a chord has no
|
||||
// on-screen state of its own, and "did that register?" is exactly the doubt to answer.
|
||||
// Transient confirmation of a mic-chord toggle (null = nothing showing). With no standing mic
|
||||
// element on screen, this is mute's only feedback: a chord has no on-screen state of its own,
|
||||
// and "did that register?" is exactly the doubt to answer.
|
||||
var micHint by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(micHint) {
|
||||
if (micHint != null) {
|
||||
@@ -413,9 +404,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
||||
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
||||
router.onExitArmed = { armed -> exitArming = armed }
|
||||
// Select + Y toggles the mic — the couch reach for the on-screen mute button, which a
|
||||
// gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing
|
||||
// to mute, and claiming otherwise would be the lie the control exists to avoid).
|
||||
// Select + Y toggles the mic — with no on-screen mute element, this chord is the whole of
|
||||
// the control. Ignored when no capture is running (there is nothing to mute, and a hint
|
||||
// saying "Microphone muted" over a mic nobody opened would be a lie).
|
||||
// A captured Sony pad whose motion this session cannot carry. Fires once per pad, at the
|
||||
// moment it is claimed, on the main thread.
|
||||
router.onMotionUnreachable = { motionHint = true }
|
||||
@@ -825,7 +816,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
|
||||
.roundToInt(),
|
||||
)
|
||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
|
||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)
|
||||
if (micWanted) {
|
||||
val sessionId =
|
||||
NativeBridge.nativeStartMic(handle, initialSettings.echoCancel)
|
||||
@@ -981,19 +972,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
}
|
||||
},
|
||||
)
|
||||
// Mic mute, LAST in the stack — the one in-stream control, so unlike the purely visual
|
||||
// overlays above it has to sit on top of the gesture layer to receive its own taps (it
|
||||
// costs the stream that small corner of touch area, which is why it exists only while a
|
||||
// capture actually runs). On TV it is the indicator alone: the Select + Y chord is the
|
||||
// control there, and a focusable button would fight the game for the D-pad.
|
||||
if (micRunning && (micMuted || !isTv)) {
|
||||
MicMuteControl(
|
||||
muted = micMuted,
|
||||
onToggle = if (isTv) null else ({ setMicMuted(!micMuted) }),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger.
|
||||
// No standing mic element here: the in-stream mute control is deliberately absent until the
|
||||
// on-screen overlay UI lands and can carry it as one of its controls. Mute itself is intact
|
||||
// — the Select + Y chord toggles it, and the hint below is what confirms the toggle.
|
||||
// Chord confirmation (gamepad/TV) — mute has no standing indicator, so this is the whole
|
||||
// of its feedback: a toggle that showed nothing at all would be indistinguishable from one
|
||||
// that never registered.
|
||||
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
|
||||
// Bottom, not top: this can coincide with a mic-chord confirmation or the exit cue, and a
|
||||
// notice landing on top of one of those would cost the user both.
|
||||
@@ -1030,47 +1014,8 @@ private fun releaseMicEffects(effects: MutableList<AudioEffect>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-stream mic control and its muted indicator, in one element: a dim mic glyph while the
|
||||
* uplink is live, a red **Muted** badge while it isn't — so the state that matters is the loud one,
|
||||
* readable at couch distance and impossible to mistake for the stream's own picture.
|
||||
*
|
||||
* [onToggle] `null` makes it a pure indicator (the TV/gamepad surface, where the Select + Y chord
|
||||
* is the control); non-null makes the badge itself the touch target. Rendering it at all is the
|
||||
* caller's decision — it means a capture is genuinely running.
|
||||
*/
|
||||
@Composable
|
||||
private fun MicMuteControl(muted: Boolean, onToggle: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(if (muted) Color(0xE0B3261E) else Color.Black.copy(alpha = 0.45f))
|
||||
.then(if (onToggle != null) Modifier.clickable(onClick = onToggle) else Modifier)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
|
||||
// Spoken state first, then the action — a talkback user needs to know they are muted
|
||||
// before they need to know how to stop being muted.
|
||||
contentDescription = if (muted) {
|
||||
"Microphone muted. Activate to unmute."
|
||||
} else {
|
||||
"Microphone live. Activate to mute."
|
||||
},
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
if (muted) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Muted", color = Color.White, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient confirmation that the mic chord (Select + Y) registered. The badge above already says
|
||||
* *muted*, but nothing on screen says *un*muted — and "did that press do anything?" is the whole
|
||||
* Transient confirmation that the mic chord (Select + Y) registered. Nothing else on screen says
|
||||
* *muted* or *un*muted, so this pill carries both — "did that press do anything?" is the whole
|
||||
* doubt a chord with no button under the finger creates. Same pill vocabulary as the other
|
||||
* in-stream cues; the caller clears it after a beat.
|
||||
*/
|
||||
|
||||
+69
-23
@@ -34,19 +34,34 @@ class ScreenshotTest {
|
||||
// cursor via an infinite animation that otherwise keeps Compose perpetually "busy", so
|
||||
// setContent's wait-for-idle never returns. Frozen, the capture is also deterministic.
|
||||
|
||||
/** Full-screen content scenes: the compose root fills the device, so a root capture is the shot. */
|
||||
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
/**
|
||||
* Full-screen content scenes: the compose root fills the device, so a root capture is the
|
||||
* shot. [statusBar] draws the fake system bar and pushes content below it (see
|
||||
* [ShotStatusFrame]) — off for the immersive surfaces (stream, console shell), which hide
|
||||
* the real bar too.
|
||||
*/
|
||||
private fun shootRoot(
|
||||
name: String,
|
||||
statusBar: Boolean = true,
|
||||
content: @androidx.compose.runtime.Composable () -> Unit,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
compose.onRoot().captureRoboImage("$out/phone-$name.png")
|
||||
}
|
||||
|
||||
/** Dialog scenes: the AlertDialog is a separate window, so capture the whole screen (all windows). */
|
||||
private fun shootScreen(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
private fun shootScreen(
|
||||
name: String,
|
||||
statusBar: Boolean = true,
|
||||
content: @androidx.compose.runtime.Composable () -> Unit,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } }
|
||||
// 1.6 s, not 0.8: a ModalBottomSheet's entrance spring is still mid-rise at 0.8 s and the
|
||||
// add-host sheet's Connect button was captured half below the frame.
|
||||
compose.mainClock.advanceTimeBy(1600)
|
||||
captureScreenRoboImage("$out/phone-$name.png")
|
||||
}
|
||||
|
||||
@@ -73,25 +88,25 @@ class ScreenshotTest {
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
||||
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
fun stream() = shootRoot("stream", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
fun streamCompact() = shootRoot("stream-compact", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
fun streamNormal() = shootRoot("stream-normal", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
|
||||
// Both banner texts, in the stream's own landscape geometry — it is bottom-centre, so the
|
||||
// aspect is load-bearing.
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamBannerPad() = shootRoot("stream-banner-pad") { StreamBannerScene(pad = true) }
|
||||
fun streamBannerPad() = shootRoot("stream-banner-pad", statusBar = false) { StreamBannerScene(pad = true) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamBannerTouch() = shootRoot("stream-banner-touch") { StreamBannerScene(pad = false) }
|
||||
fun streamBannerTouch() = shootRoot("stream-banner-touch", statusBar = false) { StreamBannerScene(pad = false) }
|
||||
|
||||
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
|
||||
@Test
|
||||
@@ -114,15 +129,15 @@ class ScreenshotTest {
|
||||
|
||||
// The console flow is the full-screen aurora takeover (a root capture).
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
fun connectingConsole() = shootRoot("connecting-console", statusBar = false) { ConnectConsoleScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
fun consoleSettings() = shootRoot("console-settings", statusBar = false) { ConsoleSettingsScene() }
|
||||
|
||||
/** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */
|
||||
@Test
|
||||
fun consoleSettingsLight() =
|
||||
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
|
||||
shootRoot("console-settings-light", statusBar = false) { ConsoleSettingsScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console actually runs in, and a DIFFERENT layout since the
|
||||
@@ -132,16 +147,16 @@ class ScreenshotTest {
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleSettingsLandscape() =
|
||||
shootRoot("console-settings-landscape") { ConsoleSettingsScene() }
|
||||
shootRoot("console-settings-landscape", statusBar = false) { ConsoleSettingsScene() }
|
||||
|
||||
// The console home, the screen the living backdrop is most of. The default sdk (36) draws the
|
||||
// real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two
|
||||
// renderings of the same palette can be compared rather than assumed equivalent.
|
||||
@Test
|
||||
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
|
||||
fun consoleHome() = shootRoot("console-home", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
@Test
|
||||
fun consoleHomeLight() = shootRoot("console-home-light") { ConsoleHomeScene(paletteId = "holo") }
|
||||
fun consoleHomeLight() = shootRoot("console-home-light", statusBar = false) { ConsoleHomeScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console UI actually runs in, and the only one wide enough to
|
||||
@@ -149,7 +164,7 @@ class ScreenshotTest {
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleHomeLandscape() = shootRoot("console-home-landscape") { ConsoleHomeScene() }
|
||||
fun consoleHomeLandscape() = shootRoot("console-home-landscape", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
/**
|
||||
* The API 31/32 field. `RuntimeShader` is API 33+, so everything below it keeps the four
|
||||
@@ -158,24 +173,46 @@ class ScreenshotTest {
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs") { ConsoleHomeScene() }
|
||||
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
// The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a
|
||||
// pale palette, because the console draws them through a ColorScheme derived from the palette's
|
||||
// ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
@Test
|
||||
fun consoleLicenses() = shootRoot("console-licenses") { ConsoleLicensesScene() }
|
||||
fun consoleLicenses() = shootRoot("console-licenses", statusBar = false) { ConsoleLicensesScene() }
|
||||
|
||||
@Test
|
||||
fun consoleLicensesLight() =
|
||||
shootRoot("console-licenses-light") { ConsoleLicensesScene(paletteId = "holo") }
|
||||
shootRoot("console-licenses-light", statusBar = false) { ConsoleLicensesScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
fun consoleControllers() = shootRoot("console-controllers", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The touch presentation, pads connected — landscape, like every store frame: the app is
|
||||
* built for horizontal use, and a portrait capture shows a layout nobody streams in.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun controllers() = shootRoot("controllers") { ControllersScene() }
|
||||
|
||||
/** The console presentation at the same landscape geometry — the store's FEEL THE GAME frame. */
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleControllersLandscape() =
|
||||
shootRoot("console-controllers-landscape", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The library coverflow with a mock shelf — the store's PICK & PLAY frame. Landscape: the
|
||||
* orientation the coverflow actually runs in, and the only one wide enough for neighbours.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun library() = shootRoot("library", statusBar = false) { LibraryScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllersLight() =
|
||||
shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") }
|
||||
shootRoot("console-controllers-light", statusBar = false) { ConsoleControllersScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
@@ -197,4 +234,13 @@ class ScreenshotTest {
|
||||
HostsScene()
|
||||
PairDialog()
|
||||
}
|
||||
|
||||
/**
|
||||
* The add-host sheet (separate window → whole-screen capture). Pixel-like geometry, not the
|
||||
* default 360×800dp: same 1080×2400 px, but at 420 dpi the extra dp headroom is what lets the
|
||||
* sheet's Connect button — the row that carries the resolution promise — fit in frame.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w411dp-h915dp-420dpi")
|
||||
fun addHost() = shootScreen("add-host") { AddHostScene() }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
package io.unom.punktfunk.screenshots
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Shader
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BatteryFull
|
||||
import androidx.compose.material.icons.filled.SignalCellular4Bar
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
@@ -35,8 +53,27 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import io.unom.punktfunk.GamepadHome
|
||||
import io.unom.punktfunk.GamepadInk
|
||||
import io.unom.punktfunk.GamepadPalette
|
||||
import coil.ImageLoader
|
||||
import coil.test.FakeImageLoaderEngine
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.AddHostSheet
|
||||
import io.unom.punktfunk.ConsoleControllersScreen
|
||||
import io.unom.punktfunk.ConsoleHeader
|
||||
import io.unom.punktfunk.ConsoleLegendInset
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
import io.unom.punktfunk.ControllersScreen
|
||||
import io.unom.punktfunk.Coverflow
|
||||
import io.unom.punktfunk.GamepadAuroraBackground
|
||||
import io.unom.punktfunk.GamepadHintBar
|
||||
import io.unom.punktfunk.PadGlyph
|
||||
import io.unom.punktfunk.PadInfo
|
||||
import io.unom.punktfunk.consoleLegendInsets
|
||||
import io.unom.punktfunk.consoleSafeArea
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.library.Artwork
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import io.unom.punktfunk.GamepadSettingsScreen
|
||||
import io.unom.punktfunk.HomeTile
|
||||
import io.unom.punktfunk.LocalGamepadInk
|
||||
@@ -70,6 +107,51 @@ internal fun ShotTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = BrandDark, content = content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Robolectric has no system UI, so every capture was missing the status bar and the content sat
|
||||
* where the bar belongs — on the Pixel render the app title collided with the camera punch-hole.
|
||||
* This frame draws a plausible bar (time left, radios right, the CENTRE left empty for the hole)
|
||||
* and pushes the scene below it, the same geometry real insets produce. The height mirrors a
|
||||
* Pixel's tall bar as measured off a real 1344×2992 capture (~145 px ≈ 40 dp).
|
||||
*/
|
||||
@Composable
|
||||
internal fun ShotStatusFrame(content: @Composable () -> Unit) {
|
||||
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(40.dp).padding(horizontal = 28.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"21:47",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Wifi, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(15.dp),
|
||||
)
|
||||
Icon(
|
||||
Icons.Filled.SignalCellular4Bar, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Icon(
|
||||
Icons.Filled.BatteryFull, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
private data class MockHost(
|
||||
val name: String,
|
||||
val address: String,
|
||||
@@ -510,8 +592,8 @@ internal fun ConsoleHomeScene(paletteId: String = "violet") {
|
||||
* whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* before this could catch light-grey body text stranded on a pastel field.
|
||||
*
|
||||
* Robolectric enumerates no input devices, so the controllers scene renders its deterministic
|
||||
* "nothing connected" state.
|
||||
* Robolectric enumerates no input devices, so the controllers scenes inject [shotPads] — the
|
||||
* deterministic connected-pads state the store listing needs.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
@@ -520,14 +602,147 @@ internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
@Composable
|
||||
internal fun ConsoleControllersScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) {
|
||||
ConsoleControllersScreen(gamepadSetting = 0, onBack = {}, navActive = false)
|
||||
// Robolectric enumerates no input devices, so the shot injects the two pads the store
|
||||
// listing talks about — the empty "no controller detected" state proves the palette but
|
||||
// sells nothing.
|
||||
ConsoleControllersScreen(
|
||||
gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The touch presentation of the same screen, with the same injected pads. Wrapped in a background
|
||||
* [Surface]: the activity provides the dark ground in the app, and without one here the content
|
||||
* color falls back to black-on-white while the cards stay dark.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ControllersScene() =
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
ControllersScreen(gamepadSetting = 0, onBack = {}, padsOverride = shotPads())
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Add a host" bottom sheet over the host grid — the store's onboarding frame. State is
|
||||
* hoisted in production (ConnectScreen), so the scene passes a filled-in form directly; the
|
||||
* mode label mirrors what a paired 120 Hz phone shows on the connect button.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddHostScene() {
|
||||
HostsScene()
|
||||
AddHostSheet(
|
||||
hostName = "Living Room PC", onHostNameChange = {},
|
||||
host = "192.168.1.42", onHostChange = {},
|
||||
port = "9777", onPortChange = {},
|
||||
connecting = false, modeLabel = "2992×1344@120",
|
||||
onDismiss = {}, onConnect = { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
|
||||
/** The two pads the store listing names: DualSense (adaptive triggers, LEDs, rumble) and Xbox. */
|
||||
internal fun shotPads() = listOf(
|
||||
PadInfo(
|
||||
name = "DualSense Wireless Controller",
|
||||
detail = "054C:0CE6 · gamepad · joystick",
|
||||
forwarded = true, controllerNumber = 1,
|
||||
resolvedPref = Gamepad.PREF_DUALSENSE, canRumble = true,
|
||||
),
|
||||
PadInfo(
|
||||
name = "Xbox Wireless Controller",
|
||||
detail = "045E:0B13 · gamepad · joystick",
|
||||
forwarded = true, controllerNumber = 2,
|
||||
resolvedPref = Gamepad.PREF_XBOXONE, canRumble = true,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Publish the palette locals `App` would normally provide. A scene that calls a console screen
|
||||
* directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently
|
||||
* prove nothing at all.
|
||||
*/
|
||||
/**
|
||||
* The game-library coverflow (the real [Coverflow] over the real console chrome) with a mock shelf.
|
||||
* The library screen itself can't be shot — its state comes off the network — so the scene rebuilds
|
||||
* the same shell [io.unom.punktfunk.LibraryScreen] draws around it: aurora, header, floating hint
|
||||
* bar. Cover art is answered synchronously by coil-test's [FakeImageLoaderEngine] with generated
|
||||
* posters, so the frozen animation clock never races an async load.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LibraryScene(paletteId: String = "violet") = ConsolePalette(paletteId) {
|
||||
val context = LocalContext.current
|
||||
val loader = remember { shotLibraryLoader(context) }
|
||||
val games = remember { shotGames() }
|
||||
val hazeState = remember { HazeState() }
|
||||
val landscape =
|
||||
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
ConsoleHeader("Living Room PC — Library")
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Coverflow(games, loader, navActive = false, onLaunch = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
listOf(PadGlyph.hint('A', "Launch"), PadGlyph.hint('B', "Close")),
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A believable shelf: four titles with art plus the Steam launcher entry (brand-mark tile). */
|
||||
private fun shotGames() = listOf(
|
||||
GameEntry("custom:aurora", "custom", "Aurora Drift", Artwork("shot://art/aurora", null, null)),
|
||||
GameEntry("steam:starfall", "steam", "Starfall Vale", Artwork("shot://art/starfall", null, null)),
|
||||
GameEntry("heroic:neon", "heroic", "Neon Circuit", Artwork("shot://art/neon", null, null)),
|
||||
GameEntry("gog:ember", "gog", "Ember Peaks", Artwork("shot://art/ember", null, null)),
|
||||
GameEntry("steam:launcher", "steam", "Steam", Artwork(null, null, null), role = "launcher", icon = "steam"),
|
||||
)
|
||||
|
||||
private fun shotLibraryLoader(context: Context): ImageLoader {
|
||||
val engine = FakeImageLoaderEngine.Builder()
|
||||
.intercept("shot://art/aurora", cover(context, 0xFF6656F2, 0xFF141040, "A"))
|
||||
.intercept("shot://art/starfall", cover(context, 0xFFE86FA8, 0xFF3A1030, "S"))
|
||||
.intercept("shot://art/neon", cover(context, 0xFF35D0C5, 0xFF0A2A33, "N"))
|
||||
.intercept("shot://art/ember", cover(context, 0xFFEF8F4B, 0xFF3A1608, "E"))
|
||||
.default(ColorDrawable(0xFF221E44.toInt()))
|
||||
.build()
|
||||
return ImageLoader.Builder(context).components { add(engine) }.build()
|
||||
}
|
||||
|
||||
/** A generated 2:3 poster: vertical brand-adjacent gradient + a big monogram. */
|
||||
private fun cover(context: Context, top: Long, bottom: Long, mark: String): Drawable {
|
||||
val w = 600
|
||||
val h = 900
|
||||
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bmp)
|
||||
canvas.drawRect(
|
||||
0f, 0f, w.toFloat(), h.toFloat(),
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = LinearGradient(
|
||||
0f, 0f, 0f, h.toFloat(), top.toInt(), bottom.toInt(), Shader.TileMode.CLAMP,
|
||||
)
|
||||
},
|
||||
)
|
||||
canvas.drawText(
|
||||
mark, w / 2f, h / 2f + 110f,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xD9FFFFFF.toInt()
|
||||
textSize = 320f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
textAlign = Paint.Align.CENTER
|
||||
},
|
||||
)
|
||||
return BitmapDrawable(context.resources, bmp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package io.unom.punktfunk.screenshots
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The same Roborazzi harness as ScreenshotTest, at Android TV geometry: 960×540dp in the
|
||||
* `television` UI mode at xhdpi (2.0×) = 1920×1080 px — the Play Store's 16:9 TV screenshot size,
|
||||
* captured 1:1 with no resampling. Only the screens that exist on a TV are shot here: the
|
||||
* gamepad-console shell (what LEANBACK_LAUNCHER opens into) and the in-stream view. Files are
|
||||
* prefixed `tv-` so the artifact separates the form factors.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class TvScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val out = "build/outputs/roborazzi"
|
||||
|
||||
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
compose.onRoot().captureRoboImage("$out/tv-$name.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
|
||||
@Test
|
||||
fun streamDetailed() =
|
||||
shootRoot("stream-detailed") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
|
||||
/** The library coverflow at TV geometry — the store's PICK & PLAY frame for the TV listing. */
|
||||
@Test
|
||||
fun library() = shootRoot("library") { LibraryScene() }
|
||||
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
}
|
||||
@@ -9,7 +9,8 @@ tolerates it being raw JSON *or* base64-encoded JSON.
|
||||
Usage (upload a new build):
|
||||
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--aab path/to/app-release.aab --track internal --status completed [--no-commit]
|
||||
--aab path/to/app-release.aab --track beta --also-track alpha \
|
||||
--status completed [--no-commit]
|
||||
|
||||
Usage (promote a build that is already on Play, no rebuild):
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
@@ -164,6 +165,9 @@ def main():
|
||||
ap.add_argument("--promote-from", metavar="TRACK",
|
||||
help="with --promote: assert the code is on TRACK, then clear TRACK")
|
||||
ap.add_argument("--track", default="internal")
|
||||
ap.add_argument("--also-track", action="append", default=[], metavar="TRACK",
|
||||
help="assign the same versionCode to this track too, in the same edit "
|
||||
"(repeatable). Canary uses it to feed open + closed testing at once.")
|
||||
ap.add_argument("--status", default="completed")
|
||||
ap.add_argument("--user-fraction", type=float,
|
||||
help="staged rollout fraction, 0<f<1; required by --status inProgress")
|
||||
@@ -183,6 +187,11 @@ def main():
|
||||
sys.exit(f"ERROR: --user-fraction must be strictly between 0 and 1 (got {a.user_fraction})")
|
||||
if a.aab and not os.path.isfile(a.aab):
|
||||
sys.exit(f"ERROR: AAB not found: {a.aab}")
|
||||
for t in a.also_track:
|
||||
# `--also-track <promote-from>` would assign and clear the same track in one edit;
|
||||
# whichever PUT lands second silently wins. Refuse the ambiguity instead.
|
||||
if t in (a.track, a.promote_from):
|
||||
sys.exit(f"ERROR: --also-track {t} duplicates --track/--promote-from")
|
||||
|
||||
notes = load_release_notes(a.release_notes_file, a.release_notes_language) \
|
||||
if a.release_notes_file else None
|
||||
@@ -209,6 +218,11 @@ def main():
|
||||
put_track(app, edit, tok, a.track, [vc], a.status, a.user_fraction, notes)
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}"
|
||||
+ (f" userFraction={a.user_fraction}" if a.user_fraction is not None else ""))
|
||||
# Same edit, so one commit (and one Play review) covers every track the code lands on —
|
||||
# the tracks can never disagree about which canary is current.
|
||||
for t in a.also_track:
|
||||
put_track(app, edit, tok, t, [vc], a.status, a.user_fraction, notes)
|
||||
print(f"assigned versionCode={vc} -> track={t} status={a.status}")
|
||||
# Same edit as the assignment above, so the code is never active on both tracks at once.
|
||||
if a.promote_from:
|
||||
put_track(app, edit, tok, a.promote_from, [], a.status)
|
||||
|
||||
@@ -230,6 +230,46 @@ object Gamepad {
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The BTN_* bit for one key event from a SOURCE_GAMEPAD device — [buttonBit] plus the
|
||||
* Select-family button of every pad that carries no `BUTTON_SELECT` scancode at all.
|
||||
*
|
||||
* Plenty of controllers deliver that button as the plain `KEYCODE_BACK` a remote's Back uses,
|
||||
* with no `BUTTON_SELECT` behind it: it is the Android-TV shape, where every input device is
|
||||
* expected to offer Back, and a pad reaches it whether the vendor prints "Back" on the button
|
||||
* (NVIDIA's SHIELD controller) or "Select"/"View" (most pads in an Android mode). Which one is
|
||||
* on the couch cannot be told from here, and does not need to be — the keycode is what routes.
|
||||
*
|
||||
* Read through [buttonBit] alone that button mapped to nothing, so it fell out of the
|
||||
* streaming branch unconsumed and reached the activity's back stack, which is the
|
||||
* deliberate-quit exit: ONE press of Select dropped the session and the host logged a client
|
||||
* quit. `KEYCODE_BACK` is in fact the ONLY keycode that can get there from a pad — a mapped
|
||||
* button is consumed here, anything with a VK is consumed on the keycode path, volume/power go
|
||||
* to the system, and a FLAG_FALLBACK BACK is swallowed — which is what identifies this as the
|
||||
* cause of such a report without knowing the hardware.
|
||||
*
|
||||
* It also meant such a pad could not produce [BTN_BACK] at all, so every shortcut built on
|
||||
* Select — the emergency exit chord this client's own start banner advertises, the mic mute,
|
||||
* the stats tier — was unreachable on exactly the devices whose users have no keyboard.
|
||||
*
|
||||
* A pad that DOES carry `BUTTON_SELECT` is unaffected in both directions: it never had the
|
||||
* bug, and this changes nothing for it.
|
||||
*
|
||||
* FLAG_FALLBACK events are excluded: those are the synthetic BACK the framework raises after
|
||||
* an unconsumed `BUTTON_*` press (a pad reporting L2/R2 as keys, say), not a button anyone
|
||||
* touched, and forwarding one would put a phantom Select on the wire. `MainActivity` drops
|
||||
* them on the keycode path for the same reason.
|
||||
*
|
||||
* Callers must gate on `SOURCE_GAMEPAD` before asking, exactly as [buttonBit]'s `KEYCODE_DPAD_*`
|
||||
* rows require: a remote's or keyboard's BACK shares this keycode and has to keep leaving the
|
||||
* stream — for a device with no pad on it, Back IS the documented way out.
|
||||
*/
|
||||
fun padButtonBit(keyCode: Int, flags: Int): Int = when {
|
||||
keyCode != KeyEvent.KEYCODE_BACK -> buttonBit(keyCode)
|
||||
flags and KeyEvent.FLAG_FALLBACK != 0 -> 0
|
||||
else -> BTN_BACK
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
|
||||
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
|
||||
|
||||
@@ -302,12 +302,17 @@ object NativeBridge {
|
||||
external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean)
|
||||
|
||||
/**
|
||||
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust.
|
||||
* Start host→client audio: Opus decode → jitter ring → AAudio, all in Rust.
|
||||
* [lowLatencyMode] (the experimental toggle) additionally tags the stream usage=Game for the
|
||||
* HAL's game-audio routing. No-op if already started. Best-effort — a failure leaves video
|
||||
* streaming.
|
||||
*
|
||||
* [isTv] steers the AAudio open ladder: a TV box starts at Shared rather than betting the
|
||||
* audio plane on an Exclusive/MMAP path whose routing we cannot verify from inside the
|
||||
* process. Passed from `FEATURE_LEANBACK` (same source as [nativeStartVideo]) because the
|
||||
* native side's own `ro.build.characteristics` check is not answered by every TV device.
|
||||
*/
|
||||
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean)
|
||||
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean, isTv: Boolean)
|
||||
|
||||
/** Stop + join the audio thread and close AAudio, without closing the session. No-op on `0`. */
|
||||
external fun nativeStopAudio(handle: Long)
|
||||
@@ -472,6 +477,16 @@ object NativeBridge {
|
||||
// cross only when the host pastes (a "fetch:" event answered by nativeClipServeText). Host
|
||||
// copies arrive as "offer:" events, fetched eagerly into the system clipboard.
|
||||
|
||||
/**
|
||||
* The management-API port the host reported in this session's `Welcome` — where its game
|
||||
* library is served — or 0 if it advertised none (older host, or no management API).
|
||||
*
|
||||
* Persist it on the host record: unlike the mDNS `mgmt` TXT, this arrives over the connection
|
||||
* we have already authenticated, so it is what makes a host that moved off 47990 browsable
|
||||
* over a VPN, a routed subnet, or when it was added by address.
|
||||
*/
|
||||
external fun nativeHostMgmtPort(handle: Long): Int
|
||||
|
||||
/** Whether the host advertised a working shared-clipboard service (HOST_CAP_CLIPBOARD). */
|
||||
external fun nativeClipSupported(handle: Long): Boolean
|
||||
|
||||
|
||||
+7
-1
@@ -19,13 +19,16 @@ data class DiscoveredHost(
|
||||
val pairingRequired: Boolean = false,
|
||||
val mac: List<String> = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN)
|
||||
val os: String = "", // TXT "os" (OS-identity chain, e.g. "linux/fedora/bazzite"); "" on older hosts
|
||||
// TXT "mgmt" — the management-API port the library is served on, distinct from `port` (the
|
||||
// native QUIC plane). null on an older host / older native lib, meaning "assume 47990".
|
||||
val mgmtPort: Int? = null,
|
||||
)
|
||||
|
||||
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||
private const val FIELD_SEP = '\u001F'
|
||||
|
||||
/**
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac␟os`),
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt`),
|
||||
* or null if it's malformed. Fields past the 6th are optional — an older native lib omits them
|
||||
* (`mac` 7th, `os` 8th). Pure — unit-tested without Android (see ParseRecordTest). The native side
|
||||
* already applied the protocol gate and address selection, so this is just field marshaling.
|
||||
@@ -46,6 +49,9 @@ fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
else emptyList(),
|
||||
os = if (f.size > 7) sanitizeOsChain(f[7]) else "",
|
||||
// 9th field, absent on an older native lib. `0` (and anything out of range) means "not
|
||||
// advertised" → null, and the caller falls back to 47990.
|
||||
mgmtPort = if (f.size > 8) f[8].toIntOrNull()?.takeIf { it in 1..65535 } else null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+36
-1
@@ -32,6 +32,16 @@ data class KnownHost(
|
||||
* first learned (or forever, against an older host).
|
||||
*/
|
||||
val os: String = "",
|
||||
/**
|
||||
* The host's management-API port (mDNS `mgmt` TXT), where the game library is served — NOT
|
||||
* [port], which is the native QUIC plane. Learned while online and kept for the same reason as
|
||||
* [mac] and [os], except this one is load-bearing: a host that moved its mgmt port off 47990
|
||||
* (the supported way to share a machine with a Sunshine fork, whose web UI owns that port)
|
||||
* served its library only while mDNS was reachable, because the advert was the sole place the
|
||||
* real port ever existed. `null` until learned — resolve with [effectiveMgmtPort].
|
||||
* Mirrors the Apple client's `StoredHost.mgmtPort` and the Rust `KnownHost.mgmt_port`.
|
||||
*/
|
||||
val mgmtPort: Int? = null,
|
||||
/** Stable record identity — see the class doc. Minted here for a genuinely new record. */
|
||||
val id: String = newRecordId(),
|
||||
/**
|
||||
@@ -54,7 +64,16 @@ data class KnownHost(
|
||||
* that no longer exist are dropped when the cards are rendered.
|
||||
*/
|
||||
val pinnedProfileIds: List<String> = emptyList(),
|
||||
)
|
||||
) {
|
||||
/**
|
||||
* Where this host's management API actually is: the port learned from its advert, else 47990.
|
||||
* The twin of the Apple client's `StoredHost.effectiveMgmtPort` and the Rust
|
||||
* `KnownHost::effective_mgmt_port`. Resolve through this — the constant is the FALLBACK, not
|
||||
* the answer.
|
||||
*/
|
||||
val effectiveMgmtPort: Int
|
||||
get() = mgmtPort ?: io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists trusted hosts — the pinned-fingerprint store *and* the saved-hosts list — keyed by
|
||||
@@ -130,6 +149,17 @@ class KnownHostStore(context: Context) {
|
||||
save(h.copy(os = os))
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn/refresh a saved host's management-API port from its live advert — same contract as
|
||||
* [learnMac]. This is the one that keeps a moved mgmt port working once mDNS isn't reachable.
|
||||
*/
|
||||
fun learnMgmtPort(address: String, port: Int, mgmtPort: Int) {
|
||||
if (mgmtPort <= 0) return
|
||||
val h = get(address, port) ?: return
|
||||
if (h.mgmtPort == mgmtPort) return
|
||||
save(h.copy(mgmtPort = mgmtPort))
|
||||
}
|
||||
|
||||
/** Forget [host] (the next connect re-pairs / re-TOFUs). */
|
||||
fun remove(host: KnownHost) {
|
||||
prefs.edit().remove(host.id).apply()
|
||||
@@ -180,6 +210,10 @@ class KnownHostStore(context: Context) {
|
||||
paired = j.optBoolean("paired", false),
|
||||
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||
os = j.optString("os", ""),
|
||||
// 0 (or absent) = never learned. `optInt` cannot express "missing", hence the sentinel
|
||||
// rather than a bare default — a record written before this field existed must decode
|
||||
// to null and fall back to 47990, not to port 0.
|
||||
mgmtPort = j.optInt("mgmt", 0).takeIf { it > 0 },
|
||||
// A record without an id can only be one this build wrote before the migration ran, or
|
||||
// a hand-edited file; minting here keeps the parse total rather than dropping a host.
|
||||
id = j.optString("id", "").ifEmpty { newRecordId() },
|
||||
@@ -266,6 +300,7 @@ class KnownHostStore(context: Context) {
|
||||
.put("paired", host.paired)
|
||||
.put("mac", host.mac.joinToString(","))
|
||||
.put("os", host.os)
|
||||
.put("mgmt", host.mgmtPort ?: 0)
|
||||
.put("clip", host.clipboardSync)
|
||||
.put("profile", host.profileId ?: "")
|
||||
.put("pins", JSONArray(host.pinnedProfileIds))
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.KeyEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of [Gamepad.padButtonBit] — the streaming branch's gamepad keycode resolution
|
||||
* (`KeyEvent`'s keycode/flag constants are compile-time-inlined ints, so no Android runtime is
|
||||
* involved). Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*
|
||||
* The regression it pins is a field report: one press of Select disconnected the session. Plenty
|
||||
* of pads deliver that button as the plain `KEYCODE_BACK` a remote uses, with no `BUTTON_SELECT`
|
||||
* scancode behind it — so it mapped to nothing, fell out of the gamepad branch unconsumed, and
|
||||
* reached the activity back stack, which is the deliberate-quit exit. The same gap made
|
||||
* [Gamepad.BTN_BACK] unreachable on those pads, and with it every shortcut built on Select: the
|
||||
* exit chord `StreamScreen`'s own start banner advertises, the mic mute, the stats tier.
|
||||
*
|
||||
* Which controller the report came from is not knowable from the logs and does not matter:
|
||||
* `KEYCODE_BACK` is the only keycode that reaches the back stack from a SOURCE_GAMEPAD device, so
|
||||
* a one-press quit identifies the button's keycode on its own.
|
||||
*/
|
||||
class PadButtonBitTest {
|
||||
|
||||
/** The report: Select on an Android-TV pad arrives as BACK and must be the Select bit. */
|
||||
@Test
|
||||
fun `a pad's BACK is its Select button`() {
|
||||
assertEquals(Gamepad.BTN_BACK, Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0))
|
||||
// Same bit either spelling reaches us by — a pad that DOES carry BUTTON_SELECT is unchanged.
|
||||
assertEquals(
|
||||
Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_SELECT, 0),
|
||||
Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* With Select mapped, the three Select chords are reachable on a pad that has only a BACK
|
||||
* keycode — which is the whole point of the mapping, not a side effect of it. Held-state
|
||||
* assembly is [GamepadRouter]'s (see `GamepadChordTest`); what is pinned here is that the
|
||||
* bits a SHIELD can actually produce cover each chord.
|
||||
*/
|
||||
@Test
|
||||
fun `the Select chords are reachable from a BACK-only pad`() {
|
||||
val select = Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0)
|
||||
val start = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_START, 0)
|
||||
val l1 = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_L1, 0)
|
||||
val r1 = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_R1, 0)
|
||||
val x = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_X, 0)
|
||||
val y = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_Y, 0)
|
||||
assertEquals(GamepadRouter.EXIT_CHORD, select or start or l1 or r1)
|
||||
assertEquals(GamepadRouter.STATS_CHORD, select or x)
|
||||
assertEquals(GamepadRouter.MIC_CHORD, select or y)
|
||||
}
|
||||
|
||||
/**
|
||||
* The synthetic BACK the framework raises after an unconsumed `BUTTON_*` press is not a button
|
||||
* anyone touched — forwarding it would put a phantom Select on the wire, and one of those
|
||||
* landing while Start + L1 + R1 were held would complete the exit chord out of nowhere.
|
||||
*/
|
||||
@Test
|
||||
fun `a fallback BACK is not a button press`() {
|
||||
assertEquals(0, Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, KeyEvent.FLAG_FALLBACK))
|
||||
// Only BACK is filtered on the flag; a real button keeps its bit whatever rides alongside.
|
||||
assertEquals(
|
||||
Gamepad.BTN_A,
|
||||
Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.FLAG_FALLBACK),
|
||||
)
|
||||
}
|
||||
|
||||
/** Everything else is [Gamepad.buttonBit] verbatim — BACK is the only row this adds. */
|
||||
@Test
|
||||
fun `every other keycode is unchanged`() {
|
||||
for (code in 0..0x400) {
|
||||
if (code == KeyEvent.KEYCODE_BACK) continue
|
||||
assertEquals(Gamepad.buttonBit(code), Gamepad.padButtonBit(code, 0))
|
||||
}
|
||||
// And BACK is genuinely a new row, not one buttonBit already had.
|
||||
assertEquals(0, Gamepad.buttonBit(KeyEvent.KEYCODE_BACK))
|
||||
}
|
||||
}
|
||||
+25
@@ -47,6 +47,31 @@ class ParseRecordTest {
|
||||
rec("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/fedora/bazzite"),
|
||||
)!!
|
||||
assertEquals("linux/fedora/bazzite", h.os)
|
||||
// A record from a native lib predating the 9th field: no mgmt port, so the caller falls
|
||||
// back to 47990. Absent must read as "unknown", never as port 0.
|
||||
assertNull(h.mgmtPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ninthFieldCarriesTheMgmtPort() {
|
||||
// 47991, not the 47990 default — a host that MOVED its mgmt port is the whole reason this
|
||||
// field is on the wire, and a test pinned to the default would pass against a hardcode.
|
||||
val h = parseHostRecord(
|
||||
rec("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/arch", "47991"),
|
||||
)!!
|
||||
assertEquals(47991, h.mgmtPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mgmtPortOutOfRangeOrUnparsableReadsAsUnknown() {
|
||||
// Unauthenticated advert data: 0 (the "not advertised" sentinel the Rust side emits),
|
||||
// a non-number, and an out-of-range value must all mean "assume the default" rather than
|
||||
// produce a port the client would then fail to connect to.
|
||||
val base = arrayOf("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/arch")
|
||||
assertNull(parseHostRecord(rec(*base, "0"))!!.mgmtPort)
|
||||
assertNull(parseHostRecord(rec(*base, "not-a-port"))!!.mgmtPort)
|
||||
assertNull(parseHostRecord(rec(*base, "70000"))!!.mgmtPort)
|
||||
assertNull(parseHostRecord(rec(*base, ""))!!.mgmtPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+692
-236
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ const PROTO: &str = "punktfunk/1";
|
||||
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||
const FIELD_SEP: char = '\u{1f}';
|
||||
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac␟os`
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt`
|
||||
/// (`␟` = [`FIELD_SEP`]). Records are newline-joined in a poll snapshot; [`Host::encode`] strips
|
||||
/// the framing bytes from every field so no value can break it. New fields append (the Kotlin
|
||||
/// parser tolerates both arities), never reorder.
|
||||
@@ -49,6 +49,10 @@ struct Host {
|
||||
/// OS-identity chain from the mDNS `os` TXT (`linux/fedora/bazzite`, ...), for the host
|
||||
/// card's OS icon. Empty if absent (older host).
|
||||
os: String,
|
||||
/// Management-API port from the mDNS `mgmt` TXT — where the game library is served, distinct
|
||||
/// from `port` (the native QUIC plane). `0` if absent. Kotlin persists it on the host record so
|
||||
/// a host that moved off 47990 keeps its library once mDNS is no longer reachable.
|
||||
mgmt: u16,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
@@ -61,7 +65,7 @@ impl Host {
|
||||
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||
}
|
||||
format!(
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
clean(&self.key),
|
||||
clean(&self.name),
|
||||
clean(&self.addr),
|
||||
@@ -70,6 +74,7 @@ impl Host {
|
||||
clean(&self.pair),
|
||||
clean(&self.mac),
|
||||
clean(&self.os),
|
||||
self.mgmt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -193,6 +198,8 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
pair: val("pair"),
|
||||
mac: val("mac"),
|
||||
os: val("os"),
|
||||
// 0 = the host didn't advertise one (older host); Kotlin then falls back to 47990.
|
||||
mgmt: val("mgmt").parse().unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -213,7 +220,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||
@@ -277,10 +284,11 @@ mod tests {
|
||||
pair: "required".into(),
|
||||
mac: "aa:bb:cc:dd:ee:ff".into(),
|
||||
os: "linux/fedora/bazzite".into(),
|
||||
mgmt: 47991,
|
||||
};
|
||||
let encoded = h.encode();
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields.len(), 8);
|
||||
assert_eq!(fields.len(), 9);
|
||||
assert_eq!(fields[0], "host-123");
|
||||
assert_eq!(fields[1], "home-worker-2");
|
||||
assert_eq!(fields[2], "192.168.1.70");
|
||||
@@ -289,6 +297,9 @@ mod tests {
|
||||
assert_eq!(fields[5], "required");
|
||||
assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff");
|
||||
assert_eq!(fields[7], "linux/fedora/bazzite");
|
||||
// A NON-default port on purpose: the whole point of carrying this field is the host that
|
||||
// moved off 47990, so a test pinned to the default would pass against a hardcoded value.
|
||||
assert_eq!(fields[8], "47991");
|
||||
assert!(
|
||||
!encoded.contains('\n'),
|
||||
"a record must never contain the record separator"
|
||||
@@ -308,13 +319,11 @@ mod tests {
|
||||
pair: "required\n".into(),
|
||||
mac: "aa:bb\u{1f}cc".into(),
|
||||
os: "linux\u{1f}evil/arch".into(),
|
||||
// A numeric field cannot smuggle a separator — it is formatted from a u16, not cleaned.
|
||||
mgmt: 47991,
|
||||
};
|
||||
let encoded = h.encode();
|
||||
assert_eq!(
|
||||
encoded.matches(FIELD_SEP).count(),
|
||||
7,
|
||||
"exactly eight fields"
|
||||
);
|
||||
assert_eq!(encoded.matches(FIELD_SEP).count(), 8, "exactly nine fields");
|
||||
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields[0], "kinjected");
|
||||
|
||||
@@ -50,6 +50,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupport
|
||||
client(handle).is_some_and(|h| h.client.host_caps() & HOST_CAP_CLIPBOARD != 0)
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeHostMgmtPort(handle)` — the management-API port the host reported in this
|
||||
/// session's `Welcome`, or `0` if it advertised none (older host / no management API).
|
||||
///
|
||||
/// Kotlin persists this on the host record, which is what lets the library screen reach a host that
|
||||
/// moved its mgmt port off 47990 WITHOUT ever having seen an mDNS advert — the VPN / routed-subnet
|
||||
/// / added-by-address cases, where the `mgmt` TXT the discovery path relies on never arrives.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostMgmtPort(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jint {
|
||||
client(handle).map_or(0, |h| jint::from(h.client.mgmt_port()))
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
|
||||
/// clipboard-related happens on either side until an `enabled: true` crosses.
|
||||
#[unsafe(no_mangle)]
|
||||
|
||||
@@ -357,10 +357,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode)` — start the Opus→AAudio playback thread.
|
||||
/// `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's game-audio
|
||||
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
|
||||
/// streaming.
|
||||
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)` — start the Opus→AAudio playback
|
||||
/// supervisor. `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's
|
||||
/// game-audio routing; `isTv` steers the AAudio open ladder (see `crate::audio::open_ladder`).
|
||||
/// No-op if already started or on a `0` handle. Best-effort: a failure leaves video streaming.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
@@ -368,6 +368,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
low_latency_mode: jboolean,
|
||||
is_tv: jboolean,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
@@ -378,7 +379,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
if guard.is_some() {
|
||||
return; // already playing
|
||||
}
|
||||
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode) {
|
||||
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode, is_tv) {
|
||||
Some(p) => *guard = Some(p),
|
||||
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "about-icon@1x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "about-icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
@@ -83,14 +83,6 @@ struct ContentView: View {
|
||||
/// never covers the video.
|
||||
@State private var isFullscreen = false
|
||||
#endif
|
||||
#if os(macOS) || os(tvOS)
|
||||
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability
|
||||
/// pattern): raised on every transition to `.streaming`, dropped by the banner's own
|
||||
/// 6-second task. Independent of the stats HUD so the keys are discoverable even with
|
||||
/// statistics off. On tvOS it carries the ONLY exits (hold Back / the pad chord) plus
|
||||
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
||||
@State private var showShortcutHint = false
|
||||
#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).
|
||||
@@ -347,9 +339,6 @@ struct ContentView: View {
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
switch phase {
|
||||
case .streaming:
|
||||
#if os(macOS) || os(tvOS)
|
||||
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||||
#endif
|
||||
#if os(iOS)
|
||||
showTouchExit = true // the off-tier exit disc's 8 s window, per session start
|
||||
#endif
|
||||
@@ -365,9 +354,14 @@ struct ContentView: View {
|
||||
// Persist on the next runloop tick: HostStore is an ObservableObject, and mutating
|
||||
// its @Published from inside .onChange (a view-update callback) trips SwiftUI's
|
||||
// "Publishing changes from within view updates". A one-tick delay is imperceptible.
|
||||
// The session's own Welcome told us where this host's library lives — the one
|
||||
// source that does not need an mDNS advert, so it also covers a host reached by
|
||||
// address over a VPN. 0 = not advertised; updateMgmtPort ignores it.
|
||||
let liveMgmtPort = model.connection?.hostMgmtPort
|
||||
let store = store
|
||||
DispatchQueue.main.async {
|
||||
store.markConnected(host.id)
|
||||
store.updateMgmtPort(host.id, port: liveMgmtPort)
|
||||
if let approvedFingerprint { store.pin(host.id, fingerprint: approvedFingerprint) }
|
||||
}
|
||||
case .idle:
|
||||
@@ -453,6 +447,10 @@ struct ContentView: View {
|
||||
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
|
||||
}
|
||||
.frame(minWidth: 940, minHeight: 620)
|
||||
// The stack draws the title, and it sits outside LibraryView's own ink — see the tvOS
|
||||
// cover. Gated, because this sheet is BOTH modes' library on macOS and the touch
|
||||
// grid's title belongs to the system background.
|
||||
.gamepadPaletteInk(gamepadUIActive)
|
||||
}
|
||||
#else
|
||||
// iOS: the cover is the TOUCH UI's presentation only. In gamepad mode the library is one
|
||||
@@ -822,6 +820,7 @@ struct ContentView: View {
|
||||
onPaired: handlePaired, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle,
|
||||
wakeOnly: { wakeOnly($0) },
|
||||
promptActive: consolePromptShowing)
|
||||
} else {
|
||||
HomeView(
|
||||
@@ -841,6 +840,7 @@ struct ContentView: View {
|
||||
onPaired: handlePaired, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle,
|
||||
wakeOnly: { wakeOnly($0) },
|
||||
promptActive: consolePromptShowing)
|
||||
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||||
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
||||
@@ -851,12 +851,29 @@ struct ContentView: View {
|
||||
.fullScreenCover(item: $pairingTarget) { host in
|
||||
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||
.onExitCommand { pairingTarget = nil }
|
||||
// A tvOS cover draws NO background of its own, and this one is attached
|
||||
// outside the launcher's `gamepadPaletteInk` — so the pairing screen used
|
||||
// to render the system's dark chrome directly over the launcher showing
|
||||
// through it, which under a pale palette is white text on a bright field
|
||||
// (the PIN prompt was all but invisible). Give it the console's own field
|
||||
// and the palette's ink, like every other screen the launcher opens. Only
|
||||
// this branch: `HomeView`'s route to the same sheet is the TOUCH UI, which
|
||||
// sits on the system background and has no palette.
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background { GamepadFormBackground() }
|
||||
.gamepadPaletteInk()
|
||||
}
|
||||
.fullScreenCover(item: $libraryTarget) { shelf in
|
||||
NavigationStack {
|
||||
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
|
||||
}
|
||||
.onExitCommand { libraryTarget = nil }
|
||||
// On the STACK, not just inside LibraryView: the navigation title is drawn by
|
||||
// the stack, which wraps that view from outside its own `gamepadPaletteInk` —
|
||||
// so the shelf's name stayed white over a pale field while the content below
|
||||
// it had already gone dark. Unconditional here because this cover only exists
|
||||
// in the launcher's branch, where the console UI is by definition drawing.
|
||||
.gamepadPaletteInk()
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
@@ -973,9 +990,15 @@ struct ContentView: View {
|
||||
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
||||
},
|
||||
onFrame: { [meter = model.meter, latency = model.latency,
|
||||
split = model.latencySplit, queue = model.clientQueue,
|
||||
offset = conn.clockOffsetNs] au in
|
||||
split = model.latencySplit, queue = model.clientQueue] au in
|
||||
meter.note(byteCount: au.data.count)
|
||||
// Read the offset PER AU (an atomic load), never in the capture list: a
|
||||
// capture-list `offset =` froze the connect-time estimate for the whole
|
||||
// session, and on a host whose wall clock steps (VM + NTP) that frozen
|
||||
// value shifted hostnet/e2e by ~15 ms between sessions while the meter's
|
||||
// impossible-sample guard hid the damage (field 2026-08-13). See
|
||||
// `PunktfunkConnection.clockOffsetNs`.
|
||||
let offset = conn.clockOffsetNs
|
||||
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
||||
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
||||
// host/network split — drained by the 1 s stats tick). receivedNs is
|
||||
@@ -1048,31 +1071,15 @@ struct ContentView: View {
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
#endif
|
||||
#if os(macOS) || os(tvOS)
|
||||
// The start-of-stream shortcut banner (Windows-client parity): the
|
||||
// The start-of-stream shortcut banner used to sit here (macOS/tvOS): the
|
||||
// platform's reserved controls on a glass pill for the first 6 seconds of
|
||||
// every session — independent of the stats HUD, so the keys are
|
||||
// discoverable even with statistics off. The banner's own task drops it
|
||||
// (cancelled cleanly if the session view goes away first). On tvOS it
|
||||
// carries the ONLY exits — Menu/B is swallowed during a session (the
|
||||
// `.onExitCommand {}` in the tvOS session branch), so the hold gestures
|
||||
// must be told to the user.
|
||||
if captureEnabled && showShortcutHint {
|
||||
Text(shortcutHintText)
|
||||
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.transition(.opacity)
|
||||
.task {
|
||||
try? await Task.sleep(for: .seconds(6))
|
||||
withAnimation(.easeOut(duration: 0.6)) {
|
||||
showShortcutHint = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// every session. It is now a page you can OPEN — About ▸ Shortcuts, on
|
||||
// both the touch and the controller surface (ShortcutsCatalog) — because
|
||||
// a message that shows once, over the stream you have just connected to,
|
||||
// is unavailable at the moment the question is actually asked. It also
|
||||
// put a composited overlay above the stream for those 6 seconds, which on
|
||||
// this path costs a refresh of display latency (see the iOS exit disc's
|
||||
// note below); the reference page costs nothing during a session.
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
.animation(.easeOut(duration: 0.2), value: model.micMuted)
|
||||
@@ -1139,23 +1146,10 @@ struct ContentView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
/// The reserved combos, told once per session. The mute segment appears only when the session
|
||||
/// actually sends a microphone — teaching a shortcut for a mic that isn't on would be a lie.
|
||||
private var shortcutHintText: String {
|
||||
let base =
|
||||
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
|
||||
return model.micAvailable ? base + " · ⌃⌥⇧A mutes the mic" : base
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 12
|
||||
#elseif os(tvOS)
|
||||
private var shortcutHintText: String {
|
||||
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
||||
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
||||
+ " · Hold Play/Pause, or Select+X on a controller, for statistics"
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
||||
#endif
|
||||
// The two `shortcutHintText` strings that used to live here — one per platform, told once per
|
||||
// session by the banner above — are now `ShortcutsCatalog.groups`, which both About pages
|
||||
// render. The mic line is still conditional there for the same reason it was here: teaching a
|
||||
// shortcut for a microphone that isn't on would be a lie.
|
||||
|
||||
// MARK: - Connect
|
||||
|
||||
@@ -1273,6 +1267,9 @@ struct ContentView: View {
|
||||
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
||||
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
||||
store.updateOsChain(host.id, chain: live.osChain) // ditto for the card's OS mark
|
||||
// ...and the mgmt port, so the library keeps working against a host that moved it once
|
||||
// this device can no longer see the advert (VPN, routed subnet, multicast-dead Wi-Fi).
|
||||
store.updateMgmtPort(host.id, port: live.mgmtPort)
|
||||
} else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
||||
// Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the
|
||||
// dial times out. With auto-wake off, connects go straight through (no packet).
|
||||
@@ -1331,6 +1328,7 @@ struct ContentView: View {
|
||||
guard !model.isBusy else { return }
|
||||
let host = StoredHost(
|
||||
name: d.name, address: d.host, port: d.port,
|
||||
mgmtPort: d.mgmtPort,
|
||||
macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses,
|
||||
osChain: d.osChain.isEmpty ? nil : d.osChain)
|
||||
store.add(host)
|
||||
|
||||
@@ -12,7 +12,10 @@ import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct GamepadAddHostView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -25,6 +28,18 @@ struct GamepadAddHostView: View {
|
||||
/// Whether this screen owns the controller — false while the shell is mid-transition or the
|
||||
/// connect takeover is up (see GamepadSettingsView's twin).
|
||||
var controllerActive = true
|
||||
/// Non-nil ⇒ this screen is EDITING that saved host rather than registering a new one: the
|
||||
/// fields start on its values and `onAdd` receives it back with only name/address/port
|
||||
/// changed, so the fingerprint, pins, binding and MACs it carries survive the edit. A
|
||||
/// re-typed address is the whole point of the screen (a host that moved), so nothing here
|
||||
/// re-derives identity from it — that is the trust store's job, not this form's.
|
||||
///
|
||||
/// Declared after the closures for the same trailing-closure reason as `close`, and it is a
|
||||
/// plain value besides, so it can never capture one.
|
||||
var editingHost: StoredHost?
|
||||
/// One-shot seed guard: `@State` cannot be initialised from a property without a custom init,
|
||||
/// and a custom init would break every existing trailing-closure call site.
|
||||
@State private var seeded = false
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so the keyboard tray still fits.
|
||||
@@ -57,12 +72,15 @@ struct GamepadAddHostView: View {
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
// Leading, like every gamepad heading — and no close chrome (B is the exit).
|
||||
Text("Add Host")
|
||||
Text(editingHost == nil ? "Add Host" : "Edit Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
if !compact {
|
||||
Text("Hosts on this network appear automatically — add one by address "
|
||||
+ "for everything else.")
|
||||
Text(editingHost == nil
|
||||
? "Hosts on this network appear automatically — add one by address "
|
||||
+ "for everything else."
|
||||
: "Rename this host, or point it at a new address — its pairing and "
|
||||
+ "pinned cards are kept.")
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.multilineTextAlignment(.leading)
|
||||
@@ -98,6 +116,17 @@ struct GamepadAddHostView: View {
|
||||
.onChange(of: port) { _, value in
|
||||
if value.count > 5 { port = String(value.prefix(5)) }
|
||||
}
|
||||
// Seed the fields from the host being edited, exactly once: re-seeding on a later appear
|
||||
// (the shell re-mounts a layer when the app returns from the background) would silently
|
||||
// throw away whatever had been typed.
|
||||
.onAppear {
|
||||
guard !seeded else { return }
|
||||
seeded = true
|
||||
guard let host = editingHost else { return }
|
||||
name = host.name
|
||||
address = host.address
|
||||
port = String(host.port)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
// The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware
|
||||
// keyboard's Esc and the macOS sheet's cancel working without chrome.
|
||||
@@ -202,7 +231,9 @@ struct GamepadAddHostView: View {
|
||||
Row(id: "name", label: "Name", value: name, placeholder: "Optional — e.g. Living Room"),
|
||||
Row(id: "address", label: "Address", value: address, placeholder: "IP or hostname"),
|
||||
Row(id: "port", label: "Port", value: port, placeholder: "9777"),
|
||||
Row(id: "add", label: "Add Host", isAction: true),
|
||||
Row(
|
||||
id: "add", label: editingHost == nil ? "Add Host" : "Save Changes",
|
||||
isAction: true),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -261,10 +292,21 @@ struct GamepadAddHostView: View {
|
||||
openKeyboard("address")
|
||||
return
|
||||
}
|
||||
onAdd(StoredHost(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
address: address.trimmingCharacters(in: .whitespaces),
|
||||
port: UInt16(port) ?? 9777))
|
||||
let typedName = name.trimmingCharacters(in: .whitespaces)
|
||||
let typedAddress = address.trimmingCharacters(in: .whitespaces)
|
||||
let typedPort = UInt16(port) ?? 9777
|
||||
if var host = editingHost {
|
||||
// Mutate a COPY of the stored record rather than building a fresh one: everything
|
||||
// this form does not show — the pinned fingerprint, WoL MACs, pinned profile
|
||||
// cards, the default binding, `addedAt` — has to survive a rename.
|
||||
host.name = typedName
|
||||
host.address = typedAddress
|
||||
host.port = typedPort
|
||||
onAdd(host)
|
||||
} else {
|
||||
onAdd(StoredHost(
|
||||
name: typedName, address: typedAddress, port: typedPort))
|
||||
}
|
||||
performClose()
|
||||
default:
|
||||
openKeyboard(id)
|
||||
|
||||
@@ -48,6 +48,13 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
var onTertiary: (() -> Void)?
|
||||
/// B → back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to).
|
||||
var onBack: (() -> Void)?
|
||||
/// UP → the focused item's own menu (the launcher's host options). Wiring it takes the whole
|
||||
/// VERTICAL axis away from scrolling: up opens the menu and down goes inert, rather than up
|
||||
/// meaning "menu" while down still stepped the strip. A horizontal carousel has no vertical
|
||||
/// travel to spend, and the desktop and Android consoles both read the axis this way — one
|
||||
/// meaning per direction is what makes the gesture learnable across the three of them.
|
||||
/// nil leaves up/down as a second way to step (what every carousel without a menu still does).
|
||||
var onUp: (() -> Void)?
|
||||
/// L1/R1 → jump this many items at once (clamped to the ends); 0 disables the shoulders.
|
||||
var shoulderJump: Int = 0
|
||||
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
|
||||
@@ -301,6 +308,17 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
// The poll carries only the buttons focus has no concept of: Y/X, the screen actions.
|
||||
input.onSecondary = onSecondary
|
||||
input.onTertiary = onTertiary
|
||||
// UP is the one direction the poll may also read here, and ONLY to open the menu — it
|
||||
// never calls `step`, so it cannot double-move against the focus engine. Routing it
|
||||
// through `.onMoveCommand` instead was the obvious alternative and the wrong one: that
|
||||
// stream is 4-way and its interception is input-source-dependent on real hardware (see
|
||||
// GamepadMenuList's tvOS note), so claiming up there risks left/right focus with it.
|
||||
// Nothing sits above the strip for the engine to move to, so this direction is free.
|
||||
if let onUp {
|
||||
input.onMove = { direction in
|
||||
if direction == .up { onUp() }
|
||||
}
|
||||
}
|
||||
#else
|
||||
input.onMove = { move($0) }
|
||||
input.onConfirm = { activate() }
|
||||
@@ -312,6 +330,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
}
|
||||
|
||||
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||
// With a menu wired, vertical is the menu's axis, not a second scroll axis — see `onUp`.
|
||||
if let onUp {
|
||||
switch direction {
|
||||
case .up: return onUp()
|
||||
case .down: return
|
||||
case .left, .right: break
|
||||
}
|
||||
}
|
||||
let forward = direction == .right || direction == .down
|
||||
step(by: forward ? 1 : -1, clampAtEnds: false)
|
||||
}
|
||||
|
||||
@@ -430,7 +430,12 @@ private struct HintCellStyle: ButtonStyle {
|
||||
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
|
||||
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||
struct GamepadScreenBackground: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from `paletteID` below rather than `\.gamepadInk`: this is mounted as a screen's
|
||||
/// `.background { }`, which the screen attaches BEFORE its own `gamepadPaletteInk()`, so the
|
||||
/// environment here is the screen's parent's — the dark default under a cover or a sheet. It
|
||||
/// only feeds a pale palette's scrim, so the symptom was subtle: the field bleached toward
|
||||
/// white instead of settling onto its own ink. (see `GamepadInk.stored`)
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
/// How far toward the form screens' quiet the field sits: 0 = the launcher's full aurora,
|
||||
/// 1 = calm, fractional mid-chase. Continuous (not a Bool) so the in-place shell can CHASE
|
||||
/// it during a push/pop — the console does the same with its `bg_mix` — and every
|
||||
|
||||
@@ -64,7 +64,10 @@ private struct HomeTile: Identifiable {
|
||||
}
|
||||
|
||||
struct GamepadHomeView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
/// Published by ContentView at the app ROOT, so this reads its own window's tier — this screen
|
||||
/// applies `gamepadPaletteInk` itself and so sits above its own copy of the environment.
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@@ -91,6 +94,10 @@ struct GamepadHomeView: View {
|
||||
/// Launch a library title on a host — the in-place library layer's activate path (iOS; the
|
||||
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
|
||||
let launchTitle: (LibraryTarget, String) -> Void
|
||||
/// Wake a host WITHOUT connecting (ContentView's `wakeOnly`) — the host menu's Wake row. The
|
||||
/// tile's own A already wakes-and-connects; this is the other half, for bringing a machine up
|
||||
/// to look at it rather than to stream from it right now.
|
||||
let wakeOnly: (StoredHost) -> Void
|
||||
/// A console prompt (GamepadPromptView) is up over the home — it polls the same controller, so
|
||||
/// this screen must stand down for as long as it is. Same handoff contract as the connect
|
||||
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
|
||||
@@ -119,6 +126,11 @@ struct GamepadHomeView: View {
|
||||
@State private var selection: GamepadHomeTarget?
|
||||
@State private var showSettings = false
|
||||
@State private var showAddHost = false
|
||||
/// The card whose options menu is up (UP on a saved tile) — see GamepadHostOptionsView.
|
||||
@State private var hostOptionsTarget: HostOptionsTarget?
|
||||
/// The host being edited. Set from the options menu, which closes itself as it opens this so
|
||||
/// the two are never stacked — depth stays ≤ 1, which is what `GamepadScreen` assumes.
|
||||
@State private var editTarget: StoredHost?
|
||||
/// The console's input drop: true for the transition's 0.26 s, during which NO layer polls
|
||||
/// the controller — a double-tapped A can't push two screens, and the held button that
|
||||
/// caused the change is long released before the next poller starts (whose own
|
||||
@@ -201,19 +213,37 @@ struct GamepadHomeView: View {
|
||||
// shell's layers above ARE the presentation.
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
GamepadSettingsView(store: store)
|
||||
GamepadSettingsView(store: store, micAvailable: model.micAvailable)
|
||||
.frame(width: 720, height: 640)
|
||||
}
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
.frame(width: 660, height: 620)
|
||||
}
|
||||
// Shorter than the forms above: a menu is five rows, and a sheet sized for a settings
|
||||
// screen would be mostly empty field under them.
|
||||
.sheet(item: $hostOptionsTarget) { target in
|
||||
hostOptionsView(target, active: true)
|
||||
.frame(width: 620, height: 460)
|
||||
}
|
||||
.sheet(item: $editTarget) { host in
|
||||
editHostView(host, active: true)
|
||||
.frame(width: 660, height: 620)
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 420)
|
||||
#elseif os(tvOS)
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
|
||||
.fullScreenCover(isPresented: $showSettings) {
|
||||
GamepadSettingsView(store: store, micAvailable: model.micAvailable)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
}
|
||||
.fullScreenCover(item: $hostOptionsTarget) { target in
|
||||
hostOptionsView(target, active: true)
|
||||
}
|
||||
.fullScreenCover(item: $editTarget) { host in
|
||||
editHostView(host, active: true)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -261,6 +291,10 @@ struct GamepadHomeView: View {
|
||||
// can be raised from ON TOP of the library (launching a title on an unpaired host), where
|
||||
// it has to win. Backing out of it reveals whatever it interrupted.
|
||||
if let host = pairingTarget { return .pair(host) }
|
||||
// Editing leads the menu that raised it: the menu clears itself on the way, so the two are
|
||||
// never both set, and if they somehow were, the screen the user asked for last should win.
|
||||
if let host = editTarget { return .editHost(host) }
|
||||
if let target = hostOptionsTarget { return .hostOptions(target) }
|
||||
if showSettings { return .settings }
|
||||
if showAddHost { return .addHost }
|
||||
if let shelf = libraryTarget { return .library(shelf) }
|
||||
@@ -277,12 +311,17 @@ struct GamepadHomeView: View {
|
||||
GamepadSettingsView(
|
||||
store: store,
|
||||
close: { if !transitioning { showSettings = false } },
|
||||
controllerActive: active)
|
||||
controllerActive: active,
|
||||
micAvailable: model.micAvailable)
|
||||
case .addHost:
|
||||
GamepadAddHostView(
|
||||
onAdd: { store.add($0) },
|
||||
close: { if !transitioning { showAddHost = false } },
|
||||
controllerActive: active)
|
||||
case .hostOptions(let target):
|
||||
hostOptionsView(target, active: active)
|
||||
case .editHost(let host):
|
||||
editHostView(host, active: active)
|
||||
case .pair(let host):
|
||||
GamepadPairView(
|
||||
host: host,
|
||||
@@ -414,6 +453,7 @@ struct GamepadHomeView: View {
|
||||
onActivate: { $0.activate() },
|
||||
onSecondary: { openLibraryForSelected() },
|
||||
onTertiary: { showSettings = true },
|
||||
onUp: { openOptionsForSelected() },
|
||||
isActive: homeOwnsController
|
||||
) { tile, entrance in
|
||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance)
|
||||
@@ -469,6 +509,14 @@ struct GamepadHomeView: View {
|
||||
glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library",
|
||||
action: { openLibraryForSelected() }))
|
||||
}
|
||||
// Only a saved card has a menu, so the cell appears only where the press does something —
|
||||
// the same honesty rule the Library cell above follows. A direction, not a button, so it
|
||||
// is a plain arrow rather than a `buttonGlyph` (see the settings screen's "Adjust").
|
||||
if case .saved = selected?.id {
|
||||
hints.append(.init(
|
||||
glyph: "arrow.up", text: "Options",
|
||||
action: { openOptionsForSelected() }))
|
||||
}
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings",
|
||||
action: { showSettings = true }))
|
||||
@@ -543,6 +591,60 @@ struct GamepadHomeView: View {
|
||||
/// `HostCardView`-only action never offered on `DiscoveredCardView`. A pinned card opens its
|
||||
/// own shelf: the selection already names which card Y was pressed on, and that card's profile
|
||||
/// is what its launches run with.
|
||||
/// The host menu, built once for all three presentations (the iOS shell layer, the macOS
|
||||
/// sheet, the tvOS cover) so the actions can't drift between them.
|
||||
///
|
||||
/// Edit REPLACES this menu rather than stacking on it — `hostOptionsTarget` is cleared as
|
||||
/// `editTarget` is set — which is the desktop console's `Nav::Replace` and what keeps the
|
||||
/// shell's "depth ≤ 1 by construction" claim true.
|
||||
@ViewBuilder
|
||||
private func hostOptionsView(_ target: HostOptionsTarget, active: Bool) -> some View {
|
||||
let host = target.host
|
||||
GamepadHostOptionsView(
|
||||
host: host,
|
||||
pinnedProfile: target.profile,
|
||||
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
|
||||
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||
&& !host.wakeMacs.isEmpty,
|
||||
onEdit: {
|
||||
guard !transitioning else { return }
|
||||
hostOptionsTarget = nil
|
||||
editTarget = host
|
||||
},
|
||||
onWake: { wakeOnly(host) },
|
||||
onForgetPairing: { store.forgetIdentity(host) },
|
||||
onRemove: { store.remove(host) },
|
||||
onUnpin: {
|
||||
guard let profile = target.profile else { return }
|
||||
store.setPinned(host.id, profileID: profile.id, pinned: false)
|
||||
},
|
||||
close: { if !transitioning { hostOptionsTarget = nil } },
|
||||
controllerActive: active)
|
||||
}
|
||||
|
||||
/// The add-host form in edit mode. `store.update` writes the record back by id, so the
|
||||
/// fingerprint, MACs, pins and binding the form never shows are preserved.
|
||||
@ViewBuilder
|
||||
private func editHostView(_ host: StoredHost, active: Bool) -> some View {
|
||||
GamepadAddHostView(
|
||||
onAdd: { store.update($0) },
|
||||
close: { if !transitioning { editTarget = nil } },
|
||||
controllerActive: active,
|
||||
editingHost: host)
|
||||
}
|
||||
|
||||
/// UP on a saved tile opens that card's menu. Only SAVED hosts have one: a discovered-but-
|
||||
/// unsaved host is not ours to rename or remove, and the two action tiles have nothing to
|
||||
/// offer — the same `HostOptionsScreen::available` gate the desktop console applies.
|
||||
private func openOptionsForSelected() {
|
||||
guard case .saved(let id, let profileID) = selection,
|
||||
let host = store.hosts.first(where: { $0.id == id })
|
||||
else { return }
|
||||
hostOptionsTarget = HostOptionsTarget(
|
||||
host: host,
|
||||
profile: profileID.flatMap { pid in profiles.profiles.first { $0.id == pid } })
|
||||
}
|
||||
|
||||
private func openLibraryForSelected() {
|
||||
guard libraryEnabled, case .saved(let id, let profileID) = selection,
|
||||
let host = store.hosts.first(where: { $0.id == id })
|
||||
@@ -657,6 +759,12 @@ private struct GamepadHostTile: View {
|
||||
|
||||
private var monogramBadge: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
|
||||
// What the glyph is drawn ON: a filled badge IS the accent, so its mark takes `onAccent`
|
||||
// — the colour picked by the accent's own luminance — exactly as the settings screen's
|
||||
// selected tab pill does. It used to take `fg`, which is chosen against the FIELD, and the
|
||||
// two disagree at both ends of the set: a pale palette put near-black on a deep accent, and
|
||||
// Graphite (accent luma ≈ 0.80) put white on a light grey.
|
||||
let glyph = tile.filled ? ink.onAccent : ink.accent
|
||||
return ZStack {
|
||||
shape.fill(tile.filled
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
@@ -664,7 +772,7 @@ private struct GamepadHostTile: View {
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
: AnyShapeStyle(ink.accent(0.16)))
|
||||
if tile.isConnecting {
|
||||
ProgressView().tint(ink.fg)
|
||||
ProgressView().tint(glyph)
|
||||
} else if let icon = tile.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: Self.iconFont, weight: .semibold))
|
||||
@@ -676,12 +784,12 @@ private struct GamepadHostTile: View {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: Self.monogramFont, height: Self.monogramFont)
|
||||
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
|
||||
.foregroundStyle(glyph)
|
||||
.accessibilityLabel(tile.osChain ?? "")
|
||||
} else {
|
||||
Text(monogram(tile.title))
|
||||
.font(.geistFixed(Self.monogramFont, .bold))
|
||||
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
|
||||
.foregroundStyle(glyph)
|
||||
}
|
||||
}
|
||||
.frame(width: Self.badgeSide, height: Self.badgeSide)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// A saved host's own actions — Wake, Copy link, Edit…, Forget pairing, Remove — reached with UP on
|
||||
// its carousel tile. The console's answer to the overflow menu the touch grid hangs off every host
|
||||
// card (HostCardView's context menu), and the Apple port of `pf-console-ui`'s HostOptionsScreen.
|
||||
//
|
||||
// Until now the gamepad UI could add a host and connect to one, and that was all: a renamed machine
|
||||
// or a host typed in with a fat-fingered address stayed wrong forever, because the only surface
|
||||
// that could edit or remove one was the touch UI. The tile is where a host is, so the tile is where
|
||||
// its actions belong.
|
||||
//
|
||||
// UP is the gesture because the carousel is horizontal — left/right are spoken for and up is free —
|
||||
// and because the desktop console and the Android console already do exactly this, so the three are
|
||||
// learned once. A pinned profile card offers only Unpin: it is a shortcut, not a second host, and
|
||||
// offering to remove the host from it would blur precisely the distinction a pin exists to draw.
|
||||
//
|
||||
// Vocabulary note: this screen says "Forget pairing" and "Remove host" where the desktop console
|
||||
// says one word, "Forget". The console has only the one action; Apple has both (HostCardView calls
|
||||
// them `onForget` = drop the pinned fingerprint and `onRemove` = delete the record), and two
|
||||
// different actions cannot share a name on the surface that offers both. The touch card's words
|
||||
// win over the other consoles' here — a user meets both Apple surfaces, and only one of them is
|
||||
// cross-platform.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
/// Which card the menu was opened on. Carries the host BY VALUE for the same reason the screen
|
||||
/// does — the carousel is rebuilt on every discovery pass, and a target that re-resolved itself
|
||||
/// could hand "Remove" a different host than the one the user was looking at.
|
||||
struct HostOptionsTarget: Identifiable {
|
||||
let host: StoredHost
|
||||
/// Non-nil ⇒ a pinned profile card rather than the host's own tile.
|
||||
var profile: StreamProfile?
|
||||
|
||||
/// Keyed on the CARD, not the host: a host and each of its pinned cards open different menus,
|
||||
/// and sharing an id would let one stand in for another mid-transition (the same rule
|
||||
/// `GamepadScreen.library` follows).
|
||||
var id: String { "\(host.id.uuidString)-\(profile?.id ?? "")" }
|
||||
}
|
||||
|
||||
struct GamepadHostOptionsView: View {
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// The host this menu was opened on, BY VALUE. Discovery rewrites the carousel on every
|
||||
/// service pass; holding an index or a live lookup would let the menu retarget itself onto
|
||||
/// whichever host slid into that slot, and "Remove" must never be able to do that.
|
||||
let host: StoredHost
|
||||
/// Non-nil ⇒ opened on a pinned profile card rather than the host's own tile.
|
||||
var pinnedProfile: StreamProfile?
|
||||
/// Whether the host is reachable right now — decides whether Wake is worth offering.
|
||||
var isOnline = false
|
||||
/// Whether waking is possible at all (the setting is on, WoL is available, a MAC is known).
|
||||
var canWake = false
|
||||
let onEdit: () -> Void
|
||||
let onWake: () -> Void
|
||||
/// Drop the pinned fingerprint — the host stays saved, and the next connect re-pairs.
|
||||
let onForgetPairing: () -> Void
|
||||
/// Delete the saved record outright.
|
||||
let onRemove: () -> Void
|
||||
let onUnpin: () -> Void
|
||||
var close: (() -> Void)?
|
||||
var controllerActive = true
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false
|
||||
#endif
|
||||
|
||||
/// Removing is the one action here with no undo, so its row ARMS on the first press and only
|
||||
/// fires on the second. The touch grid removes behind a system confirmation dialog; a console
|
||||
/// is driven by a thumbstick from across a room, which is a good reason to be at least as
|
||||
/// strict as it is, and none at all to be looser.
|
||||
@State private var armed = false
|
||||
@State private var copied = false
|
||||
@State private var focusID: String?
|
||||
|
||||
private enum Action: String {
|
||||
case wake
|
||||
case copyLink
|
||||
case edit
|
||||
case forgetPairing
|
||||
case remove
|
||||
case unpin
|
||||
case cancel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
items: rows,
|
||||
focusID: $focusID,
|
||||
onActivate: { run($0.action) },
|
||||
onBack: { performClose() },
|
||||
isActive: controllerActive
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
if !compact {
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.geistFixed(metrics.detailFont, .medium))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayBlur(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(detail)
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
GamepadHintBar(hints: hints)
|
||||
}
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.trailing, 22)
|
||||
.padding(
|
||||
.bottom,
|
||||
gamepadLegendBottomPadding(
|
||||
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayBlur(edge: .bottom) }
|
||||
}
|
||||
.background {
|
||||
if !hostedInShell { GamepadFormBackground() }
|
||||
}
|
||||
.gamepadPaletteInk()
|
||||
// Moving the focus off the armed Remove row disarms it: an arming that outlives the row it
|
||||
// was made on is a trap, and the thumb that wandered away is exactly the hesitation the
|
||||
// two-press rule exists to catch.
|
||||
.onChange(of: focusID) { _, id in
|
||||
if id != Action.remove.rawValue { armed = false }
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.background {
|
||||
Button("Cancel") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
pinnedProfile.map { "\(host.displayName) · \($0.name)" } ?? host.displayName
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
private struct Row: Identifiable {
|
||||
let action: Action
|
||||
let label: String
|
||||
var icon: String
|
||||
var isDestructive = false
|
||||
var id: String { action.rawValue }
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
// A pinned card is a shortcut, not a host: everything host-level is deliberately absent.
|
||||
if pinnedProfile != nil {
|
||||
return [
|
||||
Row(action: .unpin, label: "Unpin card", icon: "pin.slash"),
|
||||
Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"),
|
||||
Row(action: .cancel, label: "Cancel", icon: "xmark"),
|
||||
]
|
||||
}
|
||||
var list: [Row] = []
|
||||
// Waking a host that is already answering would just sit there counting seconds.
|
||||
if canWake, !isOnline {
|
||||
list.append(Row(action: .wake, label: "Wake host", icon: "power"))
|
||||
}
|
||||
list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"))
|
||||
list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil"))
|
||||
// Only a paired host has a pairing to drop.
|
||||
if host.pinnedSHA256 != nil {
|
||||
list.append(Row(
|
||||
action: .forgetPairing, label: "Forget pairing", icon: "lock.open"))
|
||||
}
|
||||
list.append(Row(
|
||||
action: .remove,
|
||||
label: armed ? "Remove host \u{2014} press again" : "Remove host",
|
||||
icon: "trash", isDestructive: true))
|
||||
list.append(Row(action: .cancel, label: "Cancel", icon: "xmark"))
|
||||
return list
|
||||
}
|
||||
|
||||
/// The explainer under the list — the same band the settings screen uses, and the only place a
|
||||
/// destructive action can say what it will actually do before it is pressed.
|
||||
private var detail: String {
|
||||
switch rows.first(where: { $0.id == focusID })?.action {
|
||||
case .wake:
|
||||
return "Send a Wake-on-LAN packet and wait for this host to answer."
|
||||
case .copyLink:
|
||||
return "Copy a punktfunk:// link to this host — paste it anywhere to connect."
|
||||
case .edit:
|
||||
return "Rename this host or change its address. Pairing and pinned cards are kept."
|
||||
case .forgetPairing:
|
||||
return "Drop the stored fingerprint. The host stays saved and the next connect "
|
||||
+ "pairs again."
|
||||
case .remove:
|
||||
return armed
|
||||
? "Press again to remove — this cannot be undone."
|
||||
: "Delete this host, its pairing and its pinned cards from this device."
|
||||
case .unpin:
|
||||
return "Remove this profile's card. The profile itself and the host are untouched."
|
||||
case .cancel, .none:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
[
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
|
||||
action: { if let id = focusID, let row = rows.first(where: { $0.id == id }) {
|
||||
run(row.action)
|
||||
} }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { performClose() }),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func run(_ action: Action) {
|
||||
switch action {
|
||||
case .wake:
|
||||
onWake()
|
||||
performClose()
|
||||
case .copyLink:
|
||||
LinkClipboard.copy(
|
||||
DeepLink.forHost(host, profile: pinnedProfile?.id).urlString)
|
||||
// No toast machinery on this surface — the row says so itself, which is the same
|
||||
// acknowledgement in the place the user is already looking.
|
||||
withAnimation(.smooth(duration: 0.2)) { copied = true }
|
||||
case .edit:
|
||||
onEdit()
|
||||
case .forgetPairing:
|
||||
onForgetPairing()
|
||||
performClose()
|
||||
case .remove:
|
||||
guard armed else {
|
||||
withAnimation(.smooth(duration: 0.2)) { armed = true }
|
||||
return
|
||||
}
|
||||
onRemove()
|
||||
performClose()
|
||||
case .unpin:
|
||||
onUnpin()
|
||||
performClose()
|
||||
case .cancel:
|
||||
performClose()
|
||||
}
|
||||
}
|
||||
|
||||
private func performClose() {
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
|
||||
// MARK: - Row rendering
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
let m = metrics
|
||||
// The destructive row wears the warning colour only once ARMED: red on a row that still
|
||||
// needs a second press reads as "this already happened".
|
||||
let danger = row.isDestructive && armed
|
||||
return HStack(spacing: 14) {
|
||||
Image(systemName: row.icon)
|
||||
.font(.system(size: m.iconFont))
|
||||
.foregroundStyle(
|
||||
danger ? GamepadInk.warningRed : (focused ? ink.accent : ink.fg(0.55)))
|
||||
.frame(width: m.iconWidth)
|
||||
Text(row.label)
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(danger ? GamepadInk.warningRed : ink.fg)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 12)
|
||||
}
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
.padding(.vertical, m.rowVPad)
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||
tint: focused ? (danger ? GamepadInk.warningRed.opacity(0.3) : ink.accent(0.30)) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||
.strokeBorder(
|
||||
danger ? GamepadInk.warningRed.opacity(0.7) : ink.fg(focused ? 0.28 : 0.06),
|
||||
lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(focused ? 1.0 : 0.98)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
.animation(.smooth(duration: 0.18), value: armed)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -67,9 +67,32 @@ struct GamepadInk: Equatable, Sendable {
|
||||
/// The shipped dark look — what a preview or a test composition gets.
|
||||
static let dark = GamepadInk.of(GamepadPalette.named("violet"))
|
||||
|
||||
/// The ink for a stored `ui_palette` id, resolved WITHOUT the environment.
|
||||
///
|
||||
/// For the screens that publish their own ink with `gamepadPaletteInk()`. A view's
|
||||
/// `@Environment` resolves against its PARENT — the modifier a screen applies to its own body
|
||||
/// covers its descendants, never the body's own `ink.…` references — so such a screen reads
|
||||
/// whatever was published ABOVE it. Nested inside another gamepad screen (the iOS shell's
|
||||
/// layers) that happens to be right; presented as a cover or a sheet (tvOS, macOS) there is
|
||||
/// nothing above it and it gets the bare dark default. That is precisely how a pale palette
|
||||
/// came out with a WHITE title, white row labels and a violet focus wash on an Apple TV, while
|
||||
/// the child views in the same screen — the hint bar, the host tiles, the glass — were
|
||||
/// correctly dark-on-pale.
|
||||
///
|
||||
/// Declare it beside an `@AppStorage(DefaultsKey.uiPalette)`, which is what re-renders the
|
||||
/// screen when the setting changes (`GamepadInkModifier` reads the same key).
|
||||
static func stored(_ paletteID: String) -> GamepadInk {
|
||||
.of(GamepadPalette.named(paletteID))
|
||||
}
|
||||
|
||||
/// The online pip — deliberately NOT palette-derived: a status colour must not change
|
||||
/// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim).
|
||||
static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29)
|
||||
/// An armed destructive action (the host menu's Remove). Palette-independent for exactly the
|
||||
/// same reason as the pip above, and the more strongly so: the one colour on this UI that
|
||||
/// means "this does not come back" cannot be allowed to drift toward the wallpaper on a warm
|
||||
/// palette, or read as a highlight on a red one.
|
||||
static let warningRed = Color(red: 0.94, green: 0.28, blue: 0.26)
|
||||
}
|
||||
|
||||
private struct GamepadInkKey: EnvironmentKey {
|
||||
|
||||
@@ -10,7 +10,10 @@ import SwiftUI
|
||||
#if os(iOS)
|
||||
|
||||
struct GamepadLibraryScreen: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@ObservedObject var store: HostStore
|
||||
let target: LibraryTarget
|
||||
let onLaunch: (String) -> Void
|
||||
|
||||
@@ -21,6 +21,8 @@ import SwiftUI
|
||||
enum GamepadScreen: Identifiable {
|
||||
case settings
|
||||
case addHost
|
||||
case hostOptions(HostOptionsTarget)
|
||||
case editHost(StoredHost)
|
||||
case pair(StoredHost)
|
||||
case library(LibraryTarget)
|
||||
|
||||
@@ -28,6 +30,10 @@ enum GamepadScreen: Identifiable {
|
||||
switch self {
|
||||
case .settings: return "settings"
|
||||
case .addHost: return "addHost"
|
||||
// Keyed on the CARD (host + pinned profile), for the same reason the library is keyed on
|
||||
// the shelf — see `HostOptionsTarget.id`.
|
||||
case .hostOptions(let target): return "hostOptions-\(target.id)"
|
||||
case .editHost(let host): return "editHost-\(host.id.uuidString)"
|
||||
case .pair(let host): return "pair-\(host.id.uuidString)"
|
||||
// Keyed on the SHELF, not the host: a host and each of its pinned cards open different
|
||||
// libraries, and sharing an id would let one stand in for another mid-transition.
|
||||
@@ -39,7 +45,7 @@ enum GamepadScreen: Identifiable {
|
||||
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
|
||||
var isForm: Bool {
|
||||
switch self {
|
||||
case .settings, .addHost, .pair: return true
|
||||
case .settings, .addHost, .hostOptions, .editHost, .pair: return true
|
||||
case .library: return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ import SwiftUI
|
||||
import GameController
|
||||
|
||||
struct LibraryCoverflowView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
let games: [GameEntry]
|
||||
let artLoader: LibraryArtLoader?
|
||||
var onLaunch: ((String) -> Void)?
|
||||
|
||||
@@ -94,6 +94,9 @@ struct LibraryView: View {
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||||
mode: gamepadUIMode)
|
||||
}
|
||||
/// True when the iOS shell already draws one persistent field behind its layers — mounting a
|
||||
/// second would double the mesh (the same rule the coverflow and the settings screen follow).
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
@@ -150,12 +153,13 @@ struct LibraryView: View {
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
if loading && games.isEmpty {
|
||||
ProgressView("Loading library…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
consoleField(
|
||||
ProgressView("Loading library…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity))
|
||||
} else if let errorText, games.isEmpty {
|
||||
errorState(errorText)
|
||||
consoleField(errorState(errorText))
|
||||
} else if games.isEmpty {
|
||||
emptyState
|
||||
consoleField(emptyState)
|
||||
} else {
|
||||
if gamepadUIActive {
|
||||
LibraryCoverflowView(
|
||||
@@ -168,6 +172,24 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The console field behind the three states that are NOT the coverflow — loading, error,
|
||||
/// empty. The coverflow mounts its own backdrop; these mounted nothing, so wherever this view
|
||||
/// is a COVER over the launcher (tvOS, macOS) they drew straight onto it: the spinner and its
|
||||
/// label sat on the launcher's own aurora with the host tiles still showing through. The same
|
||||
/// field as the coverflow's (not the calmed form one), so nothing shifts under the content when
|
||||
/// the titles land and the coverflow takes over.
|
||||
///
|
||||
/// Only in gamepad mode: the plain grid's states belong on the system background, as before.
|
||||
@ViewBuilder private func consoleField(_ view: some View) -> some View {
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
view.background {
|
||||
if gamepadUIActive, !hostedInShell { GamepadScreenBackground() }
|
||||
}
|
||||
#else
|
||||
view
|
||||
#endif
|
||||
}
|
||||
|
||||
private var grid: some View {
|
||||
// Design D4: launcher entries get their own section above the titles, never interleaved.
|
||||
// Both headers appear only when both groups exist, so a library without launcher entries
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// can wait for layout instead of guessing with a fixed sleep.
|
||||
|
||||
#if DEBUG
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -43,6 +44,17 @@ enum ScreenshotMode {
|
||||
/// readiness ping for the capture script.
|
||||
struct ScreenshotHostView: View {
|
||||
let scene: ShotScene
|
||||
|
||||
init(scene: ShotScene) {
|
||||
self.scene = scene
|
||||
// Pin the palette for the capture. The aurora screens read the LIVE `uiPalette` default,
|
||||
// and a reused Simulator (or a dev Mac) carries whatever was last picked there — the
|
||||
// Apple TV set once shipped out on a sunset palette that a test device had persisted.
|
||||
// Idempotent, and only ever runs in shot mode (this view exists behind that gate).
|
||||
UserDefaults.standard.set(
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_PALETTE"] ?? "violet",
|
||||
forKey: DefaultsKey.uiPalette)
|
||||
}
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
@@ -35,6 +35,11 @@ enum ShotScenes {
|
||||
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotSettings())
|
||||
},
|
||||
// 06–10 are the iOS/macOS console-shell block below; the library is cross-platform
|
||||
// (tvOS renders the same coverflow), hence the number above that range.
|
||||
ShotScene(name: "11-library", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotLibrary())
|
||||
},
|
||||
]
|
||||
#if os(iOS) || os(macOS)
|
||||
// The gamepad-mode console screens (no tvOS — native focus engine there). Dev-only shots
|
||||
@@ -68,6 +73,13 @@ enum ShotScenes {
|
||||
ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .timedOut, gamepadUI: false))
|
||||
},
|
||||
// FEEL THE GAME — the controller test panel with injected pads. Gated with the
|
||||
// console block because ControllerTestView doesn't build on tvOS, not because it
|
||||
// is a console screen. Landscape like the rest of the store set: the app is built
|
||||
// for horizontal use, so the two pads sit as side-by-side columns (see the scene).
|
||||
ShotScene(name: "12-controllers", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotControllers())
|
||||
},
|
||||
]
|
||||
#endif
|
||||
scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) {
|
||||
@@ -193,6 +205,24 @@ enum ShotMock {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// A believable shelf for the library coverflow. Decoded rather than constructed:
|
||||
/// `GameEntry`'s memberwise init is internal to PunktfunkKit, and Codable is its public
|
||||
/// construction surface. No art URLs — the posters render their deterministic fallback
|
||||
/// (title tiles, the Steam entry its brand mark), which is also what keeps the shot offline.
|
||||
static let games: [GameEntry] = {
|
||||
let json = """
|
||||
[
|
||||
{"id": "custom:aurora", "store": "custom", "title": "Aurora Drift", "art": {}},
|
||||
{"id": "steam:starfall", "store": "steam", "title": "Starfall Vale", "art": {}},
|
||||
{"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit", "art": {}},
|
||||
{"id": "gog:ember", "store": "gog", "title": "Ember Peaks", "art": {}},
|
||||
{"id": "steam:launcher", "store": "steam", "title": "Steam", "art": {},
|
||||
"role": "launcher", "icon": "steam"}
|
||||
]
|
||||
"""
|
||||
return (try? JSONDecoder().decode([GameEntry].self, from: Data(json.utf8))) ?? []
|
||||
}()
|
||||
|
||||
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
|
||||
static let fingerprint = hostFingerprint(0)
|
||||
|
||||
@@ -230,6 +260,19 @@ private struct ShotHome: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Library
|
||||
|
||||
/// The library coverflow with the mock shelf — the store listing's PICK & PLAY frame. The real
|
||||
/// `LibraryCoverflowView`, no network: artless entries settle to their deterministic fallback
|
||||
/// posters, and the entrance's 700 ms backstop has long fired by the time the driver captures.
|
||||
private struct ShotLibrary: View {
|
||||
var body: some View {
|
||||
LibraryCoverflowView(
|
||||
games: ShotMock.games, artLoader: nil,
|
||||
onLaunch: { _ in }, onDismiss: {}, controllerActive: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gamepad-mode console screens (dev-only glass preview)
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
@@ -244,7 +287,8 @@ private struct ShotGamepadHome: View {
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||
onPaired: { _, _ in }, waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in },
|
||||
wakeOnly: { _ in })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,12 +347,68 @@ private struct ShotConnect: View {
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||
onPaired: { _, _ in }, waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in },
|
||||
wakeOnly: { _ in })
|
||||
} else {
|
||||
ShotHome()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Controllers (the pads the store listing names)
|
||||
|
||||
/// The FEEL THE GAME frame: the controller test panel rendering the two pads the listing talks
|
||||
/// about. A GCController cannot be constructed, so the panel draws injected `ShotPad`s — the
|
||||
/// DualSense leads with the feedback surface (adaptive-trigger effects, rumble backend, lightbar
|
||||
/// + player LEDs), the Xbox pad carries the input readout, frozen mid-game.
|
||||
private struct ShotControllers: View {
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
// The panel is a window-modal sheet in the app — float it at sheet width over the
|
||||
// dimmed host grid, the way the other mac sheet shots read.
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
|
||||
ControllerTestView(shotPads: Self.pads)
|
||||
.frame(width: 500, height: 840)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(radius: 40, y: 16)
|
||||
}
|
||||
#else
|
||||
// Landscape canvas: one column per pad, so neither story is cut by the short height —
|
||||
// the DualSense feedback surface left, the Xbox live-input readout right.
|
||||
HStack(spacing: 0) {
|
||||
ControllerTestView(shotPads: [Self.pads[0]])
|
||||
ControllerTestView(shotPads: [Self.pads[1]])
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Transport/battery/player ride in `detail` — the panel has no dedicated battery row.
|
||||
/// Each pad shows a different half of the panel: the DualSense skips the input card (the
|
||||
/// effect grid is the marketing point), the Xbox pad skips rumble and shows the readout.
|
||||
static let pads: [ControllerTestView.ShotPad] = [
|
||||
.init(
|
||||
name: "DualSense Wireless Controller",
|
||||
detail: "Bluetooth · 85% · Player 1",
|
||||
isDualSense: true, hasAdaptiveTriggers: true, hasLight: true,
|
||||
rumbleBackend: "DualSense HID · Bluetooth"),
|
||||
.init(
|
||||
name: "Xbox Wireless Controller",
|
||||
detail: "Bluetooth · 60% · Player 2",
|
||||
isDualSense: false, hasAdaptiveTriggers: false, hasLight: false,
|
||||
input: .init(
|
||||
leftStick: .init(x: -0.31, y: 0.54),
|
||||
rightStick: .init(x: 0.72, y: -0.16),
|
||||
leftTrigger: 0.08, rightTrigger: 0.62,
|
||||
buttons: [
|
||||
("A", true), ("B", false), ("X", false), ("Y", false),
|
||||
("LB", false), ("RB", true), ("L3", false), ("R3", false),
|
||||
("Menu", false), ("Opts", false),
|
||||
("↑", false), ("↓", false), ("←", false), ("→", false),
|
||||
])),
|
||||
]
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Edit host (add/edit sheet with the Wake-on-LAN MAC field)
|
||||
|
||||
@@ -20,6 +20,18 @@ import SwiftUI
|
||||
/// instrument: any visible overlay forces the metal layer through the compositor, which costs a
|
||||
/// refresh period on the vsync-latched platforms — this is how to measure with it off.
|
||||
private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats")
|
||||
/// Mirror the 1 Hz vitals line to STDOUT as well as the unified log.
|
||||
///
|
||||
/// Exists for **tvOS, where the unified log is unreachable**: `log stream --device` is gone from
|
||||
/// modern macOS, `log collect --device-name` needs root and then fails "Device not configured"
|
||||
/// (an Apple TV has no USB to fall back to), and libimobiledevice pairs against a different
|
||||
/// database than Xcode. Stdout, however, IS bridged — `xcrun devicectl device process launch
|
||||
/// --console -e '{"PUNKTFUNK_STATS_STDOUT":"1"}' io.unom.punktfunk` streams these lines straight
|
||||
/// to the Mac. That is the only way to read a session's numbers with the **stats overlay OFF**,
|
||||
/// which matters because the overlay is itself a composited layer over the Metal one — i.e. a
|
||||
/// plausible cause of the very present-floor inflation the overlay is used to measure.
|
||||
/// Env-gated: no cost, and no stdout noise, unless someone is deliberately measuring.
|
||||
private let statsToStdout = ProcessInfo.processInfo.environment["PUNKTFUNK_STATS_STDOUT"] == "1"
|
||||
|
||||
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
|
||||
/// values. NSLock instead of an actor — the writer is the (non-async) pump thread.
|
||||
@@ -137,6 +149,25 @@ final class SessionModel: ObservableObject {
|
||||
/// and under stage-1.
|
||||
@Published var osFloorP50Ms = 0.0
|
||||
@Published var osFloorValid = false
|
||||
/// The deadline link's `preferredFrameLatency` ASK beside its property READBACK (see
|
||||
/// `PresentLinkInfo` — it exists because tvOS has no reachable log). ⚠ The readback is NOT
|
||||
/// a grant: it is a plain float property, so it echoes whatever was stored unless the
|
||||
/// system clamps the setter. readback ≠ ask ⇒ a visible clamp (the one signal the API can
|
||||
/// give); readback == ask proves nothing — `osFloorP50Ms` (the measured vend lead) is the
|
||||
/// truth-teller (field 2026-08-13: readback 1.00 beside a 32.5 ms floor).
|
||||
@Published var linkLatencyAskFrames: Float = 0
|
||||
@Published var linkLatencyFrames: Float = 0
|
||||
@Published var linkRangeMinHz: Float = 0
|
||||
@Published var linkRangeMaxHz: Float = 0
|
||||
@Published var linkDrawables = 0
|
||||
@Published var linkInfoValid = false
|
||||
/// Impossible samples the HOST-ANCHORED meters (host+network, end-to-end) refused this
|
||||
/// second (`LatencyMeter.drainTrimmed`). Nonzero means the clock offset is lying and every
|
||||
/// host-anchored p50/p95 this window is a TRUNCATED distribution — the HUD marks the window
|
||||
/// suspect instead of letting a trimmed tail pose as a healthy small number (the field
|
||||
/// "e2e 0–3 ms" reading, 2026-08-13). Client-local stages can't go negative, so they carry
|
||||
/// no such term.
|
||||
@Published var skewTrimPerS = 0
|
||||
/// The AUDIO plane's latency, from the playback ring (`SessionAudio.Stats`): how much decoded
|
||||
/// audio is queued ahead of the speaker, and where that PUTS it relative to the picture
|
||||
/// (positive = audio behind). `audioValid` is false until playback runs.
|
||||
@@ -683,6 +714,10 @@ final class SessionModel: ObservableObject {
|
||||
displayValid = false
|
||||
clientQueueValid = false
|
||||
osFloorValid = false
|
||||
linkInfoValid = false
|
||||
// Drop the previous session's grant too — the shared box outlives the session, and a new
|
||||
// link may never come up (a non-deadline rung has none at all).
|
||||
PresentLinkInfo.shared.clear()
|
||||
audioValid = false
|
||||
lostFrames = 0
|
||||
lostPct = 0
|
||||
@@ -904,6 +939,10 @@ final class SessionModel: ObservableObject {
|
||||
} else {
|
||||
self.endToEndValid = false
|
||||
}
|
||||
// Drained even when the stats drains came back empty — with a badly wrong offset
|
||||
// an entire window is refused and only this counter still tells the story.
|
||||
self.skewTrimPerS =
|
||||
self.latency.drainTrimmed() + self.endToEnd.drainTrimmed()
|
||||
if let d = self.decodeStage.drain() {
|
||||
self.decodeP50Ms = d.p50Ms
|
||||
self.decodeValid = true
|
||||
@@ -923,6 +962,18 @@ final class SessionModel: ObservableObject {
|
||||
} else {
|
||||
self.osFloorValid = false
|
||||
}
|
||||
// The display link's latency ask + property readback (deadline rung only) — a
|
||||
// LEVEL, not a window, so it is read rather than drained.
|
||||
if let l = PresentLinkInfo.shared.snapshot() {
|
||||
self.linkLatencyAskFrames = l.ask
|
||||
self.linkLatencyFrames = l.latency
|
||||
self.linkRangeMinHz = l.rangeMin
|
||||
self.linkRangeMaxHz = l.rangeMax
|
||||
self.linkDrawables = l.drawables
|
||||
self.linkInfoValid = true
|
||||
} else {
|
||||
self.linkInfoValid = false
|
||||
}
|
||||
if let q = self.clientQueue.drain() {
|
||||
self.clientQueueP50Ms = q.p50Ms
|
||||
self.clientQueueValid = true
|
||||
@@ -951,6 +1002,14 @@ final class SessionModel: ObservableObject {
|
||||
// Swift Int is 64-bit → %lld, NOT %d (which is a 32-bit C int); macOS 26's
|
||||
// strict String(format:) validator rejects the %d/Int mismatch and drops
|
||||
// the whole line (a cascade error that also mis-blames the float args).
|
||||
//
|
||||
// ⚠ Every invalid-field fallback below MUST be a typed `-1.0` (or a
|
||||
// `Double(...)`-wrapped value), never a bare `-1`: in this variadic
|
||||
// `CVarArg` context the ternary does NOT unify to Double — the untyped
|
||||
// literal goes in as Int, and `%f` then reads Int64(-1)'s all-ones bit
|
||||
// pattern, which IS a quiet NaN. Field 2026-08-13 (tvOS, stage-1, the
|
||||
// first session ever to have invalid fields while frames flowed): every
|
||||
// fallback printed `nan`. Latent since the line was added.
|
||||
format: "fps=%lld presents=%lld e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||
+ "decode_p50=%.1f display_p50=%.1f lost=%lld "
|
||||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f "
|
||||
@@ -958,22 +1017,35 @@ final class SessionModel: ObservableObject {
|
||||
// In the log as well as on the HUD because the overlay is only up when
|
||||
// someone thought to turn it on, and the reports that need these
|
||||
// numbers arrive after the fact.
|
||||
+ "audio_buffer=%lld audio_av_offset=%lld",
|
||||
+ "audio_buffer=%lld audio_av_offset=%lld "
|
||||
// The deadline link's latency ask + property readback (both -1 on
|
||||
// non-deadline rungs) — appended so the PUNKTFUNK_FRAME_LATENCY
|
||||
// ladder is readable over the stdout channel with the HUD off,
|
||||
// which is the only honest way to run it on a tvOS device.
|
||||
+ "link_ask=%.2f link_readback=%.2f "
|
||||
// Impossible samples the host-anchored meters refused this window:
|
||||
// nonzero ⇒ the clock offset is lying and e2e/hostnet above are
|
||||
// truncated distributions — disregard their p50/p95.
|
||||
+ "skew_trim=%lld",
|
||||
frames,
|
||||
displayWindow?.count ?? 0,
|
||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||
self.endToEndValid ? self.endToEndP95Ms : -1,
|
||||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
||||
self.decodeValid ? self.decodeP50Ms : -1,
|
||||
self.displayValid ? self.displayP50Ms : -1,
|
||||
self.endToEndValid ? self.endToEndP50Ms : -1.0,
|
||||
self.endToEndValid ? self.endToEndP95Ms : -1.0,
|
||||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1.0,
|
||||
self.decodeValid ? self.decodeP50Ms : -1.0,
|
||||
self.displayValid ? self.displayP50Ms : -1.0,
|
||||
lost,
|
||||
self.osFloorValid ? self.osFloorP50Ms : -1,
|
||||
self.displayValid ? self.displayAdjP50Ms : -1,
|
||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
|
||||
self.clientQueueValid ? self.clientQueueP50Ms : -1,
|
||||
self.osFloorValid ? self.osFloorP50Ms : -1.0,
|
||||
self.displayValid ? self.displayAdjP50Ms : -1.0,
|
||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1.0,
|
||||
self.clientQueueValid ? self.clientQueueP50Ms : -1.0,
|
||||
self.audioValid ? self.audioBufferMs : -1,
|
||||
self.audioValid ? self.audioAvOffsetMs : 0)
|
||||
self.audioValid ? self.audioAvOffsetMs : 0,
|
||||
self.linkInfoValid ? Double(self.linkLatencyAskFrames) : -1.0,
|
||||
self.linkInfoValid ? Double(self.linkLatencyFrames) : -1.0,
|
||||
self.skewTrimPerS)
|
||||
statsLog.info("\(line, privacy: .public)")
|
||||
if statsToStdout { print("pf.stats \(line)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,29 @@ struct StreamHUDView: View {
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
// The deadline link's frame-latency ASK beside its property READBACK. ⚠ The
|
||||
// readback is NOT a grant — the property echoes whatever we stored (field
|
||||
// 2026-08-13: 1.00 beside a 32.5 ms `os present` floor). The line earns its
|
||||
// place because a readback that DIFFERS from the ask is the one clamp signal
|
||||
// the API can give, and on tvOS the screen is the only place to read either
|
||||
// (no log is reachable on an Apple TV; see PresentLinkInfo).
|
||||
if model.linkInfoValid {
|
||||
Text("link latency ask \(model.linkLatencyAskFrames, specifier: "%.2f") readback \(model.linkLatencyFrames, specifier: "%.2f") · range \(model.linkRangeMinHz, specifier: "%.0f")-\(model.linkRangeMaxHz, specifier: "%.0f") Hz · drawables \(model.linkDrawables)")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
// The clock-offset tripwire: host-anchored meters refused samples as
|
||||
// impossible (≤ 0 after offset correction) this second. When this shows,
|
||||
// e2e and host+network above are TRUNCATED distributions — a wrong skew
|
||||
// offset shifted them and the impossible half was trimmed — so their
|
||||
// p50/p95 flatter the stream (the field "e2e 0–3 ms" reading). Orange on
|
||||
// purpose: every other stat here stays legible-quiet, but a number that
|
||||
// has stopped meaning anything must not.
|
||||
if model.skewTrimPerS > 0 {
|
||||
Text("clock offset suspect — \(model.skewTrimPerS)/s impossible samples trimmed; e2e & host+network unreliable")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
// Client-queue wait (reassembly receipt → decode pull, ABI v9 split): ~0 on
|
||||
// a healthy stream and hidden as noise; shown from 2 ms — a persistent value
|
||||
// is a client-side standing backlog that pre-split builds displayed as
|
||||
|
||||
@@ -28,6 +28,10 @@ struct AboutView: View {
|
||||
|
||||
#if !os(tvOS)
|
||||
@State private var showAcknowledgements = false
|
||||
/// The in-session controls. They used to announce themselves in a 6-second banner at the start
|
||||
/// of every stream; that banner is gone, so this page is where they live now — including for
|
||||
/// touch users on a Mac, who saw it too.
|
||||
@State private var showShortcuts = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
@@ -44,6 +48,9 @@ struct AboutView: View {
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
Section {
|
||||
shortcutsRow
|
||||
}
|
||||
Section {
|
||||
linkRow("Documentation", systemImage: "book", url: Destination.docs)
|
||||
linkRow("Community", systemImage: "bubble.left.and.bubble.right",
|
||||
@@ -63,6 +70,21 @@ struct AboutView: View {
|
||||
// A SHEET, not a push — on iPad the settings detail column is deliberately not a
|
||||
// NavigationStack (an inner one doubles the title bar), so a NavigationLink from here
|
||||
// pushed into a context with no back button and stranded the licenses on screen.
|
||||
// A sheet for the same reason Acknowledgements is one — see that modifier's note on the
|
||||
// iPad detail column not being a NavigationStack.
|
||||
.sheet(isPresented: $showShortcuts) {
|
||||
NavigationStack {
|
||||
ShortcutsView(micAvailable: ShortcutsCatalog.micPlausible)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { showShortcuts = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(width: 560, height: 460)
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $showAcknowledgements) {
|
||||
NavigationStack {
|
||||
AcknowledgementsView()
|
||||
@@ -135,6 +157,24 @@ struct AboutView: View {
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
private var shortcutsRow: some View {
|
||||
Button {
|
||||
showShortcuts = true
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Shortcuts", systemImage: "command")
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
private var acknowledgementsRow: some View {
|
||||
Button {
|
||||
showAcknowledgements = true
|
||||
@@ -168,6 +208,11 @@ struct AboutView: View {
|
||||
tvAddress("Community", Destination.community)
|
||||
tvAddress("Source code", Destination.source)
|
||||
}
|
||||
// Both push here: this page really is inside a navigation stack on tvOS, which is
|
||||
// the case the sheets above exist to work around elsewhere.
|
||||
NavigationLink("Shortcuts") {
|
||||
ShortcutsView(micAvailable: false) // tvOS has no app-accessible mic
|
||||
}
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
Text("Punktfunk's source is open under MIT or Apache-2.0.")
|
||||
.font(.geist(20, relativeTo: .caption))
|
||||
@@ -219,21 +264,39 @@ struct AppIconView: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if let icon = Self.bundleIcon {
|
||||
icon.image
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
// iOS ships the icon UNMASKED — the springboard applies the rounded shape at
|
||||
// draw time, so used raw it is a hard-cornered square. macOS bakes its own
|
||||
// shape (and margins) into the image, and clipping that would cut into it.
|
||||
.clipShape(RoundedRectangle(
|
||||
cornerRadius: icon.needsMask ? side * Self.iOSCornerRatio : 0,
|
||||
style: .continuous))
|
||||
// The mask is applied ONLY where it is wanted. A `cornerRadius: 0` RoundedRectangle
|
||||
// is not a no-op — it still clips to the layout frame, which crops any art whose
|
||||
// aspect ratio isn't the frame's (the TV's 400x240 icon lost its ends to it).
|
||||
// iOS ships the icon UNMASKED — the springboard applies the rounded shape at draw
|
||||
// time, so used raw it is a hard-cornered square. macOS bakes its own shape (and
|
||||
// margins) into the image, and clipping that would cut into it.
|
||||
if icon.needsMask {
|
||||
icon.image
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(
|
||||
cornerRadius: side * Self.iOSCornerRatio, style: .continuous))
|
||||
} else {
|
||||
icon.image
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
}
|
||||
} else {
|
||||
monogram
|
||||
}
|
||||
}
|
||||
// tvOS's icon is a 400×240 rectangle, not a squircle — framing it square would letterbox
|
||||
// it inside a box two thirds empty. `side` means HEIGHT there, and the width follows the
|
||||
// real 5:3 art. A MAX frame rather than a fixed one: with a fixed width the image cannot
|
||||
// shrink when its row is tight, so it overflows and is clipped by whatever is above it
|
||||
// instead — `.fit` inside a max frame gives back the whole icon, just smaller.
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: side * (400.0 / 240.0), maxHeight: side)
|
||||
#else
|
||||
.frame(width: side, height: side)
|
||||
#endif
|
||||
.accessibilityHidden(true) // the app's name is the next line
|
||||
}
|
||||
|
||||
@@ -267,7 +330,14 @@ struct AppIconView: View {
|
||||
else { return nil }
|
||||
return (Image(uiImage: image), true)
|
||||
#else
|
||||
return nil // tvOS: layered icons have no single image to load
|
||||
// tvOS ships the icon as a parallax image STACK (Back/Circle1/Circle2/Front), which has
|
||||
// no single image to load — which is why this used to return nil and every About page on
|
||||
// the TV drew the "P" monogram instead of the app's own mark. `AboutAppIcon` is those
|
||||
// four layers flattened into one asset, generated from the SAME art the stack uses so it
|
||||
// cannot drift into being a second, subtly different icon. Already masked and composited,
|
||||
// so it needs no rounding of ours.
|
||||
guard let image = UIImage(named: "AboutAppIcon") else { return nil }
|
||||
return (Image(uiImage: image), false)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
|
||||
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
|
||||
//
|
||||
// Every card renders a plain value model (`ShotPad` / `InputSnapshot`) that the live path samples
|
||||
// out of the real pad each timeline tick. A GCController cannot be constructed, and the App Store
|
||||
// screenshot harness needs this panel with pads the capture machine doesn't have — ShotScenes
|
||||
// injects them via `shotPads` (the same seam Android's ControllersScreen grew for its capture).
|
||||
//
|
||||
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
|
||||
// layout); macOS + iOS/iPadOS cover the validation need.
|
||||
|
||||
@@ -14,10 +19,63 @@ import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ControllerTestView: View {
|
||||
/// What one panel section says about a pad, as plain values. The live path flattens the
|
||||
/// active `DiscoveredController` into one; the screenshot harness hands the panel pads that
|
||||
/// were never connected. `input`/`rumbleBackend` are the harness's section knobs (nil hides
|
||||
/// that card) — the live path always shows both, fed from the live pad and tester.
|
||||
struct ShotPad: Identifiable {
|
||||
let name: String
|
||||
/// The header's second line. Production shows the GC product category; a shot packs
|
||||
/// transport/battery/player facts into it (the panel has no dedicated battery row).
|
||||
let detail: String
|
||||
let isDualSense: Bool
|
||||
let hasAdaptiveTriggers: Bool
|
||||
let hasLight: Bool
|
||||
var input: InputSnapshot? = nil
|
||||
var rumbleBackend: String? = nil
|
||||
var id: String { name }
|
||||
}
|
||||
|
||||
/// One frame of the input readout. The live path samples the real `GCExtendedGamepad` into
|
||||
/// one of these on every 30 Hz tick; the harness writes a mid-game frame by hand.
|
||||
struct InputSnapshot {
|
||||
struct Stick {
|
||||
var x: Float
|
||||
var y: Float
|
||||
var pressed = false
|
||||
}
|
||||
struct Touch {
|
||||
/// Finger position in GC's -1...1 axes; nil = lifted. (GC snaps a lifted finger to
|
||||
/// exactly (0, 0), so a real (0, 0) contact is indistinguishable anyway.)
|
||||
var primary: CGPoint?
|
||||
var secondary: CGPoint?
|
||||
var clicked = false
|
||||
}
|
||||
struct Motion {
|
||||
var gyro: SIMD3<Double>
|
||||
var accel: SIMD3<Double>
|
||||
}
|
||||
var leftStick: Stick
|
||||
var rightStick: Stick
|
||||
var leftTrigger: Float = 0
|
||||
var rightTrigger: Float = 0
|
||||
/// Grid order; label → pressed.
|
||||
var buttons: [(String, Bool)]
|
||||
var touchpad: Touch?
|
||||
var motion: Motion?
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
@StateObject private var tester = ControllerTester()
|
||||
|
||||
/// Screenshot-harness injection — nil (the app) renders the live active pad.
|
||||
private let shotPads: [ShotPad]?
|
||||
|
||||
init(shotPads: [ShotPad]? = nil) {
|
||||
self.shotPads = shotPads
|
||||
}
|
||||
|
||||
@State private var heavyOn = false
|
||||
@State private var lightOn = false
|
||||
@State private var intensity = 0.75
|
||||
@@ -62,12 +120,12 @@ struct ControllerTestView: View {
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let active = gamepads.active {
|
||||
header(active)
|
||||
inputCard
|
||||
rumbleCard()
|
||||
triggerCard(active)
|
||||
extrasCard(active)
|
||||
if let shotPads {
|
||||
ForEach(shotPads) { pad in
|
||||
shotPanel(pad)
|
||||
}
|
||||
} else if let active = gamepads.active {
|
||||
livePanel(active)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No controller",
|
||||
@@ -81,9 +139,10 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 420, minHeight: 540)
|
||||
.onAppear { tester.target(gamepads.active?.controller) }
|
||||
.onDisappear { tester.stop() }
|
||||
.onAppear { if shotPads == nil { tester.target(gamepads.active?.controller) } }
|
||||
.onDisappear { if shotPads == nil { tester.stop() } }
|
||||
.onChange(of: gamepads.active?.id) { _, _ in
|
||||
guard shotPads == nil else { return }
|
||||
heavyOn = false
|
||||
lightOn = false
|
||||
playerLED = -1
|
||||
@@ -91,16 +150,53 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Panels
|
||||
|
||||
@ViewBuilder
|
||||
private func livePanel(_ active: GamepadManager.DiscoveredController) -> some View {
|
||||
let pad = Self.describe(active)
|
||||
header(pad)
|
||||
liveInputCard
|
||||
rumbleCard(backend: tester.rumbleBackend, health: tester.rumbleHealth)
|
||||
triggerCard(pad)
|
||||
extrasCard(pad)
|
||||
}
|
||||
|
||||
/// An injected pad's cards, in the live panel's order. The adaptive-trigger card is skipped
|
||||
/// outright for a pad without them — the live path's "needs a DualSense" hint is a diagnosis,
|
||||
/// and a capture has nothing to diagnose.
|
||||
@ViewBuilder
|
||||
private func shotPanel(_ pad: ShotPad) -> some View {
|
||||
header(pad)
|
||||
if let input = pad.input {
|
||||
card("Input") { inputReadout(input) }
|
||||
}
|
||||
if let backend = pad.rumbleBackend {
|
||||
rumbleCard(backend: backend, health: nil)
|
||||
}
|
||||
if pad.hasAdaptiveTriggers {
|
||||
triggerCard(pad)
|
||||
}
|
||||
extrasCard(pad)
|
||||
}
|
||||
|
||||
/// The live pad, flattened to what the panel renders about it.
|
||||
private static func describe(_ c: GamepadManager.DiscoveredController) -> ShotPad {
|
||||
ShotPad(
|
||||
name: c.name, detail: c.productCategory, isDualSense: c.isDualSense,
|
||||
hasAdaptiveTriggers: c.hasAdaptiveTriggers, hasLight: c.hasLight)
|
||||
}
|
||||
|
||||
// MARK: Header
|
||||
|
||||
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
private func header(_ pad: ShotPad) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||
Image(systemName: pad.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
Text(pad.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(pad.detail).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -108,13 +204,13 @@ struct ControllerTestView: View {
|
||||
|
||||
// MARK: Input
|
||||
|
||||
private var inputCard: some View {
|
||||
private var liveInputCard: some View {
|
||||
card("Input") {
|
||||
// Poll the live controller at 30 Hz — no handlers installed, so nothing else's
|
||||
// capture is disturbed.
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
|
||||
if let gp = gamepads.active?.controller.extendedGamepad {
|
||||
inputReadout(gp, controller: gamepads.active?.controller)
|
||||
inputReadout(Self.snapshot(gp, controller: gamepads.active?.controller))
|
||||
} else {
|
||||
Text("Not an extended gamepad").foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -122,40 +218,82 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// One readout frame off the live pad.
|
||||
private static func snapshot(
|
||||
_ g: GCExtendedGamepad, controller: GCController?
|
||||
) -> InputSnapshot {
|
||||
var buttons: [(String, Bool)] = [
|
||||
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
||||
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
||||
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
||||
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
||||
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
||||
("Menu", g.buttonMenu.isPressed),
|
||||
("Opts", g.buttonOptions?.isPressed ?? false),
|
||||
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
||||
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
||||
]
|
||||
let tp = touchpad(g)
|
||||
if let tp { buttons.append(("Pad", tp.button.isPressed)) }
|
||||
return InputSnapshot(
|
||||
leftStick: .init(
|
||||
x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
||||
pressed: g.leftThumbstickButton?.isPressed ?? false),
|
||||
rightStick: .init(
|
||||
x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
||||
pressed: g.rightThumbstickButton?.isPressed ?? false),
|
||||
leftTrigger: g.leftTrigger.value, rightTrigger: g.rightTrigger.value,
|
||||
buttons: buttons,
|
||||
touchpad: tp.map {
|
||||
.init(primary: finger($0.primary), secondary: finger($0.secondary),
|
||||
clicked: $0.button.isPressed)
|
||||
},
|
||||
motion: controller?.motion.map { m -> InputSnapshot.Motion in
|
||||
let a = totalAccel(m)
|
||||
return .init(
|
||||
gyro: .init(m.rotationRate.x, m.rotationRate.y, m.rotationRate.z),
|
||||
accel: .init(a.0, a.1, a.2))
|
||||
})
|
||||
}
|
||||
|
||||
private static func finger(_ pad: GCControllerDirectionPad) -> CGPoint? {
|
||||
let x = pad.xAxis.value, y = pad.yAxis.value
|
||||
// GC snaps a lifted finger to exactly (0, 0).
|
||||
return (x == 0 && y == 0) ? nil : CGPoint(x: CGFloat(x), y: CGFloat(y))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
|
||||
private func inputReadout(_ s: InputSnapshot) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
||||
pressed: g.leftThumbstickButton?.isPressed ?? false)
|
||||
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
||||
pressed: g.rightThumbstickButton?.isPressed ?? false)
|
||||
stick("L", s.leftStick)
|
||||
stick("R", s.rightStick)
|
||||
VStack(spacing: 8) {
|
||||
triggerBar("L2", value: g.leftTrigger.value)
|
||||
triggerBar("R2", value: g.rightTrigger.value)
|
||||
triggerBar("L2", value: s.leftTrigger)
|
||||
triggerBar("R2", value: s.rightTrigger)
|
||||
}
|
||||
}
|
||||
buttonGrid(g)
|
||||
if let tp = Self.touchpad(g) {
|
||||
buttonGrid(s.buttons)
|
||||
if let tp = s.touchpad {
|
||||
touchpadView(tp)
|
||||
}
|
||||
if let m = controller?.motion {
|
||||
if let m = s.motion {
|
||||
motionReadout(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
|
||||
private func stick(_ label: String, _ s: InputSnapshot.Stick) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.secondary.opacity(0.3))
|
||||
Circle()
|
||||
.fill(pressed ? Color.accentColor : Color.secondary)
|
||||
.fill(s.pressed ? Color.accentColor : Color.secondary)
|
||||
.frame(width: 12, height: 12)
|
||||
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
|
||||
.offset(x: CGFloat(s.x) * 22, y: CGFloat(-s.y) * 22) // GC y is +up
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||||
Text("\(label) \(sgn(s.x)),\(sgn(s.y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,20 +313,8 @@ struct ControllerTestView: View {
|
||||
.frame(width: 150)
|
||||
}
|
||||
|
||||
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
|
||||
var items: [(String, Bool)] = [
|
||||
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
||||
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
||||
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
||||
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
||||
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
||||
("Menu", g.buttonMenu.isPressed),
|
||||
("Opts", g.buttonOptions?.isPressed ?? false),
|
||||
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
||||
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
||||
]
|
||||
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
|
||||
return LazyVGrid(
|
||||
private func buttonGrid(_ items: [(String, Bool)]) -> some View {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
|
||||
) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
@@ -203,12 +329,9 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func touchpadView(
|
||||
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||
button: GCControllerButtonInput)
|
||||
) -> some View {
|
||||
private func touchpadView(_ tp: InputSnapshot.Touch) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||
Text("Touchpad\(tp.clicked ? " — click" : "")")
|
||||
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||
@@ -219,29 +342,25 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
|
||||
let x = pad.xAxis.value, y = pad.yAxis.value
|
||||
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
|
||||
return Circle().fill(color).frame(width: 10, height: 10)
|
||||
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
|
||||
.opacity(active ? 1 : 0)
|
||||
private func fingerDot(_ p: CGPoint?, color: Color) -> some View {
|
||||
Circle().fill(color).frame(width: 10, height: 10)
|
||||
.offset(x: (p?.x ?? 0) * 71, y: -(p?.y ?? 0) * 33)
|
||||
.opacity(p == nil ? 0 : 1)
|
||||
}
|
||||
|
||||
private func motionReadout(_ m: GCMotion) -> some View {
|
||||
let a = Self.totalAccel(m)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
private func motionReadout(_ m: InputSnapshot.Motion) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f", m.gyro.x, m.gyro.y, m.gyro.z))
|
||||
.font(.caption2.monospaced())
|
||||
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
|
||||
Text(String(format: "accel %+.2f %+.2f %+.2f", m.accel.x, m.accel.y, m.accel.z))
|
||||
.font(.caption2.monospaced())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Rumble
|
||||
|
||||
private func rumbleCard() -> some View {
|
||||
private func rumbleCard(backend: String, health: String?) -> some View {
|
||||
card("Rumble") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Strength", selection: $intensity) {
|
||||
@@ -253,9 +372,9 @@ struct ControllerTestView: View {
|
||||
.pickerStyle(.segmented)
|
||||
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||
Toggle("Light motor (right)", isOn: $lightOn)
|
||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||
Label("Backend: \(backend)", systemImage: "waveform")
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
if let problem = tester.rumbleHealth {
|
||||
if let problem = health {
|
||||
Label(problem, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.orange)
|
||||
}
|
||||
@@ -276,9 +395,9 @@ struct ControllerTestView: View {
|
||||
|
||||
// MARK: Adaptive triggers
|
||||
|
||||
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
private func triggerCard(_ pad: ShotPad) -> some View {
|
||||
card("Adaptive triggers") {
|
||||
if c.hasAdaptiveTriggers {
|
||||
if pad.hasAdaptiveTriggers {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Apply to", selection: $triggerTarget) {
|
||||
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
|
||||
@@ -315,8 +434,8 @@ struct ControllerTestView: View {
|
||||
// MARK: Lightbar + player LED
|
||||
|
||||
@ViewBuilder
|
||||
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
if c.hasLight {
|
||||
private func extrasCard(_ pad: ShotPad) -> some View {
|
||||
if pad.hasLight {
|
||||
card("Lightbar & player LED") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
|
||||
@@ -42,14 +42,22 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
|
||||
case controller = "Controller"
|
||||
case interface = "Interface"
|
||||
case profiles = "Profiles"
|
||||
/// Trailing, like Profiles: both are built from something other than the settings store, and
|
||||
/// About is where the strip ends because it is the one section that changes nothing.
|
||||
case about = "About"
|
||||
}
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from `paletteID` below, NOT from `\.gamepadInk` — this screen publishes that value
|
||||
/// itself and so sits above its own copy (see `GamepadInk.stored`). Reading the environment
|
||||
/// here is what left the title, the tab pills and every row label white-on-pale on tvOS.
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
/// The About section's link rows (never used on tvOS, which has no browser).
|
||||
@Environment(\.openURL) private var openURL
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
|
||||
/// itself (ContentView owns the instance).
|
||||
@@ -61,6 +69,9 @@ struct GamepadSettingsView: View {
|
||||
/// console's input drop) and while the connect takeover is up; a system presentation never
|
||||
/// needs the gate and keeps the default.
|
||||
var controllerActive = true
|
||||
/// Whether this device has a microphone at all — passed through to the About page's shortcuts
|
||||
/// reference, which must not list a mute key on a device that can't mute anything.
|
||||
var micAvailable = true
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@@ -132,6 +143,14 @@ struct GamepadSettingsView: View {
|
||||
/// The direction of the last value step (+1 right/forward, -1 left) — picks which edge the
|
||||
/// changed value slides in from, so the animation follows the user's motion.
|
||||
@State private var lastAdjustDelta = 1
|
||||
/// A reading surface opened from the About tab, replacing the row list the way the pin picker
|
||||
/// does. Depth is 1: neither page opens anything further.
|
||||
private enum AboutPage: Equatable {
|
||||
case shortcuts
|
||||
case licenses
|
||||
}
|
||||
|
||||
@State private var aboutPage: AboutPage?
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
@@ -157,9 +176,9 @@ struct GamepadSettingsView: View {
|
||||
.foregroundStyle(ink.fg)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
// The picker is one layer deeper — its rows aren't sections of anything, so the
|
||||
// strip would be a control that does nothing while it's up.
|
||||
if pinTarget == nil { tabStrip }
|
||||
// The picker and the About reading pages are one layer deeper — their rows aren't
|
||||
// sections of anything, so the strip would be a control that does nothing.
|
||||
if pinTarget == nil, aboutPage == nil { tabStrip }
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
@@ -326,16 +345,62 @@ struct GamepadSettingsView: View {
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
|
||||
/// Where the product actually lives — kept together so the three can be checked against the
|
||||
/// README in one glance (the touch `AboutView` holds the same three).
|
||||
private enum Destination {
|
||||
static let docs = URL(string: "https://docs.punktfunk.unom.io")!
|
||||
static let community = URL(string: "https://discord.gg/kaPNvzMuGU")!
|
||||
static let source = URL(string: "https://git.unom.io/unom/punktfunk")!
|
||||
}
|
||||
|
||||
/// "Version 0.29.0 (100000)" — the build number only when it says something the version does
|
||||
/// not. Mirrors `AboutView.versionLine`; a bug report is worth more with it.
|
||||
private static var versionLine: String {
|
||||
let info = Bundle.main.infoDictionary
|
||||
let short = info?["CFBundleShortVersionString"] as? String ?? "—"
|
||||
let build = info?["CFBundleVersion"] as? String
|
||||
guard let build, !build.isEmpty, build != short else { return "Version \(short)" }
|
||||
return "Version \(short) (\(build))"
|
||||
}
|
||||
|
||||
/// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which
|
||||
/// layer the row list currently is.
|
||||
private var title: String {
|
||||
pinTarget.map { "Pin “\($0.name)”" } ?? "Settings"
|
||||
if let profile = pinTarget { return "Pin “\(profile.name)”" }
|
||||
switch aboutPage {
|
||||
case .shortcuts: return "Shortcuts"
|
||||
case .licenses: return "Acknowledgements"
|
||||
case nil: return "Settings"
|
||||
}
|
||||
}
|
||||
|
||||
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
|
||||
/// picker — where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
|
||||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||||
private var hints: [GamepadHint] {
|
||||
// A reading page is scrolled, not operated: offering A would be the same lie a dimmed row
|
||||
// used to tell. Only Back remains.
|
||||
if aboutPage != nil {
|
||||
return [.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { back() })]
|
||||
}
|
||||
// The About rows open things rather than change them, so A reads "Open" and there is no
|
||||
// Adjust cell — left/right genuinely does nothing there.
|
||||
if pinTarget == nil, tab == .about {
|
||||
let sections: [GamepadHint] = showsSectionHint
|
||||
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
|
||||
text: "Section", action: { step(tabBy: 1) })]
|
||||
: []
|
||||
return sections + [
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Open",
|
||||
action: { if let focusID { activate(id: focusID) } }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { back() }),
|
||||
]
|
||||
}
|
||||
guard pinTarget != nil else {
|
||||
// The shoulders change section, so that cell leads — where it fits and where the
|
||||
// shoulders exist at all (see `showsSectionHint`).
|
||||
@@ -383,6 +448,9 @@ struct GamepadSettingsView: View {
|
||||
if let profile = pinTarget {
|
||||
pinTarget = nil
|
||||
focusID = "profile-\(profile.id)"
|
||||
} else if let page = aboutPage {
|
||||
aboutPage = nil
|
||||
focusID = page == .shortcuts ? "shortcuts" : "licenses"
|
||||
} else {
|
||||
performClose()
|
||||
}
|
||||
@@ -390,7 +458,53 @@ struct GamepadSettingsView: View {
|
||||
|
||||
// MARK: - Row rendering
|
||||
|
||||
@ViewBuilder
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
switch row.kind {
|
||||
case .control: controlRow(row, focused: focused)
|
||||
case .footer:
|
||||
Text(row.label)
|
||||
.font(.geist(metrics.detailFont, .medium, relativeTo: .caption))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(ink.fg(focused ? 0.7 : 0.45))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 18)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
case .heading:
|
||||
Text(row.label)
|
||||
.font(.geist(metrics.labelFont, .bold, relativeTo: .headline))
|
||||
.foregroundStyle(ink.fg(0.75))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, metrics.rowHPad)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 2)
|
||||
case .prose:
|
||||
// Focus here means "this is the part you are scrolled to", not "press A" — so it is a
|
||||
// quiet wash rather than the control rows' full glass.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(row.label)
|
||||
.font(.geistFixed(metrics.valueFont, .medium))
|
||||
.foregroundStyle(ink.fg(0.95))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !row.value.isEmpty {
|
||||
Text(row.value)
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.6))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, metrics.rowHPad)
|
||||
.padding(.vertical, metrics.rowVPad * 0.7)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: metrics.rowCorner, style: .continuous)
|
||||
.fill(ink.fg(focused ? 0.08 : 0))
|
||||
}
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
}
|
||||
}
|
||||
|
||||
private func controlRow(_ row: Row, focused: Bool) -> some View {
|
||||
let m = metrics
|
||||
// No section header: the tab strip names the section now, and repeating it above the
|
||||
// first row of every tab was just a second label saying the same word.
|
||||
@@ -502,10 +616,23 @@ struct GamepadSettingsView: View {
|
||||
/// `activate(id:)`, not per closure, so no row builder can forget it.
|
||||
/// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.)
|
||||
var enabled = true
|
||||
/// How this row DRAWS. Every tab but About is `.control` — the glass row with a label and
|
||||
/// a value. About is a reading surface as much as a menu, so it also has a heading and a
|
||||
/// block of prose, which are rows only so the focus list can scroll them (the same trick
|
||||
/// `Licenses.chunked` plays for tvOS focus).
|
||||
var kind: Kind = .control
|
||||
/// Left/right step; returns whether the value actually changed (false ⇒ boundary thud).
|
||||
let adjust: (Int) -> Bool
|
||||
/// A — cycle forward (wrapping) / flip.
|
||||
let activate: () -> Void
|
||||
|
||||
enum Kind {
|
||||
case control
|
||||
case heading
|
||||
case prose
|
||||
/// Quiet, centred trailing text — the About tab's version line.
|
||||
case footer
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
|
||||
@@ -527,9 +654,133 @@ struct GamepadSettingsView: View {
|
||||
/// controller wiring and the tvOS focus engine carry over as is).
|
||||
private var rows: [Row] {
|
||||
if let profile = pinTarget { return pinRows(for: profile) }
|
||||
if let page = aboutPage {
|
||||
switch page {
|
||||
case .shortcuts: return shortcutRows
|
||||
case .licenses: return licenseRows
|
||||
}
|
||||
}
|
||||
if tab == .about { return aboutRows }
|
||||
return allRows.filter { $0.tab == tab }
|
||||
}
|
||||
|
||||
// MARK: - About
|
||||
|
||||
/// The About section: the ways out, plus the two reading surfaces. The identity itself (icon,
|
||||
/// name, version, tagline) is the HEADER while this tab is up — see `aboutIdentity` — not a
|
||||
/// row, so the list holds no focus stop that does nothing when pressed.
|
||||
private var aboutRows: [Row] {
|
||||
var list: [Row] = [
|
||||
aboutAction(
|
||||
id: "shortcuts", icon: "command", label: "Shortcuts", value: "While streaming",
|
||||
detail: "What to press during a session on this device — and on a controller.",
|
||||
open: .shortcuts),
|
||||
aboutAction(
|
||||
id: "licenses", icon: "text.document", label: "Acknowledgements",
|
||||
value: "MIT or Apache-2.0",
|
||||
detail: "Punktfunk's own licence and the third-party components it uses.",
|
||||
open: .licenses),
|
||||
]
|
||||
list.append(contentsOf: [
|
||||
aboutLink(id: "docs", icon: "book", label: "Documentation", url: Destination.docs),
|
||||
aboutLink(
|
||||
id: "community", icon: "bubble.left.and.bubble.right", label: "Community",
|
||||
url: Destination.community),
|
||||
aboutLink(
|
||||
id: "source", icon: "chevron.left.forwardslash.chevron.right",
|
||||
label: "Source code", url: Destination.source),
|
||||
])
|
||||
// The version sits UNDER the rows rather than in a header card above them. The card that
|
||||
// used to head this tab carried the app icon, and on tvOS that icon is a 400x240
|
||||
// rectangle that would not survive contact with a layout built for square art — three
|
||||
// attempts at framing it were still cropping it on the real TV. A version string answers
|
||||
// the only question anyone actually opens About to ask, and has no aspect ratio to get
|
||||
// wrong. `.footer` draws it quiet and centred, so it reads as a footer and not a row you
|
||||
// failed to press.
|
||||
list.append(Row(
|
||||
id: "version", tab: .about, icon: "", label: Self.versionLine, value: "",
|
||||
detail: "", adjustable: false, enabled: true, kind: .footer,
|
||||
adjust: { _ in false }, activate: {}))
|
||||
return list
|
||||
}
|
||||
|
||||
private func aboutAction(
|
||||
id: String, icon: String, label: String, value: String, detail: String, open: AboutPage
|
||||
) -> Row {
|
||||
Row(
|
||||
id: id, tab: .about, icon: icon, label: label, value: value, detail: detail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false },
|
||||
activate: {
|
||||
// Focus lands on the page's first row — the focus list's reconcile follows this
|
||||
// id when the row set swaps underneath it (the pin picker's pattern).
|
||||
focusID = open == .shortcuts ? shortcutRows.first?.id : licenseRows.first?.id
|
||||
aboutPage = open
|
||||
})
|
||||
}
|
||||
|
||||
/// tvOS has no browser and no `openURL`, so an address there is text to read off the screen
|
||||
/// rather than a link to nowhere — the same call the touch About page makes.
|
||||
private func aboutLink(id: String, icon: String, label: String, url: URL) -> Row {
|
||||
let shown = url.absoluteString.replacingOccurrences(of: "https://", with: "")
|
||||
#if os(tvOS)
|
||||
return Row(
|
||||
id: id, tab: .about, icon: icon, label: label, value: shown,
|
||||
detail: "Open this address on a phone or computer.",
|
||||
adjustable: false, adjust: { _ in false }, activate: {})
|
||||
#else
|
||||
return Row(
|
||||
id: id, tab: .about, icon: icon, label: label, value: shown,
|
||||
detail: "Opens in your browser.",
|
||||
adjustable: false, adjust: { _ in false }, activate: { openURL(url) })
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The shortcuts reference — the same `ShortcutsCatalog` the touch About page renders, so the
|
||||
/// two can never drift.
|
||||
private var shortcutRows: [Row] {
|
||||
ShortcutsCatalog.groups(micAvailable: micAvailable).flatMap { group -> [Row] in
|
||||
[aboutText(id: "group-\(group.title)", label: group.title, kind: .heading)]
|
||||
+ group.items.map { item in
|
||||
aboutText(
|
||||
id: "sc-\(group.title)-\(item.keys)", label: item.keys, value: item.text,
|
||||
kind: .prose)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The licence wall, one row per pre-chunked page (`Licenses.chunked`, which exists so tvOS
|
||||
/// can page it by focus steps) — so it scrolls with the stick and needs no machinery here.
|
||||
private var licenseRows: [Row] {
|
||||
var list: [Row] = [
|
||||
aboutText(id: "lic-heading", label: "Punktfunk", kind: .heading),
|
||||
aboutText(
|
||||
id: "lic-summary",
|
||||
label: "Punktfunk's source is open under MIT or Apache-2.0. It ships the Geist "
|
||||
+ "typeface under the SIL Open Font License 1.1, and uses the third-party "
|
||||
+ "components below, each under its own license.",
|
||||
kind: .prose),
|
||||
]
|
||||
for (i, chunk) in Licenses.chunked(Licenses.appLicense).enumerated() {
|
||||
list.append(aboutText(id: "lic-app-\(i)", label: chunk, kind: .prose))
|
||||
}
|
||||
list.append(aboutText(
|
||||
id: "lic-third-heading", label: "Third-party software", kind: .heading))
|
||||
for (i, chunk) in Licenses.thirdPartyNoticesChunks.enumerated() {
|
||||
list.append(aboutText(id: "lic-third-\(i)", label: chunk, kind: .prose))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private func aboutText(
|
||||
id: String, label: String, value: String = "", kind: Row.Kind
|
||||
) -> Row {
|
||||
Row(
|
||||
id: id, tab: .about, icon: "", label: label, value: value, detail: "",
|
||||
adjustable: false, enabled: true, kind: kind,
|
||||
adjust: { _ in false }, activate: {})
|
||||
}
|
||||
|
||||
/// Every row on the screen, tagged with its section. Built as one list (not per tab) so the
|
||||
/// platform-conditional insertions below can still place a row RELATIVE to another by id.
|
||||
private var allRows: [Row] {
|
||||
|
||||
@@ -114,6 +114,10 @@ enum SettingsFields {
|
||||
.init(name: "invert_scroll", key: DefaultsKey.invertScroll,
|
||||
overlay: \.invertScroll, effective: \.invertScroll)
|
||||
}
|
||||
static var inhibitShortcuts: SettingsField<Bool> {
|
||||
.init(name: "inhibit_shortcuts", key: DefaultsKey.inhibitShortcuts,
|
||||
overlay: \.inhibitShortcuts, effective: \.inhibitShortcuts)
|
||||
}
|
||||
static var modifierLayout: SettingsField<String> {
|
||||
.init(name: "modifier_layout", key: DefaultsKey.modifierLayout,
|
||||
overlay: \.modifierLayout, effective: \.modifierLayout)
|
||||
@@ -205,6 +209,7 @@ extension SettingsView {
|
||||
#endif
|
||||
#if os(macOS)
|
||||
base.mouseMode = mouseMode
|
||||
base.inhibitShortcuts = inhibitShortcuts
|
||||
base.vsync = vsync
|
||||
base.windowedSafePresent = windowedSafePresent
|
||||
#endif
|
||||
|
||||
@@ -515,6 +515,9 @@ extension SettingsView {
|
||||
Text("Desktop (absolute)").tag(MouseInputMode.desktop.rawValue)
|
||||
}
|
||||
}
|
||||
described(inhibitShortcutsDescription, field: "inhibit_shortcuts") {
|
||||
Toggle("Capture system shortcuts", isOn: scoped(SettingsFields.inhibitShortcuts))
|
||||
}
|
||||
#endif
|
||||
described(
|
||||
(ModifierLayout(rawValue: effective.modifierLayout) ?? .mac).detail,
|
||||
@@ -534,6 +537,19 @@ extension SettingsView {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Dynamic like the captions above, because the setting genuinely has no effect under the
|
||||
/// desktop mouse model (system chords stay local there on every client) — and a toggle that
|
||||
/// silently does nothing should say so instead of leaving the user to find out.
|
||||
private var inhibitShortcutsDescription: String {
|
||||
if (MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop {
|
||||
return "⌘ shortcuts stay on this Mac under the desktop mouse model. Switch Mouse "
|
||||
+ "input to Capture to send them to the host."
|
||||
}
|
||||
return "Sends ⌘ shortcuts to the host while input is captured, so ⌘Q and friends reach "
|
||||
+ "the remote desktop instead of this app. ⌘⎋ always stays local — it is what "
|
||||
+ "releases capture."
|
||||
}
|
||||
|
||||
/// The SELECTED mouse model explained — dynamic, like the touch-mode caption.
|
||||
private var mouseModeDescription: String {
|
||||
switch MouseInputMode(rawValue: effective.mouseMode) ?? .capture {
|
||||
|
||||
@@ -115,6 +115,10 @@ struct SettingsView: View {
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.mouseMode) var mouseMode = MouseInputMode.capture.rawValue
|
||||
/// Cross-client `inhibit_shortcuts` — here, the ⌘-chord passthrough (⌘Q & co. reach the host
|
||||
/// instead of the app menu while captured). macOS-only: it is the one platform whose window
|
||||
/// system hands a plain app no keyboard grab, so the client has to claim the chords itself.
|
||||
@AppStorage(DefaultsKey.inhibitShortcuts) var inhibitShortcuts = true
|
||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// The in-session controls, written down once and read by every surface that shows them.
|
||||
//
|
||||
// This replaced the start-of-stream banner (ContentView's `showShortcutHint`): a 6-second pill
|
||||
// that told you the controls exactly once, while you were busy looking at the thing you had just
|
||||
// connected to, and then never again. A reference you can OPEN answers the question at the moment
|
||||
// it is actually asked — which is the second session, not the first.
|
||||
//
|
||||
// The catalog is data rather than a view so both About pages render the same words: the touch
|
||||
// `AboutView` (a Form) and the controller-first `GamepadAboutView` (a console list). The banner
|
||||
// was macOS/tvOS-only, so deleting it would have cost Mac TOUCH users the one place those keys
|
||||
// were written down — hence the touch surface gets this too, not just the gamepad UI.
|
||||
//
|
||||
// Per-platform by `#if`, because the honest answer really is different: tvOS has no keyboard and
|
||||
// no menu bar, iOS has a touch gesture nothing else has, and macOS is the only one that has to
|
||||
// explain mouse capture. A controller's chords are the one section common to all three — they are
|
||||
// the same buttons on every client (`GamepadCapture.escapeChord` / `.statsChord`), which is the
|
||||
// whole point of a cross-client chord.
|
||||
|
||||
import AVFoundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// One line of the reference: what you press, and what it does.
|
||||
struct ShortcutItem: Identifiable {
|
||||
/// Stable within its group — the keys are unique per group by construction.
|
||||
var id: String { keys }
|
||||
/// The chord itself, rendered monospaced so ⌃⌥⇧-style runs stay legible.
|
||||
let keys: String
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct ShortcutGroup: Identifiable {
|
||||
var id: String { title }
|
||||
let title: String
|
||||
let items: [ShortcutItem]
|
||||
}
|
||||
|
||||
enum ShortcutsCatalog {
|
||||
/// Whether a mute key is worth listing when no session is running, for the About page reached
|
||||
/// from settings. `SessionModel.micAvailable` is the authority DURING a session — it also
|
||||
/// consults the profile the session actually resolved — but a reference page opened between
|
||||
/// sessions has no session to ask, so it answers the device-level half of the same question:
|
||||
/// a platform with an app-accessible input, the mic setting on, and the OS not refusing.
|
||||
/// `.notDetermined` counts, exactly as it does there: the prompt is simply still pending.
|
||||
static var micPlausible: Bool {
|
||||
#if os(tvOS)
|
||||
return false // no app-accessible microphone
|
||||
#else
|
||||
guard UserDefaults.standard.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true
|
||||
else { return false }
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized, .notDetermined: return true
|
||||
default: return false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// `micAvailable` gates the mute row — a device with no microphone would otherwise be told
|
||||
/// about a key that does nothing, which is the failure the old banner already avoided.
|
||||
static func groups(micAvailable: Bool) -> [ShortcutGroup] {
|
||||
var groups: [ShortcutGroup] = []
|
||||
#if os(macOS)
|
||||
var keyboard: [ShortcutItem] = [
|
||||
.init(keys: "Click", text: "Capture the mouse and keyboard for the stream"),
|
||||
.init(keys: "⌃⌥⇧Q", text: "Release the mouse and keyboard back to this Mac"),
|
||||
.init(keys: "⌃⌥⇧D", text: "Disconnect"),
|
||||
.init(keys: "⌃⌥⇧S", text: "Cycle the statistics overlay"),
|
||||
]
|
||||
if micAvailable {
|
||||
keyboard.append(.init(keys: "⌃⌥⇧A", text: "Mute or unmute the microphone"))
|
||||
}
|
||||
groups.append(.init(title: "Keyboard", items: keyboard))
|
||||
#elseif os(iOS)
|
||||
// iPad with a hardware keyboard gets the same cross-client set as the Mac (StreamCommands
|
||||
// publishes it either way); a phone simply never sees a keyboard to press it on.
|
||||
var keyboard: [ShortcutItem] = [
|
||||
.init(keys: "⌃⌥⇧Q", text: "Release the pointer back to this device"),
|
||||
.init(keys: "⌃⌥⇧D", text: "Disconnect"),
|
||||
.init(keys: "⌃⌥⇧S", text: "Cycle the statistics overlay"),
|
||||
]
|
||||
if micAvailable {
|
||||
keyboard.append(.init(keys: "⌃⌥⇧A", text: "Mute or unmute the microphone"))
|
||||
}
|
||||
groups.append(.init(title: "Hardware keyboard", items: keyboard))
|
||||
groups.append(.init(title: "Touch", items: [
|
||||
.init(keys: "Three-finger tap", text: "Cycle the statistics overlay"),
|
||||
]))
|
||||
#elseif os(tvOS)
|
||||
// The remote section leads on tvOS: it carries the ONLY exits. Menu/B is swallowed during
|
||||
// a session (ContentView's `.onExitCommand {}`), so a user who does not know the hold
|
||||
// gesture is genuinely stuck — which is why this was the one banner that could not simply
|
||||
// be deleted without putting the words somewhere findable first.
|
||||
groups.append(.init(title: "Siri Remote", items: [
|
||||
.init(keys: "Hold Back", text: "Disconnect"),
|
||||
.init(keys: "Touch surface", text: "Move the pointer"),
|
||||
.init(keys: "Press", text: "Click"),
|
||||
.init(keys: "Play/Pause", text: "Right-click"),
|
||||
.init(keys: "Hold Play/Pause", text: "Cycle the statistics overlay"),
|
||||
]))
|
||||
#endif
|
||||
// Every client's controller speaks these two chords — see GamepadCapture.escapeChord and
|
||||
// .statsChord, which a test pins against their GameController element lists.
|
||||
groups.append(.init(title: "Controller", items: [
|
||||
.init(keys: "L1 + R1 + Start + Select", text: "Hold to disconnect"),
|
||||
.init(keys: "Select + X", text: "Cycle the statistics overlay"),
|
||||
.init(keys: "Hold Select", text: "Press the host's guide button"),
|
||||
]))
|
||||
return groups
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard-interface reference — a sheet from `AboutView` on iOS/macOS, a pushed page on
|
||||
/// tvOS — so the keys the start-of-stream banner used to carry are still one press away.
|
||||
/// (The controller-first surface renders the same catalog itself; see `GamepadAboutView`.)
|
||||
struct ShortcutsView: View {
|
||||
let micAvailable: Bool
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
// No `Form`/`.formStyle(.grouped)` worth using at 10 feet, and the rows are read, not
|
||||
// operated — a plain scrolling column at TV sizes says the same thing with less chrome.
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 30) {
|
||||
ForEach(ShortcutsCatalog.groups(micAvailable: micAvailable)) { group in
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(group.title)
|
||||
.font(.geist(28, .semibold, relativeTo: .headline))
|
||||
ForEach(group.items) { item in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 20) {
|
||||
Text(item.keys)
|
||||
.font(.geistFixed(22, .medium))
|
||||
.frame(minWidth: 300, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(item.text)
|
||||
.font(.geist(22, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 1000, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(60)
|
||||
}
|
||||
.navigationTitle("Shortcuts")
|
||||
#else
|
||||
form
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private var form: some View {
|
||||
Form {
|
||||
ForEach(ShortcutsCatalog.groups(micAvailable: micAvailable)) { group in
|
||||
Section(group.title) {
|
||||
ForEach(group.items) { item in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
Text(item.keys)
|
||||
.font(.geistFixed(13, .medium))
|
||||
.foregroundStyle(.primary)
|
||||
// A fixed column keeps the descriptions aligned; the chords vary
|
||||
// from "Click" to "L1 + R1 + Start + Select".
|
||||
.frame(minWidth: 132, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(item.text)
|
||||
.font(.geist(13, relativeTo: .footnote))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Shortcuts")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -162,6 +162,17 @@ final class HostStore: ObservableObject {
|
||||
hosts[i].osChain = chain
|
||||
}
|
||||
|
||||
/// Learn/refresh this host's management-API port from its live advert — same contract as
|
||||
/// `updateMacs`. Until this existed, `StoredHost.mgmtPort` was declared and read but never
|
||||
/// written, so `effectiveMgmtPort` always answered 47990 and a host that had moved its mgmt
|
||||
/// port simply had no working library here.
|
||||
func updateMgmtPort(_ hostID: UUID, port: UInt16?) {
|
||||
guard let port, port > 0,
|
||||
let i = hosts.firstIndex(where: { $0.id == hostID }),
|
||||
hosts[i].mgmtPort != port else { return }
|
||||
hosts[i].mgmtPort = port
|
||||
}
|
||||
|
||||
/// Bind this host to a settings profile, or to "Default settings" (nil) — the ONLY way the
|
||||
/// default changes. A one-off "Connect with ▸" deliberately never lands here (§5.2:
|
||||
/// predictable, not sticky).
|
||||
|
||||
@@ -18,7 +18,10 @@ import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
struct GamepadPairView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@@ -32,8 +32,11 @@ final class AudioDeviceWatcher {
|
||||
/// posts one last change as it is torn down, and other AVAudioEngines in the process are not
|
||||
/// ours to restart.
|
||||
private let isOurs: (AnyObject?) -> Bool
|
||||
/// Delivered on the main queue.
|
||||
private let onChange: (Reason) -> Void
|
||||
/// Delivered on the main queue. The second argument is the engine that posted the change
|
||||
/// (`.engineConfiguration` only; nil for the HAL listener) — the owner needs the OBJECT, not
|
||||
/// just the reason, because an engine that is RUNNING when the notification lands is one the
|
||||
/// owner already restarted: acting on that echo is how a rebuild loop starts.
|
||||
private let onChange: (Reason, AnyObject?) -> Void
|
||||
|
||||
private let lock = NSLock()
|
||||
private var configObserver: NSObjectProtocol?
|
||||
@@ -41,7 +44,7 @@ final class AudioDeviceWatcher {
|
||||
private var defaultOutputListener: AudioObjectPropertyListenerBlock?
|
||||
#endif
|
||||
|
||||
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason) -> Void) {
|
||||
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason, AnyObject?) -> Void) {
|
||||
self.isOurs = isOurs
|
||||
self.onChange = onChange
|
||||
}
|
||||
@@ -63,7 +66,7 @@ final class AudioDeviceWatcher {
|
||||
let posted = note.object as AnyObject?
|
||||
DispatchQueue.main.async {
|
||||
guard let self, self.isOurs(posted) else { return }
|
||||
self.onChange(.engineConfiguration)
|
||||
self.onChange(.engineConfiguration, posted)
|
||||
}
|
||||
}
|
||||
lock.lock()
|
||||
@@ -77,7 +80,8 @@ final class AudioDeviceWatcher {
|
||||
// (the voice-processing engine, which is the DEFAULT macOS configuration and which no Mac
|
||||
// here can even initialize). The HAL is told either way.
|
||||
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
|
||||
self?.onChange(.defaultOutputDevice) // on the main queue — registered against it below
|
||||
// On the main queue — registered against it below. No engine posted this, so nil.
|
||||
self?.onChange(.defaultOutputDevice, nil)
|
||||
}
|
||||
var address = Self.defaultOutputAddress()
|
||||
let status = AudioObjectAddPropertyListenerBlock(
|
||||
|
||||
@@ -42,7 +42,10 @@ public enum AudioDevices {
|
||||
return channelCount(id, scope: kAudioObjectPropertyScopeInput)
|
||||
}
|
||||
|
||||
private static func defaultInputDevice() -> AudioDeviceID? {
|
||||
/// The device the system is currently capturing from — the key `SessionAudio`'s
|
||||
/// voice-processing gate latches a start failure against (the failure is a property of the
|
||||
/// input device, so a new device earns a fresh attempt).
|
||||
static func defaultInputDevice() -> AudioDeviceID? {
|
||||
systemDevice(kAudioHardwarePropertyDefaultInputDevice)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// The two policy decisions of the device-change recovery, extracted where a unit test can reach
|
||||
// them. Both exist because of one field incident (2026-08-14, Mac Studio): the voice-processing
|
||||
// engine could not start on a 6-channel input device, every rebuild re-tried it, and the failed
|
||||
// attempt's HAL churn (VPIO builds and tears down an aggregate device) re-stopped the fallback
|
||||
// engines — which posted the configuration change that scheduled the next rebuild. A ~2.5 s
|
||||
// metronome of audio gaps, forever, with each rebuild also stalling the main thread (where macOS
|
||||
// input capture lives), so the stream's INPUT cut out on the same beat. The session-side wiring
|
||||
// lives in `SessionAudio`; the decisions live here because the loop shipped precisely because
|
||||
// they could not be tested without a mic and a session.
|
||||
|
||||
#if os(macOS)
|
||||
import CoreAudio
|
||||
#endif
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
/// Should a rebuild try the combined (voice-processing) topology again?
|
||||
///
|
||||
/// A VPIO start failure is a property of the INPUT DEVICE (its channel count and format), not of
|
||||
/// the moment: retrying it on the same device fails the same way, and the attempt is not free —
|
||||
/// engaging and abandoning the voice processor churns the HAL hard enough to stop the healthy
|
||||
/// fallback engines. So a failure latches until the default input actually changes; a new device
|
||||
/// earns exactly one fresh attempt (it may well support VPIO), and its own failure latches again.
|
||||
struct CombinedTopologyGate {
|
||||
private var failed = false
|
||||
/// The default input device the failure was observed on — nil is a real value here ("failed
|
||||
/// with no resolvable input device"), which is why `failed` is tracked separately.
|
||||
private var failedInput: AudioDeviceID?
|
||||
|
||||
/// The combined topology failed with `input` as the default input device.
|
||||
mutating func noteFailure(input: AudioDeviceID?) {
|
||||
failed = true
|
||||
failedInput = input
|
||||
}
|
||||
|
||||
/// True when the combined topology is worth attempting with `input` as the default input
|
||||
/// device. A device change clears the latch — the answer is about the CURRENT hardware, and
|
||||
/// coming back to a device that failed before earns a fresh attempt too (the failure may have
|
||||
/// been the mid-transition kind, and one attempt per device change cannot loop).
|
||||
mutating func shouldTry(input: AudioDeviceID?) -> Bool {
|
||||
guard failed else { return true }
|
||||
guard input == failedInput else {
|
||||
failed = false
|
||||
failedInput = nil
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The delay before the next engine rebuild — the base debounce/floor behaviour, plus an
|
||||
/// escalating floor when rebuilds CHAIN (each one retriggered by its predecessor's own fallout).
|
||||
///
|
||||
/// One device switch produces one rebuild: its trigger burst is coalesced upstream, so the next
|
||||
/// trigger normally arrives minutes later and gets the base floor. A trigger that arrives hard on
|
||||
/// the heels of the last rebuild, again and again, is a rebuild answering itself — and since the
|
||||
/// recovery cannot always identify its own echo, the backstop is to keep answering but at a
|
||||
/// doubling floor, so an unforeseen feedback shape costs one audio blip per half-minute instead
|
||||
/// of a metronome. A quiet stretch resets the ladder to full responsiveness.
|
||||
struct RebuildBackoff {
|
||||
/// Let the burst of triggers from one switch land before rebuilding.
|
||||
static let debounce: TimeInterval = 0.15
|
||||
/// Floor between two rebuilds.
|
||||
static let floor: TimeInterval = 0.5
|
||||
/// The escalated floor's cap: looping recoveries settle at one attempt per this interval.
|
||||
static let floorCap: TimeInterval = 30
|
||||
/// A trigger this long after the last rebuild is unrelated to it — the chain resets.
|
||||
static let chainWindow: TimeInterval = 10
|
||||
|
||||
/// Consecutive rebuilds whose trigger arrived within `chainWindow` of the previous rebuild.
|
||||
private(set) var chain = 0
|
||||
private var lastRebuildAt: TimeInterval = -.infinity
|
||||
|
||||
/// The delay to schedule the next rebuild with, for a trigger arriving at `now`
|
||||
/// (`systemUptime`). Mutates the chain accounting: call once per SCHEDULED rebuild, not per
|
||||
/// coalesced trigger.
|
||||
mutating func delay(now: TimeInterval) -> TimeInterval {
|
||||
let since = now - lastRebuildAt
|
||||
chain = since < Self.chainWindow ? chain + 1 : 0
|
||||
let floor = min(Self.floor * pow(2, Double(min(chain, 6))), Self.floorCap)
|
||||
return max(Self.debounce, floor - since)
|
||||
}
|
||||
|
||||
/// The rebuild actually ran at `now` — the reference the next trigger's `delay` measures from.
|
||||
mutating func noteRebuild(at now: TimeInterval) {
|
||||
lastRebuildAt = now
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,18 @@ final class AudioRing: @unchecked Sendable {
|
||||
private static let maxTargetMS = 70
|
||||
private static let headroomMS = 30
|
||||
private static let hardCapMS = 90
|
||||
private static let deprimeAfter = 4
|
||||
/// How long the ring may run short before it goes back to priming, in MILLISECONDS of
|
||||
/// starvation — not a count of callbacks. As a count (it was 4) the hysteresis meant a
|
||||
/// different span of time on every device, because a callback is not a unit of time: 4 of them
|
||||
/// is ~44 ms on a Mac's ~11 ms quantum and **20 ms on iOS**, whose session asks for a short IO
|
||||
/// buffer. A Wi-Fi delivery stall therefore de-primed this ring on every bunching cycle where
|
||||
/// the same policy rode it out elsewhere — measured on the shared Rust policy at 120 audible
|
||||
/// gaps per 10 minutes at a 5 ms quantum, against 3 at 8 ms and 1 at 16 ms on an identical
|
||||
/// link. Mirrors `JitterTuning::COREAUDIO.deprime_ms`.
|
||||
private static let deprimeMS = 60
|
||||
/// Floor in callbacks under `deprimeMS`, so a large-quantum device keeps real hysteresis
|
||||
/// instead of de-priming on the first short read. Mirrors `MIN_DEPRIME_CALLBACKS`.
|
||||
private static let minDeprimeCallbacks = 2
|
||||
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
|
||||
private static let frameMS = 5
|
||||
/// Depth average must exceed target by this before drift correction fires — the middle of the
|
||||
@@ -93,7 +104,12 @@ final class AudioRing: @unchecked Sendable {
|
||||
private var writeIdx = 0
|
||||
private var primed = false
|
||||
private var renderQuantum = 0
|
||||
/// Consecutive short reads, and the audio they starved for in interleaved samples. BOTH gate
|
||||
/// the de-prime (see `deprimeMS`): the run must be at least that long AND at least
|
||||
/// `minDeprimeCallbacks` callbacks, so the fuse is the same span of time whatever the device's
|
||||
/// quantum without collapsing to a hair trigger on a large-quantum device.
|
||||
private var emptyReads = 0
|
||||
private var emptyRun = 0
|
||||
private var depthAvg: Double = 0
|
||||
private var overRun = 0
|
||||
/// The live target in interleaved samples — `targetMS` grown by underrun pressure
|
||||
@@ -240,8 +256,10 @@ final class AudioRing: @unchecked Sendable {
|
||||
min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS),
|
||||
target + renderQuantum)
|
||||
if writeIdx - readIdx > cap {
|
||||
readIdx = writeIdx - cap
|
||||
depthAvg = Double(cap)
|
||||
// Crossfaded, like the smooth shed — see `dropFront`. This is the correction a
|
||||
// bunching link actually pays, so it is the one that most needs not to click.
|
||||
dropFront(writeIdx - readIdx - cap)
|
||||
depthAvg = Double(writeIdx - readIdx)
|
||||
overRun = 0
|
||||
}
|
||||
}
|
||||
@@ -262,6 +280,7 @@ final class AudioRing: @unchecked Sendable {
|
||||
if available >= target {
|
||||
primed = true
|
||||
emptyReads = 0
|
||||
emptyRun = 0
|
||||
// The refill just banked this much: seed the average with it rather than letting
|
||||
// it climb from wherever the drought left it — a freshly-primed ring would
|
||||
// otherwise read as hollow for the EWMA's whole settling time, and the FIRST
|
||||
@@ -348,15 +367,23 @@ final class AudioRing: @unchecked Sendable {
|
||||
if ranShort {
|
||||
quietRun = 0
|
||||
emptyReads += 1
|
||||
emptyRun += count
|
||||
underrunCount += 1
|
||||
if emptyReads >= Self.deprimeAfter || hollow {
|
||||
// The consecutive-empties hysteresis protects a FULL ring from one late packet.
|
||||
// Starved for `deprimeMS` of audio, over at least `minDeprimeCallbacks` callbacks.
|
||||
// Both, because either alone is wrong at one end of the quantum range: time alone is a
|
||||
// hair trigger on a device whose single quantum already exceeds the window, and a
|
||||
// callback count alone is the device-dependent fuse this replaced.
|
||||
let starved = emptyRun >= Self.deprimeMS * perMS
|
||||
&& emptyReads >= Self.minDeprimeCallbacks
|
||||
if starved || hollow {
|
||||
// The starvation hysteresis protects a FULL ring from one late packet.
|
||||
// A hollow ring is the opposite case: the target has been raised but the depth
|
||||
// never re-banked (growth is a promise; only a re-prime cashes it), and riding
|
||||
// that out is a click per bunching period, forever. The click just heard has
|
||||
// already paid for the refill — take it now.
|
||||
primed = false
|
||||
emptyReads = 0
|
||||
emptyRun = 0
|
||||
}
|
||||
if !restored {
|
||||
underrunsInWindow += 1
|
||||
@@ -375,12 +402,14 @@ final class AudioRing: @unchecked Sendable {
|
||||
// the path above takes over. A near-miss is pressure, not quiet.
|
||||
quietRun = 0
|
||||
emptyReads = 0
|
||||
emptyRun = 0
|
||||
if !nearMissGrown, !restored {
|
||||
nearMissGrown = true
|
||||
targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS)
|
||||
}
|
||||
} else {
|
||||
emptyReads = 0
|
||||
emptyRun = 0
|
||||
quietRun += count
|
||||
// Without a sync request, time is the only evidence that hard-won slack is no longer
|
||||
// needed, so a grown target waits out the long window. A request for less IS evidence,
|
||||
@@ -402,13 +431,21 @@ final class AudioRing: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one protocol frame from the front, linearly crossfading the seam so the correction is
|
||||
/// inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; caller holds
|
||||
/// the lock.
|
||||
private func shedOneFrame() {
|
||||
let drop = Self.frameMS * perMS
|
||||
/// Drop one protocol frame from the front — the smooth drift correction.
|
||||
private func shedOneFrame() { dropFront(Self.frameMS * perMS) }
|
||||
|
||||
/// Drop `drop` interleaved samples from the front, linearly crossfading the seam so the
|
||||
/// correction is inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`;
|
||||
/// caller holds the lock.
|
||||
///
|
||||
/// Used by BOTH corrections. The hard-cap trim in `write` used to splice raw, on the reasoning
|
||||
/// that a ring which blew its ceiling is already a discontinuity — but that describes the
|
||||
/// ARRIVALS, not the samples either side of the seam, which are ordinary continuous audio. It
|
||||
/// is also the drop that actually fires here: a bunching Wi-Fi link trims far more often than
|
||||
/// drift sheds, so the one path left unfaded was the audible one.
|
||||
private func dropFront(_ drop: Int) {
|
||||
let available = writeIdx - readIdx
|
||||
guard available > drop else { return }
|
||||
guard drop > 0, available > drop else { return }
|
||||
let fade = min(Self.crossfadeMS * perMS, min(drop, available - drop))
|
||||
let capacity = buf.count
|
||||
if fade > 0 {
|
||||
|
||||
@@ -63,15 +63,24 @@ public final class SessionAudio {
|
||||
private var micMuted = false
|
||||
/// The playback jitter ring — created by whichever engine starts playback first and KEPT
|
||||
/// across an engine rebuild (the permission-grant upgrade in `startEngines` swaps engines,
|
||||
/// not the ring, so the drain thread never has to be re-pointed). Main-thread confined,
|
||||
/// like every start path.
|
||||
/// not the ring, so the drain thread never has to be re-pointed). Guarded by `stateLock`:
|
||||
/// the start paths run on `engineQueue`, while `stats` reads from the main thread.
|
||||
private var ring: AudioRing?
|
||||
/// Every engine build, start, stop and rebuild runs here, serially — and NOT on the main
|
||||
/// thread. macOS captures and sends input from the main thread, so the seconds a
|
||||
/// voice-processing start can take (~1.9 s measured in the 2026-08-14 field loop) would
|
||||
/// freeze the stream's input for exactly that long — the recovery must never make the main
|
||||
/// thread wait on the audio server. The main queue keeps only the trigger bookkeeping
|
||||
/// (debounce, backoff, retry ladder), which is cheap by construction.
|
||||
private let engineQueue = DispatchQueue(
|
||||
label: "io.unom.punktfunk.audio.engines", qos: .userInitiated)
|
||||
/// The video plane's end-to-end meter (capture→on-glass), if the owner wired one — the
|
||||
/// reference the A/V sync loop steers the ring against. `nil` leaves the loop inert and the
|
||||
/// ring exactly as it was before sync existed, which is also what the stage-1 fallback
|
||||
/// presenter gets: it decodes and presents inside the layer with no per-frame stamp, so it can
|
||||
/// offer no reference, and a loop with no reference must not invent one. Main-thread confined,
|
||||
/// like `ring`; the meter itself is internally locked and read from the drain thread.
|
||||
/// offer no reference, and a loop with no reference must not invent one. Written ONCE in
|
||||
/// `start()` before anything is dispatched (the queue hop orders it for `startDrain`); the
|
||||
/// meter itself is internally locked and read from the drain thread.
|
||||
private var videoLatency: LatencyMeter?
|
||||
#if !os(macOS)
|
||||
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
|
||||
@@ -99,7 +108,8 @@ public final class SessionAudio {
|
||||
// MARK: - Device changes (see `installDeviceChangeRecovery`)
|
||||
|
||||
/// What `start()` was asked for, so a rebuild can put back the SAME topology the session was
|
||||
/// started with. Main-thread confined, like the start paths that read it.
|
||||
/// started with. Guarded by `stateLock` (written on the caller's thread, read when a rebuild
|
||||
/// fires on the main queue).
|
||||
private var startConfig: StartConfig?
|
||||
private struct StartConfig {
|
||||
let speakerUID: String
|
||||
@@ -110,20 +120,23 @@ public final class SessionAudio {
|
||||
}
|
||||
/// Watches the hardware for us (see `AudioDeviceWatcher`). Guarded by `stateLock`.
|
||||
private var deviceWatcher: AudioDeviceWatcher?
|
||||
/// Whether the engines have been built at least once. Distinguishes "not started yet" (iOS
|
||||
/// starts asynchronously) from "started and dead", which is what the recovery may act on.
|
||||
/// Main-thread confined.
|
||||
/// Whether the engines have been built at least once. Distinguishes "not started yet" (every
|
||||
/// platform starts asynchronously now) from "started and dead", which is what the recovery
|
||||
/// may act on. Guarded by `stateLock` (set on `engineQueue`, read on the main queue).
|
||||
private var enginesAttempted = false
|
||||
/// A rebuild is already on the main queue — one device switch produces a burst of triggers
|
||||
/// and they must collapse into one restart. Main-thread confined.
|
||||
private var rebuildQueued = false
|
||||
/// `systemUptime` of the last rebuild, so a device that renegotiates in a loop cannot spin
|
||||
/// the session. Main-thread confined.
|
||||
private var lastRebuildAt: TimeInterval = 0
|
||||
/// Let the burst of triggers from one switch land before rebuilding.
|
||||
private static let rebuildDebounce: TimeInterval = 0.15
|
||||
/// Floor between two rebuilds.
|
||||
private static let rebuildFloor: TimeInterval = 0.5
|
||||
/// Debounce/floor for the next rebuild, with an escalating floor when rebuilds chain (each
|
||||
/// retriggered by its predecessor — see `RebuildBackoff`). Main-thread confined.
|
||||
private var rebuildBackoff = RebuildBackoff()
|
||||
#if os(macOS)
|
||||
/// Latches a voice-processing start failure per input device, so a rebuild never re-attempts
|
||||
/// a topology that deterministically fails — the retry is what turned one failure into a
|
||||
/// rebuild loop (see `CombinedTopologyGate` and the note on `installDeviceChangeRecovery`).
|
||||
/// `engineQueue`-confined, like the start paths that consult and feed it.
|
||||
private var combinedGate = CombinedTopologyGate()
|
||||
#endif
|
||||
/// Retries when a rebuild's `start()` loses the race with a device that is still going away
|
||||
/// (0.3 s, 0.6 s, 1.2 s). A failed rebuild leaves no engine to post the next notification,
|
||||
/// so this ladder — and, on macOS, the HAL listener — is all that stands between a mistimed
|
||||
@@ -151,11 +164,12 @@ public final class SessionAudio {
|
||||
}
|
||||
|
||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
|
||||
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed). On macOS
|
||||
/// the engines start synchronously on the caller's (main) thread. On iOS/tvOS start() is
|
||||
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
|
||||
/// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not
|
||||
/// on return. The mic may start later still if the permission prompt is pending.
|
||||
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed).
|
||||
/// ASYNCHRONOUS on every platform: the engines start on `engineQueue` (iOS/tvOS activate the
|
||||
/// AVAudioSession off the main thread first), gated by `!flag.isStopped` — so playback is
|
||||
/// live shortly after, not on return. An engine start can block on the audio server for
|
||||
/// seconds, and the caller's (main) thread is where macOS input capture lives — it must
|
||||
/// never wait. The mic may start later still if the permission prompt is pending.
|
||||
/// `echoCancel` picks the engine topology — see the header note and `wantsCombined`.
|
||||
///
|
||||
/// `videoLatency` is the session's END-TO-END latency meter (capture→on-glass). Pass it to arm
|
||||
@@ -166,26 +180,33 @@ public final class SessionAudio {
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool,
|
||||
videoLatency: LatencyMeter? = nil
|
||||
) {
|
||||
self.videoLatency = videoLatency
|
||||
self.videoLatency = videoLatency // before any dispatch below — startDrain reads it
|
||||
// Before any engine exists: the recovery watches the hardware, not the engines, and the
|
||||
// config it rebuilds from has to be recorded whether or not this start succeeds.
|
||||
stateLock.lock()
|
||||
startConfig = StartConfig(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||
stateLock.unlock()
|
||||
installDeviceChangeRecovery(micEnabled: micEnabled)
|
||||
#if os(macOS)
|
||||
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||
startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||
// No AVAudioSession on macOS — but the engines start on `engineQueue`, never the
|
||||
// caller's (main) thread: a voice-processing start can block on the audio server for
|
||||
// seconds, and the main thread is where input capture lives.
|
||||
engineQueue.async { [weak self] in
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||
}
|
||||
#else
|
||||
// Configure + activate the session OFF the main thread (it blocks on the audio server),
|
||||
// then start the engines back on the main thread once it's active — engine routing/format
|
||||
// then start the engines on `engineQueue` once it's active — engine routing/format
|
||||
// depend on the active session. A stop() racing in between is caught by the flag guard.
|
||||
Self.sessionQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activateAudioSession(micEnabled: micEnabled)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self.engineQueue.async { [weak self] in
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
@@ -234,11 +255,23 @@ public final class SessionAudio {
|
||||
try session.setCategory(
|
||||
.playAndRecord, mode: .default,
|
||||
options: [.allowBluetoothA2DP, .mixWithOthers])
|
||||
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
|
||||
// Uplink latency: ask for 10 ms IO quanta at the wire rate (the default ~23 ms
|
||||
// quantum is most of the mic path's burst latency). Best-effort — the hardware
|
||||
// has the final word (a Bluetooth route will ignore both), and whatever quantum
|
||||
// is actually granted, the capture tap handles the buffers it gets.
|
||||
try? session.setPreferredIOBufferDuration(0.005)
|
||||
//
|
||||
// 10 ms, NOT the 5 ms this used to ask for. The IO buffer duration is a property
|
||||
// of the whole IO unit, so a shorter quantum is not free to the PLAYBACK side —
|
||||
// and it bought the uplink nothing, because the encoder frames at 10 ms
|
||||
// (`installMicTap` installs with `bufferSize: 480` and `OpusEncoder` consumes
|
||||
// whole `framesPerPacket` chunks): at a 5 ms quantum the tap simply fired twice
|
||||
// per packet, for the same packet latency. What it did buy was a halved deadline
|
||||
// for the render callback and — because the de-prime fuse used to be a callback
|
||||
// COUNT — half the starvation hysteresis in the jitter ring, on the one platform
|
||||
// whose transport bunches hardest. Both ends of that are fixed now (`AudioRing`
|
||||
// measures the fuse in ms), but there is still no reason to ask for a quantum
|
||||
// finer than the packets we send.
|
||||
try? session.setPreferredIOBufferDuration(0.010)
|
||||
try? session.setPreferredSampleRate(48_000)
|
||||
} else {
|
||||
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||
@@ -247,6 +280,16 @@ public final class SessionAudio {
|
||||
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||
#endif
|
||||
try session.setActive(true)
|
||||
// What we were actually GRANTED, not what we asked for. Both are best-effort, and the
|
||||
// ring's behaviour depends on the quantum it really gets — without this, a report of
|
||||
// audio jitter arrives with no way to tell a 10 ms session from a 5 ms or a 23 ms one,
|
||||
// which is exactly the gap that made the last round of this take a simulation to close.
|
||||
log.info("""
|
||||
AVAudioSession active: io_buffer_ms=\
|
||||
\(session.ioBufferDuration * 1000, format: .fixed(precision: 2)) \
|
||||
sample_rate=\(Int(session.sampleRate)) \
|
||||
route=\(session.currentRoute.outputs.first?.portType.rawValue ?? "none")
|
||||
""")
|
||||
#if os(iOS)
|
||||
// Only the `.playAndRecord` session can land on the earpiece, and only it accepts an
|
||||
// output override — so the mic-off (`.playback`) path deliberately does neither.
|
||||
@@ -320,12 +363,15 @@ public final class SessionAudio {
|
||||
#endif
|
||||
|
||||
/// Build + start the engines — combined (voice-processed) or split, per `wantsCombined` —
|
||||
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
|
||||
/// iOS/tvOS the session is already active by the time this runs.
|
||||
/// with the mic uplink only when enabled + authorized. Runs on `engineQueue` (a start can
|
||||
/// block on the audio server for seconds — never the main thread); on iOS/tvOS the session
|
||||
/// is already active by the time this runs.
|
||||
private func startEngines(
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
|
||||
) {
|
||||
stateLock.lock()
|
||||
enginesAttempted = true // even if every path below fails — see `reviveStoppedEngines`
|
||||
stateLock.unlock()
|
||||
#if os(tvOS)
|
||||
// No app-accessible microphone input on tvOS — playback only.
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
@@ -334,9 +380,25 @@ public final class SessionAudio {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
return
|
||||
}
|
||||
#if os(macOS)
|
||||
// A rebuild must not re-attempt a voice-processing start that already failed on this
|
||||
// input device: the failure repeats, and the failed attempt's HAL churn stops the healthy
|
||||
// fallback engines — the 2026-08-14 rebuild loop (see `CombinedTopologyGate`).
|
||||
var combined = wantsCombined(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
echoCancel: echoCancel)
|
||||
if combined, !combinedGate.shouldTry(input: AudioDevices.defaultInputDevice()) {
|
||||
log.info("""
|
||||
voice processing already failed on this input device — split engines, no echo \
|
||||
cancellation
|
||||
""")
|
||||
combined = false
|
||||
}
|
||||
#else
|
||||
let combined = wantsCombined(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
echoCancel: echoCancel)
|
||||
#endif
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
if combined {
|
||||
@@ -352,7 +414,8 @@ public final class SessionAudio {
|
||||
// drain thread carry over — see `makePlaybackChain`).
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.engineQueue.async { [weak self] in
|
||||
guard let self, granted, !self.flag.isStopped else { return }
|
||||
if combined {
|
||||
self.stateLock.lock()
|
||||
@@ -491,6 +554,17 @@ public final class SessionAudio {
|
||||
/// - the route-change and media-services-reset notifications, iOS/tvOS, where the session and
|
||||
/// not the device is what moves.
|
||||
///
|
||||
/// And three defenses keep the recovery from ANSWERING ITSELF — a rebuild is not a silent
|
||||
/// act (a voice-processing start builds and tears down HAL aggregates, and every fresh engine
|
||||
/// renegotiates its IO), so its own fallout can retrigger it. The 2026-08-14 field loop was
|
||||
/// exactly that: VPIO failed on a 6-channel mic, every rebuild re-tried it, and the failure's
|
||||
/// churn stopped the fallback engines — audio and (via the main thread) INPUT cutting out
|
||||
/// every ~2.5 s for the whole session. The defenses: a configuration change from an engine
|
||||
/// that is RUNNING is a rebuild's echo and is ignored (`hardwareMoved`); a VPIO failure is
|
||||
/// latched per input device and never re-attempted on it (`CombinedTopologyGate`); and
|
||||
/// rebuilds that chain anyway back off exponentially instead of metronoming
|
||||
/// (`RebuildBackoff`).
|
||||
///
|
||||
/// `micEnabled` only decides whether the mic-bearing session observers are worth installing.
|
||||
/// Main thread.
|
||||
private func installDeviceChangeRecovery(micEnabled: Bool) {
|
||||
@@ -501,7 +575,7 @@ public final class SessionAudio {
|
||||
|
||||
let watcher = AudioDeviceWatcher(
|
||||
isOurs: { [weak self] posted in self?.ownsEngine(posted) ?? false },
|
||||
onChange: { [weak self] reason in self?.hardwareMoved(reason) })
|
||||
onChange: { [weak self] reason, posted in self?.hardwareMoved(reason, posted: posted) })
|
||||
stateLock.lock()
|
||||
deviceWatcher = watcher
|
||||
stateLock.unlock()
|
||||
@@ -527,10 +601,17 @@ public final class SessionAudio {
|
||||
/// question — is playback still where it should be — but they answer it differently: an engine
|
||||
/// that told us it stopped is definitive, while the default device moving might not concern us
|
||||
/// at all.
|
||||
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason) {
|
||||
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason, posted: AnyObject?) {
|
||||
guard !flag.isStopped else { return }
|
||||
switch reason {
|
||||
case .engineConfiguration:
|
||||
// The engine stops itself BEFORE posting this — so an engine that is RUNNING when the
|
||||
// notification lands on the main queue is one a rebuild already replaced or restarted:
|
||||
// the notification is the rebuild's own echo, and answering it is how the recovery
|
||||
// loops. A change that stops the engine again after this posts again, and the HAL
|
||||
// backstop checks placement independently, so ignoring a live engine's echo can never
|
||||
// strand a stopped one.
|
||||
if let engine = posted as? AVAudioEngine, engine.isRunning { return }
|
||||
scheduleEngineRebuild(reason: reason.rawValue)
|
||||
case .defaultOutputDevice:
|
||||
#if os(macOS)
|
||||
@@ -550,7 +631,10 @@ public final class SessionAudio {
|
||||
/// output device at the moment it connected — and leaving it silent for good. On iOS the same
|
||||
/// flag keeps this from racing the asynchronous start, where no engine yet is normal.
|
||||
private func reviveStoppedEngines(_ reason: String) {
|
||||
guard !flag.isStopped, enginesAttempted, !playbackIsLive else { return }
|
||||
stateLock.lock()
|
||||
let attempted = enginesAttempted
|
||||
stateLock.unlock()
|
||||
guard !flag.isStopped, attempted, !playbackIsLive else { return }
|
||||
scheduleEngineRebuild(reason: "playback is stopped and \(reason)")
|
||||
}
|
||||
|
||||
@@ -572,15 +656,43 @@ public final class SessionAudio {
|
||||
private func scheduleEngineRebuild(reason: String) {
|
||||
guard !rebuildQueued else { return }
|
||||
rebuildQueued = true
|
||||
let since = ProcessInfo.processInfo.systemUptime - lastRebuildAt
|
||||
let delay = max(Self.rebuildDebounce, Self.rebuildFloor - since)
|
||||
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
|
||||
let delay = rebuildBackoff.delay(now: ProcessInfo.processInfo.systemUptime)
|
||||
if rebuildBackoff.chain >= 2 {
|
||||
// Each rebuild is retriggering the next — a feedback shape the echo guard and the
|
||||
// topology gate did not identify. Keep answering (a real recovery must not be
|
||||
// abandoned), but say what is happening: this line repeating IS the diagnosis.
|
||||
log.warning("""
|
||||
audio engine rebuilds are chaining (\(self.rebuildBackoff.chain) in a row — \
|
||||
\(reason)); backing off \(Int(delay * 1000)) ms
|
||||
""")
|
||||
} else {
|
||||
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.rebuildEngines(attempt: 0)
|
||||
self?.rebuildFire(attempt: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The scheduled rebuild came due (main queue): close out the bookkeeping and hand the
|
||||
/// actual engine work to `engineQueue` — the teardown + start can block on the audio server
|
||||
/// for seconds, and the main thread is where macOS captures and sends the stream's input.
|
||||
/// A trigger arriving while the work is in flight schedules a fresh rebuild rather than
|
||||
/// being swallowed; `engineQueue` is serial, so the two never interleave.
|
||||
private func rebuildFire(attempt: Int) {
|
||||
rebuildQueued = false
|
||||
guard !flag.isStopped else { return }
|
||||
stateLock.lock()
|
||||
let config = startConfig
|
||||
stateLock.unlock()
|
||||
guard let config else { return }
|
||||
rebuildBackoff.noteRebuild(at: ProcessInfo.processInfo.systemUptime)
|
||||
engineQueue.async { [weak self] in
|
||||
self?.performRebuild(config: config, attempt: attempt)
|
||||
}
|
||||
}
|
||||
|
||||
/// Put back the topology this session was started with, on whatever hardware is there now.
|
||||
/// Runs on `engineQueue`.
|
||||
///
|
||||
/// A full rebuild rather than a `start()` on the stopped engine, because the mic side has to
|
||||
/// follow too: `installMicTap` reads the input's live format, and the voice processor
|
||||
@@ -588,10 +700,8 @@ public final class SessionAudio {
|
||||
/// across (`makePlaybackChain` reuses it, `startDrain` is idempotent), so the drain thread
|
||||
/// keeps decoding right through the switch and its overflow policy has already dropped
|
||||
/// everything that went stale while the engine was down.
|
||||
private func rebuildEngines(attempt: Int) {
|
||||
rebuildQueued = false
|
||||
guard !flag.isStopped, let config = startConfig else { return }
|
||||
lastRebuildAt = ProcessInfo.processInfo.systemUptime
|
||||
private func performRebuild(config: StartConfig, attempt: Int) {
|
||||
guard !flag.isStopped else { return }
|
||||
tearDownEngines()
|
||||
startEngines(
|
||||
speakerUID: config.speakerUID, micUID: config.micUID, micChannel: config.micChannel,
|
||||
@@ -604,6 +714,18 @@ public final class SessionAudio {
|
||||
log.info("audio engines restarted on the current device")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.rebuildFailed(attempt: attempt)
|
||||
}
|
||||
}
|
||||
|
||||
/// A rebuild's playback did not come back (main queue) — walk the retry ladder. Retries
|
||||
/// when a rebuild's `start()` loses the race with a device that is still going away
|
||||
/// (0.3 s, 0.6 s, 1.2 s): a failed rebuild leaves no engine to post the next notification,
|
||||
/// so this ladder — and, on macOS, the HAL listener — is all that stands between a mistimed
|
||||
/// switch and a silent session.
|
||||
private func rebuildFailed(attempt: Int) {
|
||||
guard !flag.isStopped else { return }
|
||||
guard attempt < Self.rebuildAttempts else {
|
||||
#if os(macOS)
|
||||
log.error("""
|
||||
@@ -615,10 +737,11 @@ public final class SessionAudio {
|
||||
#endif
|
||||
return
|
||||
}
|
||||
guard !rebuildQueued else { return } // a fresh trigger already queued a full rebuild
|
||||
rebuildQueued = true // holds off a trigger that would only race this ladder
|
||||
let delay = Self.rebuildDebounce * Double(1 << (attempt + 1))
|
||||
let delay = RebuildBackoff.debounce * Double(1 << (attempt + 1))
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.rebuildEngines(attempt: attempt + 1)
|
||||
self?.rebuildFire(attempt: attempt + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,9 +887,13 @@ public final class SessionAudio {
|
||||
public let avOffsetMS: Int
|
||||
}
|
||||
|
||||
/// A snapshot of `Stats`, or nil before playback starts. Main thread (`ring` is main-confined;
|
||||
/// the ring's own numbers are taken under its lock, so they describe one instant).
|
||||
/// A snapshot of `Stats`, or nil before playback starts. Safe from any thread (the handle is
|
||||
/// taken under `stateLock`; the ring's own numbers are taken under its lock, so they
|
||||
/// describe one instant).
|
||||
public var stats: Stats? {
|
||||
stateLock.lock()
|
||||
let ring = self.ring
|
||||
stateLock.unlock()
|
||||
guard let s = ring?.stats else { return nil }
|
||||
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
|
||||
}
|
||||
@@ -791,7 +918,7 @@ public final class SessionAudio {
|
||||
/// The playback jitter ring + the source node draining it — shared by the plain playback
|
||||
/// engine and the combined voice-processing engine, and REUSED across an engine rebuild
|
||||
/// (same session, same ring: the drain thread keeps writing right through the swap). nil
|
||||
/// when the host's channel layout can't be expressed (already logged). Main thread.
|
||||
/// when the host's channel layout can't be expressed (already logged). Runs on `engineQueue`.
|
||||
private func makePlaybackChain()
|
||||
-> (ring: AudioRing, source: AVAudioSourceNode, format: AVAudioFormat)?
|
||||
{
|
||||
@@ -801,8 +928,10 @@ public final class SessionAudio {
|
||||
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
|
||||
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
|
||||
// rather than a prefill passed in here.
|
||||
stateLock.lock()
|
||||
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
self.ring = ring
|
||||
stateLock.unlock()
|
||||
|
||||
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
|
||||
// uses an explicit wire-order channel layout; the mixer downmixes to the output device when
|
||||
@@ -961,6 +1090,17 @@ public final class SessionAudio {
|
||||
// MARK: - Mic (mic → host)
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The combined topology failed to come up. On macOS, latch the input device it failed on so
|
||||
/// a rebuild goes straight to the split topology instead of re-running the failure — the
|
||||
/// failed attempt is what churns the HAL and retriggers the recovery (see
|
||||
/// `CombinedTopologyGate`). On iOS routes are session-managed and a VPIO failure is the
|
||||
/// transient route-transition kind, so nothing is latched there.
|
||||
private func noteCombinedFailure() {
|
||||
#if os(macOS)
|
||||
combinedGate.noteFailure(input: AudioDevices.defaultInputDevice())
|
||||
#endif
|
||||
}
|
||||
|
||||
/// One engine, both directions: engage the system voice processor on the shared IO unit
|
||||
/// (AEC + noise suppression + AGC), hang the playback source off its render side and the
|
||||
/// mic tap off its capture side. Every failure falls back to a WORKING configuration —
|
||||
@@ -979,6 +1119,7 @@ public final class SessionAudio {
|
||||
voice processing unavailable (\(error.localizedDescription)) — separate \
|
||||
engines, no echo cancellation
|
||||
""")
|
||||
noteCombinedFailure()
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
@@ -1032,6 +1173,7 @@ public final class SessionAudio {
|
||||
// processor won't engage at all, already does exactly this; this arm used to give up
|
||||
// on the mic instead, which is how a whole session could go silent uplink-only.)
|
||||
engine.stop()
|
||||
noteCombinedFailure()
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
@@ -1042,6 +1184,7 @@ public final class SessionAudio {
|
||||
log.error("combined engine failed to start: \(error.localizedDescription)")
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
noteCombinedFailure()
|
||||
// Same rule: a working mic without echo cancellation beats no mic at all.
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
|
||||
@@ -61,6 +61,16 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable {
|
||||
/// (`sanitizeOsChain`) — drives the host card's OS mark and is persisted like the MACs.
|
||||
/// Empty when not advertised (older host). Advisory/unauthenticated like the rest.
|
||||
public let osChain: String
|
||||
/// The host's management-API port (mDNS `mgmt` TXT) — where the game library is served, NOT
|
||||
/// `port`, which is the native QUIC plane. nil when not advertised (older host), and the
|
||||
/// client then assumes `punktfunkDefaultMgmtPort`.
|
||||
///
|
||||
/// Persisted onto the saved host like the MACs and the OS chain, and for a sharper reason:
|
||||
/// `StoredHost.mgmtPort` has existed all along but nothing ever wrote it, so
|
||||
/// `effectiveMgmtPort` always resolved to 47990. A host that moved its mgmt port off 47990 —
|
||||
/// the supported way to share a machine with a Sunshine fork, whose web UI owns that port —
|
||||
/// therefore had no working library on any Apple client at all.
|
||||
public let mgmtPort: UInt16?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -211,12 +221,12 @@ public final class HostDiscovery: ObservableObject {
|
||||
public static func debugAdvert(
|
||||
id: String, name: String, host: String, port: UInt16 = 9777,
|
||||
fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true,
|
||||
macAddresses: [String] = [], osChain: String = ""
|
||||
macAddresses: [String] = [], osChain: String = "", mgmtPort: UInt16? = nil
|
||||
) -> DiscoveredHost {
|
||||
DiscoveredHost(
|
||||
id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex,
|
||||
requiresPairing: requiresPairing, allowsTofu: allowsTofu,
|
||||
macAddresses: macAddresses, osChain: osChain)
|
||||
macAddresses: macAddresses, osChain: osChain, mgmtPort: mgmtPort)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -429,6 +439,7 @@ public final class HostDiscovery: ObservableObject {
|
||||
var id: String?
|
||||
var macs: [String] = []
|
||||
var osChain = ""
|
||||
var mgmtPort: UInt16?
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
fp = entry(txt, "fp")
|
||||
pair = entry(txt, "pair")
|
||||
@@ -438,13 +449,16 @@ public final class HostDiscovery: ObservableObject {
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
osChain = sanitizeOsChain(entry(txt, "os") ?? "")
|
||||
// Unauthenticated input, so range-check rather than trust: a non-numeric or 0 value
|
||||
// means "not advertised" and the client falls back to the default.
|
||||
mgmtPort = entry(txt, "mgmt").flatMap(UInt16.init).flatMap { $0 > 0 ? $0 : nil }
|
||||
}
|
||||
return DiscoveredHost(
|
||||
id: (id?.isEmpty == false) ? id! : name,
|
||||
name: name, host: address, port: port,
|
||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||
allowsTofu: pair == "optional", macAddresses: macs,
|
||||
osChain: osChain)
|
||||
osChain: osChain, mgmtPort: mgmtPort)
|
||||
}
|
||||
|
||||
private static func key(_ result: NWBrowser.Result) -> String {
|
||||
|
||||
@@ -382,12 +382,29 @@ public final class PunktfunkConnection {
|
||||
/// the client draws its own (a visible system cursor over the stream).
|
||||
public private(set) var resolvedCompositor: Compositor = .auto
|
||||
|
||||
/// Host clock minus client clock (nanoseconds), from the connect-time wall-clock skew handshake
|
||||
/// (`punktfunk_connection_clock_offset_ns`). Add it to a local `CLOCK_REALTIME` instant to
|
||||
/// express that instant in the host's capture clock — the clock each `AccessUnit.ptsNs` is
|
||||
/// stamped in — so a glass-to-glass latency (present/enqueue time minus `ptsNs`) is valid across
|
||||
/// machines. `0` = no correction (an older host that didn't answer, or synchronized clocks).
|
||||
public private(set) var clockOffsetNs: Int64 = 0
|
||||
/// Host clock minus client clock (nanoseconds) — LIVE: the connect-time skew handshake's
|
||||
/// estimate, kept fresh by the core's mid-stream re-syncs (every 60 s plus immediately on a
|
||||
/// suspected wall-clock step; `punktfunk_connection_clock_offset_now_ns`, ABI v10). Add it to
|
||||
/// a local `CLOCK_REALTIME` instant to express that instant in the host's capture clock — the
|
||||
/// clock each `AccessUnit.ptsNs` is stamped in — so a glass-to-glass latency (present/enqueue
|
||||
/// time minus `ptsNs`) is valid across machines. `0` = no correction (an older host that
|
||||
/// didn't answer, synchronized clocks, or a closed connection).
|
||||
///
|
||||
/// ⚠ LIVE means DO NOT CACHE. Until 2026-08-13 this was a connect-time snapshot, and the
|
||||
/// core's own doc names the failure: "after an NTP step or slow drift the connect-time value
|
||||
/// silently corrupts every capture-clock comparison." The field evidence was stark — two
|
||||
/// sessions minutes apart against the same wired host read hostnet 17–21 ms, then a
|
||||
/// physically impossible 4.4 ms (the host is a VM; VM wall clocks step), and LatencyMeter's
|
||||
/// impossible-sample guard silently trimmed the shifted-negative half, so the HUD showed a
|
||||
/// plausible small number instead of an alarm. Read this property at each use — it is an
|
||||
/// atomic load behind the FFI — and never park it in a `let` or a closure capture list.
|
||||
/// Cross-thread reads follow the `framesDropped()` precedent.
|
||||
public var clockOffsetNs: Int64 {
|
||||
guard let handle else { return 0 }
|
||||
var offset: Int64 = 0
|
||||
_ = punktfunk_connection_clock_offset_now_ns(handle, &offset)
|
||||
return offset
|
||||
}
|
||||
|
||||
/// The video encoder bitrate (kbps) the host actually configured — the requested
|
||||
/// `bitrateKbps` clamped to the host's range ([500, 2 000 000] kbps), or its default
|
||||
@@ -435,6 +452,14 @@ public final class PunktfunkConnection {
|
||||
/// 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
|
||||
/// The host's management-API port, from this session's `Welcome` — where its game library is
|
||||
/// served. `0` when the host advertised none (an older host, or one with no management API);
|
||||
/// resolve through `StoredHost.effectiveMgmtPort` rather than dialing a `0`.
|
||||
///
|
||||
/// Read this after a connect and persist it: it is the only source that does not depend on
|
||||
/// mDNS, so it is what makes a moved mgmt port work for a host reached over a VPN or added by
|
||||
/// address on a network where discovery never functions.
|
||||
public private(set) var hostMgmtPort: UInt16 = 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.
|
||||
@@ -635,9 +660,6 @@ public final class PunktfunkConnection {
|
||||
var comp: UInt32 = 0
|
||||
_ = punktfunk_connection_compositor(handle, &comp)
|
||||
resolvedCompositor = Compositor(rawValue: comp) ?? .auto
|
||||
var offset: Int64 = 0
|
||||
_ = punktfunk_connection_clock_offset_ns(handle, &offset)
|
||||
clockOffsetNs = offset
|
||||
var br: UInt32 = 0
|
||||
_ = punktfunk_connection_bitrate(handle, &br)
|
||||
resolvedBitrateKbps = br
|
||||
@@ -663,6 +685,12 @@ public final class PunktfunkConnection {
|
||||
var caps: UInt8 = 0
|
||||
_ = punktfunk_connection_host_caps(handle, &caps)
|
||||
hostCaps = caps
|
||||
// Where this host serves its game library, straight from the session's Welcome. 0 = the
|
||||
// host advertised none (older host / no management API), and the caller keeps whatever it
|
||||
// already had. This is the answer that does NOT require an mDNS advert to have been seen.
|
||||
var mgmt: UInt16 = 0
|
||||
_ = punktfunk_connection_mgmt_port(handle, &mgmt)
|
||||
hostMgmtPort = mgmt
|
||||
}
|
||||
|
||||
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||
|
||||
@@ -86,6 +86,21 @@ public final class InputCapture {
|
||||
/// its Esc suppression need it in both states).
|
||||
private var cmdKeysDown: Set<UInt32> = []
|
||||
|
||||
#if os(macOS)
|
||||
/// Windows VKs the ⌘-chord passthrough sent DOWN (see the keyDown monitor). macOS stops
|
||||
/// delivering keyUp for ordinary keys while Command is held, so the release half of ⌘Q/⌘W/…
|
||||
/// cannot be relied on to arrive through the responder chain at all: these are flushed when
|
||||
/// the last ⌘ comes up (`flushCommandChord`), which is what stands between the host and a
|
||||
/// key held down for the rest of the session.
|
||||
private var commandChordVKs: Set<UInt32> = []
|
||||
|
||||
/// Mirrors StreamLayerView's live mouse model — ⌃⌥⇧M flips it mid-session, so it can't be
|
||||
/// read from the settings. The ⌘-chord passthrough stays off under the desktop model, matching
|
||||
/// what the SDL clients' keyboard grab does: a remote desktop is something you ⌘Tab away from,
|
||||
/// not into.
|
||||
public var desktopMouse = false
|
||||
#endif
|
||||
|
||||
#if !os(macOS)
|
||||
/// The key currently auto-repeating, and the timer driving it. iOS/tvOS only — see
|
||||
/// `startAutoRepeat`. Main-queue only, like every other field here.
|
||||
@@ -244,19 +259,27 @@ public final class InputCapture {
|
||||
) { [weak self] _ in
|
||||
self?.releaseAll()
|
||||
})
|
||||
// ⌘⎋ — the capture toggle — is detected here so it works in both states. ONLY
|
||||
// that one combo is intercepted: swallowing keys wholesale at the monitor level
|
||||
// risks starving GC's own delivery, so the no-beep behavior lives in
|
||||
// StreamLayerView (first responder consumes keyDown/keyUp while captured).
|
||||
// (On iOS there is no NSEvent monitor — the GC key handler detects the combo.)
|
||||
// This monitor is the FIRST thing in the app to see a key: AppKit calls it before
|
||||
// `sendEvent:`, so before any menu key equivalent and before StreamLayerView's keyDown.
|
||||
// Returning nil discards the event outright — which cuts BOTH of those off, and on macOS
|
||||
// the second one is the host's only key path (the GCKeyboard send is iOS-only; see
|
||||
// `attach(keyboard:)`). So the rule here is: anything swallowed must either be handled
|
||||
// client-side or forwarded to the host from inside this block, because nothing downstream
|
||||
// will get a second chance at it.
|
||||
//
|
||||
// ⌘⎋ (capture toggle) and ⌃⌥⇧M (mouse model) are client-side in BOTH states; ⌃⌥⇧Q/D/S/A
|
||||
// and ⌃⌘F are client-side only while forwarding (released, the events pass through and the
|
||||
// menu's identical key equivalents handle them). Every OTHER ⌘ chord is the HOST's while
|
||||
// captured — see `forwardsCommandChord`. (On iOS there is no NSEvent monitor — the GC key
|
||||
// handler detects the combos.)
|
||||
#if os(macOS)
|
||||
keyEventMonitor = NSEvent.addLocalMonitorForEvents(
|
||||
matching: [.keyDown]
|
||||
) { [weak self] event in
|
||||
guard let self else { return event }
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
let flags = Self.chordFlags(event)
|
||||
if event.keyCode == 53 /* Esc */, flags == .command {
|
||||
self.suppressedVK = 0x1B // the same physical Esc is en route via GC
|
||||
self.suppressedVK = 0x1B // VK_ESC — its keyUp still reaches the responder chain
|
||||
self.onToggleCapture?()
|
||||
return nil
|
||||
}
|
||||
@@ -266,7 +289,7 @@ public final class InputCapture {
|
||||
// (latched like ⌘⎋'s Esc) so it doesn't type into the host, and swallow the
|
||||
// event so it doesn't beep.
|
||||
if event.keyCode == 46 /* M */, flags == [.control, .option, .shift] {
|
||||
self.suppressedVK = 0x4D // VK_M — the same physical M is en route via GC
|
||||
self.suppressedVK = 0x4D // VK_M — its keyUp still reaches the responder chain
|
||||
self.onToggleMouseMode?()
|
||||
return nil
|
||||
}
|
||||
@@ -304,10 +327,34 @@ public final class InputCapture {
|
||||
// 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.suppressedVK = 0x46 // VK_F — its keyUp still reaches the responder chain
|
||||
self.onToggleFullscreen?()
|
||||
return nil
|
||||
}
|
||||
// Every OTHER ⌘ chord belongs to the HOST while captured — the cross-client "capture
|
||||
// system shortcuts" setting, which the Apple client had no answer to because SDL's
|
||||
// keyboard grab is what implements it everywhere else. Without this the app menu's key
|
||||
// equivalents fire first, so ⌘Q quits the client instead of reaching the compositor as
|
||||
// Super+Q — one of the most-bound chords on a Linux desktop, and the reported break.
|
||||
//
|
||||
// It has to SEND from here: returning nil is what keeps the menu out, and it takes
|
||||
// StreamLayerView's keyDown — the host's only key path on macOS — out with it.
|
||||
// Chords with no host VK are swallowed but not sent: doing nothing beats a menu
|
||||
// opening under a captured stream. The ⌘ itself needs no handling — modifiers arrive
|
||||
// as flagsChanged, which this monitor never sees, so it was already forwarded as
|
||||
// VK_LWIN/VK_RWIN (or Alt, under the Windows modifier layout) when it went down.
|
||||
//
|
||||
// The two cheap conditions are repeated in front of the call on purpose: off-session,
|
||||
// `SessionSettings.current` re-reads the whole defaults suite, and this monitor sees
|
||||
// every keystroke the app receives — including the ones typed into the host list.
|
||||
if self.forwarding, flags.contains(.command), Self.forwardsCommandChord(
|
||||
keyCode: event.keyCode, flags: flags, forwarding: self.forwarding,
|
||||
inhibitShortcuts: SessionSettings.current.inhibitShortcuts,
|
||||
desktopMouse: self.desktopMouse
|
||||
) {
|
||||
if let vk = Self.keyCodeToVK[event.keyCode] { self.sendCommandChordKey(vk) }
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
#endif
|
||||
@@ -358,6 +405,9 @@ public final class InputCapture {
|
||||
cmdKeysDown.removeAll()
|
||||
chordModifiersDown.removeAll()
|
||||
suppressedVK = nil
|
||||
#if os(macOS)
|
||||
commandChordVKs.removeAll() // their releases are in `pressedVKs`, flushed just below
|
||||
#endif
|
||||
for vk in pressedVKs {
|
||||
emitKey(vk, down: false)
|
||||
}
|
||||
@@ -522,7 +572,15 @@ public final class InputCapture {
|
||||
// Keep cmdKeysDown in step (the ⌘⎋ toggle + Esc suppression read it); sendKey
|
||||
// adds the VK to pressedVKs so releaseAll/blur flushes a held modifier cleanly.
|
||||
if vk == 0x5B || vk == 0x5C {
|
||||
if down { cmdKeysDown.insert(vk) } else { cmdKeysDown.remove(vk) }
|
||||
if down {
|
||||
cmdKeysDown.insert(vk)
|
||||
} else {
|
||||
cmdKeysDown.remove(vk)
|
||||
// Last ⌘ up: release the chord keys whose own keyUp macOS never delivered. BEFORE
|
||||
// the ⌘'s own release goes out, so the host never sees the letter outlive the
|
||||
// modifier it was pressed with.
|
||||
if cmdKeysDown.isEmpty { flushCommandChord() }
|
||||
}
|
||||
}
|
||||
sendKey(vk, down: down)
|
||||
}
|
||||
@@ -552,6 +610,68 @@ public final class InputCapture {
|
||||
}
|
||||
return (mod.vk, down)
|
||||
}
|
||||
|
||||
// MARK: - ⌘ chord passthrough
|
||||
|
||||
/// The four modifiers a client chord is ever spelled with, isolated from the incidental bits
|
||||
/// `deviceIndependentFlagsMask` also carries: Caps Lock, and the `.function`/`.numericPad`
|
||||
/// pair every arrow and F-key sets. Equality against the raw masked flags meant a chord
|
||||
/// stopped being recognized the moment Caps Lock was on — ⌘⎋ and ⌃⌥⇧Q, both escape hatches,
|
||||
/// included. That was survivable while the monitor claimed six chords; it is not, now that it
|
||||
/// swallows every ⌘ chord there is.
|
||||
static let chordFlagMask: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
|
||||
|
||||
/// One event's chord modifiers (see `chordFlagMask`).
|
||||
static func chordFlags(_ event: NSEvent) -> NSEvent.ModifierFlags {
|
||||
event.modifierFlags.intersection(chordFlagMask)
|
||||
}
|
||||
|
||||
/// The ⌘ chords the CLIENT keeps while captured, which is to say: the way out. ⌘⎋ releases
|
||||
/// the mouse/keyboard and ⌃⌘F leaves fullscreen — hand either of those to the host and a
|
||||
/// captured stream becomes a room with no door. (⌃⌥⇧Q/D/S/A carry no ⌘ and never reach here.)
|
||||
static func isClientReservedChord(keyCode: UInt16, flags: NSEvent.ModifierFlags) -> Bool {
|
||||
if keyCode == 53, flags == .command { return true } // ⌘⎋ — capture toggle
|
||||
if keyCode == 3, flags == [.control, .command] { return true } // ⌃⌘F — fullscreen
|
||||
return false
|
||||
}
|
||||
|
||||
/// Does this keyDown get taken off AppKit and forwarded to the host instead? Only while input
|
||||
/// is actually captured, only with the cross-client `inhibit_shortcuts` on, and never under the
|
||||
/// desktop mouse model (where the chords stay local by design) — and never for the client's own
|
||||
/// reserved chords, whatever the setting says.
|
||||
static func forwardsCommandChord(
|
||||
keyCode: UInt16, flags: NSEvent.ModifierFlags,
|
||||
forwarding: Bool, inhibitShortcuts: Bool, desktopMouse: Bool
|
||||
) -> Bool {
|
||||
guard forwarding, inhibitShortcuts, !desktopMouse else { return false }
|
||||
guard flags.contains(.command) else { return false }
|
||||
return !isClientReservedChord(keyCode: keyCode, flags: flags)
|
||||
}
|
||||
|
||||
/// Forward one key of a ⌘ chord the monitor just took off AppKit, remembering it so its
|
||||
/// release can be synthesized (see `commandChordVKs`).
|
||||
private func sendCommandChordKey(_ vk: UInt32) {
|
||||
commandChordVKs.insert(vk)
|
||||
sendKey(vk, down: true)
|
||||
}
|
||||
|
||||
/// Release whatever the ⌘-chord passthrough sent down and is still held — called when the last
|
||||
/// physical ⌘ comes up. A keyUp that DID arrive has already taken its VK out of `pressedVKs`,
|
||||
/// so this only fires for the ones macOS swallowed.
|
||||
private func flushCommandChord() {
|
||||
// Same cause, different victim: a one-shot latch whose key-up never arrived goes on to eat
|
||||
// the NEXT press of that key (⌃⌘F's F, ⌘⎋'s Esc). Once ⌘ is up, a pending latch is stale.
|
||||
suppressedVK = nil
|
||||
guard !commandChordVKs.isEmpty else { return }
|
||||
for vk in commandChordVKs where pressedVKs.contains(vk) {
|
||||
pressedVKs.remove(vk)
|
||||
emitKey(vk, down: false)
|
||||
if inputDebug {
|
||||
inputLog.debug("key \(vk, privacy: .public) up SYNTHESIZED (⌘ chord release)")
|
||||
}
|
||||
}
|
||||
commandChordVKs.removeAll()
|
||||
}
|
||||
#endif
|
||||
|
||||
private func attach(mouse: GCMouse) {
|
||||
|
||||
@@ -15,8 +15,9 @@ import Foundation
|
||||
/// `record(ptsNs:atNs:offsetNs:)` at present.
|
||||
///
|
||||
/// For the host-anchored intervals (capture→…) the sample is `end + offset - pts_ns`, where
|
||||
/// `pts_ns` is the host's capture wall clock (the AU's pts) and the connect-time **clock-skew
|
||||
/// offset** (`PunktfunkConnection.clockOffsetNs`, host minus client) makes the difference valid
|
||||
/// `pts_ns` is the host's capture wall clock (the AU's pts) and the LIVE **clock-skew
|
||||
/// offset** (`PunktfunkConnection.clockOffsetNs`, host minus client, mid-stream re-synced —
|
||||
/// read it per record, never cached) makes the difference valid
|
||||
/// across machines. `offsetNs == 0` means an old host that didn't answer the skew handshake (or
|
||||
/// genuinely synced clocks) — the number is then only meaningful same-host, and the HUD tags the
|
||||
/// end-to-end line `(same-host clock)`.
|
||||
@@ -24,6 +25,8 @@ public final class LatencyMeter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var samplesUs: [Int64] = []
|
||||
private var skewCorrected = false
|
||||
/// Samples `record` refused as impossible since the last `drainTrimmed` (see the guard).
|
||||
private var trimmed = 0
|
||||
/// The most recent sample and the instant it ended, for `latestSample(asOfNs:maxAgeMs:)` —
|
||||
/// a LEVEL, not a window, so `drain` deliberately leaves both alone.
|
||||
private var latestNs: Int64 = 0
|
||||
@@ -49,8 +52,19 @@ public final class LatencyMeter: @unchecked Sendable {
|
||||
public func record(ptsNs: UInt64, atNs: Int64, offsetNs: Int64) {
|
||||
let latNs = atNs &+ offsetNs &- Int64(bitPattern: ptsNs)
|
||||
// Drop absurd values (a clock step, a wildly wrong offset, garbage pts, or a stage whose
|
||||
// start stamp is missing/after its end) — samples are clamped to (0, 10 s).
|
||||
guard latNs > 0, latNs < 10_000_000_000 else { return }
|
||||
// start stamp is missing/after its end) — samples are clamped to (0, 10 s). COUNTED, not
|
||||
// silent: a cluster of non-positive samples is the signature of a wrong clock offset
|
||||
// (client-local stages can't go negative), and a meter that quietly trims the impossible
|
||||
// half of a shifted distribution presents the surviving tail as a plausible small number
|
||||
// — field 2026-08-13: "e2e 0–3 ms p50 / 23 ms p95" on a session whose true hostnet was
|
||||
// ~18 ms. `drainTrimmed` surfaces the count so the window can be MARKED suspect instead
|
||||
// of looking healthy.
|
||||
guard latNs > 0, latNs < 10_000_000_000 else {
|
||||
lock.lock()
|
||||
trimmed += 1
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
lock.lock()
|
||||
samplesUs.append(latNs / 1000)
|
||||
latestNs = latNs
|
||||
@@ -99,6 +113,18 @@ public final class LatencyMeter: @unchecked Sendable {
|
||||
public let skewCorrected: Bool
|
||||
}
|
||||
|
||||
/// Take-and-reset the count of impossible samples `record` refused (see its guard). Drained
|
||||
/// SEPARATELY from `drain()` on purpose: with a badly wrong offset EVERY sample of a window
|
||||
/// can be non-positive, `drain()` then returns `nil` — and a count folded into `Stats` would
|
||||
/// vanish with it, hiding the very windows that scream loudest. This survives an empty window.
|
||||
public func drainTrimmed() -> Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let n = trimmed
|
||||
trimmed = 0
|
||||
return n
|
||||
}
|
||||
|
||||
/// Percentiles over the samples accumulated since the last drain, then reset the window. `nil`
|
||||
/// when no samples arrived in the interval.
|
||||
public func drain() -> Stats? {
|
||||
|
||||
@@ -56,17 +56,27 @@ enum WindowedPresentMode: String, Sendable {
|
||||
/// 203-nit diffuse white at EDR 1.0 (the display's SDR-white level) and lets the system tone-map the
|
||||
/// brighter highlights into the panel's headroom. This is the missing anchor that made the old HDR path
|
||||
/// render "way too bright" (no `edrMetadata` → no reference-white anchoring); a LARGER value renders
|
||||
/// dimmer. Matches the host's standard PQ reference white.
|
||||
/// dimmer.
|
||||
///
|
||||
/// ⚠️ This is one half of a pair: the host has to map SDR content into the PQ container at the SAME
|
||||
/// luminance, and pins it to 203 in `pf-vdisplay`'s `SDR_REFERENCE_WHITE_NITS`. When they disagree
|
||||
/// every pixel is off by the ratio — a gamescope host left on gamescope's own 400-nit default put
|
||||
/// the stream nearly a stop bright, which read as a glaring, over-saturated Steam UI and washed-out
|
||||
/// HDR game content at the same time. Change one end without the other and that gap re-opens.
|
||||
private let hdrReferenceWhiteNits: Float = 203.0
|
||||
|
||||
/// PUNKTFUNK_SDR_COLORSPACE=srgb — A/B hatch for the SDR layer's colour tag. Today the SDR layer
|
||||
/// ships with `colorspace = nil`, which on macOS means NO colour matching: the BT.709/sRGB-encoded
|
||||
/// stream is displayed with the panel's native primaries — mild oversaturation on every P3 Mac.
|
||||
/// `srgb` tags the layer so CoreAnimation colour-matches it into the panel's gamut (the strictly
|
||||
/// correct rendering). Kept OFF by default until the on-glass A/B confirms it (the nil path is the
|
||||
/// long-proven look, and some users may prefer the vivid rendition); flip the default once verified.
|
||||
private let sdrColorspaceOverride: CGColorSpace? = {
|
||||
guard ProcessInfo.processInfo.environment["PUNKTFUNK_SDR_COLORSPACE"] == "srgb" else {
|
||||
/// The SDR layer's colour tag. `colorspace = nil` means NO colour matching: the BT.709-encoded
|
||||
/// stream is handed to the compositor untagged and drawn in the display's native space. That is
|
||||
/// mild oversaturation on a P3 Mac or iPad, and on a tvOS display composited for HDR it also lifts
|
||||
/// the black floor — the 2026-08-13 field report of greys where blacks should be, which arrived
|
||||
/// with the client's own HDR switch already OFF, so no other stage had tagged those pixels either.
|
||||
/// Tagging lets CoreAnimation colour-match into whatever the output actually is, which is the
|
||||
/// strictly correct rendering, so it is now the default.
|
||||
///
|
||||
/// `PUNKTFUNK_SDR_COLORSPACE=none` restores the old untagged look — the A/B lever if a panel
|
||||
/// regresses, or for anyone who preferred the more vivid rendition.
|
||||
private let sdrColorspace: CGColorSpace? = {
|
||||
guard ProcessInfo.processInfo.environment["PUNKTFUNK_SDR_COLORSPACE"] != "none" else {
|
||||
return nil
|
||||
}
|
||||
return CGColorSpace(name: CGColorSpace.sRGB)
|
||||
@@ -425,6 +435,14 @@ public final class MetalVideoPresenter {
|
||||
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
|
||||
/// `configure` call is ordered before the thread starts, so it doesn't race).
|
||||
private var hdrActive = false
|
||||
/// Has `configureColor` run even once? `hdrActive` starts `false`, so a session that is SDR from
|
||||
/// the first frame matches the initial state and used to fall straight through `configure`'s
|
||||
/// guard — the layer then kept `make()`'s bare config, which never assigns a colour space, and
|
||||
/// the SDR stream presented untagged for the whole session. That also made
|
||||
/// `PUNKTFUNK_SDR_COLORSPACE` dead code on exactly the sessions it was meant to fix, so an
|
||||
/// operator A/B-ing it in the field saw nothing change. Same-state calls after the first are
|
||||
/// still no-ops, which is what the guard is for.
|
||||
private var didConfigureColor = false
|
||||
/// tvOS only: whether HDR frames currently present as PQ PASSTHROUGH (display has HDR headroom
|
||||
/// — its own tone-map applies) vs the in-shader tone-map fallback. Render-thread confined;
|
||||
/// derived from the staged display headroom at the top of every `render`.
|
||||
@@ -549,6 +567,11 @@ public final class MetalVideoPresenter {
|
||||
layer.contentsGravity = .resizeAspect
|
||||
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the display-link /
|
||||
// MAIN thread) has to block waiting for one to free.
|
||||
// ⚠ This is the STAGE-2/3 depth. Stage-4 (deadline pacing, the iOS/tvOS default) never
|
||||
// calls `nextDrawable()` — the link vends every drawable — so the third slot only gives
|
||||
// the compositor room to queue a second present ahead of scanout, i.e. the two-refresh
|
||||
// present floor. `Stage2Pipeline.startDeadlinePresenter` clamps it to 2 for that pacing;
|
||||
// keep the two in step if this number ever changes.
|
||||
layer.maximumDrawableCount = 3
|
||||
|
||||
return MetalVideoPresenter(
|
||||
@@ -592,13 +615,16 @@ public final class MetalVideoPresenter {
|
||||
stagingLock.lock()
|
||||
let passthrough = stagedDisplayHeadroom > 1.0
|
||||
stagingLock.unlock()
|
||||
guard hdr != hdrActive || (hdr && passthrough != hdrPassthroughActive) else { return }
|
||||
guard !didConfigureColor || hdr != hdrActive
|
||||
|| (hdr && passthrough != hdrPassthroughActive)
|
||||
else { return }
|
||||
hdrActive = hdr
|
||||
hdrPassthroughActive = passthrough
|
||||
#else
|
||||
guard hdr != hdrActive else { return }
|
||||
guard !didConfigureColor || hdr != hdrActive else { return }
|
||||
hdrActive = hdr
|
||||
#endif
|
||||
didConfigureColor = true
|
||||
configureColor(hdr: hdr)
|
||||
}
|
||||
|
||||
@@ -628,9 +654,10 @@ public final class MetalVideoPresenter {
|
||||
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
||||
} else {
|
||||
// SDR-composited display: PQ would render untone-mapped (blown out) — the
|
||||
// pf_frag_hdr_tv shader tone-maps to SDR instead.
|
||||
// pf_frag_hdr_tv shader tone-maps to SDR instead. Its output is BT.709, so it
|
||||
// carries the same SDR tag as a genuinely SDR session.
|
||||
layer.pixelFormat = .bgra8Unorm
|
||||
layer.colorspace = nil
|
||||
layer.colorspace = sdrColorspace
|
||||
}
|
||||
#else
|
||||
layer.pixelFormat = .rgba16Float
|
||||
@@ -641,12 +668,11 @@ public final class MetalVideoPresenter {
|
||||
layer.edrMetadata = makeEDR(lastHdrMeta)
|
||||
#endif
|
||||
} else {
|
||||
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable. Default: nil colorspace = NO
|
||||
// colour matching on macOS (the panel's native primaries — the long-proven look,
|
||||
// slightly oversaturated on P3 panels); PUNKTFUNK_SDR_COLORSPACE=srgb tags the layer
|
||||
// for correct colour matching instead (A/B pending — see sdrColorspaceOverride).
|
||||
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable, tagged so CoreAnimation
|
||||
// colour-matches it into the output rather than drawing it in the panel's native
|
||||
// space (see sdrColorspace; PUNKTFUNK_SDR_COLORSPACE=none restores untagged).
|
||||
layer.pixelFormat = .bgra8Unorm
|
||||
layer.colorspace = sdrColorspaceOverride
|
||||
layer.colorspace = sdrColorspace
|
||||
#if !os(tvOS)
|
||||
layer.wantsExtendedDynamicRangeContent = false
|
||||
layer.edrMetadata = nil
|
||||
|
||||
@@ -260,13 +260,22 @@ final class SessionPresenter {
|
||||
// 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).
|
||||
// drains on display cadence). Stage-1 resolves from the persisted picker only in DEBUG;
|
||||
// in release the ENV alone reaches it (the stage-1 pump below stays the automatic
|
||||
// Metal-missing fallback either way).
|
||||
#if DEBUG
|
||||
let allowStage1 = true
|
||||
#else
|
||||
let allowStage1 = false
|
||||
// The gate exists so a LEFTOVER value can't revive the freeze-prone fallback — but the
|
||||
// persisted picker is no longer read at all (setting: nil below), so the only channel
|
||||
// left is the env, and an env var is never leftover: it takes a devicectl/Xcode launch
|
||||
// to exist. It must stay openable on Release because Release is the only build that
|
||||
// measures presentation honestly, and stage-1 is the one rung that presents on the
|
||||
// hardware video plane instead of through the GPU compositor — the A/B for the tvOS
|
||||
// two-refresh present floor (field 2026-08-13: PUNKTFUNK_PRESENTER=stage1 on a Release
|
||||
// build silently ran stage-4, which would have false-negatived that A/B).
|
||||
let allowStage1 =
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"] == "stage1"
|
||||
#endif
|
||||
let explicit = PresenterChoice.explicit(
|
||||
setting: nil, // the legacy DefaultsKey.presenter picker value is no longer read
|
||||
@@ -336,7 +345,7 @@ final class SessionPresenter {
|
||||
} else {
|
||||
let pump = StreamPump()
|
||||
pump.start(
|
||||
connection: connection, layer: baseLayer,
|
||||
connection: connection, layer: baseLayer, endToEndMeter: endToEndMeter,
|
||||
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
|
||||
self.pump = pump
|
||||
}
|
||||
|
||||
@@ -271,6 +271,64 @@ final class LatestBox<T>: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The deadline link's frame-latency ASK and property READBACK, published for the HUD to render.
|
||||
///
|
||||
/// ⚠ A readback is NOT a grant. `preferredFrameLatency` is a plain read-write float
|
||||
/// (CAMetalDisplayLink.h carries no doc contract), so reading it returns whatever we last
|
||||
/// stored unless the system actively clamps the setter — and the 2026-08-13 field run proved
|
||||
/// how misleading that is: it read 1.00 while the measured vend lead sat at 1.95 refresh
|
||||
/// periods. The number that tells the truth about scheduling is the vend lead (the HUD's
|
||||
/// `os present` floor), never this property. The line still earns its place twice over: a
|
||||
/// readback that DIFFERS from the ask is the one clamp signal the API can give, and the ask
|
||||
/// must be visible on screen because **on tvOS no log is reachable** — `log stream --device`
|
||||
/// is gone from modern macOS, `log collect --device-name` needs root and then fails "Device
|
||||
/// not configured" because an Apple TV has no USB to fall back to, and the libimobiledevice
|
||||
/// pairing is a different database from Xcode's. Console.app is a GUI.
|
||||
///
|
||||
/// A process-global rather than a sixth parameter threaded through SessionModel → StreamView →
|
||||
/// controller → SessionPresenter → Stage2Pipeline → delegate: it is write-once-per-session
|
||||
/// diagnostics, and this file already keeps `presentDebug`/`presentLog` at file scope. Reset by
|
||||
/// `clear()` at session start so a stale session's answer can never be read as this one's.
|
||||
public final class PresentLinkInfo: @unchecked Sendable {
|
||||
public static let shared = PresentLinkInfo()
|
||||
private let lock = NSLock()
|
||||
private var ask: Float = 0
|
||||
private var latency: Float = 0
|
||||
private var rangeMin: Float = 0
|
||||
private var rangeMax: Float = 0
|
||||
private var drawables: Int = 0
|
||||
private var present = false
|
||||
|
||||
private init() {}
|
||||
|
||||
func publish(ask: Float, latency: Float, rangeMin: Float, rangeMax: Float, drawables: Int) {
|
||||
lock.lock()
|
||||
self.ask = ask
|
||||
self.latency = latency
|
||||
self.rangeMin = rangeMin
|
||||
self.rangeMax = rangeMax
|
||||
self.drawables = drawables
|
||||
present = true
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Session start — a link that never comes up must not leave the previous one's answer up.
|
||||
public func clear() {
|
||||
lock.lock()
|
||||
present = false
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// `nil` until the link's first update (or on a non-deadline rung, which has no link).
|
||||
public func snapshot()
|
||||
-> (ask: Float, latency: Float, rangeMin: Float, rangeMax: Float, drawables: Int)?
|
||||
{
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return present ? (ask, latency, rangeMin, rangeMax, drawables) : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -312,9 +370,24 @@ private final class FrameRateHint: @unchecked Sendable {
|
||||
return p
|
||||
}
|
||||
private static func range(hz: Float, boosted: Bool) -> CAFrameRateRange {
|
||||
#if os(tvOS)
|
||||
// A TV is a FIXED-rate display: there is no ProMotion panel to lift and no Pencil to
|
||||
// sample for, so the `max(hz, 120)` ceiling below asks a 60 Hz Apple TV to accept
|
||||
// anything up to 120. A range is a promise about how variable our cadence may be, and a
|
||||
// scheduler handed 60…120 on a fixed 60 Hz display has every reason to keep a frame of
|
||||
// slack in hand — which is what a two-refresh `targetPresentationTimestamp` IS. Pin all
|
||||
// three bounds to the stream rate so the deadline has nothing to hedge against.
|
||||
// (Field 2026-08-13, Apple TV 4K / tvOS 27: `os present` stuck at ~2 × 16.67 with
|
||||
// `preferredFrameLatency = 1` asked for and re-asserted every update; shrinking the
|
||||
// drawable pool to 2 moved it not at all.) `boosted` is deliberately ignored — it exists
|
||||
// for pen proximity, which tvOS does not have.
|
||||
_ = boosted
|
||||
return CAFrameRateRange(minimum: hz, maximum: hz, preferred: hz)
|
||||
#else
|
||||
let cap = max(hz, 120)
|
||||
let preferred = boosted ? cap : hz
|
||||
return CAFrameRateRange(minimum: preferred, maximum: cap, preferred: preferred)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,18 +508,28 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
||||
private let phase: PhaseReporter?
|
||||
/// 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.
|
||||
/// shown display/e2e numbers. Self-adapting: ~1 refresh period is the goal, ~2 means the
|
||||
/// compositor is running a frame ahead of us (what a 3-slot drawable pool bought it before
|
||||
/// `startDeadlinePresenter` clamped stage-4 to 2). 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.
|
||||
/// The pool depth this session vends from (`startDeadlinePresenter` sets it on the layer).
|
||||
/// Carried only so the one-shot line below reports the two halves of the depth question
|
||||
/// together — a `preferredFrameLatency` of 1 against a 3-slot pool is the configuration that
|
||||
/// measured a two-refresh floor in the field, and reading either number alone hides that.
|
||||
private let drawableCount: Int
|
||||
/// The `preferredFrameLatency` this session asks for — 1 by default, PUNKTFUNK_FRAME_LATENCY
|
||||
/// for the on-device ladder (see `startDeadlinePresenter` for the ladder's design).
|
||||
private let latencyAsk: Float
|
||||
/// One-shot: log the link's preferredFrameLatency READBACK after the first re-assert. A
|
||||
/// readback differing from the ask ⇒ the system clamps the property (the one clamp signal
|
||||
/// it can give); a readback EQUAL to the ask proves nothing — only vendLeadMs does (see
|
||||
/// PresentLinkInfo's doc for the field lesson).
|
||||
private var loggedEffective = false
|
||||
|
||||
init(
|
||||
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
|
||||
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?,
|
||||
phase: PhaseReporter?
|
||||
phase: PhaseReporter?, drawableCount: Int, latencyAsk: Float
|
||||
) {
|
||||
self.stash = stash
|
||||
self.renderSignal = renderSignal
|
||||
@@ -454,23 +537,34 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
||||
self.stats = stats
|
||||
self.floorMeter = floorMeter
|
||||
self.phase = phase
|
||||
self.drawableCount = drawableCount
|
||||
self.latencyAsk = latencyAsk
|
||||
}
|
||||
|
||||
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
|
||||
// Re-assert the latency ask 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 link.preferredFrameLatency != latencyAsk { link.preferredFrameLatency = latencyAsk }
|
||||
// Publish every update, not just the first: the range is re-applied from the staged hint
|
||||
// above (mode switch / rate change), and `preferredFrameLatency` is re-asserted right
|
||||
// here — so the readback can change mid-session, and a write-once snapshot would keep
|
||||
// showing the answer to a question we have since asked again. Cheap: five stores under
|
||||
// an uncontended lock, once per refresh.
|
||||
let range = link.preferredFrameRateRange
|
||||
PresentLinkInfo.shared.publish(
|
||||
ask: latencyAsk, latency: link.preferredFrameLatency, rangeMin: range.minimum,
|
||||
rangeMax: range.maximum, drawables: drawableCount)
|
||||
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)
|
||||
format: "deadline link up: preferredFrameLatency ask=%.2f readback=%.2f "
|
||||
+ "maxDrawables=%d range=%.0f-%.0f preferred=%.0f",
|
||||
latencyAsk, link.preferredFrameLatency, drawableCount,
|
||||
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.
|
||||
@@ -729,7 +823,13 @@ public final class Stage2Pipeline {
|
||||
/// (which withhold concealed frames) and driven by the pump (arm on a gap, poll per iteration).
|
||||
private let gate = ReanchorGate(framesDropped: 0)
|
||||
private var token = StopFlag()
|
||||
private var offsetNs: Int64 = 0
|
||||
/// LIVE host↔client clock offset, read AT EACH RECORD — never cached per session. Until
|
||||
/// 2026-08-13 this was a `let` snapshot of the connect-time handshake, and on a host whose
|
||||
/// wall clock steps (a VM under NTP) the frozen value silently shifted every host-anchored
|
||||
/// stat — field evidence: hostnet 17–21 ms one session, a physically impossible 4.4 ms the
|
||||
/// next, same wired host. The core re-syncs the estimate mid-stream (60 s + step detection);
|
||||
/// each call is an atomic load behind the FFI.
|
||||
private var clockOffset: () -> Int64 = { 0 }
|
||||
/// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()`
|
||||
/// — otherwise a pump iteration already past its `token.isStopped` check can rebuild a decode session
|
||||
/// right after the reset (a brief orphan session). `pumpJoinable` is armed by `start`, consumed by
|
||||
@@ -831,7 +931,7 @@ public final class Stage2Pipeline {
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||
) {
|
||||
offsetNs = connection.clockOffsetNs
|
||||
clockOffset = { connection.clockOffsetNs } // live (re-synced) — see the field doc
|
||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||
decodeReport.bind(connection) // arm the Automatic-bitrate decode signal for this session
|
||||
phaseReporter.bind(connection) // arm phase reports (flushed only by the deadline link)
|
||||
@@ -1001,7 +1101,7 @@ public final class Stage2Pipeline {
|
||||
let ring = ring
|
||||
let endToEndMeter = endToEndMeter
|
||||
let displayMeter = displayMeter
|
||||
let offsetNs = offsetNs
|
||||
let clockOffset = clockOffset
|
||||
let renderSignal = renderSignal
|
||||
let renderStopped = renderStopped
|
||||
// Present policy — the user's V-Sync setting (default OFF = immediate, the long-proven
|
||||
@@ -1075,7 +1175,7 @@ public final class Stage2Pipeline {
|
||||
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
|
||||
// End-to-end = capture→on-glass, measured directly (skew-corrected via the
|
||||
// connect-time clock offset) — the HUD headline.
|
||||
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
|
||||
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: clockOffset())
|
||||
// Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME,
|
||||
// so no skew offset applies.
|
||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||
@@ -1134,11 +1234,52 @@ public final class Stage2Pipeline {
|
||||
let presenter = presenter
|
||||
let endToEndMeter = endToEndMeter
|
||||
let displayMeter = displayMeter
|
||||
let offsetNs = offsetNs
|
||||
let clockOffset = clockOffset
|
||||
let hint = frameRateHint
|
||||
let layer = presenter.layer
|
||||
let stash = LatestBox<CAMetalDrawable>()
|
||||
|
||||
// ⭐ Shrink the drawable pool to 2 for THIS pacing — the measured fix for a present floor
|
||||
// stuck at two refreshes (field 2026-08-13, Apple TV 4K / tvOS 27: `os present +32.5` at
|
||||
// 60 Hz = 1.95 × 16.67, i.e. the system running a whole frame ahead of us).
|
||||
//
|
||||
// `maximumDrawableCount` is 3 from MetalVideoPresenter.make(), and its rationale there —
|
||||
// "more in-flight drawables before nextDrawable() has to block" — is a STAGE-2 concern.
|
||||
// Stage-4 never calls nextDrawable(): every drawable is vended by the link
|
||||
// (`update.drawable` → stash → `render(into:)`), so the third slot buys this path nothing
|
||||
// and costs it a refresh — a pool of 3 is exactly the room the compositor needs to keep
|
||||
// two presents queued ahead of scanout, which is what `preferredFrameLatency = 1` is
|
||||
// asking it not to do. Two slots is the shallowest pool that still double-buffers: one
|
||||
// vended (stashed or being rendered), one being scanned out.
|
||||
//
|
||||
// Set HERE, not on the link thread: this runs before either the render thread or the link
|
||||
// thread exists, so the layer still has a single writer (the render thread owns
|
||||
// drawableSize/format afterwards — see MetalVideoPresenter's threading notes).
|
||||
// PUNKTFUNK_DRAWABLE_COUNT=3 restores the old depth for an on-glass A/B without a
|
||||
// rebuild; values outside 2...3 are ignored (CAMetalLayer's own accepted range).
|
||||
let drawableCount =
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_DRAWABLE_COUNT"]
|
||||
.flatMap(Int.init)
|
||||
.flatMap { (2...3).contains($0) ? $0 : nil } ?? 2
|
||||
layer.maximumDrawableCount = drawableCount
|
||||
|
||||
// The frame-latency ASK (default 1 — wake as late as fits: latch the NEXT refresh).
|
||||
// PUNKTFUNK_FRAME_LATENCY overrides it for the on-device ladder. The property is a
|
||||
// FLOAT, so sub-frame asks (0.5) are expressible; whether the scheduler honours them —
|
||||
// or reacts to the property at all — is exactly what the ladder measures. Field
|
||||
// 2026-08-13 (Apple TV 4K, tvOS 27): ask 1 → vend lead 1.95 refresh periods, and the
|
||||
// readback echoed the ask throughout (it is a plain property — see PresentLinkInfo).
|
||||
// The discriminating runs, watching `os present` (the vend lead), are:
|
||||
// ask=2 → lead grows to ~3 ⇒ the property WORKS and the tvOS floor is ~ask+1;
|
||||
// lead stays ~2 ⇒ the property is INERT here — stop pulling this lever.
|
||||
// ask=0.5 → any lead below ~1.9 ⇒ a real in-regime win to then tune.
|
||||
// Clamped to 0...4: negatives/NaN are meaningless, and beyond 4 asked-for frames of
|
||||
// latency nothing is being measured.
|
||||
let latencyAsk =
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_FRAME_LATENCY"]
|
||||
.flatMap(Float.init)
|
||||
.flatMap { $0.isFinite ? min(max($0, 0), 4) : nil } ?? 1
|
||||
|
||||
let floorMeter = presentFloorMeter
|
||||
let phaseReporter = phaseReporter
|
||||
// The link starts LAZILY — the render thread triggers this after the FIRST decoded
|
||||
@@ -1151,9 +1292,10 @@ public final class Stage2Pipeline {
|
||||
let linkThread = Thread {
|
||||
let delegate = DeadlineLinkDelegate(
|
||||
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
|
||||
floorMeter: floorMeter, phase: phaseReporter)
|
||||
floorMeter: floorMeter, phase: phaseReporter,
|
||||
drawableCount: drawableCount, latencyAsk: latencyAsk)
|
||||
let link = CAMetalDisplayLink(metalLayer: layer)
|
||||
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
|
||||
link.preferredFrameLatency = latencyAsk // see the ladder note above
|
||||
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)
|
||||
@@ -1223,7 +1365,7 @@ public final class Stage2Pipeline {
|
||||
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||
let atNs = presentedNs
|
||||
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
|
||||
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
|
||||
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: clockOffset())
|
||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
|
||||
}
|
||||
|
||||
@@ -17,9 +17,18 @@ final class StreamPump {
|
||||
|
||||
/// Pump thread: pull AUs, wrap, enqueue. Non-IDR AUs before the first format
|
||||
/// description are dropped. `onFrame`/`onSessionEnd` fire on the pump thread.
|
||||
///
|
||||
/// `endToEndMeter` is stage-1's ONLY latency instrument, and it measures capture→ENQUEUE —
|
||||
/// not capture→glass like the Metal rungs: the layer decodes AND presents after our hand-off,
|
||||
/// and AVSampleBufferDisplayLayer has no presented callback, so the tail past enqueue (its
|
||||
/// internal decode + the video-plane flip) is unmeasurable from the app. Cross-rung
|
||||
/// comparisons must read this as e2e MINUS decode+display and settle the remainder on
|
||||
/// camera. It is still worth wiring: matching pre-tail halves between rungs pins any felt
|
||||
/// difference on the present tail — the video-plane-vs-compositor question itself.
|
||||
func start(
|
||||
connection: PunktfunkConnection,
|
||||
layer: AVSampleBufferDisplayLayer,
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||
@@ -158,7 +167,14 @@ final class StreamPump {
|
||||
// flagging it DoNotDisplay — the layer still decodes it (keeping the reference
|
||||
// chain fed) but shows the last GOOD picture until a clean re-anchor lifts the
|
||||
// gate. Folded from the AU's wire flags (stage-1 has no decode callback).
|
||||
if !gate.onDecoded(flags: au.flags) {
|
||||
if gate.onDecoded(flags: au.flags) {
|
||||
// Capture→enqueue (see start's doc). Only frames that will DISPLAY:
|
||||
// a withheld frame never reaches glass, so its enqueue instant would
|
||||
// dilute the population the Metal rungs are compared against. The
|
||||
// offset is read PER ENQUEUE — it is live (mid-stream re-synced) and
|
||||
// caching it rebuilds the stale-offset corruption (see clockOffsetNs).
|
||||
endToEndMeter?.record(ptsNs: au.ptsNs, offsetNs: connection.clockOffsetNs)
|
||||
} else {
|
||||
StreamPump.setDoNotDisplay(sample)
|
||||
}
|
||||
layer.enqueue(sample)
|
||||
|
||||
@@ -410,8 +410,9 @@ public final class StreamLayerView: NSView {
|
||||
// keycode) → Windows VK and forward via InputCapture.sendKey, then CONSUME (return without
|
||||
// super) to stop the responder chain's "unhandled keyDown" beep. Keys with no VK mapping
|
||||
// are still consumed while captured so they don't beep either. The ⌘⎋ toggle's Esc is
|
||||
// swallowed upstream by InputCapture's keyDown monitor (suppressedVK), so it never gets
|
||||
// here as a send; ⌘-combos still arrive via performKeyEquivalent and stay functional (⌘D).
|
||||
// swallowed upstream by InputCapture's keyDown monitor (suppressedVK), so it never gets here
|
||||
// as a send — and so are ⌘ combos generally while captured, which that monitor forwards to the
|
||||
// host itself (`forwardsCommandChord`) rather than letting a menu key equivalent claim them.
|
||||
// Modifier keys never fire keyDown/keyUp — they come through flagsChanged below.
|
||||
public override var acceptsFirstResponder: Bool { true }
|
||||
// A click after the app was inactive (Cmd-Tab away and back) must reach mouseDown so the
|
||||
@@ -570,6 +571,9 @@ public final class StreamLayerView: NSView {
|
||||
let wasCaptured = captured
|
||||
if wasCaptured { releaseCapture() }
|
||||
desktopMouse = on
|
||||
// The ⌘-chord passthrough is off under the desktop model (system chords stay local there,
|
||||
// as on every other client) — and the model moves live, so the capture is told, not asked.
|
||||
inputCapture?.desktopMouse = on
|
||||
if wasCaptured { engageCapture(fromClick: false) }
|
||||
window?.invalidateCursorRects(for: self)
|
||||
if on, let p = reappearAt, let sp = cgScreenPoint(forHostX: p.x, p.y) {
|
||||
@@ -917,6 +921,7 @@ public final class StreamLayerView: NSView {
|
||||
) ?? .capture
|
||||
let absOK = connection.resolvedCompositor != .gamescope
|
||||
desktopMouse = mode == .desktop && absOK
|
||||
capture.desktopMouse = desktopMouse
|
||||
if mode == .desktop && !absOK {
|
||||
streamInputLog.info("desktop mouse mode unavailable on a gamescope host (relative-only) — using capture")
|
||||
}
|
||||
|
||||
@@ -758,10 +758,18 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// the switch never lands, so an SDR-composited display can't show blown-out PQ either way.
|
||||
/// Applied once per session, as soon as the window and the negotiated mode both exist; the
|
||||
/// stop() teardown clears it.
|
||||
///
|
||||
/// ⚠️ Gated on the STREAM being HDR (`connection.isHDR`), not just on the user's HDR setting.
|
||||
/// The criteria below hardcode BT.2020 + ST.2084 PQ, so without that check an ordinary SDR
|
||||
/// session drove an HDR-capable TV into PQ output — which is a standard way to raise the black
|
||||
/// floor, since the Apple TV switches HDMI to limited-range levels in its HDR modes and a set
|
||||
/// configured for full-range then renders code 16 as grey. Layout re-runs this, so a session
|
||||
/// that flips to HDR mid-stream still picks the mode up on the next pass.
|
||||
private func applyDisplayCriteriaIfNeeded() {
|
||||
guard let manager = view.window?.avDisplayManager, let connection,
|
||||
manager.preferredDisplayCriteria == nil,
|
||||
SessionSettings.current.hdrEnabled
|
||||
SessionSettings.current.hdrEnabled,
|
||||
connection.isHDR
|
||||
else { return }
|
||||
let mode = connection.currentMode()
|
||||
guard mode.width > 0, mode.height > 0, mode.refreshHz > 0 else { return }
|
||||
|
||||
@@ -157,6 +157,16 @@ public enum DefaultsKey {
|
||||
/// Read live at the wire boundary by `InputCapture`. Control/Shift never move (same position on
|
||||
/// both keyboards).
|
||||
public static let modifierLayout = "punktfunk.modifierLayout"
|
||||
/// Send system chords to the host while input is captured — the cross-client
|
||||
/// `inhibit_shortcuts`, ON by default. On the SDL clients it is SDL's keyboard grab (Alt+Tab,
|
||||
/// the Windows key); macOS has no such grab from a plain app, so `InputCapture`'s keyDown
|
||||
/// monitor implements it by taking every ⌘ chord off AppKit before a menu key equivalent can
|
||||
/// fire and forwarding it instead — which is what makes ⌘Q reach the host's compositor rather
|
||||
/// than quitting the client. Off keeps the chords local (the second-screen/work profile).
|
||||
/// The client's own reserved chords (⌘⎋, ⌃⌘F, ⌃⌥⇧…) are never forwarded either way, and — as
|
||||
/// on the SDL clients — the setting has no effect under the `desktop` mouse model, which is
|
||||
/// something you ⌘Tab *away* from. macOS-only today; nothing reads it on iOS/tvOS.
|
||||
public static let inhibitShortcuts = "punktfunk.inhibitShortcuts"
|
||||
/// 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
|
||||
/// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost
|
||||
|
||||
@@ -33,6 +33,9 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
public var touchMode = "trackpad"
|
||||
public var mouseMode = "capture"
|
||||
public var invertScroll = false
|
||||
/// Cross-client `inhibit_shortcuts` (default on): system chords reach the host while input is
|
||||
/// captured. See `DefaultsKey.inhibitShortcuts` — on macOS this is the ⌘-chord passthrough.
|
||||
public var inhibitShortcuts = true
|
||||
public var gamepadType = 0
|
||||
public var gamepadForwarding = true
|
||||
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
|
||||
@@ -97,6 +100,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
touchMode = str(DefaultsKey.touchMode, touchMode)
|
||||
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
|
||||
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
|
||||
inhibitShortcuts = bool(DefaultsKey.inhibitShortcuts, inhibitShortcuts)
|
||||
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
|
||||
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
|
||||
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
|
||||
@@ -177,6 +181,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
if let v = overlay.touchMode { s.touchMode = v }
|
||||
if let v = overlay.mouseMode { s.mouseMode = v }
|
||||
if let v = overlay.invertScroll { s.invertScroll = v }
|
||||
if let v = overlay.inhibitShortcuts { s.inhibitShortcuts = v }
|
||||
if let v = overlay.gamepadType { s.gamepadType = v }
|
||||
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
|
||||
if let v = overlay.systemButtons { s.systemButtons = v }
|
||||
|
||||
@@ -109,6 +109,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
public var touchMode: String?
|
||||
public var mouseMode: String?
|
||||
public var invertScroll: Bool?
|
||||
public var inhibitShortcuts: Bool?
|
||||
public var gamepadType: Int?
|
||||
public var gamepadForwarding: Bool?
|
||||
public var systemButtons: String?
|
||||
@@ -153,6 +154,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
case touchMode = "touch_mode"
|
||||
case mouseMode = "mouse_mode"
|
||||
case invertScroll = "invert_scroll"
|
||||
case inhibitShortcuts = "inhibit_shortcuts"
|
||||
case gamepadType = "gamepad"
|
||||
case gamepadForwarding = "gamepad_forwarding"
|
||||
case systemButtons = "system_buttons"
|
||||
@@ -189,6 +191,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
touchMode = str(.touchMode)
|
||||
mouseMode = str(.mouseMode)
|
||||
invertScroll = bool(.invertScroll)
|
||||
inhibitShortcuts = bool(.inhibitShortcuts)
|
||||
gamepadType = int(.gamepadType)
|
||||
gamepadForwarding = bool(.gamepadForwarding)
|
||||
systemButtons = str(.systemButtons)
|
||||
@@ -227,6 +230,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
try c.encodeIfPresent(touchMode, forKey: AnyKey(Key.touchMode.rawValue))
|
||||
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
|
||||
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
|
||||
try c.encodeIfPresent(inhibitShortcuts, forKey: AnyKey(Key.inhibitShortcuts.rawValue))
|
||||
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
|
||||
@@ -283,6 +287,7 @@ public enum OverlayField {
|
||||
case "touch_mode": overlay.touchMode = nil
|
||||
case "mouse_mode": overlay.mouseMode = nil
|
||||
case "invert_scroll": overlay.invertScroll = nil
|
||||
case "inhibit_shortcuts": overlay.inhibitShortcuts = nil
|
||||
case "gamepad": overlay.gamepadType = nil
|
||||
case "gamepad_forwarding": overlay.gamepadForwarding = nil
|
||||
case "system_buttons": overlay.systemButtons = nil
|
||||
@@ -321,6 +326,7 @@ public enum OverlayField {
|
||||
case "touch_mode": return o.touchMode != nil
|
||||
case "mouse_mode": return o.mouseMode != nil
|
||||
case "invert_scroll": return o.invertScroll != nil
|
||||
case "inhibit_shortcuts": return o.inhibitShortcuts != nil
|
||||
case "gamepad": return o.gamepadType != nil
|
||||
case "gamepad_forwarding": return o.gamepadForwarding != nil
|
||||
case "system_buttons": return o.systemButtons != nil
|
||||
|
||||
@@ -32,7 +32,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
|
||||
let engine = AVAudioEngine()
|
||||
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||
let watcher = AudioDeviceWatcher(
|
||||
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
|
||||
isOurs: { $0 === engine }, onChange: { reason, _ in reasons.append(reason) })
|
||||
watcher.start()
|
||||
defer { watcher.stop() }
|
||||
|
||||
@@ -51,7 +51,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
|
||||
let stranger = AVAudioEngine()
|
||||
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||
let watcher = AudioDeviceWatcher(
|
||||
isOurs: { $0 === ours }, onChange: { reasons.append($0) })
|
||||
isOurs: { $0 === ours }, onChange: { reason, _ in reasons.append(reason) })
|
||||
watcher.start()
|
||||
defer { watcher.stop() }
|
||||
|
||||
@@ -66,7 +66,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
|
||||
let engine = AVAudioEngine()
|
||||
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||
let watcher = AudioDeviceWatcher(
|
||||
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
|
||||
isOurs: { $0 === engine }, onChange: { reason, _ in reasons.append(reason) })
|
||||
watcher.start()
|
||||
watcher.stop()
|
||||
|
||||
@@ -93,7 +93,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
|
||||
}
|
||||
|
||||
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reasons.append($0) })
|
||||
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reason, _ in reasons.append(reason) })
|
||||
watcher.start()
|
||||
defer {
|
||||
_ = Self.setDefaultOutput(original)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// The two decisions that ended the 2026-08-14 rebuild loop, driven with a synthetic clock.
|
||||
//
|
||||
// The loop's shape, for the plant-the-defect cases below: the voice-processing engine fails to
|
||||
// start (~1.9 s spent trying), the fallback comes up, and its own HAL fallout retriggers the
|
||||
// recovery ~0.6 s later — forever. Restore either defect (retry the failed topology, or keep the
|
||||
// flat 0.5 s floor) and the session pays an audio gap every ~2.5 s for as long as it lives.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class AudioRebuildPolicyTests: XCTestCase {
|
||||
// MARK: - RebuildBackoff
|
||||
|
||||
/// The first trigger of a session keeps the old behaviour: the burst-coalescing debounce.
|
||||
func testFirstTriggerWaitsOnlyTheDebounce() {
|
||||
var backoff = RebuildBackoff()
|
||||
XCTAssertEqual(backoff.delay(now: 1000), RebuildBackoff.debounce)
|
||||
}
|
||||
|
||||
/// One rebuild, then quiet: the next real device switch minutes later is answered at full
|
||||
/// responsiveness — the ladder must never make a HEALTHY recovery sluggish.
|
||||
func testAnIsolatedSwitchLongAfterTheLastRebuildResetsTheChain() {
|
||||
var backoff = RebuildBackoff()
|
||||
_ = backoff.delay(now: 1000)
|
||||
backoff.noteRebuild(at: 1000.2)
|
||||
// Chained once (a second switch soon after — legitimate, e.g. AirPods out then back in).
|
||||
_ = backoff.delay(now: 1001)
|
||||
backoff.noteRebuild(at: 1002)
|
||||
// Minutes of quiet, then a fresh switch: base debounce again, chain forgotten.
|
||||
XCTAssertEqual(backoff.delay(now: 1300), RebuildBackoff.debounce)
|
||||
XCTAssertEqual(backoff.chain, 0)
|
||||
}
|
||||
|
||||
/// THE FIELD LOOP, against the real constants: a trigger 0.6 s after every rebuild, ten
|
||||
/// minutes long. The flat 0.5 s floor produced a rebuild every ~2.5 s — ~240 audio gaps.
|
||||
/// The ladder must cut that by an order of magnitude and settle at the floor cap.
|
||||
func testAChainedLoopBacksOffToTheFloorCap() {
|
||||
var backoff = RebuildBackoff()
|
||||
var now: TimeInterval = 0
|
||||
var rebuilds = 0
|
||||
var lastDelay: TimeInterval = 0
|
||||
let end: TimeInterval = 600
|
||||
while now < end {
|
||||
lastDelay = backoff.delay(now: now)
|
||||
now += lastDelay // the scheduled rebuild fires...
|
||||
backoff.noteRebuild(at: now)
|
||||
rebuilds += 1
|
||||
now += 0.6 // ...and its fallout retriggers the recovery 0.6 s later.
|
||||
}
|
||||
XCTAssertEqual(
|
||||
lastDelay, RebuildBackoff.floorCap - 0.6, accuracy: 0.01,
|
||||
"a persistent loop should settle at one rebuild per floorCap")
|
||||
XCTAssertLessThanOrEqual(
|
||||
rebuilds, 30,
|
||||
"\(rebuilds) rebuilds in 10 min — the ladder is not escalating (the shipped flat "
|
||||
+ "floor produced ~240)")
|
||||
// And the loop's END must restore responsiveness: quiet, then a real switch.
|
||||
XCTAssertEqual(backoff.delay(now: now + 120), RebuildBackoff.debounce)
|
||||
}
|
||||
|
||||
/// The ladder's exponent is clamped — a loop that runs for hours must neither overflow nor
|
||||
/// push the interval past the cap.
|
||||
func testTheFloorNeverExceedsTheCap() {
|
||||
var backoff = RebuildBackoff()
|
||||
var now: TimeInterval = 0
|
||||
for _ in 0..<1000 {
|
||||
let delay = backoff.delay(now: now)
|
||||
XCTAssertLessThanOrEqual(delay, RebuildBackoff.floorCap)
|
||||
now += delay
|
||||
backoff.noteRebuild(at: now)
|
||||
now += 0.1
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
// MARK: - CombinedTopologyGate
|
||||
|
||||
/// The loop's fuel: re-attempting the voice-processing start that just failed. Same input
|
||||
/// device ⇒ never again.
|
||||
func testAFailureLatchesForTheDeviceItFailedOn() {
|
||||
var gate = CombinedTopologyGate()
|
||||
XCTAssertTrue(gate.shouldTry(input: 42), "an unfailed gate must allow the attempt")
|
||||
gate.noteFailure(input: 42)
|
||||
XCTAssertFalse(gate.shouldTry(input: 42))
|
||||
XCTAssertFalse(gate.shouldTry(input: 42), "the latch must hold across rebuilds")
|
||||
}
|
||||
|
||||
/// The failure is a property of the DEVICE: a different default input earns a fresh attempt,
|
||||
/// and its own failure latches again — one attempt per device change can never loop.
|
||||
func testADifferentInputDeviceEarnsOneFreshAttempt() {
|
||||
var gate = CombinedTopologyGate()
|
||||
gate.noteFailure(input: 42)
|
||||
XCTAssertTrue(gate.shouldTry(input: 7))
|
||||
gate.noteFailure(input: 7)
|
||||
XCTAssertFalse(gate.shouldTry(input: 7))
|
||||
// Back to the first device: the earlier failure may have been mid-transition — one fresh
|
||||
// attempt again, not a permanent ban.
|
||||
XCTAssertTrue(gate.shouldTry(input: 42))
|
||||
}
|
||||
|
||||
/// "No resolvable input device" is a real failure key too, distinct from "never failed".
|
||||
func testFailingWithNoInputDeviceLatchesForNoInputDevice() {
|
||||
var gate = CombinedTopologyGate()
|
||||
gate.noteFailure(input: nil)
|
||||
XCTAssertFalse(gate.shouldTry(input: nil))
|
||||
XCTAssertTrue(gate.shouldTry(input: 42), "a device appearing is a device change")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -106,6 +106,77 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
"a single short read must not force a full re-prime")
|
||||
}
|
||||
|
||||
/// THE regression that made an iPad crackle where a Mac did not: the de-prime fuse must be the
|
||||
/// same SPAN OF TIME whatever the device's IO quantum. It used to be a callback COUNT (4), and
|
||||
/// a callback is not a unit of time — the same 4 was ~44 ms on a Mac's ~11 ms quantum and 20 ms
|
||||
/// on iOS, whose session asked for a 5 ms IO buffer. A Wi-Fi delivery stall therefore de-primed
|
||||
/// this ring on every bunching cycle where the identical policy rode it out elsewhere (measured
|
||||
/// on the shared Rust policy: 120 audible gaps per 10 min at a 5 ms quantum against 3 at 8 ms).
|
||||
/// Plant the defect by restoring a fixed count and the quanta below stop agreeing.
|
||||
///
|
||||
/// Mirrors `deprime_fuse_is_a_duration_not_a_callback_count` in `punktfunk_core::audio`.
|
||||
func testDeprimeFuseIsADurationNotACallbackCount() {
|
||||
let deprimeMS = 60 // AudioRing.deprimeMS / JitterTuning::COREAUDIO.deprime_ms
|
||||
let quanta = [5, 8, 10, 16, 21]
|
||||
var deprimedAt: [Int: Int] = [:]
|
||||
for quantumMS in quanta {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = quantumMS * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
// Prime DEEP: the depth average is seeded with the refill, so `hollow` stays false for
|
||||
// the EWMA's whole settling second and the starvation fuse — not the hollow shortcut —
|
||||
// is what this measures.
|
||||
let big = [Float](repeating: 0.5, count: 80 * perMS)
|
||||
big.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: big.count) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(
|
||||
scratch.contains { $0 != 0 }, "q=\(quantumMS)ms: must play after priming")
|
||||
|
||||
// Starve on a trickle far under what the device takes: every read runs short but still
|
||||
// carries audio, so an all-zero read can only mean the ring gave up and re-primed.
|
||||
let trickle = [Float](repeating: 0.5, count: max(perMS, want / 4))
|
||||
var starvedMS = 0
|
||||
var deprimedAfterMS: Int?
|
||||
for _ in 0..<2_000 {
|
||||
trickle.withUnsafeBufferPointer {
|
||||
ring.write($0.baseAddress!, count: trickle.count)
|
||||
}
|
||||
let short = ring.bufferedSamples < want
|
||||
scratch.withUnsafeMutableBufferPointer {
|
||||
ring.read(into: $0.baseAddress!, count: want)
|
||||
}
|
||||
if scratch.allSatisfy({ $0 == 0 }) {
|
||||
deprimedAfterMS = starvedMS
|
||||
break
|
||||
}
|
||||
if short { starvedMS += quantumMS }
|
||||
}
|
||||
guard let deprimedAfterMS else {
|
||||
return XCTFail("q=\(quantumMS)ms: never de-primed at all")
|
||||
}
|
||||
deprimedAt[quantumMS] = deprimedAfterMS
|
||||
}
|
||||
|
||||
// Each quantum must give up somewhere around the fuse. The band is wide on purpose: at a
|
||||
// short quantum the HOLLOW shortcut legitimately fires a little before the fuse does (the
|
||||
// target has grown, the depth was never re-banked, so the click is taken early and spent
|
||||
// on a full refill — see `deprimeDebtMS`), and that is the policy working, not drift.
|
||||
for (q, ms) in deprimedAt.sorted(by: { $0.key < $1.key }) {
|
||||
XCTAssertTrue(
|
||||
(deprimeMS - 20...deprimeMS + 25).contains(ms),
|
||||
"q=\(q)ms de-primed after \(ms) ms, nowhere near the \(deprimeMS) ms fuse — "
|
||||
+ "\(deprimedAt.sorted { $0.key < $1.key })")
|
||||
}
|
||||
// ...and THE property: the fuse must not SCALE with the quantum. As a callback count these
|
||||
// same devices de-primed after 20/32/40/64/84 ms — a 4.2x spread, which is exactly why an
|
||||
// iPad crackled where a Mac did not. Measured in time the spread collapses to ~1.3x.
|
||||
let spread = Double(deprimedAt.values.max()!) / Double(deprimedAt.values.min()!)
|
||||
XCTAssertLessThan(
|
||||
spread, 1.6,
|
||||
"de-prime time still scales with the IO quantum (\(String(format: "%.2f", spread))x "
|
||||
+ "across \(deprimedAt.sorted { $0.key < $1.key })) — the fuse is a count again")
|
||||
}
|
||||
|
||||
/// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`, updated for
|
||||
/// near-miss growth: the drain's LAST full read (less than a frame left over) already grows
|
||||
/// the floor before anything was audible, clustered genuine underruns raise it further, and
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Pins the macOS ⌘-chord passthrough — the rule deciding which keyDowns `InputCapture`'s local
|
||||
/// monitor takes off AppKit and forwards to the host instead of letting a menu key equivalent
|
||||
/// claim them. Two things are worth a test rather than a comment:
|
||||
///
|
||||
/// * ⌘Q reaching the host at all. That is the whole point — it is the compositor chord on
|
||||
/// Hyprland/KDE/GNOME, and it used to quit the client.
|
||||
/// * ⌘⎋ and ⌃⌘F NOT reaching it, under every combination. They are the way out of a captured
|
||||
/// stream; forward either and the user is locked in.
|
||||
final class CommandChordTests: XCTestCase {
|
||||
// kVK_ANSI_* — physical positions, layout-independent (the same constants the monitor uses).
|
||||
private let q: UInt16 = 12, w: UInt16 = 13, h: UInt16 = 4, m: UInt16 = 46
|
||||
private let f: UInt16 = 3, esc: UInt16 = 53, leftArrow: UInt16 = 123
|
||||
|
||||
/// Captured, setting on, capture mouse model — the shipping default.
|
||||
private func forwards(
|
||||
_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags,
|
||||
forwarding: Bool = true, inhibit: Bool = true, desktop: Bool = false
|
||||
) -> Bool {
|
||||
InputCapture.forwardsCommandChord(
|
||||
keyCode: keyCode, flags: flags, forwarding: forwarding,
|
||||
inhibitShortcuts: inhibit, desktopMouse: desktop)
|
||||
}
|
||||
|
||||
func testCommandChordsGoToTheHostWhileCaptured() {
|
||||
XCTAssertTrue(forwards(q, .command)) // ⌘Q — the reported break
|
||||
XCTAssertTrue(forwards(w, .command))
|
||||
XCTAssertTrue(forwards(h, .command))
|
||||
XCTAssertTrue(forwards(m, .command))
|
||||
XCTAssertTrue(forwards(q, [.command, .shift])) // ⇧⌘Q
|
||||
XCTAssertTrue(forwards(m, [.command, .control, .option, .shift]))
|
||||
}
|
||||
|
||||
func testTheEscapeHatchesAreNeverForwarded() {
|
||||
// ⌘⎋ releases capture, ⌃⌘F leaves fullscreen. Neither may ever reach the host.
|
||||
XCTAssertFalse(forwards(esc, .command))
|
||||
XCTAssertFalse(forwards(f, [.control, .command]))
|
||||
XCTAssertTrue(InputCapture.isClientReservedChord(keyCode: esc, flags: .command))
|
||||
XCTAssertTrue(
|
||||
InputCapture.isClientReservedChord(keyCode: f, flags: [.control, .command]))
|
||||
}
|
||||
|
||||
/// The reservation is exact: it is ⌘⎋ and ⌃⌘F specifically, not "anything with Esc or F in
|
||||
/// it". ⇧⌘⎋ and ⌘F are the host's like any other chord.
|
||||
func testNeighbouringChordsAreNotReserved() {
|
||||
XCTAssertTrue(forwards(esc, [.command, .shift]))
|
||||
XCTAssertTrue(forwards(f, .command))
|
||||
XCTAssertFalse(InputCapture.isClientReservedChord(keyCode: f, flags: .command))
|
||||
}
|
||||
|
||||
func testNothingWithoutCommandIsClaimedHere() {
|
||||
// The ⌃⌥⇧ family and bare keys reach the monitor's earlier blocks / the responder chain.
|
||||
XCTAssertFalse(forwards(q, [.control, .option, .shift]))
|
||||
XCTAssertFalse(forwards(q, []))
|
||||
XCTAssertFalse(forwards(esc, []))
|
||||
}
|
||||
|
||||
func testReleasedCaptureLeavesTheMenuAlone() {
|
||||
// Not forwarding = the user is in the local UI: ⌘Q must quit the app, ⌘W close the window.
|
||||
XCTAssertFalse(forwards(q, .command, forwarding: false))
|
||||
XCTAssertFalse(forwards(w, .command, forwarding: false))
|
||||
}
|
||||
|
||||
func testTheCrossClientSettingTurnsItOff() {
|
||||
XCTAssertFalse(forwards(q, .command, inhibit: false))
|
||||
}
|
||||
|
||||
func testTheDesktopMouseModelKeepsChordsLocal() {
|
||||
// Matches the SDL clients' keyboard grab: a remote desktop is something you ⌘Tab away from.
|
||||
XCTAssertFalse(forwards(q, .command, desktop: true))
|
||||
XCTAssertFalse(forwards(q, .command, inhibit: true, desktop: true))
|
||||
}
|
||||
|
||||
/// `deviceIndependentFlagsMask` also carries Caps Lock and the `.function`/`.numericPad` bits
|
||||
/// every arrow key sets, so comparing it for equality made chords stop being recognized in
|
||||
/// exactly the states a user does not connect to their keyboard: Caps Lock on, or the chord
|
||||
/// spelled with an arrow. `chordFlags` isolates the four real modifiers.
|
||||
func testCapsLockAndArrowBitsDoNotChangeAChord() throws {
|
||||
let capsQ = try XCTUnwrap(keyEvent(q, [.command, .capsLock]))
|
||||
XCTAssertEqual(InputCapture.chordFlags(capsQ), .command)
|
||||
XCTAssertTrue(forwards(q, InputCapture.chordFlags(capsQ)))
|
||||
|
||||
// ⌘⎋ with Caps Lock on is still the escape hatch, not a chord for the host.
|
||||
let capsEsc = try XCTUnwrap(keyEvent(esc, [.command, .capsLock]))
|
||||
XCTAssertEqual(InputCapture.chordFlags(capsEsc), .command)
|
||||
XCTAssertFalse(forwards(esc, InputCapture.chordFlags(capsEsc)))
|
||||
|
||||
// ⌘← — arrows set .function|.numericPad, which say nothing about the chord.
|
||||
let cmdLeft = try XCTUnwrap(keyEvent(leftArrow, [.command, .function, .numericPad]))
|
||||
XCTAssertEqual(InputCapture.chordFlags(cmdLeft), .command)
|
||||
XCTAssertTrue(forwards(leftArrow, InputCapture.chordFlags(cmdLeft)))
|
||||
}
|
||||
|
||||
/// A forwarded chord is only useful if the key has a host VK — the monitor swallows either
|
||||
/// way, so an unmapped one would silently do nothing. Spot-check the common ⌘ letters.
|
||||
func testTheCommonChordKeysMapToHostVKs() {
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[q], 0x51) // VK 'Q'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[w], 0x57) // VK 'W'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[h], 0x48) // VK 'H'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[m], 0x4D) // VK 'M'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[leftArrow], 0x25) // VK_LEFT
|
||||
}
|
||||
|
||||
private func keyEvent(_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags) -> NSEvent? {
|
||||
NSEvent.keyEvent(
|
||||
with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
||||
windowNumber: 0, context: nil, characters: "", charactersIgnoringModifiers: "",
|
||||
isARepeat: false, keyCode: keyCode)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -45,7 +45,7 @@ BUNDLE_ID="io.unom.punktfunk"
|
||||
# The App Store set, in listing order — the first three are what most people ever see, so they are
|
||||
# the stream itself, the machines it found, and the couch/controller mode. Everything else in
|
||||
# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`.
|
||||
SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
|
||||
SCENES=(${SCENES:-01-stream 02-hosts 11-library 12-controllers 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
|
||||
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
|
||||
|
||||
mkdir -p "$OUT"
|
||||
@@ -63,9 +63,13 @@ require_xcode() {
|
||||
# ---------------------------------------------------------------------------- macOS
|
||||
|
||||
shoot_macos() {
|
||||
log "macOS — building (swift build -c release)…"
|
||||
swift build -c release >/dev/null
|
||||
local bin=".build/release/PunktfunkClient"
|
||||
# DEBUG build, deliberately: the whole shot harness lives behind `#if DEBUG`
|
||||
# (ScreenshotHost/ScreenshotScenes), so a release binary launches as the NORMAL app, never
|
||||
# prints PF_SHOT_WINDOW, and every scene "never reported a window". Debug renders the same
|
||||
# pixels — SwiftUI has no release-only visuals.
|
||||
log "macOS — building (swift build)…"
|
||||
swift build >/dev/null
|
||||
local bin=".build/debug/PunktfunkClient"
|
||||
[ -x "$bin" ] || die "build produced no $bin"
|
||||
|
||||
for scene in "${SCENES[@]}"; do
|
||||
@@ -142,6 +146,14 @@ shoot_sim() {
|
||||
# incremental build instead of cold-building into a throwaway tmpdir — CI pins this
|
||||
# (apple.yml); local runs keep the self-cleaning mktemp default.
|
||||
local dd; dd="${PF_SHOT_DERIVED_DATA:-$(mktemp -d)}"; mkdir -p "$dd"
|
||||
# tvOS-SIMULATOR trap (Xcode 26.6 and the 27 beta, local only so far): the build planner
|
||||
# schedules the SwiftPM MACRO plugin targets that swiftui-navigation-transitions pulls in
|
||||
# (OnceMacro/SwizzlingMacro/AssociationMacro) for the *tvOS* triple and never plans their
|
||||
# swift-syntax dependencies at all — "unable to resolve module dependency: 'SwiftSyntax'".
|
||||
# Device archives and iOS builds don't hit it (only the tvOS target links that package), and
|
||||
# prebuilt-vs-source swift-syntax makes no difference. Until Xcode fixes the planner, the
|
||||
# workaround is temporarily unlinking SwiftUINavigationTransitions from the tvOS target
|
||||
# (HomeView's use is canImport-guarded — the push transition degrades to the crossfade).
|
||||
xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \
|
||||
-sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \
|
||||
CODE_SIGNING_ALLOWED=NO build >/dev/null \
|
||||
|
||||
@@ -796,7 +796,9 @@ from the config directory for a true factory reset."
|
||||
);
|
||||
return NEEDS_INTERACTION;
|
||||
}
|
||||
match library::fetch_games(&host.addr, library::DEFAULT_MGMT_PORT, &identity, pin) {
|
||||
// The port this host actually serves its library on — learned from its advert and saved,
|
||||
// falling back to 47990. Reaching for the constant here is what broke a moved port.
|
||||
match library::fetch_games(&host.addr, host.effective_mgmt_port(), &identity, pin) {
|
||||
Ok(games) => {
|
||||
if has(args, "--json") {
|
||||
let rows: Vec<serde_json::Value> = games
|
||||
|
||||
@@ -1108,6 +1108,18 @@ impl HostsPage {
|
||||
{
|
||||
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
|
||||
}
|
||||
// Same for its management port — and this one is not cosmetic: without it a host
|
||||
// that moved off 47990 loses its library the moment mDNS is unavailable, because
|
||||
// the advert was the only place the real port ever lived.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && a.mgmt_port.is_some())
|
||||
{
|
||||
if let Some(p) = a.mgmt_port {
|
||||
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
|
||||
}
|
||||
}
|
||||
saved.push_back(HostCard {
|
||||
connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()),
|
||||
kind: CardKind::Saved {
|
||||
@@ -1183,18 +1195,33 @@ impl HostsPage {
|
||||
});
|
||||
}
|
||||
|
||||
/// The advertised mgmt port for the host `req` points at, when a matching live
|
||||
/// advert carries the `mgmt` TXT.
|
||||
/// The mgmt port for the host `req` points at: a matching live advert's `mgmt` TXT first,
|
||||
/// else the port a previous advert taught us and we saved on the host record.
|
||||
///
|
||||
/// The saved rung is not redundant. Reading the advert alone meant a host that had moved its
|
||||
/// mgmt port off 47990 served its library on the LAN and nowhere else — over a VPN, a routed
|
||||
/// subnet, or any multicast-dead network there is no advert to read, and the fallback silently
|
||||
/// went back to a port nothing was listening on. `None` here still means "assume the default".
|
||||
fn mgmt_port_for(&self, req: &ConnectRequest) -> Option<u16> {
|
||||
self.adverts
|
||||
let matches_req = |fp: &str, addr: &str, port: u16| {
|
||||
req.fp_hex
|
||||
.as_deref()
|
||||
.is_some_and(|want| !fp.is_empty() && fp == want)
|
||||
|| (addr == req.addr && port == req.port)
|
||||
};
|
||||
if let Some(p) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| {
|
||||
req.fp_hex
|
||||
.as_deref()
|
||||
.is_some_and(|fp| !a.fp_hex.is_empty() && a.fp_hex == fp)
|
||||
|| (a.addr == req.addr && a.port == req.port)
|
||||
})
|
||||
.find(|a| matches_req(&a.fp_hex, &a.addr, a.port))
|
||||
.and_then(|a| a.mgmt_port)
|
||||
{
|
||||
return Some(p);
|
||||
}
|
||||
crate::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| matches_req(&h.fp_hex, &h.addr, h.port))
|
||||
.and_then(|h| h.mgmt_port)
|
||||
}
|
||||
|
||||
/// Rename a saved host — an entry in an alert, then upsert + refresh.
|
||||
|
||||
@@ -73,8 +73,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
paired: k.is_some_and(|h| h.paired) || fake,
|
||||
saved: k.is_some(),
|
||||
online: false,
|
||||
// Explicit --mgmt wins; else the port this host's advert taught us and we saved;
|
||||
// else 47990. The middle rung is what survives mDNS being unavailable later.
|
||||
mgmt_port: arg_value("--mgmt")
|
||||
.and_then(|p| p.parse().ok())
|
||||
.or_else(|| k.and_then(|h| h.mgmt_port))
|
||||
.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
can_wake: false,
|
||||
last_used: k.and_then(|h| h.last_used),
|
||||
@@ -181,7 +184,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
vsync: settings_at_start.vsync,
|
||||
allow_vrr: settings_at_start.allow_vrr,
|
||||
json_status,
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32], mgmt_port: u16| {
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
trust::touch_last_used(&fp_hex);
|
||||
// A request-access connect just succeeded → the operator approved us. Save the
|
||||
@@ -191,6 +194,10 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
trust::persist_host(&p.name, &p.addr, p.port, &fp_hex, true);
|
||||
}
|
||||
}
|
||||
// Where this host serves its library, from the session's own Welcome — recorded
|
||||
// AFTER the persist above so a host saved by this very connect gets it too. `0` =
|
||||
// the host advertised none, and the call is a no-op.
|
||||
trust::learn_mgmt_port_by_fp(&fp_hex, mgmt_port);
|
||||
})),
|
||||
overlay: Some(Box::new(overlay)),
|
||||
window_size: crate::session_main::window_size(&settings_at_start),
|
||||
@@ -682,6 +689,12 @@ impl ServiceState {
|
||||
|| (d.addr == h.addr && d.port == h.port)
|
||||
});
|
||||
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
|
||||
// Write the advertised mgmt port down while the host is visible, so this console
|
||||
// keeps working against a moved port once it is not. No-op (and no disk write)
|
||||
// when unchanged, so this is safe on every refresh tick.
|
||||
if let Some(p) = advert.and_then(|d| d.mgmt_port) {
|
||||
pf_client_core::trust::learn_mgmt_port(&h.fp_hex, &h.addr, h.port, p);
|
||||
}
|
||||
let row = HostRow {
|
||||
key: key.clone(),
|
||||
name: host_display_name(&h.name, &h.addr),
|
||||
@@ -691,8 +704,12 @@ impl ServiceState {
|
||||
paired: h.paired,
|
||||
saved: true,
|
||||
online,
|
||||
// Live advert first, then what we saved from an earlier one, then 47990 —
|
||||
// the same three rungs `os` uses just below. Reading the advert ALONE is why
|
||||
// a host on a moved mgmt port lost its library the moment mDNS went quiet.
|
||||
mgmt_port: advert
|
||||
.and_then(|d| d.mgmt_port)
|
||||
.or(h.mgmt_port)
|
||||
.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
can_wake: !online && !h.mac.is_empty(),
|
||||
last_used: h.last_used,
|
||||
|
||||
@@ -986,9 +986,16 @@ mod session_main {
|
||||
vsync: settings.vsync,
|
||||
allow_vrr: settings.allow_vrr,
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32], mgmt_port: u16| {
|
||||
let fp = trust::hex(&fingerprint);
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
trust::touch_last_used(&fp);
|
||||
// Save where this host serves its library, learned from the session's own
|
||||
// Welcome rather than an mDNS advert — so it keeps working on a network where
|
||||
// discovery never does. `0` = the host advertised none; leave what we have.
|
||||
if mgmt_port != 0 {
|
||||
trust::learn_mgmt_port_by_fp(&fp, mgmt_port);
|
||||
}
|
||||
})),
|
||||
// The Skia console UI (stats OSD, capture HUD) — compiled out of the
|
||||
// power-user build (`--no-default-features` drops the `ui` feature).
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
MSIX package manifest for the punktfunk Windows client (WinUI 3 via windows-reactor).
|
||||
|
||||
This is a TEMPLATE: packaging/pack-msix.ps1 substitutes {VERSION} (4-part numeric, e.g.
|
||||
0.2.137.0) and {PUBLISHER} (must EXACTLY equal the signing cert's subject DN — default
|
||||
`CN=unom` for the self-signed CI cert; a real code-signing cert just passes its own subject).
|
||||
0.2.137.0) and {PUBLISHER} (must EXACTLY equal the signing cert's subject DN — the default is
|
||||
the verified subject of the Azure `unom-io` certificate profile; the self-signed fallback mints
|
||||
a throwaway cert with that same subject so canary and release share a package identity).
|
||||
|
||||
Package identity is Name + Publisher, so changing {PUBLISHER} makes this a DIFFERENT package:
|
||||
installs of the older publisher cannot be upgraded in place and must be uninstalled first. That
|
||||
is a user-visible migration, not a packaging detail — mention it in the release notes. pack-msix.ps1
|
||||
reads the signature back off the packed .msix and fails the build if the two ever drift.
|
||||
|
||||
Why this packages cleanly even though the app was built "unpackaged": windows-reactor calls
|
||||
MddBootstrapInitialize2 with OnPackageIdentity_NOOP (crates/libs/reactor/src/app.rs), so under
|
||||
|
||||
@@ -56,34 +56,45 @@ MSIX requires a strictly 4-part numeric version. The workflow computes:
|
||||
|
||||
## Signing & install
|
||||
|
||||
CI signs every build with a **stable self-signed code-signing cert** (`CN=unom`, SHA-1
|
||||
`CD1EFDEEEC9743AFC38F56C5AF30C5A3009BE941`, valid to 2036). Its public half is checked in as
|
||||
[`punktfunk-codesign.cer`](punktfunk-codesign.cer); the private `.pfx` + password live in the
|
||||
`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD` Actions secrets. Because it's the *same* cert every build,
|
||||
trusting it is **one-time, per machine** — once imported, every future build and in-place upgrade is
|
||||
trusted with no further prompt:
|
||||
CI signs every build with **Azure Artifact Signing** (formerly Trusted Signing) — account
|
||||
`unomsigning`, certificate profile `unom-io`, endpoint `https://neu.codesigning.azure.net/`. That
|
||||
chain is publicly trusted, so **there is nothing to import**:
|
||||
|
||||
```powershell
|
||||
# once per machine (elevated): trust the publisher
|
||||
Import-Certificate -FilePath .\punktfunk-codesign.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople
|
||||
# then install the package for your CPU (and re-run for each upgrade — no re-trust needed)
|
||||
# install the package for your CPU (and re-run for each upgrade)
|
||||
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_x64.msix # Intel/AMD
|
||||
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_arm64.msix # ARM64 (Snapdragon, etc.)
|
||||
```
|
||||
|
||||
The matching `.cer` is also published next to each `.msix` in the registry, so it's always at hand.
|
||||
|
||||
The MSIX declares a dependency on the Windows App SDK 2.x runtime; install
|
||||
[the App SDK runtime](https://aka.ms/windowsappsdk) if `Add-AppxPackage` reports a missing
|
||||
`Microsoft.WindowsAppRuntime.2` framework.
|
||||
|
||||
`pack-msix.ps1` signing precedence: it uses the **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** secrets
|
||||
when present (the stable cert above), else generates an *ephemeral* self-signed cert (forks / local
|
||||
builds without the secrets). Either way it exports the signing cert's public `.cer` for the import.
|
||||
**To move to a publicly-trusted (no-import) cert** — Azure Artifact Signing or a public OV cert —
|
||||
replace the two secrets with the new `.pfx`; the cert's subject DN must equal the manifest
|
||||
`Publisher`, so pass a matching `-Publisher` (it's stamped into the package `Identity`, and changing
|
||||
it changes the package identity → a one-time reinstall).
|
||||
### How signing resolves
|
||||
|
||||
`pack-msix.ps1` picks a backend in this order:
|
||||
|
||||
1. **Azure Artifact Signing** when `AZURE_CODESIGNING_ENDPOINT` / `_ACCOUNT` / `_PROFILE` are all
|
||||
set (the workflow sets them; they aren't secret). Credentials come from `AZURE_TENANT_ID` /
|
||||
`AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` — the `punktfunk-ci-signing` service principal, which
|
||||
holds only the *Artifact Signing Certificate Profile Signer* role scoped to the `unom-io` profile.
|
||||
Keys are HSM-backed and never leave Azure, so there is no `.pfx` and no `.cer` is emitted.
|
||||
2. **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** — the older stable self-signed cert (`CN=unom`,
|
||||
public half checked in as [`punktfunk-codesign.cer`](punktfunk-codesign.cer)), kept as a fallback.
|
||||
3. An **ephemeral** self-signed cert (forks / local builds with no secrets at all).
|
||||
|
||||
Modes 2 and 3 still export a `.cer` to import into `Cert:\LocalMachine\TrustedPeople` first. On a
|
||||
`v*` tag, a build with no real signing backend **fails closed** rather than shipping a throwaway.
|
||||
|
||||
Two things about Azure mode that are easy to get wrong:
|
||||
|
||||
- **Timestamping is mandatory, not best-effort.** Azure mints a leaf cert per request that expires in
|
||||
about three days. An untimestamped signature therefore stops verifying within days of release, so
|
||||
the script refuses to retry without one (modes 2 and 3 keep the old best-effort retry).
|
||||
- **The manifest `Publisher` must equal the signer's subject exactly**, because MSIX package identity
|
||||
is Name + Publisher. The default `-Publisher` is the `unom-io` profile's verified subject; after
|
||||
signing, the script reads the signature back off the `.msix` and fails the build on any drift.
|
||||
Changing it makes a *different* package — existing installs must be uninstalled, not upgraded.
|
||||
|
||||
## Building locally
|
||||
|
||||
|
||||
@@ -13,15 +13,22 @@
|
||||
packaging/windows/pack-host-installer.ps1 still ships them for its amf-qsv encode path.
|
||||
|
||||
Signing cert precedence:
|
||||
0. Azure Artifact Signing (formerly Trusted Signing) when AZURE_CODESIGNING_ENDPOINT/_ACCOUNT/
|
||||
_PROFILE are all set. HSM-backed, so there is no .pfx and nothing to export: the chain is
|
||||
publicly trusted, so no .cer is produced and MSIX_CER_PATH stays unset.
|
||||
1. -PfxBase64 / -PfxPassword (a real or shared code-signing cert, e.g. from CI secrets) — the
|
||||
cert's subject DN MUST match -Publisher (which is stamped into the manifest Identity).
|
||||
2. otherwise an EPHEMERAL self-signed code-signing cert with subject = -Publisher is generated
|
||||
in-process. The package installs only where that cert is trusted, so the matching public
|
||||
.cer is exported next to the .msix for the user to import (Trusted People) before install.
|
||||
Swap in a real cert later with zero manifest changes — just pass -PfxBase64/-Publisher.
|
||||
This fallback is for canary/CI/dev ONLY: on a v* tag build a missing cert is a hard failure
|
||||
(-RequireSignedCert), never a silent downgrade to a throwaway cert.
|
||||
|
||||
WHICHEVER mode runs, the signed .msix is read back and its signer subject compared to -Publisher;
|
||||
a mismatch fails the build. MSIX package identity is Name + Publisher, so a publisher that does
|
||||
not match the signer is not a cosmetic problem — Add-AppxPackage rejects the package outright,
|
||||
and it would only be discovered by a user trying to install the release.
|
||||
|
||||
Run on the Windows runner (or the dev VM) with the MSVC/Windows SDK present.
|
||||
|
||||
.EXAMPLE
|
||||
@@ -36,9 +43,21 @@ param(
|
||||
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release output dir (has the exe)
|
||||
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64', # package ProcessorArchitecture + artifact suffix
|
||||
[string]$OutDir = (Join-Path $TargetDir 'msix'),
|
||||
[string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN
|
||||
# MUST equal the signing cert subject DN — this is the verified subject the Azure 'unom-io'
|
||||
# certificate profile issues. The 'ü' is written as an escape, not a literal: this file is UTF-8
|
||||
# with no BOM, and read by anything other than pwsh 7 a literal would silently mojibake into a
|
||||
# publisher that no longer matches the signer, which surfaces only as an Add-AppxPackage refusal
|
||||
# on a user's machine. Verified against the real signer after signing below.
|
||||
[string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE",
|
||||
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # optional: base64 of a code-signing .pfx
|
||||
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
|
||||
# Azure Artifact Signing. All three select it, ahead of any .pfx. Credentials arrive through the
|
||||
# environment via DefaultAzureCredential (AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET)
|
||||
# rather than as arguments, so they cannot leak into a process listing or a transcript.
|
||||
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT, # e.g. https://neu.codesigning.azure.net/
|
||||
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT, # signing account name
|
||||
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE, # certificate profile name
|
||||
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB, # path to Azure.CodeSigning.Dlib.dll
|
||||
# 'auto' (default) = required iff this is a v* tag build; 'true'/'false' to force. See below.
|
||||
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto'
|
||||
)
|
||||
@@ -64,6 +83,28 @@ function Find-SdkTool([string]$name) {
|
||||
if (-not $hit) { throw "$name not found under $root — install the Windows 10/11 SDK." }
|
||||
$hit.FullName
|
||||
}
|
||||
# Azure.CodeSigning.Dlib.dll ships in the Microsoft.Trusted.Signing.Client NuGet package, which has
|
||||
# no installer and no fixed location — hence an explicit override first, then the paths the runner
|
||||
# setup uses (packaging/windows/README.md). Newest wins, so a package update needs no edit here.
|
||||
function Find-AzureDlib([string]$Explicit) {
|
||||
if ($Explicit) {
|
||||
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
|
||||
return (Resolve-Path $Explicit).Path
|
||||
}
|
||||
$roots = @(
|
||||
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
|
||||
'C:\trusted-signing\microsoft.trusted.signing.client'
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
|
||||
Sort-Object LastWriteTime | Select-Object -Last 1
|
||||
if (-not $hit) {
|
||||
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
|
||||
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
|
||||
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
|
||||
}
|
||||
$hit.FullName
|
||||
}
|
||||
$makeappx = Find-SdkTool 'makeappx.exe'
|
||||
$signtool = Find-SdkTool 'signtool.exe'
|
||||
Write-Host "makeappx: $makeappx"
|
||||
@@ -159,13 +200,34 @@ $requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/
|
||||
else { [Convert]::ToBoolean($RequireSignedCert) }
|
||||
$pfxPath = Join-Path $OutDir 'signing.pfx'
|
||||
$cerPath = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}.cer"
|
||||
if ($PfxBase64) {
|
||||
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
|
||||
$signMode = 'selfsigned'
|
||||
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
|
||||
$signMode = 'azure'
|
||||
$AzureDlib = Find-AzureDlib $AzureDlib
|
||||
# signtool takes the account/profile from this file (/dmdf), not the command line.
|
||||
@{
|
||||
Endpoint = $AzureEndpoint
|
||||
CodeSigningAccountName = $AzureAccount
|
||||
CertificateProfileName = $AzureProfile
|
||||
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
|
||||
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
|
||||
Write-Host " dlib: $AzureDlib"
|
||||
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
|
||||
if (-not [Environment]::GetEnvironmentVariable($v)) {
|
||||
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
|
||||
"DefaultAzureCredential; without the service-principal trio it falls through to an " +
|
||||
"interactive login that cannot complete on a runner and hangs the build.")
|
||||
}
|
||||
}
|
||||
} elseif ($PfxBase64) {
|
||||
$signMode = 'pfx'
|
||||
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
|
||||
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
|
||||
} elseif ($requireCert) {
|
||||
throw ("release build ($env:GITHUB_REF) with no MSIX_CERT_PFX_B64 — refusing to fall back to an " +
|
||||
"ephemeral self-signed cert. Restore the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD repo " +
|
||||
"secrets, or pass -RequireSignedCert false if this really is a test build.")
|
||||
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 — " +
|
||||
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
|
||||
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
|
||||
} else {
|
||||
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
|
||||
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
|
||||
@@ -178,35 +240,80 @@ if ($PfxBase64) {
|
||||
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
|
||||
}
|
||||
|
||||
# Always export the public .cer from the pfx. For a self-signed / private-trust cert it's the file
|
||||
# users import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that
|
||||
# import is a one-time, per-machine step that keeps working across upgrades. For a public-CA cert
|
||||
# it's just an unused extra (harmless). The manifest Publisher must equal the cert's subject DN.
|
||||
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
|
||||
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
|
||||
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
|
||||
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
|
||||
if ($pubCert.Subject -ne $Publisher) {
|
||||
Write-Warning "cert subject '$($pubCert.Subject)' != manifest Publisher '$Publisher' — Add-AppxPackage will reject the mismatch. Pass -Publisher '$($pubCert.Subject)'."
|
||||
# Export the public .cer from the pfx. For a self-signed / private-trust cert it's the file users
|
||||
# import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that
|
||||
# import is a one-time, per-machine step that keeps working across upgrades. Azure signing is
|
||||
# HSM-backed: there is no pfx to read and its chain is publicly trusted, so no .cer is produced.
|
||||
if ($signMode -ne 'azure') {
|
||||
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
|
||||
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
|
||||
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
|
||||
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
|
||||
}
|
||||
|
||||
# --- sign (timestamp best-effort) ---
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
& $signtool ($signArgs + @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256', $msix))
|
||||
# --- sign ---
|
||||
# The timestamp is best-effort for a .pfx whose cert outlives the release, but MANDATORY under Azure
|
||||
# signing: those leaf certs are minted per request and expire in ~3 days, so an untimestamped
|
||||
# signature stops verifying within days of shipping. Retrying without one there would produce a
|
||||
# package that installs on the runner and fails for every user that weekend — so the fallback is
|
||||
# gated on the mode rather than applied blindly.
|
||||
if ($signMode -eq 'azure') {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
|
||||
$ts = 'http://timestamp.acs.microsoft.com'
|
||||
} else {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
$ts = 'http://timestamp.digicert.com'
|
||||
}
|
||||
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $msix))
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($signMode -eq 'azure') {
|
||||
throw ("timestamped sign failed ($LASTEXITCODE) — NOT retrying without a timestamp. An Azure " +
|
||||
"signing cert is valid for ~3 days; an untimestamped signature would go untrusted " +
|
||||
"within days of release.")
|
||||
}
|
||||
Write-Warning "timestamped sign failed — retrying without a timestamp"
|
||||
& $signtool ($signArgs + @($msix))
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed ($LASTEXITCODE)" }
|
||||
}
|
||||
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Read the signature back off the packed .msix and hold it against the manifest Publisher. MSIX
|
||||
# package identity is Name + Publisher, so a publisher that doesn't match the signer isn't cosmetic:
|
||||
# Add-AppxPackage refuses the package outright. Checking the ACTUAL signer (rather than a pfx we
|
||||
# happen to hold) is the only form of this check that works in every signing mode, and failing the
|
||||
# build here is the difference between a red pipeline and a release nobody can install.
|
||||
# Deliberately asymmetric: a subject we CAN read and that DISAGREES is a hard failure, but a subject
|
||||
# we cannot read at all is only a warning. Get-AuthenticodeSignature's support for the .msix/.appx
|
||||
# subject interface varies by Windows version, and signtool has already reported success by this
|
||||
# point — turning "the check could not run" into a build break would trade a real defect we catch for
|
||||
# an imaginary one we invent.
|
||||
$signerSubject = $null
|
||||
try { $signerSubject = (Get-AuthenticodeSignature $msix).SignerCertificate.Subject } catch { }
|
||||
if (-not $signerSubject) {
|
||||
Write-Warning ("could not read a signer subject back from $msix, so Publisher/signer agreement is " +
|
||||
"UNVERIFIED on this box. If the package is rejected at Add-AppxPackage time, compare " +
|
||||
"`signtool verify /pa /v` against the manifest Publisher '$Publisher' by hand.")
|
||||
} elseif ($signerSubject -ne $Publisher) {
|
||||
throw ("signer subject does not match the manifest Publisher, so this package cannot install:`n" +
|
||||
" signer : '$signerSubject'`n" +
|
||||
" Publisher : '$Publisher'`n" +
|
||||
"Pass -Publisher '$signerSubject' (or fix the certificate profile) and repack.")
|
||||
} else {
|
||||
Write-Host "verified signer subject matches manifest Publisher: $signerSubject"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==> MSIX: $msix"
|
||||
Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):"
|
||||
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople"
|
||||
if ($signMode -eq 'azure') {
|
||||
Write-Host "==> signed by a publicly trusted CA — nothing for users to import."
|
||||
} else {
|
||||
Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):"
|
||||
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople"
|
||||
}
|
||||
# emit paths for the workflow to publish (only under CI, where GITHUB_ENV is set)
|
||||
if ($env:GITHUB_ENV) {
|
||||
"MSIX_PATH=$msix" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
if ($signMode -ne 'azure') { "MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 }
|
||||
}
|
||||
|
||||
@@ -691,6 +691,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
fp_hex: Some(k.fp_hex.clone()),
|
||||
pair_optional: false,
|
||||
mac: k.mac.clone(),
|
||||
mgmt_port: k.mgmt_port,
|
||||
profile: None,
|
||||
launch: None,
|
||||
};
|
||||
@@ -715,6 +716,18 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
}) {
|
||||
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
|
||||
}
|
||||
// Same for its management port — load-bearing, unlike the two above: a host moved off
|
||||
// 47990 loses its library entirely once mDNS is gone unless we write the port down.
|
||||
if let Some(p) = hosts
|
||||
.iter()
|
||||
.find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
&& h.mgmt_port.is_some()
|
||||
})
|
||||
.and_then(|h| h.mgmt_port)
|
||||
{
|
||||
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
|
||||
}
|
||||
let can_wake = !online && !k.mac.is_empty();
|
||||
let menu = {
|
||||
let (svc, target) = (props.svc.clone(), target.clone());
|
||||
@@ -1046,6 +1059,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||
pair_optional: h.pair == "optional",
|
||||
mac: h.mac.clone(),
|
||||
mgmt_port: h.mgmt_port,
|
||||
profile: None,
|
||||
launch: None,
|
||||
};
|
||||
@@ -1140,6 +1154,11 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
mac: Vec::new(),
|
||||
// Added by hand, so nothing has told us where its mgmt API is: fall back to
|
||||
// 47990 (exactly today's behaviour) until an advert teaches us otherwise.
|
||||
// A host that moved its mgmt port AND is never visible on mDNS still needs the
|
||||
// host to announce the port in-band — see the note in `Target::mgmt_port`.
|
||||
mgmt_port: None,
|
||||
profile: None,
|
||||
launch: None,
|
||||
},
|
||||
|
||||
@@ -104,7 +104,7 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
|
||||
let mut state = LibraryState::default();
|
||||
let games = match library::fetch_games(
|
||||
&target.addr,
|
||||
library::DEFAULT_MGMT_PORT,
|
||||
target.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
&identity,
|
||||
pin,
|
||||
) {
|
||||
@@ -120,7 +120,10 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
|
||||
}
|
||||
|
||||
// Seed cached posters; queue the art pipeline for the rest.
|
||||
let base = library::base_url(&target.addr, library::DEFAULT_MGMT_PORT);
|
||||
let base = library::base_url(
|
||||
&target.addr,
|
||||
target.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
);
|
||||
let cache = art_cache_dir();
|
||||
let mut jobs: VecDeque<(String, Vec<String>)> = VecDeque::new();
|
||||
for g in &games {
|
||||
|
||||
@@ -103,6 +103,11 @@ pub(crate) struct Target {
|
||||
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a
|
||||
/// magic packet before connecting to an offline host. Empty when none is known.
|
||||
pub(crate) mac: Vec<String>,
|
||||
/// This host's management-API port (saved store or live advert), where the library screen
|
||||
/// fetches from. `None` = unknown, use [`pf_client_core::library::DEFAULT_MGMT_PORT`]. Carried
|
||||
/// on the target for the same reason as `mac`: the library screen has no `KnownHost` in hand,
|
||||
/// and assuming 47990 there is what made a moved mgmt port work on the LAN but not over a VPN.
|
||||
pub(crate) mgmt_port: Option<u16>,
|
||||
/// A ONE-OFF settings profile for this connect ("Connect with"): `Some(id)` overrides the
|
||||
/// host's binding for this launch, `Some("")` forces the global defaults on a bound host,
|
||||
/// `None` honors the binding. It never rebinds anything — the default changes only through
|
||||
@@ -406,6 +411,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
fp_hex: p.host.fp_hex.clone(),
|
||||
pair_optional: false,
|
||||
mac: p.host.mac.clone(),
|
||||
mgmt_port: p.host.mgmt_port,
|
||||
profile: p.profile_override.clone(),
|
||||
launch: None, // routed explicitly below (initiate_launch*)
|
||||
};
|
||||
@@ -447,6 +453,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
fp_hex: u.fp.clone(),
|
||||
pair_optional: false,
|
||||
mac: Vec::new(),
|
||||
// A link carries no mgmt port (nor a MAC), so this stays unknown until
|
||||
// an advert teaches it — same fallback as the hand-added case.
|
||||
mgmt_port: None,
|
||||
profile: u.profile.clone(),
|
||||
launch: u.launch.clone(),
|
||||
};
|
||||
|
||||
@@ -62,10 +62,18 @@
|
||||
{
|
||||
"type": "application",
|
||||
"name": "punktfunk-gamescope",
|
||||
"version": "upstream gamescope pinned by packaging/nix/gamescope.nix (nixpkgs) or built by packaging/gamescope/build-punktfunk-gamescope.sh, plus 3 local patches from packaging/gamescope/patches/",
|
||||
"version": "upstream gamescope pinned by packaging/nix/gamescope.nix (nixpkgs) or built by packaging/gamescope/build-punktfunk-gamescope.sh, plus the local patch series from packaging/gamescope/patches/",
|
||||
"description": "Patched gamescope compositor distributed via sysext/Arch/nix channels alongside the host",
|
||||
"licenses": [{ "license": { "id": "BSD-2-Clause" } }],
|
||||
"externalReferences": [{ "type": "vcs", "url": "https://github.com/ValveSoftware/gamescope" }]
|
||||
},
|
||||
{
|
||||
"type": "application",
|
||||
"name": "Bun",
|
||||
"version": "1.3.14 (pinned in .gitea/workflows/windows-host.yml)",
|
||||
"description": "Portable JavaScript runtime bundled in the Windows host installer to run the web console (.output) and the plugin/script runner. Embeds JavaScriptCore (LGPL-2.1).",
|
||||
"licenses": [{ "license": { "id": "MIT" } }],
|
||||
"externalReferences": [{ "type": "vcs", "url": "https://github.com/oven-sh/bun" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Vendored & bundled components — CVE watch and update cadence
|
||||
|
||||
Due-diligence record for every third-party component that ships with Punktfunk but is
|
||||
**not** tracked by a package manager's advisory feed (CRA Art. 13(5); Annex I Part II §1).
|
||||
Everything resolved through Cargo/bun/pnpm lockfiles is already scanned weekly by
|
||||
`.gitea/workflows/audit.yml` (cargo-audit against RustSec, bun/pnpm audit) — this file
|
||||
covers what those scanners cannot see: vendored source trees, git-rev pins, and binaries
|
||||
staged into installers. The component inventory itself lives in
|
||||
`compliance/sbom/manual-components.cdx.json` and is merged into every release SBOM;
|
||||
keep the two files in sync when a component is added, removed, or re-pinned.
|
||||
|
||||
Owner for all of it: Enrico (sole maintainer). Standing cadence: **walk this table once
|
||||
per quarter and before every stable release**; act immediately on any advisory from the
|
||||
watch feeds below.
|
||||
|
||||
| Component | Where / pin | How to update | Watch |
|
||||
|---|---|---|---|
|
||||
| **pyrowave** (+ Granite, volk, Vulkan-Headers subtree) | `crates/pyrowave-sys/vendor/pyrowave`, pin = `PYROWAVE_COMMIT` in `scripts/vendor-pyrowave.sh`; exact commits recorded in `vendor/pyrowave/PUNKTFUNK-VENDOR.txt` | Bump the commit in the script, re-run it (network required; never from CI), re-apply `crates/pyrowave-sys/patches/`. ⚠️ **Bitstream changes are protocol-affecting** — the wire bit means "PyroWave as of this pin"; a bitstream-changing bump must bump the protocol version and re-diff the Apple Metal hand-port (see the script header). | GitHub releases/commits of Themaister/pyrowave + Themaister/Granite (niche projects, no CVE feed — repo watch is the feed) |
|
||||
| **libvpl** 2.17.0 | `crates/libvpl-sys/vendor/libvpl` (dispatcher statically linked; needs cmake + libclang) | Manual re-vendor from intel/libvpl at the new tag; rebuild `libvpl-sys` | Intel Security Center (INTEL-SA advisories for oneVPL/media) + intel/libvpl releases |
|
||||
| **windows-rs** git pin | `rev = acb5a1a7…` on microsoft/windows-rs (workspace `[patch]`/git deps: `windows`, `windows-reactor`, …) | Move the rev / return to crates.io once the needed fixes are released. Note: cargo-audit matches these by name+version from Cargo.lock, but a pre-release rev may not map cleanly onto RustSec advisories — treat the pin itself as the thing to retire. | RustSec (already weekly) + microsoft/windows-rs releases |
|
||||
| **usbfs-iso / uac-host** git pin | `rev = f3de1fd…` on unom-io/usbfs-iso | First-party fork — we are upstream; fix in the fork, move the rev | Own repo (issues land in our tracker) |
|
||||
| **FFmpeg** (host encode only) | Linux: system `libav*` (distro-updated, not ours to patch — but Arch soname majors can break us, see ffmpeg9 note). Windows: AMF/QSV shared DLLs staged from `FFMPEG_DIR` by `pack-host-installer.ps1`; LGPL notice bundled | Windows: rebuild/refresh the staged DLL set, ship in the next installer. Linux: nothing to ship; verify against new distro majors | ffmpeg-security announcements (ffmpeg.org security page) — a libav* CVE in decode/parse paths we use ⇒ refresh the Windows DLLs without undue delay |
|
||||
| **SDL3** | Desktop clients, dynamically linked; system-provided or bundled per platform package | Bump the bundled copy in the affected package; system copies are distro-updated | libsdl-org/SDL GitHub security advisories + releases |
|
||||
| **gamescope** + patch series | Pin in `packaging/nix/gamescope.nix` / built by `packaging/gamescope/build-punktfunk-gamescope.sh`; local patches in `packaging/gamescope/patches/` | Bump the pin, re-rebase the patch series, rebuild sysext/Arch/nix + .deb channels. ⚠️ the gamescope CI legs are best-effort: a broken patch shows up as a *missing package*, not a red build | ValveSoftware/gamescope releases + security advisories |
|
||||
| **Bun runtime** 1.3.14 | Pinned in `.gitea/workflows/windows-host.yml` (`bun-v1.3.14`); bundled portable in the Windows host installer to run the web console + plugin runner. Embeds JavaScriptCore | Bump the version string in the workflow; next installer build picks it up | oven-sh/bun releases (security notes ride in release notes) |
|
||||
|
||||
Not on this list on purpose:
|
||||
|
||||
- **VB-CABLE** — no longer bundled (audio-substrate program, 2026-08; the host mints its
|
||||
own virtual audio devices). If it ever returns, it returns to this table first.
|
||||
- **openh264 / rav1d CPU decode floor** — crates.io dependencies with vendored C/asm
|
||||
inside the `-sys` crates; cargo-audit tracks the crate advisories, and the upstream
|
||||
(Cisco openh264, memorysafety/rav1d) security feeds surface through RustSec. No
|
||||
separate manual watch needed unless we pin them to git.
|
||||
|
||||
## Security-update availability (CRA: ≥10 years)
|
||||
|
||||
Where users fetch fixes, and why old artifacts don't vanish (verified 2026-08-14):
|
||||
|
||||
- **Gitea releases + package registries** (git.unom.io): no cleanup rules configured,
|
||||
and Gitea does not expire releases or packages on its own — the full release history
|
||||
(v0.17.x through current) is still served with assets. Blobs live in the `unom-git`
|
||||
S3 bucket with an R2 mirror, and the box is restic-backed every 6 h. Old release
|
||||
assets (and their `.sha256` sidecars) therefore stay downloadable.
|
||||
- **Bazzite sysext feeds**: stable channels publish with `KEEP=0` (keep everything);
|
||||
only canary channels prune (`KEEP=6`) — see `rpm.yml` + `publish-sysext-feed.sh`.
|
||||
- **Flatpak repo** (flatpak.unom.io): published by rsync *without* `--delete`; old
|
||||
OSTree commits accumulate, both channels stay in the signed summary.
|
||||
- **Policy**: never add cleanup that deletes *security* releases; if storage pressure
|
||||
ever forces pruning, prune canary builds, never tagged stable releases. SBOMs are
|
||||
release assets, so the ≥10-year SBOM retention rides on the same guarantee.
|
||||
@@ -180,6 +180,23 @@ pub struct Stats {
|
||||
/// decoder's submission returning in ~0.1 ms is not "decoded"); software measures
|
||||
/// the synchronous CPU decode.
|
||||
pub decode_ms: f32,
|
||||
/// Whether `decode_ms` OVERLAPS the presenter's `display` stage instead of tiling
|
||||
/// with it — true on the asynchronous native-Vulkan rung, false everywhere else.
|
||||
///
|
||||
/// The other stages are a per-frame partition of `e2e`: `pts →(host+net)→ received
|
||||
/// →(decode)→ decoded →(display)→ displayed`. That holds while `decoded` is a
|
||||
/// COMPLETION stamp, which it is on the synchronous rungs. On the native-Vulkan rung
|
||||
/// `receive_frame` returns at SUBMISSION (~0.1 ms) and the stamp shipped to the
|
||||
/// presenter is taken there, so the GPU decode happens INSIDE the `display` stage —
|
||||
/// `host+net` and `display` already tile `e2e` between them, and `decode` (measured
|
||||
/// received → fence-complete) re-counts the GPU work that `display` contains.
|
||||
///
|
||||
/// A 2026-08-13 field report read the row as a breakdown and asked why the parts did
|
||||
/// not add up: `host 5.4 · net 0.3 · decode 6.6 · display 1.4` against `e2e 8.1`. They
|
||||
/// do add up — without `decode` (5.4 + 0.3 + 1.4 ≈ 8.1). The figure is a true reading
|
||||
/// of a real quantity sitting in a row that reads like a partition, so the OSD renders
|
||||
/// it off that line rather than beside stages it does not tile with.
|
||||
pub decode_overlaps_display: bool,
|
||||
/// Unrecoverable network frame drops this window, and their share of
|
||||
/// received+lost (%). The OSD renders the counter line only when nonzero.
|
||||
pub lost: u32,
|
||||
@@ -770,6 +787,10 @@ fn pump(
|
||||
// corrected), `decode` = received→decoded (client-local). p50 per 1 s window.
|
||||
let mut hostnet_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut decode_us: Vec<u64> = Vec::with_capacity(256);
|
||||
// Whether this window's decode samples came from the async (submission-stamped) rung, so
|
||||
// the OSD keeps them off the partition line. Latches per window alongside the samples,
|
||||
// rather than being read off the rung name — a demote mid-window changes both together.
|
||||
let mut decode_overlaps = false;
|
||||
// Adaptive bitrate: report the decode stage back to the core controller only when it's armed
|
||||
// (Automatic, non-PyroWave). Constant for the session — resolve once, gate the per-frame call.
|
||||
let wants_decode = connector.wants_decode_latency();
|
||||
@@ -1118,6 +1139,12 @@ fn pump(
|
||||
// `decode` stage: received→decode COMPLETE, single clock.
|
||||
match hw_fence {
|
||||
Some((sem, value)) => {
|
||||
// A fence means `decoded_ns` above was stamped at SUBMISSION, so
|
||||
// the GPU decode lands inside the presenter's `display` stage and
|
||||
// this figure re-counts it: it does NOT tile with the others.
|
||||
// Recorded so the OSD can render it off the partition line
|
||||
// (`Stats::decode_overlaps_display`).
|
||||
decode_overlaps = true;
|
||||
if decode_us.is_empty()
|
||||
&& decoder.wait_hw_decoded(sem, value, 50_000_000)
|
||||
{
|
||||
@@ -1433,6 +1460,7 @@ fn pump(
|
||||
host_pace_ms: pace_p50 as f32 / 1000.0,
|
||||
staged,
|
||||
decode_ms: dec_p50 as f32 / 1000.0,
|
||||
decode_overlaps_display: decode_overlaps,
|
||||
lost,
|
||||
lost_pct: if lost > 0 {
|
||||
lost as f32 * 100.0 / (frames_n + lost) as f32
|
||||
@@ -1461,6 +1489,7 @@ fn pump(
|
||||
bytes_n = 0;
|
||||
hostnet_us.clear();
|
||||
decode_us.clear();
|
||||
decode_overlaps = false;
|
||||
host_us_win.clear();
|
||||
net_us_win.clear();
|
||||
queue_us_win.clear();
|
||||
|
||||
@@ -313,6 +313,20 @@ pub struct KnownHost {
|
||||
/// sleep. `default` (and elided when empty) so pre-existing stores load unchanged.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub os: String,
|
||||
/// The host's management-API port (mDNS `mgmt` TXT), where the game library is served —
|
||||
/// distinct from `port`, which is the native QUIC plane. Learned from the advert while the
|
||||
/// host is online and persisted here for the same reason as `mac` and `os`: so it survives the
|
||||
/// advert going away.
|
||||
///
|
||||
/// That is not a cosmetic loss like a missing OS icon. A host that moved its mgmt port off
|
||||
/// 47990 — the supported fix for sharing a machine with a Sunshine fork, whose web UI owns
|
||||
/// that port — was reachable only for as long as mDNS was: on a VPN, a routed subnet, or a
|
||||
/// multicast-dead network the library silently went blank, because the port the client had
|
||||
/// already been told was never written down. `None` = never learned, resolve via
|
||||
/// [`KnownHost::effective_mgmt_port`]. Optional + `default` so pre-existing stores load
|
||||
/// (the Apple client's `StoredHost.mgmtPort` is the same field for the same reason).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mgmt_port: Option<u16>,
|
||||
/// Share this machine's clipboard with THIS host (design/clipboard-and-file-transfer.md
|
||||
/// §5.3 — the Apple client's `StoredHost.clipboardSync`). Per-host, not global: handing a
|
||||
/// host your clipboard is a trust decision about that host. Default off; the host must
|
||||
@@ -353,6 +367,7 @@ impl Default for KnownHost {
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
os: String::new(),
|
||||
mgmt_port: None,
|
||||
clipboard_sync: false,
|
||||
profile_id: None,
|
||||
pinned_profiles: Vec::new(),
|
||||
@@ -362,6 +377,17 @@ impl Default for KnownHost {
|
||||
}
|
||||
|
||||
impl KnownHost {
|
||||
/// Where this host's management API actually is: the port learned from its advert, else the
|
||||
/// compiled-in 47990. The twin of the Apple client's `StoredHost.effectiveMgmtPort`.
|
||||
///
|
||||
/// Every library/art call resolves through this rather than reaching for
|
||||
/// [`crate::library::DEFAULT_MGMT_PORT`] directly — that constant is the FALLBACK, not the
|
||||
/// answer, and call sites that treated it as the answer are why a moved port only worked while
|
||||
/// mDNS was up.
|
||||
pub fn effective_mgmt_port(&self) -> u16 {
|
||||
self.mgmt_port.unwrap_or(crate::library::DEFAULT_MGMT_PORT)
|
||||
}
|
||||
|
||||
/// This host's pinned profiles that still exist, in card order, without duplicates — what
|
||||
/// a grid renders. Dangling pins (the profile was deleted) simply disappear, per design
|
||||
/// §5.2a: a pin is presentation state, never a reason to show an error.
|
||||
@@ -506,6 +532,13 @@ impl KnownHosts {
|
||||
if !entry.os.is_empty() {
|
||||
h.os = entry.os;
|
||||
}
|
||||
// And for the learned mgmt port. Stated explicitly rather than left to the
|
||||
// does-not-mention-it rule below: this one is load-bearing (a host that moved off
|
||||
// 47990 is unreachable for the library without it), so a reconnect upsert that
|
||||
// carries `None` must visibly not clear what a discovery taught us.
|
||||
if entry.mgmt_port.is_some() {
|
||||
h.mgmt_port = entry.mgmt_port;
|
||||
}
|
||||
// Everything below is state the user set ON this record, which a refresh (a
|
||||
// reconnect, a re-pair, a rediscovery) never carries and therefore must never
|
||||
// clear: the per-host clipboard decision — which survives today only because this
|
||||
@@ -581,6 +614,9 @@ impl KnownHosts {
|
||||
if h.os.is_empty() {
|
||||
h.os = old.os;
|
||||
}
|
||||
if h.mgmt_port.is_none() {
|
||||
h.mgmt_port = old.mgmt_port;
|
||||
}
|
||||
if h.profile_id.is_none() {
|
||||
h.profile_id = old.profile_id;
|
||||
}
|
||||
@@ -692,6 +728,27 @@ pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) {
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's management-API port from its live advert (mDNS `mgmt` TXT),
|
||||
/// matched like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when
|
||||
/// unchanged, so the hosts page can call it on every discovery tick without churning the store.
|
||||
///
|
||||
/// This is what makes a moved mgmt port outlive mDNS. Until it existed the port was read straight
|
||||
/// off the live advert and thrown away, so the library worked on the LAN and went blank over a VPN.
|
||||
pub fn learn_mgmt_port(fp_hex: &str, addr: &str, port: u16, mgmt_port: u16) {
|
||||
if mgmt_port == 0 {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.mgmt_port == Some(mgmt_port) {
|
||||
return;
|
||||
}
|
||||
h.mgmt_port = Some(mgmt_port);
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
|
||||
/// fingerprint). No-op — and no disk write — when unchanged. Called from the wake-and-wait flow when
|
||||
/// a woken host reappears on a different IP than the stored one, so this and future connects dial the
|
||||
@@ -725,6 +782,28 @@ pub fn touch_last_used(fp_hex: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a host's management-API port learned from the **session's own `Welcome`**, keyed by
|
||||
/// fingerprint alone — the identity a just-connected client is certain of.
|
||||
///
|
||||
/// This is the mDNS-free path, and the one that matters most: [`learn_mgmt_port`] can only fire
|
||||
/// where an advert is visible, whereas this fires on any successful connect, including a host
|
||||
/// added by IP on a network where discovery has never worked. No-op — and no disk write — when
|
||||
/// the fingerprint isn't stored or the value is unchanged, so it is safe on every connect.
|
||||
pub fn learn_mgmt_port_by_fp(fp_hex: &str, mgmt_port: u16) {
|
||||
if fp_hex.is_empty() || mgmt_port == 0 {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp_hex) else {
|
||||
return;
|
||||
};
|
||||
if h.mgmt_port == Some(mgmt_port) {
|
||||
return;
|
||||
}
|
||||
h.mgmt_port = Some(mgmt_port);
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Run the SPAKE2 PIN ceremony against a host. `device_name` is the label the HOST
|
||||
/// stores this client under (its paired-devices list); the 90 s budget covers a
|
||||
/// human-typed PIN. Returns the host's now-verified certificate fingerprint to pin.
|
||||
@@ -1781,6 +1860,9 @@ mod tests {
|
||||
last_used: Some(1000),
|
||||
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
|
||||
os: "linux/fedora/bazzite".into(),
|
||||
// Deliberately NOT 47990: a host that moved its mgmt port is the case this field
|
||||
// exists for, so the default would make the assertions below pass vacuously.
|
||||
mgmt_port: Some(47991),
|
||||
clipboard_sync: true,
|
||||
profile_id: Some("aaaaaaaaaaaa".into()),
|
||||
pinned_profiles: vec!["bbbbbbbbbbbb".into()],
|
||||
@@ -1804,6 +1886,9 @@ mod tests {
|
||||
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
|
||||
// The learned OS chain rides the same rule as `mac`: a carrier-less upsert keeps it.
|
||||
assert_eq!(h.os, "linux/fedora/bazzite");
|
||||
// And the learned mgmt port. If a reconnect could reset this to None the host would fall
|
||||
// back to 47990 and its library would 404 — the exact regression this rule prevents.
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
assert!(h.clipboard_sync);
|
||||
assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa"));
|
||||
assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]);
|
||||
@@ -1823,6 +1908,51 @@ mod tests {
|
||||
assert_eq!(k.hosts[0].pinned_profiles, vec!["dddddddddddd".to_string()]);
|
||||
}
|
||||
|
||||
/// The mgmt port a host advertises has to OUTLIVE the advert: a store written before the field
|
||||
/// existed must load, resolve to 47990, and then take and keep a learned value. Without the
|
||||
/// middle rung a host moved off 47990 (to share a box with a Sunshine fork, whose web UI owns
|
||||
/// that port) served its library on the LAN and nowhere else — over a VPN or a routed subnet
|
||||
/// there is no advert to read and the client silently went back to a dead port.
|
||||
#[test]
|
||||
fn mgmt_port_survives_a_store_that_predates_it_and_then_persists() {
|
||||
// A store written before the field existed: no `mgmt_port` key at all.
|
||||
let old = r#"{"hosts":[{
|
||||
"name": "Gaming PC", "addr": "192.168.1.50", "port": 9777,
|
||||
"fp_hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"paired": true
|
||||
}]}"#;
|
||||
let mut k: KnownHosts = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(k.hosts[0].mgmt_port, None, "absent key decodes to None");
|
||||
assert_eq!(
|
||||
k.hosts[0].effective_mgmt_port(),
|
||||
crate::library::DEFAULT_MGMT_PORT,
|
||||
"unknown resolves to the compiled-in default, i.e. today's behaviour"
|
||||
);
|
||||
// Unset stays out of the serialized form, so an untouched store is byte-stable.
|
||||
assert!(!serde_json::to_string(&k).unwrap().contains("mgmt_port"));
|
||||
|
||||
// Learning one (what a discovery tick does) takes effect and round-trips.
|
||||
k.hosts[0].mgmt_port = Some(47991);
|
||||
assert_eq!(k.hosts[0].effective_mgmt_port(), 47991);
|
||||
let round: KnownHosts = serde_json::from_str(&serde_json::to_string(&k).unwrap()).unwrap();
|
||||
assert_eq!(round.hosts[0].mgmt_port, Some(47991));
|
||||
|
||||
// A re-key carries it onto the surviving record — otherwise a host that regenerated its
|
||||
// identity would silently drop back to 47990.
|
||||
let fresh = fp('a');
|
||||
let mut k2 = k;
|
||||
k2.upsert_trusted(KnownHost {
|
||||
name: "Gaming PC".into(),
|
||||
addr: "192.168.1.50".into(),
|
||||
port: 9777,
|
||||
fp_hex: fresh.clone(),
|
||||
paired: true,
|
||||
..Default::default()
|
||||
});
|
||||
let kept = k2.hosts.iter().find(|h| h.fp_hex == fresh).unwrap();
|
||||
assert_eq!(kept.mgmt_port, Some(47991), "re-key must not lose the port");
|
||||
}
|
||||
|
||||
/// A host that regenerated its identity (reinstall, wiped ProgramData, re-key) ends up with
|
||||
/// ONE record for its address — the live one. This is the `.173` lockout: `upsert` keys on
|
||||
/// the fingerprint, so the re-paired host used to be appended beside the dead record, and
|
||||
@@ -1840,6 +1970,7 @@ mod tests {
|
||||
last_used: Some(1000),
|
||||
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
|
||||
os: "windows".into(),
|
||||
mgmt_port: Some(47991),
|
||||
clipboard_sync: true,
|
||||
profile_id: Some("aaaaaaaaaaaa".into()),
|
||||
pinned_profiles: vec!["bbbbbbbbbbbb".into()],
|
||||
@@ -1864,6 +1995,9 @@ mod tests {
|
||||
// What describes the BOX rides along, so a reinstall doesn't cost the user their setup.
|
||||
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
|
||||
assert_eq!(h.os, "windows");
|
||||
// The mgmt port describes the BOX, not the retired certificate: a reinstall must not send
|
||||
// the library back to 47990 on a host that serves it somewhere else.
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa"));
|
||||
assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]);
|
||||
assert_eq!(h.last_used, Some(1000));
|
||||
|
||||
@@ -245,11 +245,22 @@ impl Overlay for SkiaOverlay {
|
||||
shared.queue_family_index as usize,
|
||||
),
|
||||
&get_proc,
|
||||
// `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, so it caps entry-point
|
||||
// validation at whatever `vkEnumerateInstanceVersion()` reports — byte-for-byte what
|
||||
// the (now removed) `BackendContext::new` did. The presenter owns the instance and its
|
||||
// `VkApplicationInfo`, so pinning a version here would just duplicate its choice.
|
||||
None,
|
||||
// 🛑 MUST be the presenter's declared version, never `None`.
|
||||
//
|
||||
// `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, which makes Skia fall
|
||||
// back to `vkEnumerateInstanceVersion()` — the LOADER's ceiling, not ours. Those are
|
||||
// not the same number: the presenter asks for 1.3, while a current Mesa loader answers
|
||||
// 1.4 (1.4.321 on SteamOS 3.7). Skia then validates a 1.4 function table against an
|
||||
// instance that only ever promised 1.3, `vkGetDeviceProcAddr` returns null for the
|
||||
// entry points above 1.3, validation fails, and `make_vulkan` hands back `None` — so
|
||||
// the console UI refuses to start and `--browse` dies with it.
|
||||
//
|
||||
// ⚠ The `0` sentinel was harmless at skia-safe 0.87 (that Skia knew nothing of 1.4, so
|
||||
// clamping to the loader was a no-op) and the 0.99 migration preserved it as
|
||||
// "byte-for-byte what `BackendContext::new` did" — true of the VALUE, false of the
|
||||
// BEHAVIOUR. It shipped in 0.28.0 and took the Deck's launcher out. 0.99's own doc for
|
||||
// this parameter says it should match `VkApplicationInfo::apiVersion`; this is that.
|
||||
Some(skvk::Version::from(shared.api_version)),
|
||||
);
|
||||
// SAFETY: the instance/physical-device/device handles come from `shared`, which owns them
|
||||
// and outlives this backend context, and `get_proc` above resolves through those same
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user