Compare commits
62
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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+510
-1
@@ -12,7 +12,166 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.28.1 — in development
|
||||
## v0.28.1
|
||||
|
||||
60 commits since v0.28.0.
|
||||
|
||||
A patch release in the strict sense: **nothing on the wire, in the C ABI, in the driver protocol or
|
||||
in the plugin contract moves.** Every host, client, driver and plugin built against v0.28.0 keeps
|
||||
working against v0.28.1 and vice versa, in both directions and with no re-pairing.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.28.0 | v0.28.1 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged |
|
||||
| C ABI | 19 | **19** | unchanged — `include/punktfunk_core.h` is byte-identical to the v0.28.0 tag |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| `api/openapi.json` | 0.27.0 | **0.28.0** | the management API **did** change (two collection deletes, below); the file carries the stamp it was regenerated under, not `0.28.1` |
|
||||
| gamescope patch level (`+pfhdrN`) | 6 | **7** | 8 patches → 9 (the linger crash); no new capability |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
|
||||
| `@punktfunk/plugin-kit` | 0.4.1 | **0.4.1** | unchanged |
|
||||
|
||||
⚠ **The `api/openapi.json` stamp is not a per-release counter** and should not be read as one. The
|
||||
drift test (`openapi_document_is_complete_and_checked_in`) normalizes `info.version` on both sides,
|
||||
so only the *surface* is gated and a version bump alone never invalidates the snapshot. The table
|
||||
row says what the file actually says. Regenerating it needs a Linux or Windows host build —
|
||||
`punktfunk-host` does not compile on macOS.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None.** No wire change, no C ABI change, no driver-protocol change, no plugin-contract change.
|
||||
Three things are worth an embedder's or packager's attention anyway, none of which break a build:
|
||||
|
||||
- **The Rust crate gained one public constant.** `punktfunk_core::client::FLUSH_COOLDOWN` was
|
||||
`pub(crate)`; the host now compares against it rather than against a copy of the number (see the
|
||||
keyframe-cadence fix below). Addition only.
|
||||
- **`NativeBridge.nativeStartAudio` takes a third argument** on Android — `isTv`. Detail in the
|
||||
Android section; this is a JNI signature change, so an out-of-tree caller must pass it.
|
||||
- **Every Linux packaging channel now ships a second gamescope artifact**, the Vulkan WSI layer,
|
||||
and a package that carries the compositor without it is *fatal* rather than degraded. If you
|
||||
repackage `punktfunk-gamescope` downstream, read the gamescope section before rebuilding.
|
||||
|
||||
### The management API gains two collection deletes — "unpair all"
|
||||
|
||||
Clearing a host's trust store meant one row-level delete per device, each with its own
|
||||
confirmation. Two new endpoints, one per pairing plane:
|
||||
|
||||
```
|
||||
DELETE /api/v1/clients -> {"unpaired": N}
|
||||
DELETE /api/v1/native/clients -> {"unpaired": N}
|
||||
```
|
||||
|
||||
They are **not** a loop over the per-fingerprint deletes. Each empties its store in ONE persisted
|
||||
write, because N deletes would rewrite and atomically rename the store N times and a failure
|
||||
partway leaves a half-emptied store with nothing saying which half. The two planes are separate
|
||||
endpoints because they own separate trust stores with separate persistence and separate revocation
|
||||
duties.
|
||||
|
||||
Being collection deletes, they carry the single delete's revocation guarantees across the whole
|
||||
set: a live session owned by any removed certificate is ended, and on the GameStream side the ENet
|
||||
control port (UDP 47999) closes, because no pairing is left to hold it open.
|
||||
|
||||
**200 with a count, not the single delete's 204/404.** "Unpair everything" is idempotent — an
|
||||
already-empty store satisfies it — and the count still distinguishes three devices from none.
|
||||
|
||||
⚠ **Both are admin-token only.** The route-classification gates match on (method, path), so the
|
||||
roster's plugin-readable `GET` does not carry over to emptying it; both new routes have explicit
|
||||
rows in the table, like every other pairing-administration route. The native endpoint answers
|
||||
**503** on a host built without that plane, which is why the console calls only the planes that
|
||||
actually have a row.
|
||||
|
||||
`UnpairAllResult` is the one new schema. `api/openapi.json` is regenerated;
|
||||
`docs-site/public/openapi.json` is re-synced from it (see **Documentation** at the end).
|
||||
|
||||
### The pad-audio "Wireless Controller" speaker hides while no client pad is attached
|
||||
|
||||
Field-confirmed (2026-08-14, the same Helldivers 2 reports as below): the per-pad audio endpoint
|
||||
the Windows host mints — a Steam-Streaming-Speakers instance stamped with a DualSense's name,
|
||||
container and 4 ch/48 kHz formats, **pre-provisioned at every host start** — is deliberately
|
||||
indistinguishable from a real DualSense speaker. That disguise is the feature during a pad
|
||||
session (libScePad titles route haptics audio at it) and a trap the rest of the time: an idle
|
||||
Helldivers 2 finds the endpoint by identity, engages its DualSense-haptics path against a device
|
||||
nothing services, and drops to 2–5 FPS 1% lows — with the host completely idle, no controller
|
||||
plugged in, and no session ever run. The reporter isolating "the DualSense speaker" and disabling
|
||||
it in mmsys.cpl restored full performance; that manual remedy is now automatic.
|
||||
|
||||
The endpoint now parks **hidden** (`DEVICE_STATE_DISABLED`, via `IPolicyConfig::
|
||||
SetEndpointVisibility` — the exact call behind mmsys.cpl's Disable) whenever no client pad is
|
||||
attached: provisioning hides it at startup (and a `PUNKTFUNK_PAD_AUDIO=0` host hides leftovers
|
||||
from earlier runs), the per-pad streamer shows it for exactly the pad's lifetime — to a game,
|
||||
indistinguishable from a DualSense arriving and leaving. The devnode, driver binding and stamps
|
||||
stay put, so the flips raise no PnP traffic and the expensive provisioning still happens once at
|
||||
boot.
|
||||
|
||||
⚠ **Operator-visible:** "Speakers (Wireless Controller)" now shows as *disabled* in the Sound
|
||||
control panel while no client pad is connected — that is the parked state, not a defect. The
|
||||
`pad-endpoint` devtest grew `show`/`hide` verbs; `tone`/`capture` need a `show` first.
|
||||
|
||||
### An idle Windows host no longer owns the box's default microphone
|
||||
|
||||
Field report (the second Helldivers 2 one — the first led to v0.28.0's mint-retry fix): with the
|
||||
host **idle**, a locally played Helldivers 2 tanks to 2–5 FPS 1% lows, and Windows' own Sound
|
||||
settings Recording tab goes unresponsive. Root cause: the audio wiring pass asserted *default
|
||||
recording = the virtual mic's capture side* on **every** pass, including the mic pump's eager
|
||||
boot pass — and `SetDefaultEndpoint` covers eCommunications, so every game's voice input bound a
|
||||
virtual microphone whose feeder only runs during a stream. Nothing ever restored it: not session
|
||||
end, not service stop. Games that hold an always-open voice capture (Helldivers 2 is Wwise +
|
||||
in-game voice — its own wiki calls the game "finicky with audio devices") stall on that dead
|
||||
endpoint.
|
||||
|
||||
The recording default is now **session-scoped**, exactly like the playback default has always
|
||||
been: parked on the virtual mic only while a desktop-audio capture is open, the operator's device
|
||||
remembered (plus an on-disk crash marker, `audio-default-rec.prev`), restored when the capture
|
||||
closes, recovered at next boot after a crash, and unparked by the uninstaller. A game launched
|
||||
*during* a stream still records the client's mic; one launched before the stream keeps the
|
||||
operator's own microphone.
|
||||
|
||||
Boxes wedged by earlier builds (which recorded nothing to restore) heal themselves: an idle
|
||||
wiring pass that finds the default recording sitting on the plan's mic capture moves it back to
|
||||
the first real microphone.
|
||||
|
||||
⚠ **Operator-visible:** outside a stream, the default recording device is now whatever you set —
|
||||
Punktfunk only takes it for the duration of a stream. If you *want* apps to record the client mic
|
||||
while idle, select "Punktfunk Microphone" manually; the host no longer re-asserts it (idle
|
||||
re-assertion used to stomp a manual choice within one mic-pump reopen).
|
||||
|
||||
### The NixOS module started a second host in root's systemd, which stole the ports from the real one
|
||||
|
||||
Found on the first real deployment of `packaging/nix/nixos-module.nix` (NixOS 26.05, punktfunk
|
||||
0.28.0-nix). The host crash-looped forever on one line:
|
||||
|
||||
```
|
||||
ERROR punktfunk_host: start RTSP server: bind RTSP 48010: Address already in use (os error 98)
|
||||
```
|
||||
|
||||
`systemd.user.*` has no per-user form in NixOS: it installs units into **every** user's systemd
|
||||
manager. `host.autoStart` then adds them to `default.target` — for every user, including **root**,
|
||||
whose `user@0.service` springs into existence the moment anybody so much as SSHes in as root. Root's
|
||||
copy of the host won the race for the fixed ports, and the desktop user's copy could never bind.
|
||||
|
||||
The failure is nastier than it sounds because every *other* listener binds first and logs success —
|
||||
the version banner, mDNS on 47989, the GameStream warning all print normally — so the log reads like
|
||||
a conflict with some unrelated program. A second copy of *itself*, running as root, is the last
|
||||
thing anyone looks for. `host.users` did not help: that option only granted `input`/`punktfunk`
|
||||
group membership and never scoped the units.
|
||||
|
||||
Fixed by rendering `ConditionUser=` on all four user units (`punktfunk-host`, `punktfunk-web`,
|
||||
`punktfunk-web-init`, `punktfunk-scripting`) from `host.users`. Each entry is written `|user` — the
|
||||
pipe makes it a *triggering* condition, which systemd ORs; plain repeated `ConditionUser=` lines are
|
||||
ANDed and would have matched nobody. With `host.users` empty the units fall back to
|
||||
`ConditionUser=!@system`, which still keeps root out while leaving a normal login free to run the
|
||||
host by hand, as the module header documents.
|
||||
|
||||
`packaging/nix/module-check.nix` gained three assertions covering both branches and the fact that
|
||||
`punktfunk-web-init` keeps its pre-existing (non-triggering) `ConditionPathExists` alongside the new
|
||||
condition. They run in the `eval` leg of `nix.yml`, and were verified to fail against the unfixed
|
||||
module before being committed.
|
||||
|
||||
### The Steam plugin synced nothing on Windows: its art is in Program Files, the art roots were not
|
||||
|
||||
@@ -58,6 +217,39 @@ silence would be the wrong answer.
|
||||
expect art, the cue is the host log's `dropped local art the proxy may not serve` line, and the knob
|
||||
is `PUNKTFUNK_LIBRARY_ART_ROOTS` (which **replaces** the defaults — list every root you need).
|
||||
|
||||
### Hyprland/Sway — the wlr-family backends asserted a cursor mode instead of negotiating it
|
||||
|
||||
🛑 **Every cursor-forward session on current Hyprland died at `select_sources`** — "pipeline build
|
||||
failed" and a black client, with `unavailable cursor mode 4` in the portal log.
|
||||
|
||||
Hyprland and wlroots both hardcoded portal `CursorMode::Metadata` whenever the session had
|
||||
negotiated the cursor channel, and never asked the backend what it supports. That is **not** a soft
|
||||
failure: xdg-desktop-portal's **frontend** validates the requested mode against the backend's
|
||||
`AvailableCursorModes` and fails the call with `"Unavailable cursor mode %x"` before the backend
|
||||
ever sees it.
|
||||
|
||||
⭐ **Measured on glass 2026-08-14, and worse than the report suggested.** Against a live Hyprland
|
||||
0.56.2 with xdg-desktop-portal-hyprland 1.4.1 and xdg-desktop-portal 1.22.1 — all current —
|
||||
`AvailableCursorModes` reads **3** (`Hidden|Embedded`) on both the backend impl interface and the
|
||||
frontend. **xdph does not offer the metadata cursor at all**, so this broke every cursor-forward
|
||||
session on current Hyprland, not merely on old installs, and **updating the portal would not have
|
||||
helped.** xdpw is the same from the other end: its `screencast.c` refuses `METADATA` outright.
|
||||
|
||||
`pf-capture`'s own portal path has always negotiated (`choose_cursor_mode`); this restates that
|
||||
ladder in `pf-vdisplay`, which may not depend on `pf-capture`. The downgrade is graceful rather than
|
||||
merely survivable: with the portal on `Embedded` no `SPA_META_Cursor` arrives, so the host feeds the
|
||||
cursor channel nothing and a cursor-forward client draws nothing of its own — **one pointer, not
|
||||
two.**
|
||||
|
||||
**`PUNKTFUNK_PORTAL_CURSOR_MODE=auto|hidden|embedded|metadata`** pins the preference for a backend
|
||||
that advertises a mode it implements badly, which negotiation cannot detect. It is a preference
|
||||
only: a pin runs the same ladder, so no value can re-create the refused request.
|
||||
|
||||
⚠ The module is declared **unconditionally**, so its ladder tests run on every CI leg rather than
|
||||
only the one that compiles `mod hyprland` — including a Linux-only test pinning our bit values
|
||||
against ashpd's enum (ashpd answers 4 for `Metadata`, the number in the report), verified
|
||||
non-vacuous by planting a wrong discriminant.
|
||||
|
||||
### Android — the audio plane trusted AAudio, and a TV box that opened a stream it never played was silent for the session
|
||||
|
||||
🛑 **Reported from the field: no audio at all on an NVIDIA Shield Android TV, stereo, with the same
|
||||
@@ -108,6 +300,84 @@ existing `debug.punktfunk.no_av_sync`: `debug.punktfunk.audio_sharing` (`exclusi
|
||||
old give-up-on-disconnect behaviour). A stream that stops taking samples after it started now says
|
||||
so at `error` level instead of looking exactly like an app with no sound.
|
||||
|
||||
### gamescope — we ship our own Vulkan WSI layer, so a game can reach an HDR10 swapchain (⚠ packager-visible)
|
||||
|
||||
🛑 **On essentially every box running a distro gamescope, no game could render HDR at all** — and
|
||||
nothing said so.
|
||||
|
||||
A game nested under gamescope gets an HDR10 swapchain from the FROG WSI layer and from nothing
|
||||
else: gamescope advertises no runtime colour-management protocol a Mesa/NVIDIA WSI could negotiate
|
||||
through. That layer speaks `gamescope_swapchain` to the compositor, and when the two disagree the
|
||||
compositor rejects the client's `swapchain_feedback` and **every Vulkan client dies on a black
|
||||
screen** with sound and input intact and no error anywhere.
|
||||
|
||||
We shipped our own compositor and *not* a layer, on the recorded grounds that the layer is
|
||||
"version-independent of the compositor binary". It is not — `wsi_layer_matches_our_gamescope()`
|
||||
exists precisely because it is not — so the host was left guessing from version triples, and that
|
||||
guess is wrong in both directions. A distro at the same upstream tag that patched the protocol
|
||||
compares EQUAL and keeps a layer that will black-screen every game; a distro at a different tag
|
||||
with a byte-identical protocol compares unequal and loses HDR for nothing. **Since we pin a rev,
|
||||
the second case is the normal one.**
|
||||
|
||||
We now build the layer from the same tree at the same rev as the compositor and ship it, so the two
|
||||
cannot drift and the guess stops being load-bearing. It installs under **our own** name
|
||||
(`VK_LAYER_PUNKTFUNK_gamescope_wsi`), at our own path, with our own enable/disable variables, so it
|
||||
coexists with the distro's rather than colliding — the Vulkan loader keys implicit layers on that
|
||||
name — and the host switches the two independently within one session.
|
||||
|
||||
`WsiPlan` resolves three states once per launch (the fallback spawns `--version` probes):
|
||||
|
||||
| state | condition | action |
|
||||
|---|---|---|
|
||||
| `Ours` | our layer is installed | enable ours, force the distro's off — **both halves, or it is a bug** |
|
||||
| `DistroKept` | no layer of ours, distro's looks compatible | touch nothing |
|
||||
| `DistroDisabled` | no layer of ours, distro's untrusted | v0.28.0's behaviour |
|
||||
|
||||
That last arm is the fail-safe: a host newer than its gamescope package behaves exactly as it did,
|
||||
rather than enabling a layer that is not there.
|
||||
|
||||
⚠ **What packagers must know.** The layer manifest carries an **absolute** `library_path` baked in
|
||||
at build time, so every channel installs the `.so` at exactly that path: literal
|
||||
`/usr/lib/punktfunk` — **not** `%{_libdir}` (which is `/usr/lib64` on Fedora) and not a Debian
|
||||
multiarch triplet. Nothing links it by soname (the loader `dlopen`s it by that path), so multilib
|
||||
has no claim. rpm and nix read the path back **out of the manifest** and fail if it names a file the
|
||||
package does not install, because a manifest pointing at nothing is the silent shape of this bug.
|
||||
A missing layer is **fatal in every channel**, not best-effort: a package carrying the compositor
|
||||
without it looks completely healthy and then silently denies every game an HDR10 swapchain.
|
||||
|
||||
The packaging scripts now take `--stage` (the DESTDIR the gamescope build script wrote) instead of
|
||||
a path to one binary, and CI caches the whole staged tree; the `gs-cache` key already hashes
|
||||
`packaging/gamescope/**`, so stale caches in the old single-file shape cannot be restored into the
|
||||
new layout. The manifest rewrite lives in `packaging/gamescope/rewrite-wsi-layer-manifest.py`
|
||||
rather than a heredoc, because the FHS builds and the Nix store both need it and must rename the
|
||||
layer identically. **NixOS has no `/usr`**, so the layer lives inside the gamescope derivation and
|
||||
the host's path is overridable with **`PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR`**, which the module sets
|
||||
— the same posture as `PUNKTFUNK_GAMESCOPE_BIN`.
|
||||
|
||||
### gamescope — HDR sessions anchored SDR white a stop bright, and never said game HDR was unreachable
|
||||
|
||||
🛑 **Field report: Steam's Big Picture UI glaring and over-saturated while HDR game content looked
|
||||
washed out, on the same stream.** Those are one error.
|
||||
|
||||
gamescope maps everything that is not an HDR game — the desktop, the Steam overlay, an SDR title —
|
||||
into the session's PQ container at `--hdr-sdr-content-nits`, and we passed that flag **only** when
|
||||
an operator had set `PUNKTFUNK_GAMESCOPE_SDR_NITS`. Unset, gamescope used its own default of
|
||||
**400**, while every first-party client anchors diffuse white at **203** (BT.2408 reference white;
|
||||
the Apple presenter hands exactly that to `CAEDRMetadata.hdr10`'s `opticalOutputScale`). The two
|
||||
ends sat nearly a stop apart, so the UI landed above SDR white and the client's tone-mapper worked
|
||||
from a reference point the host had never used, flattening the content around it.
|
||||
|
||||
**The flag is now always passed, defaulting to 203.** `PUNKTFUNK_GAMESCOPE_SDR_NITS` still
|
||||
overrides it for anyone who wants a brighter or dimmer desktop — it is the anchor, not a taste
|
||||
knob. ⭐ Because it is an env var, a field A/B needs **no rebuild**.
|
||||
|
||||
Separately, and visible in the same log: the two HDR decisions in a gamescope session were made
|
||||
independently. `hdr_args()` never consulted `wsi_layer_matches_our_gamescope()`, so when the layer
|
||||
check fired the session launched **advertising HDR while having made an HDR10 swapchain
|
||||
unreachable for every game in it** — a title told to render HDR rendered it into an SDR swapchain
|
||||
and looked washed out, with nothing anywhere saying why. It now warns. The behaviour of the check
|
||||
itself is deliberately unchanged; the section above is the real fix.
|
||||
|
||||
### punktfunk-gamescope `+pfhdr7` — a lingered session no longer dies of its own capture teardown
|
||||
|
||||
🛑 **On client disconnect the host keeps the headless gamescope alive so a reconnect resumes the
|
||||
@@ -127,6 +397,200 @@ four coredumps on 4K60 HDR + composited cursor, zero after; disconnect/reconnect
|
||||
lingered session. Banner `+pfhdr6` → `+pfhdr7` (no new capability — but "reconnect lost my game"
|
||||
triage must be able to read a box's exposure off its banner, the same rule as `+pfhdr5`/`6`).
|
||||
|
||||
### Apple — the stats overlay lied three ways, and every host-anchored number with it
|
||||
|
||||
🛑 **Two sessions minutes apart on the same wire read `hostnet_p50` 17–21 ms, then a physically
|
||||
impossible 4.4 ms** — host-side encode alone is ~4.7. Three independent defects, all of which
|
||||
corrupt any measurement taken against a host clock:
|
||||
|
||||
- **A frozen clock-offset.** The client consumed the **connect-time** skew offset and cached it —
|
||||
in a `Stage2Pipeline` field, in a `StreamPump` `let`, and in a `ContentView` closure **capture
|
||||
list** feeding the hostnet meter and the host/network splitter. The core keeps a *live* estimate
|
||||
(`punktfunk_connection_clock_offset_now_ns`, ABI v10, re-synced every 60 s and on suspected
|
||||
wall-clock steps) whose own doc says the connect-time value "silently corrupts every
|
||||
capture-clock comparison" after an NTP step — **and a VM host steps.**
|
||||
`PunktfunkConnection.clockOffsetNs` is now the live read (an atomic load behind the FFI), read at
|
||||
use: per record, per AU, per enqueue. The Swift audio plane's AvSync observation takes the same
|
||||
live value.
|
||||
- **Silently trimmed impossible samples.** `LatencyMeter`'s guard (≤ 0 after offset correction)
|
||||
dropped samples without counting them, so a wrong offset did not invalidate a window — it trimmed
|
||||
the impossible half of the shifted distribution and presented the surviving tail as a plausible
|
||||
small number. That is the origin of the historical "0 ms network / 0 ms e2e" readings. Refusals
|
||||
are now counted and drained **separately from `Stats`** — deliberately, because a fully-poisoned
|
||||
window drains to `nil` and a count inside `Stats` would vanish with it. The HUD shows an orange
|
||||
**`clock offset suspect`** line and the stats line grew **`skew_trim=N`**; nonzero means
|
||||
disregard `e2e`/`hostnet` for that window.
|
||||
- **`-1` fallbacks printing as `NaN`.** In a `CVarArg` context `cond ? someDouble : -1` does **not**
|
||||
unify to `Double` — the literal goes in as `Int`, and `%f` reads `Int64(-1)`'s all-ones bit
|
||||
pattern, which is a quiet NaN. Latent since the 1 Hz stats line existed. All fallbacks are now
|
||||
typed `-1.0`.
|
||||
|
||||
⚠ **Any client-side e2e or hostnet figure recorded before this release is suspect** and worth
|
||||
re-measuring rather than trusted as a baseline.
|
||||
|
||||
Two new levers ship with the tvOS present-floor investigation, both env-only:
|
||||
**`PUNKTFUNK_FRAME_LATENCY`** (float 0…4, default 1) makes the `preferredFrameLatency` ask
|
||||
adjustable, so an on-device ladder can establish whether the property does anything on tvOS — the
|
||||
previous "immovable two-refresh floor" verdict rested on a **readback** of a plain read-write
|
||||
float, which is not a grant. **`PUNKTFUNK_PRESENTER=stage1` now resolves on Release builds** (the
|
||||
persisted picker stays DEBUG-gated; an env var takes a `devicectl`/Xcode launch to exist, so it is
|
||||
never a leftover). Stage-1 presents on the hardware video plane rather than through the GPU
|
||||
compositor — the one rung that can dodge the two-refresh regime — and the field A/B that concluded
|
||||
otherwise had silently run stage-4, because the gate keyed on build config.
|
||||
|
||||
### Apple — two colour faults: an SDR stream shipped untagged, and it forced the TV into HDR10
|
||||
|
||||
- **The SDR layer was never tagged.** `configure(hdr:)` guards on `hdr != hdrActive` and
|
||||
`hdrActive` starts `false`, so a session that is SDR from its first frame matched the initial
|
||||
state, fell through the guard, and `configureColor` never ran once — the layer kept `make()`'s
|
||||
bare configuration, which assigns no colour space. An untagged `CAMetalLayer` gets no colour
|
||||
matching: a BT.709 stream is drawn in the display's native space. Mild oversaturation on a P3 Mac
|
||||
or iPad; on a tvOS display composited for HDR it also lifts the black floor. ⚠ It also made
|
||||
`PUNKTFUNK_SDR_COLORSPACE` **dead code on exactly the sessions it exists to fix**, so a field A/B
|
||||
of that knob would have shown no change.
|
||||
- **An SDR stream drove an HDR-capable TV into PQ output.** `applyDisplayCriteriaIfNeeded` builds a
|
||||
synthetic format description hardcoding BT.2020 primaries, ST.2084 and the BT.2020 matrix, then
|
||||
hands it to `AVDisplayManager` — and its guard checked only that no criteria had been set and that
|
||||
the user's HDR *setting* was on, never that **the stream** was HDR. That setting defaults to true.
|
||||
The Apple TV switches HDMI to limited range in its HDR modes, so a set configured for full range
|
||||
renders code 16 as grey rather than black. Now gated on `connection.isHDR` as well; layout re-runs
|
||||
it, so a session that flips to HDR mid-stream still picks the mode up.
|
||||
|
||||
### Apple — the macOS device-change recovery could answer itself forever (mic on)
|
||||
|
||||
**Streaming from a Mac with the microphone enabled cut audio AND input on a ~2.5 s metronome
|
||||
while video ran untouched** (field, 2026-08-14: a Mac Studio whose default input is a 6-channel
|
||||
device). The chain: the voice-processing engine cannot start on that mic, every rebuild re-tried
|
||||
it, and the failed attempt's HAL churn (VPIO builds and tears down an aggregate device) stopped
|
||||
the healthy fallback engines — which posted the `AVAudioEngineConfigurationChange` that scheduled
|
||||
the next rebuild. Each ~1.9 s rebuild runs on the main thread, where macOS input capture and
|
||||
sending live, so input froze on the same beat — and since audio, input and mic share the QUIC
|
||||
datagram plane while video rides its own socket, the wire signature read as a network fault and
|
||||
the host's METRONOMIC heuristic pointed at the display stack. Three defenses, layered because no
|
||||
single one covers every feedback shape:
|
||||
|
||||
- **A voice-processing start failure latches per input device** (`CombinedTopologyGate`): a
|
||||
rebuild goes straight to the split topology instead of re-running a failure that is a property
|
||||
of the device. A different default input earns exactly one fresh attempt.
|
||||
- **A configuration change posted by an engine that is RUNNING is the rebuild's own echo, and is
|
||||
ignored**: an engine stops itself before posting, so a live poster was already restarted.
|
||||
- **Rebuilds that chain anyway back off exponentially** (`RebuildBackoff`: 0.5 s floor doubling
|
||||
to a 30 s cap, reset by 10 s of quiet) — an unforeseen loop costs one blip per half-minute
|
||||
instead of a metronome, and the chaining itself logs a WARN that names the condition.
|
||||
|
||||
iOS/tvOS behaviour is untouched (routes are session-managed there; nothing is latched). Until a
|
||||
client carries this, the field workaround is turning the client microphone off.
|
||||
|
||||
**And the engines no longer start on the main thread at all.** An engine start can block on the
|
||||
audio server for seconds (~1.9 s per attempt in the field case) and macOS captures and sends the
|
||||
stream's input from the main thread — so even a single legitimate device switch froze input for
|
||||
the length of the rebuild, loop or no loop. All engine build/start/teardown now runs on a
|
||||
per-session serial `engineQueue`; the main queue keeps only the trigger bookkeeping (debounce,
|
||||
backoff, retry ladder), which is cheap by construction. ⚠ Embedder-visible edge:
|
||||
`SessionAudio.start()` is now asynchronous on macOS too (it always was on iOS/tvOS) — playback is
|
||||
live shortly after the call, not on return, and `stats` is safe from any thread.
|
||||
|
||||
### Apple gamepad UI — a host menu, and About becomes a page
|
||||
|
||||
**UP on a saved tile opens Wake / Copy link / Edit… / Forget pairing / Remove.** The desktop and
|
||||
Android consoles have had this for a while; this is the Apple port, so the three consoles are
|
||||
learned once. Wiring UP takes the whole vertical axis away from scrolling (down goes inert) — a
|
||||
horizontal carousel has no vertical travel to spend, and one meaning per direction is what makes
|
||||
the gesture learnable. **Remove arms on the first press and fires on the second**, disarming if
|
||||
focus wanders off the row: the touch grid gets a system confirmation dialog, and a thumbstick from
|
||||
across a room deserves at least as much. Edit reuses `GamepadAddHostView` seeded from the record and
|
||||
writes a **copy** back through `HostStore.update`, so the fingerprint, MACs, pins and binding the
|
||||
form never shows survive a rename; it **replaces** the menu rather than stacking on it, keeping the
|
||||
shell's "depth ≤ 1 by construction" true. A pinned profile card offers only Unpin — it is a
|
||||
shortcut, not a second host.
|
||||
|
||||
**The start-of-stream shortcut banner is retired.** Telling someone the controls for six seconds,
|
||||
over the stream they just connected to, answers the question at the one moment nobody is asking it
|
||||
— and it put a composited overlay above the stream to do it. The words are now a catalogue rendered
|
||||
in an About page you can open, which is also its own section rather than the last row of Interface.
|
||||
Its remaining fixes: the identity card became a version line under the rows, a zero-radius clip is
|
||||
still a clip (it cropped the TV's wide icon), and the card ignored the row column.
|
||||
|
||||
⚠ **Apple console screens read the ink they publish.** A SwiftUI screen cannot read the environment
|
||||
value it publishes in the same view — so a pale palette stayed white-on-white on Apple TV. Fixed
|
||||
across every console screen.
|
||||
|
||||
### Console UI — Skia sized its function table to the loader, not to what we promised
|
||||
|
||||
🛑 **On a Steam Deck the console home died on update**, and in a stream the same failure quietly
|
||||
cost the stats OSD and capture HUD.
|
||||
|
||||
The skia-safe 0.87 → 0.99 move swapped `BackendContext::new` for `new_builder(…, None)` and
|
||||
recorded the `None` as "byte-for-byte what the removed constructor did". True of the **value**,
|
||||
false of the **behaviour**: `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, and the newer
|
||||
Skia acts on that sentinel by falling back to **`vkEnumerateInstanceVersion()` — the loader's
|
||||
ceiling, not ours.** The presenter declares 1.3; a current Mesa answers 1.4 (1.4.321 on SteamOS
|
||||
3.7, host and inside the flatpak sandbox alike). Skia then validates a 1.4 function table against an
|
||||
instance that only promised 1.3, `vkGetDeviceProcAddr` returns null for the entry points in
|
||||
between, and `make_vulkan` hands back `None`. At 0.87 the sentinel was inert because that Skia knew
|
||||
nothing of Vulkan 1.4 — **which is why this surfaced the moment v0.28.0 landed.**
|
||||
|
||||
`run.rs` makes an overlay that cannot init fatal for `--browse`, so the Decky panel's button and the
|
||||
gamepad-UI library shortcut both failed to open. The presenter now publishes
|
||||
`SharedDevice::api_version` — `min(what we declared, what the loader reports)` — and
|
||||
`SkiaOverlay::init` passes it instead of `None`. ⚠ `pf-presenter`'s `vk` module is
|
||||
`cfg(any(linux, windows))`, so this was never Deck-specific.
|
||||
|
||||
### pf-vkdecode — AV1's "maximum parameters" level is not a level above the ceiling
|
||||
|
||||
🛑 **Every AV1 session demoted to D3D11VA** with `stream level (seq_level_idx 31) above the device's
|
||||
maxLevel (AV1 Std level 23)` — on hardware decoding the stream trivially on the rung it fell
|
||||
through to.
|
||||
|
||||
`seq_level_idx` is a 5-bit field: Annex A defines 0…23 (levels 2.0…7.3), reserves 24…30, and makes
|
||||
**31 the "maximum parameters" level — the spec's own way of saying the bitstream is not constrained
|
||||
to a level.** `StdVideoAV1Level` stops at 7.3 = 23, so 31 has no Std code point and the index-coded
|
||||
comparison that holds across 0…23 says nothing: `31 > 23` is true even of a device that decodes
|
||||
everything AV1 can name, which is what makes it useless as a capability test. We write no AV1 level
|
||||
on any host encode path, so whichever sentinel the vendor's encoder defaults to is what the client
|
||||
must accept. This is the AV1 half of the same defect fixed for H.264/H.265 in v0.28.0, which was
|
||||
left alone on the premise that no over-declaration had been seen in the field — the reporter's log
|
||||
from that same day already showed otherwise.
|
||||
|
||||
### Client stats — the stage line is a partition again
|
||||
|
||||
A field reader added up `host 5.4 · net 0.3 · decode 6.6 · display 1.4` against `e2e 8.1` and asked
|
||||
why the parts did not sum. Fair question: they sum **without** `decode`.
|
||||
|
||||
The stages *are* a per-frame partition of e2e — pts →(host+net)→ received →(decode)→ decoded
|
||||
→(display)→ displayed — for as long as the `decoded` stamp is a **completion** stamp. On the
|
||||
synchronous rungs it is. On the **native-Vulkan** rung `receive_frame` returns at *submission*
|
||||
(~0.1 ms) and the stamp is taken there, so `display` is measured from submit and the GPU decode
|
||||
happens **inside** it. `host+net` and `display` already tile e2e; the `decode` figure (received →
|
||||
fence-complete) re-counts the GPU work `display` contains — two figures with one overlap, printed
|
||||
as though they tiled.
|
||||
|
||||
On that rung `decode` now leaves the stage line and gets its own, carrying the two caveats a reader
|
||||
needs: it is **one sample per window** there, not the p50 every other figure on that line is, and it
|
||||
is already inside `display`, so adding it double-counts. The synchronous rungs are untouched.
|
||||
⚠ **Deliberately not changed:** the one-sample-per-window design. A per-frame fence wait serialises
|
||||
the decode pipeline (an APU's 19 ms decode capping a 5120×1440 stream at ~51 fps) and polling
|
||||
quantises every sample up by a frame interval. The reporting was the defect, not the sampling.
|
||||
|
||||
### Host — two warnings that named the wrong subsystem
|
||||
|
||||
Both fired in the same 2026-08-13 field log, and both sent an investigation somewhere innocent:
|
||||
|
||||
- **"Client keyframe recoveries are METRONOMIC — a periodic host/display disturbance … is the
|
||||
likely cause"**, at `period_s=2.0`, naming three host subsystems. **2.0 s is the *client's*
|
||||
`FLUSH_COOLDOWN`.** The receive-backlog guard sheds a standing queue with a flush plus a keyframe
|
||||
request, rate-limited to one per cooldown, so a client that cannot sustain the stream asks for a
|
||||
keyframe at exactly that spacing for as long as it stays behind. **Perfect periodicity is the
|
||||
signature of a fixed software cooldown, not of a physical disturbance.** The host now compares
|
||||
against `punktfunk_core::client::FLUSH_COOLDOWN` itself rather than a copy of the number, so the
|
||||
two cannot drift.
|
||||
- **"The audio encode thread could not keep up — captured audio was DROPPED"**, worst case
|
||||
`dropped_chunks=11251`. Not one sample anybody wanted was lost. PipeWire negotiated a 128-frame
|
||||
quantum, so the plane produces 48000/128 = 375 chunks/s and a 30 s window holds exactly 11250 —
|
||||
a 100 % drop rate at `peak_db=-120.0`, digital silence. Every one of the ten warnings straddled a
|
||||
**session boundary**, and `dropped_chunks/375` matches the seconds with *no live session* in that
|
||||
window to within a fraction of a second. The warning no longer fires for idle seconds.
|
||||
|
||||
### NixOS — the plugin runner was installed, running, and reported missing
|
||||
|
||||
🛑 **On NixOS every plugin *package* op failed with "the plugin runner isn't installed", on a box
|
||||
@@ -165,6 +629,51 @@ NixOS ships only `sh` in `/bin`, so `gamelease`'s hand-off test and `pyrowave_re
|
||||
handshake-rung test failed there for reasons unrelated to the code under test. Both now resolve a
|
||||
real binary rather than assuming an FHS path.
|
||||
|
||||
### Documentation
|
||||
|
||||
**`docs-site/public/openapi.json` was stale again, and by the same mechanism as last release.**
|
||||
v0.28.0 fixed it once (it was five releases behind at `0.21.0`); the scanner-removal regen then
|
||||
updated `api/openapi.json` alone and it drifted a second time inside that same cycle. It has now
|
||||
drifted a third time, across the unpair-all endpoints — the docs-site copy was still stamped
|
||||
`0.27.0` and missing both collection deletes. Re-synced; the two files are byte-identical again.
|
||||
|
||||
⚠ **The copy is a documented manual step (`cp api/openapi.json docs-site/public/openapi.json`,
|
||||
CONTRIBUTING.md) and nothing in CI enforces it.** Three drifts in two release cycles is the
|
||||
argument for gating it; until something does, **treat the copy as part of regenerating, not as a
|
||||
follow-up.**
|
||||
|
||||
### Linux — the data-plane threads finally get the priority they ask for (⚠ packager-visible)
|
||||
|
||||
**On every Linux host to date, `pf_frame::thread_qos`'s per-thread renice was a silent no-op** —
|
||||
it needs CAP_SYS_NICE or a raised RLIMIT_NICE, no packaging channel granted either, and the host
|
||||
binary can never carry a file capability (KWin identification, the 0.26.0-1 incident). So the
|
||||
capture/encode and send threads ran at nice 0, and a CPU-saturating burst on the host — a fresh
|
||||
game launch's shader-compile storm is the canonical one — descheduled them at will. A 2026-08-14
|
||||
field log showed the result end to end: 5 ms audio datagrams leaving late enough to stutter, the
|
||||
client's delay signal rising, and ABR cutting a gigabit-Ethernet session to its 5 Mbps floor with
|
||||
zero packet loss — while the box carried 708 Mbps cleanly minutes later, once the storm passed.
|
||||
|
||||
**The renice now falls back to RealtimeKit** (`MakeThreadHighPriorityWithPID`, one blocking
|
||||
system-bus call per boosted thread) — the same unprivileged broker PipeWire clients use, present
|
||||
on effectively every desktop install. No capability enters the host's permitted set, so KWin
|
||||
identification is untouched. Boxes with neither rtkit nor the new limit keep today's best-effort
|
||||
no-op, one debug line per thread.
|
||||
|
||||
**The audio plane is boosted at all for the first time.** The 5 ms Opus capture→encode→send loop,
|
||||
the PipeWire capture mainloop thread (its `process` callbacks run there — PipeWire's own
|
||||
`module-rt` only covers data loops we don't use), and the pad-audio streamer now take the same
|
||||
boost the video threads always asked for. The audio loop is `critical`: a scheduling stall there
|
||||
is directly audible where a late video frame is one presentation slip.
|
||||
|
||||
⚠ **Packagers: a new `user@.service.d` drop-in.** rpm/deb/Arch (and the Bazzite sysext, via the
|
||||
RPM) now ship `packaging/linux/50-punktfunk-nice.conf` →
|
||||
`/usr/lib/systemd/system/user@.service.d/50-punktfunk-nice.conf` (`LimitNICE=-15`), so the direct
|
||||
`setpriority()` also works where rtkit isn't running. It raises a session *limit*, from the next
|
||||
login — nothing is reprioritized by itself. The NixOS module instead sets
|
||||
`security.rtkit.enable = lib.mkDefault true` (rtkit is not a given there). It remains true that
|
||||
**no channel may ever grant the host binary a file capability** — this change is the sanctioned
|
||||
route to the same end.
|
||||
|
||||
---
|
||||
|
||||
## v0.28.0
|
||||
|
||||
Generated
+37
-36
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -2343,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3031,7 +3031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3049,7 +3049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3071,7 +3071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3081,7 +3081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3107,7 +3107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3115,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.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3133,11 +3134,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3166,14 +3167,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3188,7 +3189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3196,7 +3197,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3208,7 +3209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3217,7 +3218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3250,7 +3251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3261,7 +3262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3272,7 +3273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3484,7 +3485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3494,7 +3495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3512,7 +3513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3529,7 +3530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
@@ -3543,7 +3544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3561,7 +3562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3593,7 +3594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3602,7 +3603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3672,7 +3673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3686,7 +3687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3709,7 +3710,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.28.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.**
|
||||
|
||||
@@ -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
|
||||
|
||||
+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
|
||||
|
||||
@@ -477,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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -354,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:
|
||||
@@ -1262,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).
|
||||
@@ -1320,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)
|
||||
|
||||
@@ -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)
|
||||
@@ -311,6 +354,61 @@ private struct ShotConnect: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -342,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)
|
||||
@@ -356,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 {
|
||||
@@ -374,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()
|
||||
@@ -513,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) {
|
||||
@@ -523,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()
|
||||
@@ -549,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)
|
||||
@@ -572,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)")
|
||||
}
|
||||
|
||||
@@ -594,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
|
||||
@@ -610,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,
|
||||
@@ -626,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("""
|
||||
@@ -637,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,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)
|
||||
}
|
||||
@@ -813,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)?
|
||||
{
|
||||
@@ -823,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
|
||||
@@ -983,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 —
|
||||
@@ -1001,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
|
||||
@@ -1054,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
|
||||
@@ -1064,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 {
|
||||
|
||||
@@ -452,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.
|
||||
@@ -677,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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
|
||||
@@ -21,6 +21,10 @@ tracing = "0.1"
|
||||
# `FramePayload::Cuda` owns a zero-copy `DeviceBuffer`; `libc` for the per-thread `setpriority`.
|
||||
pf-zerocopy = { path = "../pf-zerocopy" }
|
||||
libc = "0.2"
|
||||
# The rtkit fallback in `thread_qos` (one blocking system-bus call per boosted thread). Same zbus
|
||||
# the host already pulls via ashpd; `tokio` mirrors ashpd's backend choice so this adds the
|
||||
# `blocking-api` surface without changing the resolved I/O backend, and no default `async-io`.
|
||||
zbus = { version = "5", default-features = false, features = ["tokio", "blocking-api"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The DXGI capture identity (`WinCaptureTarget`/`D3d11Frame`/`pack_luid`/`make_device`) + the GPU
|
||||
|
||||
@@ -44,10 +44,9 @@ pub fn boost_thread_priority(critical: bool) {
|
||||
// Best-effort nice of the CALLING thread. On Linux `setpriority(PRIO_PROCESS, 0, …)` acts on
|
||||
// the calling thread (the kernel resolves who==0 to the current task/tid), and both call
|
||||
// sites run inside their worker thread — so this nices exactly the capture/encode (critical)
|
||||
// and send (non-critical) threads, nothing else. Silently no-ops without CAP_SYS_NICE / a
|
||||
// raised RLIMIT_NICE, which is fine. We deliberately do NOT use SCHED_RR/FIFO by default: a
|
||||
// realtime CPU class can preempt the compositor AND the game's own render thread, adding the
|
||||
// very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR).
|
||||
// and send (non-critical) threads, nothing else. We deliberately do NOT use SCHED_RR/FIFO by
|
||||
// default: a realtime CPU class can preempt the compositor AND the game's own render thread,
|
||||
// adding the very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR).
|
||||
let nice = if critical { -10 } else { -5 };
|
||||
// SAFETY: `setpriority` takes three by-value integers and no pointers, so there is nothing to
|
||||
// alias or outlive. `PRIO_PROCESS` with `who == 0` targets the calling task on Linux and
|
||||
@@ -57,10 +56,24 @@ pub fn boost_thread_priority(critical: bool) {
|
||||
if rc == 0 {
|
||||
tracing::debug!(critical, nice, "thread nice raised");
|
||||
} else {
|
||||
tracing::debug!(
|
||||
critical,
|
||||
"setpriority(nice) no-op (needs CAP_SYS_NICE / RLIMIT_NICE)"
|
||||
);
|
||||
// The direct call needs CAP_SYS_NICE or a raised RLIMIT_NICE, and the host binary can
|
||||
// NEVER carry a file capability (a capped process's /proc/<pid>/exe is unreadable to
|
||||
// KWin, which kills desktop streaming — the 0.26.0-1 field incident). RealtimeKit is
|
||||
// the sanctioned unprivileged path: the same broker PipeWire's clients use, present on
|
||||
// effectively every desktop install. Packaging also ships a `user@.service.d`
|
||||
// LimitNICE drop-in so the direct call works on rtkit-less boxes — but only from the
|
||||
// next login, and existing installs upgrade the binary alone; rtkit is what fixes the
|
||||
// installed base. A 2026-08-14 field log showed exactly this rung missing: every
|
||||
// fresh-launch shader storm descheduled the unprioritized audio/send threads.
|
||||
match linux_rtkit::make_high_priority(nice) {
|
||||
Ok(()) => tracing::debug!(critical, nice, "thread nice raised via rtkit"),
|
||||
Err(e) => tracing::debug!(
|
||||
critical,
|
||||
reason = %e,
|
||||
"setpriority(nice) no-op (needs CAP_SYS_NICE / RLIMIT_NICE, and rtkit \
|
||||
was unavailable)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
@@ -68,3 +81,43 @@ pub fn boost_thread_priority(critical: bool) {
|
||||
let _ = critical;
|
||||
}
|
||||
}
|
||||
|
||||
/// RealtimeKit fallback for [`boost_thread_priority`]: ask the system-bus broker
|
||||
/// (`org.freedesktop.RealtimeKit1`) to renice the calling thread when the direct
|
||||
/// `setpriority` was refused. This is how PulseAudio/PipeWire clients get their boosts on a
|
||||
/// stock desktop — no capability anywhere, which matters here because a file capability on the
|
||||
/// host binary breaks KWin's client identification outright.
|
||||
///
|
||||
/// Only the high-priority (nice) verb is used, never `MakeThreadRealtime` — the SCHED_RR
|
||||
/// reservations in [`boost_thread_priority`]'s comment apply to rtkit-granted RR too (and the
|
||||
/// RT verb additionally demands an RLIMIT_RTTIME we don't set).
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_rtkit {
|
||||
/// One-shot blocking D-Bus call. Must be made from a plain worker thread, never from async
|
||||
/// context — which already holds for every caller: `boost_thread_priority` acts on the
|
||||
/// calling thread, so it only ever runs inside the dedicated capture/encode/send threads.
|
||||
/// The connection is per-call rather than cached: this runs at most a handful of times per
|
||||
/// session (thread starts), and holding a system-bus connection for the session's lifetime
|
||||
/// to save microseconds at session start is a bad trade against a wedged bus daemon pinning
|
||||
/// a socket in every session forever.
|
||||
pub(super) fn make_high_priority(nice: i32) -> Result<(), zbus::Error> {
|
||||
// SAFETY: `gettid` takes no arguments, touches no memory, and returns the calling
|
||||
// thread's kernel tid — always valid on Linux.
|
||||
let tid = unsafe { libc::syscall(libc::SYS_gettid) } as u64;
|
||||
let pid = u64::from(std::process::id());
|
||||
let conn = zbus::blocking::Connection::system()?;
|
||||
// `MakeThreadHighPriorityWithPID(u64 process, u64 thread, i32 priority)` — priority is a
|
||||
// nice level, floored by rtkit's MinNiceLevel (defaults well below our -10). The WithPID
|
||||
// variant with our own pid is the explicit spelling of "this thread of this process";
|
||||
// rtkit still authenticates the caller via the bus, so it grants nothing a plain
|
||||
// `setpriority` caller couldn't be granted.
|
||||
conn.call_method(
|
||||
Some("org.freedesktop.RealtimeKit1"),
|
||||
"/org/freedesktop/RealtimeKit1",
|
||||
Some("org.freedesktop.RealtimeKit1"),
|
||||
"MakeThreadHighPriorityWithPID",
|
||||
&(pid, tid, nice),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,30 @@ pub struct HostConfig {
|
||||
/// text ("Living Room PC"); the DNS-level `<label>.local.` target keeps using a sanitized
|
||||
/// machine-safe label, so a spacey display name can't produce an invalid mDNS record.
|
||||
pub host_name: Option<String>,
|
||||
/// `PUNKTFUNK_MGMT_BIND` — the management API's listen address (`IP:PORT`), equivalent to the
|
||||
/// `--mgmt-bind` CLI flag, which still wins when both are given. Unset = `0.0.0.0:47990`.
|
||||
///
|
||||
/// This exists so moving the port SURVIVES: `--mgmt-bind` lives in a unit file / service
|
||||
/// registration that a package upgrade rewrites, whereas `host.env` is operator-owned and is
|
||||
/// the documented place every other knob lives. The motivating case is coexistence with a
|
||||
/// Sunshine fork — 47990 is *their* web UI port as well as our management API, and it is the
|
||||
/// only port the two share once the GameStream planes are off, so moving it is the whole fix.
|
||||
///
|
||||
/// Kept as the raw string rather than a parsed `SocketAddr`: this crate is the
|
||||
/// parse-once-from-env layer, and `main.rs` owns turning a bad value into the same
|
||||
/// `bad --mgmt-bind (want IP:PORT)` error the flag produces, from one place.
|
||||
pub mgmt_bind: Option<String>,
|
||||
/// `PUNKTFUNK_NATIVE_PORT` — the native punktfunk/1 (QUIC) control port, equivalent to the
|
||||
/// `--native-port` CLI flag, which still wins. Unset = 9777.
|
||||
///
|
||||
/// Same survives-an-upgrade argument as [`Self::mgmt_bind`]: `--native-port` lives in an
|
||||
/// ExecStart a package rewrites. Unlike the mgmt port, the CLIENT side of moving this already
|
||||
/// worked — `KnownHost.port` is persisted per host and `--connect HOST:PORT` names it — so this
|
||||
/// key is the last piece of making the native port genuinely movable.
|
||||
///
|
||||
/// Raw string, parsed in `main.rs`, for the same reason as `mgmt_bind`: a typo'd port must be a
|
||||
/// startup ERROR, not a silent fall back to 9777 while the operator believes they moved it.
|
||||
pub native_port: Option<String>,
|
||||
/// `PUNKTFUNK_GAMESTREAM` — enable the GameStream/Moonlight-compat planes (nvhttp pairing,
|
||||
/// RTSP, ENet control, `_nvstream` mDNS) from `host.env`, equivalent to the `--gamestream`
|
||||
/// CLI flag (either source turns it on). **Default OFF** — the secure native-only host: the
|
||||
@@ -214,6 +238,15 @@ pub struct HostConfig {
|
||||
/// showing the wrong monitor is worse than showing none). Linux-only today; see
|
||||
/// `design/per-monitor-portal-capture.md`.
|
||||
pub capture_monitor: Option<String>,
|
||||
/// `PUNKTFUNK_PORTAL_CURSOR_MODE` — `auto` (default) · `hidden` · `embedded` · `metadata`.
|
||||
/// Pin the ScreenCast cursor mode the Linux portal backends PREFER, instead of the one the
|
||||
/// session negotiates (`metadata` when the client draws the pointer itself, `embedded`
|
||||
/// otherwise). The pin is a preference, not a command: it still runs through
|
||||
/// `portal_cursor::pick`, so it can never ask a backend for a mode the backend does not
|
||||
/// advertise — that closes the session rather than degrading, which is the failure this knob
|
||||
/// sits next to. Exists for the backend that advertises a mode it implements badly, where
|
||||
/// negotiation has nothing to go on; `embedded` is the safe answer there.
|
||||
pub portal_cursor_mode: Option<String>,
|
||||
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
|
||||
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
|
||||
pub compositor: Option<String>,
|
||||
@@ -365,6 +398,14 @@ impl HostConfig {
|
||||
host_name: val("PUNKTFUNK_HOST_NAME")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
// Blank-is-unset, like `host_name` above: an operator who comments a value out by
|
||||
// emptying it (`PUNKTFUNK_MGMT_BIND=`) means "default", not "parse the empty string".
|
||||
mgmt_bind: val("PUNKTFUNK_MGMT_BIND")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
native_port: val("PUNKTFUNK_NATIVE_PORT")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
// Default OFF, explicit-on grammar: the Moonlight-compat planes are opt-in
|
||||
// everywhere (see the field doc); `--gamestream` on the CLI also turns them on.
|
||||
gamestream: env_on("PUNKTFUNK_GAMESTREAM").unwrap_or(false),
|
||||
@@ -401,6 +442,12 @@ impl HostConfig {
|
||||
capture_monitor: val("PUNKTFUNK_CAPTURE_MONITOR")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
// Same emptied-to-None rule: a bare `PUNKTFUNK_PORTAL_CURSOR_MODE=` left in a host.env
|
||||
// means "not set", not an unrecognised value to warn about. The spellings are parsed
|
||||
// (and warned about) at the use site, `pf-vdisplay`'s `portal_cursor::want`.
|
||||
portal_cursor_mode: val("PUNKTFUNK_PORTAL_CURSOR_MODE")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
||||
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
||||
|
||||
@@ -39,6 +39,14 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// [`SessionOpts::on_connected`]'s callback: the host's certificate fingerprint, then the
|
||||
/// management-API port from its `Welcome` (`0` = it advertised none).
|
||||
///
|
||||
/// A named type rather than the inline `Box<dyn FnMut(...)>` because adding the second parameter
|
||||
/// tipped it over `clippy::type_complexity` — factoring it out is what that lint asks for, and it
|
||||
/// gives the two positional arguments somewhere to be documented.
|
||||
pub type ConnectedFn = Box<dyn FnMut([u8; 32], u16)>;
|
||||
|
||||
pub struct SessionOpts {
|
||||
pub window_title: String,
|
||||
/// Start fullscreen (gamescope / `--fullscreen`).
|
||||
@@ -84,9 +92,14 @@ pub struct SessionOpts {
|
||||
pub allow_vrr: bool,
|
||||
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
||||
pub json_status: bool,
|
||||
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
||||
/// binary's business — this loop stays store-agnostic).
|
||||
pub on_connected: Option<Box<dyn FnMut([u8; 32])>>,
|
||||
/// Called once on `Connected` with the host's fingerprint and the management-API port the
|
||||
/// host reported in its `Welcome` (`0` = it advertised none). Trust persistence is the
|
||||
/// binary's business — this loop stays store-agnostic.
|
||||
///
|
||||
/// The port rides along because this is the one moment a client is guaranteed to have it
|
||||
/// WITHOUT mDNS: the session it just authenticated carries it. A client that saves it here
|
||||
/// can browse the library of a host it has only ever reached by address.
|
||||
pub on_connected: Option<ConnectedFn>,
|
||||
/// The console-UI overlay (§6.1) — `None` is the Skia-free power-user build (stats
|
||||
/// stay stdout-only). An overlay whose `init` fails degrades to `None` with a
|
||||
/// warning rather than killing the session. Browse mode requires one.
|
||||
@@ -1377,9 +1390,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
apply_capture(&mut window, &mouse, true, cap.desktop(), inhibit_shortcuts);
|
||||
st.capture = Some(cap);
|
||||
st.cursor_chan = Some(crate::cursor::CursorChannel::new(&c));
|
||||
// Read the mgmt port BEFORE `c` is moved into `st` — the Welcome's answer to
|
||||
// "where is this host's library", which the binary persists so it survives
|
||||
// without ever needing an mDNS advert.
|
||||
let mgmt_port = c.mgmt_port();
|
||||
st.connector = Some(c);
|
||||
if let Some(f) = opts.on_connected.as_mut() {
|
||||
f(fingerprint);
|
||||
f(fingerprint, mgmt_port);
|
||||
}
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Streaming);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="dpms">
|
||||
<copyright><![CDATA[
|
||||
SPDX-FileCopyrightText: 2015 Martin Gräßlin
|
||||
|
||||
SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
]]></copyright>
|
||||
<interface name="org_kde_kwin_dpms_manager" version="1">
|
||||
<description summary="Output dpms manager">
|
||||
The Dpms manager allows to get a org_kde_kwin_dpms for a given wl_output.
|
||||
The org_kde_kwin_dpms provides the currently used VESA Display Power Management
|
||||
Signaling state (see https://en.wikipedia.org/wiki/VESA_Display_Power_Management_Signaling ).
|
||||
In addition it allows to request a state change. A compositor is not obliged to honor it
|
||||
and will normally automatically switch back to on state.
|
||||
|
||||
Warning! The protocol described in this file is a desktop environment
|
||||
implementation detail. Regular clients must not use this protocol.
|
||||
Backward incompatible changes may be added without bumping the major
|
||||
version of the extension.
|
||||
</description>
|
||||
<request name="get">
|
||||
<description summary="Get org_kde_kwin_dpms for wl_output">
|
||||
Factory request to get the org_kde_kwin_dpms for a given wl_output.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="org_kde_kwin_dpms"/>
|
||||
<arg name="output" type="object" interface="wl_output"/>
|
||||
</request>
|
||||
</interface>
|
||||
<interface name="org_kde_kwin_dpms" version="1">
|
||||
<description summary="Dpms for a wl_output">
|
||||
This interface provides information about the VESA DPMS state for a wl_output.
|
||||
It gets created through the request get on the org_kde_kwin_dpms_manager interface.
|
||||
|
||||
On creating the resource the server will push whether DPSM is supported for the output,
|
||||
the currently used DPMS state and notifies the client through the done event once all
|
||||
states are pushed. Whenever a state changes the set of changes is committed with the
|
||||
done event.
|
||||
</description>
|
||||
<event name="supported">
|
||||
<description summary="Event indicating whether DPMS is supported on the wl_output">
|
||||
This event gets pushed on binding the resource and indicates whether the wl_output
|
||||
supports DPMS. There are operation modes of a Wayland server where DPMS might not
|
||||
make sense (e.g. nested compositors).
|
||||
</description>
|
||||
<arg name="supported" type="uint" summary="Boolean value whether DPMS is supported (1) for the wl_output or not (0)"/>
|
||||
</event>
|
||||
<enum name="mode">
|
||||
<entry name="On" value="0"/>
|
||||
<entry name="Standby" value="1"/>
|
||||
<entry name="Suspend" value="2"/>
|
||||
<entry name="Off" value="3"/>
|
||||
</enum>
|
||||
<event name="mode">
|
||||
<description summary="Event indicating used DPMS mode">
|
||||
This mode gets pushed on binding the resource and provides the currently used
|
||||
DPMS mode. It also gets pushed if DPMS is not supported for the wl_output, in that
|
||||
case the value will be On.
|
||||
|
||||
The event is also pushed whenever the state changes.
|
||||
</description>
|
||||
<arg name="mode" type="uint" summary="The new currently used mode"/>
|
||||
</event>
|
||||
<event name="done">
|
||||
<description summary="All changes are pushed">
|
||||
This event gets pushed on binding the resource once all other states are pushed.
|
||||
|
||||
In addition it gets pushed whenever a state changes to tell the client that all
|
||||
state changes have been pushed.
|
||||
</description>
|
||||
</event>
|
||||
<request name="set">
|
||||
<description summary="Request DPMS state change for the wl_output">
|
||||
Requests that the compositor puts the wl_output into the passed mode. The compositor
|
||||
is not obliged to change the state. In addition the compositor might leave the mode
|
||||
whenever it seems suitable. E.g. the compositor might return to On state on user input.
|
||||
|
||||
The client should not assume that the mode changed after requesting a new mode.
|
||||
Instead the client should listen for the mode event.
|
||||
</description>
|
||||
<arg name="mode" type="uint" summary="Requested mode"/>
|
||||
</request>
|
||||
<request name="release" type="destructor">
|
||||
<description summary="release the dpms object"/>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
|
||||
@@ -824,6 +824,15 @@ pub mod admission;
|
||||
#[path = "vdisplay/linux/portal_config.rs"]
|
||||
mod portal_config;
|
||||
|
||||
/// Which ScreenCast cursor mode to REQUEST — negotiated against `AvailableCursorModes` instead of
|
||||
/// hardcoded, because a mode the backend does not advertise closes the session outright.
|
||||
///
|
||||
/// Declared unconditionally for the same reason as `portal_config` above: the ladder is pure
|
||||
/// integer work whose tests are the only place its behaviour is observable without a compositor,
|
||||
/// so they should run on every platform's CI rather than only where the callers compile.
|
||||
#[path = "vdisplay/linux/portal_cursor.rs"]
|
||||
mod portal_cursor;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/hyprland.rs"]
|
||||
mod hyprland;
|
||||
@@ -839,6 +848,15 @@ mod kwin;
|
||||
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
|
||||
mod kwin_output_mgmt;
|
||||
|
||||
// DPMS control of the box's live KDE desktop (org_kde_kwin_dpms) — how a bare-spawn gamescope
|
||||
// session honors `Topology::Exclusive`: the spawn is its own headless compositor, so the desktop's
|
||||
// physical outputs can't be *disabled* (KWin refuses zero enabled outputs and no output there is
|
||||
// ours) — they are put to DPMS-off for the stream instead, refcounted across concurrent spawns.
|
||||
// Consumed by `gamescope` (best-effort, with kscreen fallback).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/kwin_dpms.rs"]
|
||||
mod kwin_dpms;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/manager.rs"]
|
||||
pub mod manager;
|
||||
|
||||
@@ -69,6 +69,11 @@ pub struct GamescopeDisplay {
|
||||
/// the decision and this session's `create`. `None` = nothing resolved it (a caller that never
|
||||
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
|
||||
route: Option<crate::GamescopeRoute>,
|
||||
/// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
|
||||
/// the release of this display's [`crate::kwin_dpms`] darken hold — pending pickup by the
|
||||
/// registry via [`VirtualDisplay::take_topology_restore`], so it runs at the display's
|
||||
/// teardown (§6.1) and never before.
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at.
|
||||
@@ -441,6 +446,14 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
self.route = route;
|
||||
}
|
||||
|
||||
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
|
||||
// The DPMS darken-hold release the bare-spawn `create` registered (Exclusive topology
|
||||
// only). The registry stores it on this display's entry and runs it at teardown — which,
|
||||
// for gamescope, is the display's OWN teardown: every spawn is its own group, and the
|
||||
// cross-session ordering lives in `kwin_dpms`'s refcount, not in the group float.
|
||||
self.pending_restore.take()
|
||||
}
|
||||
|
||||
fn poolable_now(&self) -> bool {
|
||||
// Only a bare SPAWN is registry-poolable (its `create` reports `Owned`); Managed and
|
||||
// Attach report `SessionManaged`/`External`, so the registry must not reuse a kept spawn
|
||||
@@ -576,6 +589,23 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
hz = mode.refresh_hz,
|
||||
"gamescope virtual output ready"
|
||||
);
|
||||
// `Topology::Exclusive`, bare-spawn edition: this spawn is its OWN headless compositor —
|
||||
// nothing above touched the box's live desktop (KWin), which would otherwise keep driving
|
||||
// the physical panel with the idle desktop for the whole stream. The KWin route disables
|
||||
// the physicals outright, but that door is closed here (KWin refuses zero enabled outputs,
|
||||
// and no output on that desktop is ours to leave enabled) — so the desktop's panels go to
|
||||
// DPMS-off instead, best-effort and self-gating (a box with no KDE desktop declines
|
||||
// quietly inside `kwin_dpms`). Placed AFTER the spawn succeeded, so a failed create never
|
||||
// blanks the user's screen. The hold is refcounted in `kwin_dpms` rather than floated
|
||||
// through the registry's group restore, because every gamescope spawn is its own group
|
||||
// (`registry::group_key`) — the float alone would re-light the panel when the FIRST of two
|
||||
// concurrent spawns ends, under the second's still-live stream. Skipped for Managed (its
|
||||
// takeover already stopped the desktop) and Attach (it mirrors a gamescope that may itself
|
||||
// be driving the physical panel) — both returned earlier in this function.
|
||||
if crate::effective_topology() == crate::policy::Topology::Exclusive {
|
||||
crate::kwin_dpms::acquire_stream_darken();
|
||||
self.pending_restore = Some(Box::new(crate::kwin_dpms::release_stream_darken));
|
||||
}
|
||||
// Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
|
||||
Ok(VirtualOutput::owned(
|
||||
node_id,
|
||||
|
||||
@@ -115,12 +115,21 @@ fn output_owner_pid(name: &str) -> Option<u32> {
|
||||
/// The Hyprland virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) adds one
|
||||
/// named headless output and spins up a portal thread owning the cast on it.
|
||||
pub struct HyprlandDisplay {
|
||||
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal
|
||||
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): PREFER portal
|
||||
/// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the
|
||||
/// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the
|
||||
/// pointer into frames, zero host-side cursor work (the pre-channel default this backend
|
||||
/// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the
|
||||
/// channel isn't silently dead here; KWin/Mutter are the validated legs).
|
||||
/// composite blend. Off (every non-channel session): prefer `Embedded` — the compositor paints
|
||||
/// the pointer into frames, zero host-side cursor work (the pre-channel default this backend
|
||||
/// always had).
|
||||
///
|
||||
/// Both are only a PREFERENCE: [`crate::portal_cursor`] settles it against what xdph actually
|
||||
/// advertises, because requesting an unadvertised mode makes xdg-desktop-portal fail the call.
|
||||
/// This used to be asserted instead, which is exactly how a cursor-forward session here became
|
||||
/// a black client.
|
||||
///
|
||||
/// ⚠️ On current xdph the metadata arm is UNREACHABLE, not merely untested: measured on .21
|
||||
/// 2026-08-14 (Hyprland 0.56.2, xdph 1.4.1) `AvailableCursorModes` = 3 — `Hidden|Embedded`
|
||||
/// only. Every session on this backend therefore resolves to `Embedded` today; KWin/Mutter
|
||||
/// remain the legs where the metadata channel is actually exercised.
|
||||
hw_cursor: bool,
|
||||
}
|
||||
|
||||
@@ -788,13 +797,7 @@ fn portal_thread(
|
||||
stop: Arc<AtomicBool>,
|
||||
hw_cursor: bool,
|
||||
) {
|
||||
// Portal cursor mode per the session's channel negotiation (see the struct doc).
|
||||
let cursor_mode = if hw_cursor {
|
||||
CursorMode::Metadata
|
||||
} else {
|
||||
CursorMode::Embedded
|
||||
};
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
@@ -818,6 +821,14 @@ fn portal_thread(
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
|
||||
)?;
|
||||
// NEGOTIATED against what xdph advertises, never asserted from `hw_cursor` alone: a
|
||||
// cursor mode the backend does not offer does not degrade — xdg-desktop-portal's
|
||||
// FRONTEND fails the call ("Unavailable cursor mode %x") before xdph sees it.
|
||||
// MEASURED on .21 2026-08-14, Hyprland 0.56.2 + xdph 1.4.1 (both current):
|
||||
// `AvailableCursorModes` = 3 (Hidden|Embedded) — metadata is NOT offered. So the old
|
||||
// hardcode killed EVERY cursor-forward session here, on today's packages, not just on
|
||||
// old installs: `unavailable cursor mode 4`, "pipeline build failed", black client.
|
||||
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdph").await;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
|
||||
@@ -704,7 +704,7 @@ fn kscreen_ok(args: &[String]) -> bool {
|
||||
/// before exiting, so a slow-but-working KWin gives us a kill on a request that already landed;
|
||||
/// any caller that treats `None` as "it failed" is asserting something it does not know, and for
|
||||
/// the restore path that assertion costs a monitor its refresh rate.
|
||||
fn kscreen_verdict(args: &[String]) -> Option<bool> {
|
||||
pub(crate) fn kscreen_verdict(args: &[String]) -> Option<bool> {
|
||||
match crate::proc::status_within(
|
||||
std::process::Command::new("kscreen-doctor").args(args),
|
||||
KSCREEN_BUDGET,
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
//! DPMS control of the box's live KDE desktop (`org_kde_kwin_dpms`) — how a bare-spawn gamescope
|
||||
//! session honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
|
||||
//!
|
||||
//! A bare spawn is its OWN headless compositor: nothing on that route touches the desktop the box
|
||||
//! is showing, so on a KDE machine the physical panel keeps displaying the (idle) desktop for the
|
||||
//! whole stream — while the same `exclusive` policy on the KWin route turns the physicals off
|
||||
//! outright. The KWin route's mechanism is closed to us here: KWin refuses an output configuration
|
||||
//! with ZERO enabled outputs, and a gamescope session has no KWin output of its own to leave
|
||||
//! enabled. DPMS is the honest translation of `exclusive` for this route — the desktop stays
|
||||
//! exactly where it is (no topology churn, no window re-homing), the panels go dark, and any
|
||||
//! LOCAL input wakes them, which is the right answer for a desktop someone can walk up to.
|
||||
//! Stream input never wakes them: it is injected into the nested gamescope's own EIS socket and
|
||||
//! does not pass through KWin.
|
||||
//!
|
||||
//! Driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the same
|
||||
//! stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a separate
|
||||
//! libkscreen/KDED layer that can be wedged while KWin itself answers fine), with a
|
||||
//! `kscreen-doctor --dpms` shell-out fallback. Best-effort everywhere — a box with no Wayland
|
||||
//! session, or a non-KDE desktop, declines quietly and the stream proceeds with the panel lit,
|
||||
//! exactly as before this module existed.
|
||||
//!
|
||||
//! **The hold is refcounted here, NOT floated through the registry's per-group restore.** Every
|
||||
//! gamescope spawn is its own display group (`registry::group_key` — deliberately, they are
|
||||
//! independent nested sessions), so the §6.1 group machinery alone would run the FIRST session's
|
||||
//! restore at that session's teardown and re-light the panel under a second, still-streaming
|
||||
//! session. Instead each exclusive spawn takes one [`acquire_stream_darken`] hold (the 0→1 edge
|
||||
//! darkens) and registers [`release_stream_darken`] as its per-display topology restore (the 1→0
|
||||
//! edge re-lights) — the same shape as `sleep_inhibit`'s refcount, riding the registry only for
|
||||
//! the *timing* of each release.
|
||||
//!
|
||||
//! Crash safety comes free: DPMS is non-persistent, so a host that dies holding the panel dark
|
||||
//! leaves nothing to journal — the screen re-lights on the next local input or compositor
|
||||
//! restart. (Contrast the Windows `pnp_disable_monitors` path, which needs a recovery journal
|
||||
//! precisely because its disable survives everything.)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use wayland_client::protocol::wl_callback::{self, WlCallback};
|
||||
use wayland_client::protocol::wl_output::{self, WlOutput};
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||
|
||||
// Client bindings for the vendored KDE dpms protocol (`protocols/dpms.xml`), generated inline like
|
||||
// the two in `kwin_output_mgmt`. Self-contained: its only foreign object type is the core
|
||||
// `wl_output`, which `wayland_client::protocol` already provides.
|
||||
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
pub mod protocol {
|
||||
use wayland_client;
|
||||
use wayland_client::protocol::*;
|
||||
|
||||
pub mod __interfaces {
|
||||
use wayland_client::protocol::__interfaces::*;
|
||||
wayland_scanner::generate_interfaces!("protocols/dpms.xml");
|
||||
}
|
||||
use self::__interfaces::*;
|
||||
|
||||
wayland_scanner::generate_client_code!("protocols/dpms.xml");
|
||||
}
|
||||
|
||||
use protocol::org_kde_kwin_dpms::{Event as DpmsEvent, OrgKdeKwinDpms as Dpms};
|
||||
use protocol::org_kde_kwin_dpms_manager::OrgKdeKwinDpmsManager as DpmsManager;
|
||||
|
||||
// The wire enum `org_kde_kwin_dpms.mode`. The XML types the `mode` request/event args as plain
|
||||
// `uint` (no `enum=` attribute), so the generated signatures take/deliver `u32` — these constants
|
||||
// are the protocol's values, kept in sync with the vendored `dpms.xml`.
|
||||
const DPMS_MODE_ON: u32 = 0;
|
||||
const DPMS_MODE_OFF: u32 = 3;
|
||||
|
||||
/// `org_kde_kwin_dpms_manager` is a frozen v1 protocol (its own header warns it may change
|
||||
/// without a version bump, but no v2 has appeared since 2015); bind `min(advertised, 1)`.
|
||||
const MANAGER_MAX: u32 = 1;
|
||||
/// `wl_output.name` — the connector name used for logging — arrived in v4. Everything else we do
|
||||
/// works at v1, so a lower advert just costs the log its names.
|
||||
const WL_OUTPUT_MAX: u32 = 4;
|
||||
|
||||
/// Overall budget for one darken/re-light operation (mirrors `kwin_output_mgmt::OP_BUDGET`):
|
||||
/// generous next to a healthy roundtrip, and only there so a wedged compositor can't pin the
|
||||
/// session-create (or group-teardown) thread.
|
||||
const OP_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Poll slice while waiting on the Wayland fd (matches `kwin_output_mgmt`).
|
||||
const POLL_MS: i32 = 100;
|
||||
|
||||
/// One output's accumulated state on this connection, keyed by its `wl_output` global name.
|
||||
#[derive(Default)]
|
||||
struct OutputState {
|
||||
proxy: Option<WlOutput>,
|
||||
/// Connector name (`DP-1`) from `wl_output.name` (v4) — logging only; the global number is
|
||||
/// the address everything operates on.
|
||||
connector: Option<String>,
|
||||
dpms: Option<Dpms>,
|
||||
/// `org_kde_kwin_dpms.supported` — `None` until the bind burst arrives.
|
||||
supported: Option<bool>,
|
||||
/// The last `org_kde_kwin_dpms.mode` seen — kept current, so the post-`set` wait can watch it
|
||||
/// flip.
|
||||
mode: Option<u32>,
|
||||
}
|
||||
|
||||
/// Everything one connection's queue accumulates.
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
manager: Option<DpmsManager>,
|
||||
/// Keyed by the `wl_output` GLOBAL NAME — a stable address for the compositor's lifetime, and
|
||||
/// the identity the darken records so the re-light (a separate, later connection) can find the
|
||||
/// same outputs again.
|
||||
outputs: HashMap<u32, OutputState>,
|
||||
/// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
|
||||
sync_done: u32,
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} => {
|
||||
if interface == DpmsManager::interface().name {
|
||||
let v = version.min(MANAGER_MAX);
|
||||
state.manager = Some(registry.bind::<DpmsManager, _, _>(name, v, qh, ()));
|
||||
} else if interface == WlOutput::interface().name {
|
||||
let v = version.min(WL_OUTPUT_MAX);
|
||||
// The global name rides in the UserData so the output's own events (and the
|
||||
// dpms object's, which gets the same stamp) can find this entry.
|
||||
let out = registry.bind::<WlOutput, _, _>(name, v, qh, name);
|
||||
state.outputs.entry(name).or_default().proxy = Some(out);
|
||||
}
|
||||
}
|
||||
// An output unplugged mid-operation: drop the entry so we never `set` on its corpse.
|
||||
wl_registry::Event::GlobalRemove { name } => {
|
||||
state.outputs.remove(&name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlOutput, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlOutput,
|
||||
event: wl_output::Event,
|
||||
global: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_output::Event::Name { name } = event {
|
||||
if let Some(o) = state.outputs.get_mut(global) {
|
||||
o.connector = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<Dpms, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &Dpms,
|
||||
event: DpmsEvent,
|
||||
global: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let Some(o) = state.outputs.get_mut(global) else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
DpmsEvent::Supported { supported } => o.supported = Some(supported != 0),
|
||||
DpmsEvent::Mode { mode } => o.mode = Some(mode),
|
||||
DpmsEvent::Done => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The manager has no events; the impl exists because `WlRegistry::bind` demands one.
|
||||
impl Dispatch<DpmsManager, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &DpmsManager,
|
||||
_: protocol::org_kde_kwin_dpms_manager::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlCallback, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlCallback,
|
||||
event: wl_callback::Event,
|
||||
serial: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_callback::Event::Done { .. } = event {
|
||||
state.sync_done = state.sync_done.max(*serial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why [`Session::open`] declined — the same honest-decline discipline as
|
||||
/// `kwin_output_mgmt::OpenFailure`: which rung said no decides both the log level and whether the
|
||||
/// `kscreen-doctor` fallback is worth attempting.
|
||||
enum OpenFailure {
|
||||
/// No Wayland connection at all (`WAYLAND_DISPLAY` unset/stale). The common case for the bare
|
||||
/// spawn's natural habitat — a headless plain-distro box with no desktop to darken.
|
||||
Connect(String),
|
||||
/// The compositor accepted the connection but did not answer the registry barrier in budget:
|
||||
/// a live but wedged session — the case the shell-out fallback exists for.
|
||||
RegistryBarrier,
|
||||
/// Connected and answering, but `org_kde_kwin_dpms_manager` is not advertised — not KWin. A
|
||||
/// definitive answer: no fallback can succeed here either (`kscreen-doctor` drives the same
|
||||
/// KDE-only machinery), so this rung declines without one.
|
||||
NoDpmsGlobal,
|
||||
/// The manager is there but the per-output DPMS state bursts never completed in budget.
|
||||
StateBarrier,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OpenFailure {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
OpenFailure::Connect(e) => write!(f, "no Wayland connection ({e})"),
|
||||
OpenFailure::RegistryBarrier => {
|
||||
write!(
|
||||
f,
|
||||
"the compositor did not answer the registry roundtrip in budget"
|
||||
)
|
||||
}
|
||||
OpenFailure::NoDpmsGlobal => {
|
||||
write!(f, "org_kde_kwin_dpms_manager is not advertised (not KWin)")
|
||||
}
|
||||
OpenFailure::StateBarrier => {
|
||||
write!(
|
||||
f,
|
||||
"the outputs' DPMS state never finished announcing in budget"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connected session with the manager bound and every output's DPMS state read.
|
||||
struct Session {
|
||||
conn: Connection,
|
||||
queue: wayland_client::EventQueue<State>,
|
||||
state: State,
|
||||
next_sync: u32,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// [`Session::connect`] for the operation named by `op`, logging the decline at a level that
|
||||
/// matches what it means: `Connect`/`NoDpmsGlobal` are the everyday non-KDE answers (most
|
||||
/// bare-spawn boxes have no desktop at all) and log at debug; the two barrier failures mean a
|
||||
/// LIVE session stopped answering — on a KDE box that is a panel left lit, so they warn.
|
||||
fn open(op: &'static str) -> Result<Session, OpenFailure> {
|
||||
let opened = Session::connect();
|
||||
if let Err(reason) = &opened {
|
||||
match reason {
|
||||
OpenFailure::Connect(_) | OpenFailure::NoDpmsGlobal => {
|
||||
tracing::debug!(op, %reason, "KWin DPMS unavailable");
|
||||
}
|
||||
OpenFailure::RegistryBarrier | OpenFailure::StateBarrier => {
|
||||
tracing::warn!(
|
||||
op,
|
||||
%reason,
|
||||
"KWin DPMS: in-process path unavailable — falling back to kscreen-doctor"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
opened
|
||||
}
|
||||
|
||||
/// Connect to the desktop's Wayland socket, bind the dpms manager + every `wl_output`, create
|
||||
/// a dpms status object per output and drain their state bursts — all bounded by [`OP_BUDGET`].
|
||||
fn connect() -> Result<Session, OpenFailure> {
|
||||
let conn = Connection::connect_to_env().map_err(|e| OpenFailure::Connect(e.to_string()))?;
|
||||
let queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
let mut s = Session {
|
||||
conn,
|
||||
queue,
|
||||
state: State::default(),
|
||||
next_sync: 0,
|
||||
};
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
// Phase 1: process the registry globals (binds the manager + every wl_output).
|
||||
if !s.sync_barrier(deadline) {
|
||||
return Err(OpenFailure::RegistryBarrier);
|
||||
}
|
||||
let Some(mgr) = s.state.manager.clone() else {
|
||||
return Err(OpenFailure::NoDpmsGlobal);
|
||||
};
|
||||
// Phase 2: one dpms status object per output (stamped with the output's global name so its
|
||||
// events land on the right entry), then a barrier that drains both the outputs' `name`
|
||||
// events and the dpms objects' supported/mode/done bursts.
|
||||
let qh = s.queue.handle();
|
||||
let bound: Vec<(u32, WlOutput)> = s
|
||||
.state
|
||||
.outputs
|
||||
.iter()
|
||||
.filter_map(|(g, o)| o.proxy.clone().map(|p| (*g, p)))
|
||||
.collect();
|
||||
for (global, out) in bound {
|
||||
let d = mgr.get(&out, &qh, global);
|
||||
if let Some(o) = s.state.outputs.get_mut(&global) {
|
||||
o.dpms = Some(d);
|
||||
}
|
||||
}
|
||||
if !s.sync_barrier(deadline) {
|
||||
return Err(OpenFailure::StateBarrier);
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
|
||||
fn sync_barrier(&mut self, deadline: Instant) -> bool {
|
||||
self.next_sync += 1;
|
||||
let serial = self.next_sync;
|
||||
let qh = self.queue.handle();
|
||||
let _cb = self.conn.display().sync(&qh, serial);
|
||||
self.pump_until(deadline, |st| st.sync_done >= serial)
|
||||
}
|
||||
|
||||
/// Bounded manual event loop — flush, dispatch, poll the fd. Mirrors
|
||||
/// `kwin_output_mgmt::Session::pump_until` (same rationale: `blocking_dispatch` can't be
|
||||
/// interrupted, so the fd is polled in [`POLL_MS`] slices against `deadline`).
|
||||
fn pump_until(&mut self, deadline: Instant, done: impl Fn(&State) -> bool) -> bool {
|
||||
loop {
|
||||
if done(&self.state) {
|
||||
return true;
|
||||
}
|
||||
if self.queue.dispatch_pending(&mut self.state).is_err() {
|
||||
return false;
|
||||
}
|
||||
if done(&self.state) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
if self.conn.flush().is_err() {
|
||||
return false;
|
||||
}
|
||||
let Some(guard) = self.conn.prepare_read() else {
|
||||
continue; // events already queued — loop dispatches them
|
||||
};
|
||||
let mut pfd = libc::pollfd {
|
||||
fd: self.conn.as_fd().as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
let timeout = (remaining.as_millis() as i32).clamp(0, POLL_MS);
|
||||
// SAFETY: `&mut pfd` points at one live, fully-initialized `libc::pollfd` on the stack
|
||||
// and the count `1` matches that single element, so `poll` reads `fd`/`events` and
|
||||
// writes `revents` strictly within `pfd`. `pfd.fd` is the Wayland connection's fd,
|
||||
// valid because `self.conn` (and the `prepare_read` guard) outlive the call. `poll`
|
||||
// blocks up to `timeout` ms and writes only `revents`; `pfd` is a fresh local that
|
||||
// aliases nothing.
|
||||
let r = unsafe { libc::poll(&mut pfd, 1, timeout) };
|
||||
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
|
||||
let _ = guard.read();
|
||||
} // else: timeout/signal — drop the guard, re-check the deadline
|
||||
}
|
||||
}
|
||||
|
||||
/// Request `target` on every DPMS-supporting output not already there — restricted to the
|
||||
/// globals in `only` when given (the re-light path, which must touch ONLY what the darken
|
||||
/// touched: a panel the USER had put to sleep before the stream is theirs to keep dark).
|
||||
/// Returns the outputs actually asked to change, `(global, connector)`, then waits (within
|
||||
/// budget) for each one's `mode` event to confirm — the protocol is explicit that `set` is a
|
||||
/// request the compositor may decline, so the confirmation is watched and its absence logged
|
||||
/// rather than assumed.
|
||||
fn set_mode(&mut self, target: u32, only: Option<&[u32]>) -> Vec<(u32, Option<String>)> {
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
let mut touched: Vec<(u32, Option<String>)> = Vec::new();
|
||||
for (global, o) in &self.state.outputs {
|
||||
if only.is_some_and(|list| !list.contains(global)) {
|
||||
continue;
|
||||
}
|
||||
if o.supported != Some(true) || o.mode == Some(target) {
|
||||
continue;
|
||||
}
|
||||
if let Some(dpms) = &o.dpms {
|
||||
dpms.set(target);
|
||||
touched.push((*global, o.connector.clone()));
|
||||
}
|
||||
}
|
||||
if touched.is_empty() {
|
||||
return touched;
|
||||
}
|
||||
let want: Vec<u32> = touched.iter().map(|(g, _)| *g).collect();
|
||||
// An output that vanished mid-wait (GlobalRemove pruned it) counts as settled — there is
|
||||
// nothing left to flip.
|
||||
let confirmed = self.pump_until(deadline, |st| {
|
||||
want.iter()
|
||||
.all(|g| st.outputs.get(g).is_none_or(|o| o.mode == Some(target)))
|
||||
});
|
||||
if !confirmed {
|
||||
tracing::warn!(
|
||||
outputs = ?touched,
|
||||
target,
|
||||
"KWin DPMS: the compositor did not confirm the mode change in budget (the \
|
||||
requests are flushed; it may still land, or KWin may have declined)"
|
||||
);
|
||||
}
|
||||
touched
|
||||
}
|
||||
}
|
||||
|
||||
/// What the 0→1 darken actually achieved — the record the 1→0 re-light undoes. Which arm did the
|
||||
/// work matters: the two are undone through different doors.
|
||||
enum Darkened {
|
||||
/// The in-process path turned these outputs off — `(wl_output global, connector)`. Global
|
||||
/// names are stable for the compositor's lifetime, so a later connection re-lights exactly
|
||||
/// these. If KWin restarted in between the names match nothing — and that is the CORRECT
|
||||
/// no-op, because a fresh KWin brings its outputs up lit anyway.
|
||||
Wayland(Vec<(u32, Option<String>)>),
|
||||
/// The `kscreen-doctor --dpms off` fallback ran (it takes no per-output address, so the
|
||||
/// re-light is the symmetric `--dpms on`).
|
||||
Kscreen,
|
||||
}
|
||||
|
||||
/// The host-wide darken hold — refcounted like `sleep_inhibit`: the 0→1 edge darkens, the 1→0
|
||||
/// edge re-lights, and everything between is bookkeeping. See the module docs for why the
|
||||
/// registry's per-group restore float can't provide this (every gamescope spawn is its own group).
|
||||
struct Holds {
|
||||
count: u32,
|
||||
/// What the 0→1 darken achieved, held until the 1→0 release undoes it. `None` while count > 0
|
||||
/// means the darken found nothing to do (no KDE, panels already dark) — the release then has
|
||||
/// nothing to undo, which is exactly right.
|
||||
darkened: Option<Darkened>,
|
||||
}
|
||||
|
||||
impl Holds {
|
||||
/// Take a hold; `true` on the 0→1 edge — the caller darkens and [`record`](Self::record)s.
|
||||
fn acquire_edge(&mut self) -> bool {
|
||||
self.count += 1;
|
||||
self.count == 1
|
||||
}
|
||||
|
||||
/// Store the 0→1 darken's outcome.
|
||||
fn record(&mut self, d: Option<Darkened>) {
|
||||
self.darkened = d;
|
||||
}
|
||||
|
||||
/// Drop a hold; `Some` on the 1→0 edge hands the caller the record to undo. A release with no
|
||||
/// hold outstanding is a caller bug (an unbalanced restore) — logged, never underflowed.
|
||||
fn release_edge(&mut self) -> Option<Darkened> {
|
||||
if self.count == 0 {
|
||||
tracing::warn!("KWin DPMS: release without a matching acquire (unbalanced restore)");
|
||||
return None;
|
||||
}
|
||||
self.count -= 1;
|
||||
if self.count == 0 {
|
||||
self.darkened.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static HOLDS: Mutex<Holds> = Mutex::new(Holds {
|
||||
count: 0,
|
||||
darkened: None,
|
||||
});
|
||||
|
||||
/// Take one darken hold for an exclusive-topology stream. The first hold turns the live KDE
|
||||
/// desktop's panels off (best-effort, bounded); later holds just count. Callers MUST balance each
|
||||
/// call with [`release_stream_darken`] — the gamescope backend does it by registering the release
|
||||
/// as the display's topology restore, so the registry runs it exactly once per display at
|
||||
/// teardown (§6.1).
|
||||
///
|
||||
/// The lock is deliberately held across the darken itself: a racing second acquire must queue
|
||||
/// behind it (and then see the recorded outcome), not observe a count of 2 with nothing darkened.
|
||||
/// Same discipline on the release side, which keeps a teardown-overlapping-connect sequence
|
||||
/// strictly ordered: re-light completes, then the new stream's darken runs.
|
||||
pub fn acquire_stream_darken() {
|
||||
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if h.acquire_edge() {
|
||||
let d = darken();
|
||||
h.record(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one darken hold; the last one out re-lights whatever the first hold's darken achieved.
|
||||
pub fn release_stream_darken() {
|
||||
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(d) = h.release_edge() {
|
||||
relight(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 0→1 darken: in-process over `org_kde_kwin_dpms` first, `kscreen-doctor --dpms off` as the
|
||||
/// wedged-compositor fallback. `None` = nothing was darkened (no desktop, not KDE, panels already
|
||||
/// off, or every arm declined) — and therefore nothing to restore.
|
||||
fn darken() -> Option<Darkened> {
|
||||
match Session::open("darken") {
|
||||
Ok(mut s) => {
|
||||
let touched = s.set_mode(DPMS_MODE_OFF, None);
|
||||
if touched.is_empty() {
|
||||
tracing::debug!(
|
||||
"KWin DPMS: no output to darken (none supported, or all already off)"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
tracing::info!(
|
||||
outputs = ?touched,
|
||||
"KWin DPMS: desktop outputs off for the exclusive gamescope stream"
|
||||
);
|
||||
Some(Darkened::Wayland(touched))
|
||||
}
|
||||
}
|
||||
// Definitive "not KDE" / "no desktop": no fallback can do better (kscreen-doctor drives
|
||||
// the same KDE-only machinery), so decline quietly — already logged by `open`.
|
||||
Err(OpenFailure::NoDpmsGlobal) | Err(OpenFailure::Connect(_)) => None,
|
||||
// A live session that stopped answering: the standalone tool rides a different stack
|
||||
// (libkscreen/KDED) and may still get through — the same rationale as `kwin.rs`'s
|
||||
// kscreen fallbacks, honest-verdict discipline included.
|
||||
Err(_) => match kscreen_dpms("off") {
|
||||
Some(true) => {
|
||||
tracing::info!(
|
||||
"KWin DPMS: desktop outputs off for the exclusive gamescope stream \
|
||||
(kscreen-doctor fallback)"
|
||||
);
|
||||
Some(Darkened::Kscreen)
|
||||
}
|
||||
// Killed at its budget — NOT a refusal: kscreen-doctor applies first and then waits
|
||||
// on the compositor, so a loaded KWin routinely lands the change and still gets
|
||||
// killed. Record the darken so the teardown re-light runs either way; a `--dpms on`
|
||||
// against a lit panel is a no-op.
|
||||
None => Some(Darkened::Kscreen),
|
||||
Some(false) => {
|
||||
tracing::warn!(
|
||||
"KWin DPMS: could not darken the desktop outputs for the exclusive topology \
|
||||
(in-process path and kscreen-doctor both declined) — the panel stays lit"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1→0 re-light. **This is the last line of defence for a dark monitor**, so every arm that
|
||||
/// gives up says so loudly (the same discipline as `kwin.rs::reenable_outputs_kscreen`) — a dark
|
||||
/// panel with no line in the log is the failure mode this chain exists to prevent. The worst case
|
||||
/// stays self-healing regardless: DPMS is non-persistent, and any local input wakes the panel.
|
||||
fn relight(d: Darkened) {
|
||||
match d {
|
||||
Darkened::Wayland(outputs) => {
|
||||
let globals: Vec<u32> = outputs.iter().map(|(g, _)| *g).collect();
|
||||
match Session::open("re-light") {
|
||||
Ok(mut s) => {
|
||||
s.set_mode(DPMS_MODE_ON, Some(&globals));
|
||||
tracing::info!(outputs = ?outputs, "KWin DPMS: desktop outputs back on");
|
||||
}
|
||||
Err(_) => match kscreen_dpms("on") {
|
||||
Some(true) | None => {
|
||||
tracing::info!(
|
||||
"KWin DPMS: desktop outputs back on (kscreen-doctor fallback)"
|
||||
);
|
||||
}
|
||||
Some(false) => {
|
||||
tracing::error!(
|
||||
outputs = ?outputs,
|
||||
"KWin DPMS: could NOT re-light the desktop outputs (in-process \
|
||||
restore and kscreen-doctor both declined) — the panel stays dark \
|
||||
until local input wakes it"
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Darkened::Kscreen => {
|
||||
if kscreen_dpms("on") == Some(false) {
|
||||
tracing::error!(
|
||||
"KWin DPMS: could NOT re-light the desktop outputs (kscreen-doctor refused \
|
||||
the --dpms on it earlier accepted the off for) — the panel stays dark until \
|
||||
local input wakes it"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `kscreen-doctor --dpms <on|off>` for its verdict, on `kwin.rs`'s shared budget and three-state
|
||||
/// convention (`Some(true)` ran and succeeded, `Some(false)` refused or unrunnable, `None` killed
|
||||
/// at the budget — which, for a tool that applies first and waits after, usually means it landed).
|
||||
fn kscreen_dpms(mode: &'static str) -> Option<bool> {
|
||||
crate::kwin::kscreen_verdict(&["--dpms".to_string(), mode.to_string()])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Darkened, Holds};
|
||||
|
||||
fn fresh() -> Holds {
|
||||
Holds {
|
||||
count: 0,
|
||||
darkened: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_acquire_darkens_later_ones_count() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge(), "0→1 must darken");
|
||||
h.record(Some(Darkened::Kscreen));
|
||||
assert!(
|
||||
!h.acquire_edge(),
|
||||
"a second concurrent stream must not re-darken"
|
||||
);
|
||||
assert!(!h.acquire_edge());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_last_release_relights() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge());
|
||||
h.record(Some(Darkened::Wayland(vec![(7, Some("DP-1".into()))])));
|
||||
assert!(!h.acquire_edge());
|
||||
// First release: a sibling still streams — the panel must stay dark.
|
||||
assert!(h.release_edge().is_none());
|
||||
// Last release hands back the record to undo.
|
||||
let d = h.release_edge();
|
||||
assert!(matches!(d, Some(Darkened::Wayland(v)) if v == vec![(7, Some("DP-1".into()))]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_darken_that_did_nothing_restores_nothing() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge());
|
||||
h.record(None); // no KDE / already dark: nothing was changed
|
||||
assert!(h.release_edge().is_none(), "nothing to undo");
|
||||
assert_eq!(h.count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbalanced_release_never_underflows() {
|
||||
let mut h = fresh();
|
||||
assert!(h.release_edge().is_none());
|
||||
assert_eq!(h.count, 0, "count must not wrap");
|
||||
// And the state machine still works afterwards.
|
||||
assert!(h.acquire_edge());
|
||||
h.record(Some(Darkened::Kscreen));
|
||||
assert!(matches!(h.release_edge(), Some(Darkened::Kscreen)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_cycle_rearms_the_darken() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge());
|
||||
h.record(Some(Darkened::Kscreen));
|
||||
assert!(h.release_edge().is_some());
|
||||
// A later stream on the same host lifetime darkens again.
|
||||
assert!(
|
||||
h.acquire_edge(),
|
||||
"the 0→1 edge must re-arm after a full cycle"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Which ScreenCast cursor mode to ASK the portal for — negotiated against what the backend
|
||||
//! advertises, rather than asserted.
|
||||
//!
|
||||
//! The portal spec is unforgiving here: `SelectSources` with a cursor mode that is absent from
|
||||
//! `AvailableCursorModes` does not quietly degrade — **xdg-desktop-portal itself rejects the call**
|
||||
//! (`"Unavailable cursor mode %x"`, an `INVALID_ARGUMENT` from the FRONTEND, which validates the
|
||||
//! request against the backend's advertised bitfield before the backend ever sees it). Both
|
||||
//! wlr-family backends used to hardcode `Metadata` whenever the session had negotiated the cursor
|
||||
//! channel, so every cursor-forward session died at `select_sources` — `unavailable cursor mode 4`
|
||||
//! (4 being `Metadata`'s bit) and a client left on a black screen behind "pipeline build failed".
|
||||
//! Field report 2026-08-14.
|
||||
//!
|
||||
//! ⚠️ This is NOT a stale-portal problem, and not Hyprland-specific. MEASURED on .21 2026-08-14 on
|
||||
//! fully current packages — Hyprland **0.56.2**, xdg-desktop-portal-hyprland **1.4.1**,
|
||||
//! xdg-desktop-portal **1.22.1** — with a live session and xdph attached (`[screencopy] init
|
||||
//! successful`): `AvailableCursorModes` reads **3** (`Hidden|Embedded`) on both the backend impl
|
||||
//! interface and the frontend. **Metadata is simply not offered by xdph today.** xdpw is the same
|
||||
//! story from the other end: its `screencast.c` refuses `METADATA` outright. So the hardcode broke
|
||||
//! every cursor-forward session on the entire wlr family, on current software — not only on old
|
||||
//! installs. (xdph 1.4.1 would itself fall back — its binary carries
|
||||
//! `"[screencopy] unsupported cursor_mode {}, fallback to {}"` — but it never gets the chance,
|
||||
//! because the frontend fails the call first.)
|
||||
//!
|
||||
//! `pf-capture`'s own portal path has always negotiated (`portal::choose_cursor_mode`) — this is
|
||||
//! that ladder, restated in the crate that owns the virtual-display backends. pf-vdisplay must not
|
||||
//! depend on pf-capture (see this crate's Cargo.toml: "never on capture/inject or the
|
||||
//! orchestrator"), so the two copies are deliberate; keep the ladders in step.
|
||||
//!
|
||||
//! Declared unconditionally although only the Linux backends call it: the ladder is pure integer
|
||||
//! work, and its tests are the whole point of the module — this is a decision that leaves no trace
|
||||
//! anyone can check without a compositor in front of them — so they run on every platform's CI
|
||||
//! rather than on the one leg that compiles `mod hyprland`.
|
||||
|
||||
/// A ScreenCast cursor mode, valued as the portal's own wire bits — which is what a backend prints
|
||||
/// when it rejects one, so `Metadata`'s `4` is literally the number in the field report.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Mode {
|
||||
/// No pointer in the cast at all.
|
||||
Hidden = 1,
|
||||
/// The compositor paints the pointer into the frames it hands us.
|
||||
Embedded = 2,
|
||||
/// The pointer rides `SPA_META_Cursor` metadata beside the frames: the compositor keeps its
|
||||
/// cheap hardware cursor plane, and the consumer either composites the shape itself or
|
||||
/// forwards it to a client that draws its own.
|
||||
Metadata = 4,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// The portal's bit for this mode.
|
||||
pub(crate) const fn bit(self) -> u32 {
|
||||
self as u32
|
||||
}
|
||||
|
||||
/// The spelling used in logs and in `PUNKTFUNK_PORTAL_CURSOR_MODE`.
|
||||
pub(crate) const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Hidden => "hidden",
|
||||
Mode::Embedded => "embedded",
|
||||
Mode::Metadata => "metadata",
|
||||
}
|
||||
}
|
||||
|
||||
/// What to ask for instead, best first, when this mode is not advertised.
|
||||
const fn fallbacks(self) -> [Mode; 2] {
|
||||
match self {
|
||||
// The session wanted out-of-band shapes and cannot have them. `Embedded` still puts a
|
||||
// pointer on the client's screen (the compositor's, burnt in) — and because no
|
||||
// `SPA_META_Cursor` then arrives, the host feeds the cursor channel nothing and a
|
||||
// cursor-forward client draws nothing of its own, so this is one pointer, not two.
|
||||
// `Hidden` is last: it streams a desktop nobody can point at.
|
||||
Mode::Metadata => [Mode::Embedded, Mode::Hidden],
|
||||
// Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture path
|
||||
// composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer.
|
||||
Mode::Embedded => [Mode::Metadata, Mode::Hidden],
|
||||
// A deliberate request for no pointer that the backend will not honour. Either
|
||||
// remaining mode shows one; prefer the cheap burnt-in pointer over metadata nothing on
|
||||
// this path is set up to draw.
|
||||
Mode::Hidden => [Mode::Embedded, Mode::Metadata],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of the ladder: what to request, and what the session actually wanted if those
|
||||
/// differ (the caller logs the gap — a silently downgraded cursor is how this class of bug hides).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Choice {
|
||||
/// The mode to put in `SelectSources`. Advertised, unless the backend advertised nothing.
|
||||
pub(crate) mode: Mode,
|
||||
/// Set only when `mode` is a downgrade: the mode the session asked for and could not have.
|
||||
pub(crate) wanted: Option<Mode>,
|
||||
}
|
||||
|
||||
/// Pick the cursor mode to request, given the backend's `AvailableCursorModes` bitfield.
|
||||
///
|
||||
/// Never returns a mode outside `advertised` unless `advertised` names none we know — see the tail
|
||||
/// comment, which is the one case with no right answer.
|
||||
pub(crate) fn pick(advertised: u32, want: Mode) -> Choice {
|
||||
if advertised & want.bit() != 0 {
|
||||
return Choice {
|
||||
mode: want,
|
||||
wanted: None,
|
||||
};
|
||||
}
|
||||
for alt in want.fallbacks() {
|
||||
if advertised & alt.bit() != 0 {
|
||||
return Choice {
|
||||
mode: alt,
|
||||
wanted: Some(want),
|
||||
};
|
||||
}
|
||||
}
|
||||
// The backend advertised no mode this build knows — 0, or only bits from a spec revision newer
|
||||
// than us. Every request is then a coin flip against a session-closing rejection; `Hidden` is
|
||||
// both the most universally implemented and the only one that cannot end up drawing two
|
||||
// pointers. The caller warns: whatever this backend is doing, we are guessing.
|
||||
Choice {
|
||||
mode: Mode::Hidden,
|
||||
wanted: Some(want),
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `PUNKTFUNK_PORTAL_CURSOR_MODE`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Pin {
|
||||
/// Unset or `auto` — the session's own negotiation decides.
|
||||
Auto,
|
||||
/// Prefer this mode instead of what the session negotiated. Still runs the ladder, so a pin
|
||||
/// can never re-create the session-killing request this module exists to prevent.
|
||||
Mode(Mode),
|
||||
/// Set to something we do not recognise. Treated as `Auto`, but the caller says so out loud —
|
||||
/// a typo'd escape hatch that silently does nothing is worse than no escape hatch.
|
||||
Unrecognised,
|
||||
}
|
||||
|
||||
/// Parse the `PUNKTFUNK_PORTAL_CURSOR_MODE` value.
|
||||
pub(crate) fn parse_pin(raw: &str) -> Pin {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "auto" => Pin::Auto,
|
||||
"hidden" | "none" => Pin::Mode(Mode::Hidden),
|
||||
"embedded" | "composited" => Pin::Mode(Mode::Embedded),
|
||||
"metadata" | "meta" => Pin::Mode(Mode::Metadata),
|
||||
_ => Pin::Unrecognised,
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode this session wants before the backend gets a say: `Metadata` when the cursor channel
|
||||
/// was negotiated (`set_hw_cursor` — the client draws the pointer, so the compositor must not burn
|
||||
/// it in), `Embedded` otherwise. `PUNKTFUNK_PORTAL_CURSOR_MODE` overrides both.
|
||||
///
|
||||
/// `backend` names the portal implementation for the log line only (`xdph`, `xdpw`).
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub(crate) fn want(hw_cursor: bool, backend: &str) -> Mode {
|
||||
let negotiated = if hw_cursor {
|
||||
Mode::Metadata
|
||||
} else {
|
||||
Mode::Embedded
|
||||
};
|
||||
let raw = match pf_host_config::config().portal_cursor_mode.as_deref() {
|
||||
Some(raw) => raw,
|
||||
None => return negotiated,
|
||||
};
|
||||
match parse_pin(raw) {
|
||||
Pin::Auto => negotiated,
|
||||
Pin::Mode(pinned) => {
|
||||
tracing::info!(
|
||||
backend,
|
||||
pinned = pinned.name(),
|
||||
negotiated = negotiated.name(),
|
||||
"ScreenCast: cursor mode pinned by PUNKTFUNK_PORTAL_CURSOR_MODE"
|
||||
);
|
||||
pinned
|
||||
}
|
||||
Pin::Unrecognised => {
|
||||
tracing::warn!(
|
||||
backend,
|
||||
value = raw,
|
||||
negotiated = negotiated.name(),
|
||||
"ScreenCast: unrecognised PUNKTFUNK_PORTAL_CURSOR_MODE (want auto|hidden|embedded|\
|
||||
metadata) — ignoring"
|
||||
);
|
||||
negotiated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Mode {
|
||||
fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
|
||||
use ashpd::desktop::screencast::CursorMode;
|
||||
match self {
|
||||
Mode::Hidden => CursorMode::Hidden,
|
||||
Mode::Embedded => CursorMode::Embedded,
|
||||
Mode::Metadata => CursorMode::Metadata,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the portal what it supports, run the ladder, and hand back the mode to put in
|
||||
/// `SelectSources`. Infallible by construction: a backend we cannot interrogate gets `Embedded`,
|
||||
/// the mode that predates the property and that every implementation has always had.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) async fn negotiate(
|
||||
proxy: &ashpd::desktop::screencast::Screencast,
|
||||
hw_cursor: bool,
|
||||
backend: &str,
|
||||
) -> ashpd::desktop::screencast::CursorMode {
|
||||
let want = want(hw_cursor, backend);
|
||||
let advertised = match proxy.available_cursor_modes().await {
|
||||
Ok(avail) => avail.bits(),
|
||||
Err(e) => {
|
||||
// `AvailableCursorModes` is a versioned property (ScreenCast v2); a portal too old to
|
||||
// publish it is also too old to have metadata, and `Embedded` is what this backend
|
||||
// requested for its whole life before the cursor channel existed.
|
||||
tracing::warn!(
|
||||
backend,
|
||||
error = %e,
|
||||
"ScreenCast: AvailableCursorModes query failed — requesting Embedded cursor"
|
||||
);
|
||||
return Mode::Embedded.to_ashpd();
|
||||
}
|
||||
};
|
||||
let choice = pick(advertised, want);
|
||||
match choice.wanted {
|
||||
None => tracing::info!(
|
||||
backend,
|
||||
advertised = format_args!("{advertised:#05b}"),
|
||||
mode = choice.mode.name(),
|
||||
"ScreenCast: cursor mode negotiated"
|
||||
),
|
||||
// The downgrade path — and the one that used to be a dead session. Loud, because a stream
|
||||
// whose pointer quietly changed hands is exactly what nobody thinks to check.
|
||||
Some(wanted) => tracing::warn!(
|
||||
backend,
|
||||
advertised = format_args!("{advertised:#05b}"),
|
||||
wanted = wanted.name(),
|
||||
mode = choice.mode.name(),
|
||||
"ScreenCast: requested cursor mode is not advertised by this portal — downgrading \
|
||||
(requesting it anyway would close the session)"
|
||||
),
|
||||
}
|
||||
choice.mode.to_ashpd()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The portal's wire values. These are ABI — a backend rejecting our request prints the
|
||||
/// number, and `4` is the one in the field report that started this module.
|
||||
#[test]
|
||||
fn mode_bits_are_the_portal_wire_values() {
|
||||
assert_eq!(Mode::Hidden.bit(), 1);
|
||||
assert_eq!(Mode::Embedded.bit(), 2);
|
||||
assert_eq!(Mode::Metadata.bit(), 4);
|
||||
}
|
||||
|
||||
/// Our `Mode` is a restatement of ashpd's `CursorMode`, whose bits enumflags2 assigns from
|
||||
/// declaration order — so a reordering upstream would silently repoint every mode. Pin it
|
||||
/// where ashpd is actually compiled.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn mode_bits_match_ashpd() {
|
||||
use ashpd::desktop::screencast::CursorMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
for m in [Mode::Hidden, Mode::Embedded, Mode::Metadata] {
|
||||
assert_eq!(
|
||||
BitFlags::from_flag(m.to_ashpd()).bits(),
|
||||
m.bit(),
|
||||
"{} drifted from ashpd",
|
||||
m.name()
|
||||
);
|
||||
}
|
||||
assert_eq!(BitFlags::from_flag(CursorMode::Metadata).bits(), 4);
|
||||
}
|
||||
|
||||
/// THE REGRESSION, with the real number: `3` is what xdph actually advertises — measured on
|
||||
/// .21 2026-08-14 against a live Hyprland 0.56.2 + xdph 1.4.1, both current. A cursor-forward
|
||||
/// session wants metadata; asking for it made xdg-desktop-portal fail the call, and the client
|
||||
/// got a black screen behind "pipeline build failed" / "unavailable cursor mode 4".
|
||||
#[test]
|
||||
fn metadata_wanted_but_unadvertised_downgrades_to_embedded() {
|
||||
// Exactly the bitfield the portal reported on glass.
|
||||
assert_eq!(Mode::Hidden.bit() | Mode::Embedded.bit(), 3);
|
||||
let c = pick(3, Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Embedded);
|
||||
assert_eq!(c.wanted, Some(Mode::Metadata));
|
||||
}
|
||||
|
||||
/// The same portal, a session with no cursor channel: already asking for what exists, so the
|
||||
/// fix must not perturb it.
|
||||
#[test]
|
||||
fn embedded_wanted_and_advertised_is_untouched() {
|
||||
let c = pick(Mode::Hidden.bit() | Mode::Embedded.bit(), Mode::Embedded);
|
||||
assert_eq!(c.mode, Mode::Embedded);
|
||||
assert_eq!(c.wanted, None);
|
||||
}
|
||||
|
||||
/// A portal that does support metadata (KWin, Mutter, xdph ≥ #366) still gets it — the point
|
||||
/// is to stop asserting, not to stop using it.
|
||||
#[test]
|
||||
fn metadata_is_used_where_advertised() {
|
||||
let all = Mode::Hidden.bit() | Mode::Embedded.bit() | Mode::Metadata.bit();
|
||||
let c = pick(all, Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Metadata);
|
||||
assert_eq!(c.wanted, None);
|
||||
}
|
||||
|
||||
/// Embedded wanted, only metadata offered: the CPU capture path composites it, so a pointer
|
||||
/// survives. (Mirrors `pf-capture`'s ladder.)
|
||||
#[test]
|
||||
fn embedded_unadvertised_falls_to_metadata_not_hidden() {
|
||||
let c = pick(Mode::Hidden.bit() | Mode::Metadata.bit(), Mode::Embedded);
|
||||
assert_eq!(c.mode, Mode::Metadata);
|
||||
assert_eq!(c.wanted, Some(Mode::Embedded));
|
||||
}
|
||||
|
||||
/// A backend offering only `Hidden`: a cursorless stream beats a closed session.
|
||||
#[test]
|
||||
fn hidden_only_backend_yields_hidden() {
|
||||
let c = pick(Mode::Hidden.bit(), Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Hidden);
|
||||
assert_eq!(c.wanted, Some(Mode::Metadata));
|
||||
}
|
||||
|
||||
/// Advertises nothing we know — no right answer, but it must still be a legal enum and flagged
|
||||
/// as a downgrade so the warn fires.
|
||||
#[test]
|
||||
fn unknown_advertisement_guesses_hidden_and_reports_a_downgrade() {
|
||||
for advertised in [0, 0b1000_0000] {
|
||||
let c = pick(advertised, Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Hidden);
|
||||
assert_eq!(c.wanted, Some(Mode::Metadata));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whatever the ladder returns must be a mode the backend named — the invariant the old
|
||||
/// hardcode broke. Exhaustive over every advertisement × every want.
|
||||
#[test]
|
||||
fn never_requests_an_unadvertised_mode() {
|
||||
let modes = [Mode::Hidden, Mode::Embedded, Mode::Metadata];
|
||||
for advertised in 1u32..=0b111 {
|
||||
for want in modes {
|
||||
let c = pick(advertised, want);
|
||||
assert!(
|
||||
advertised & c.mode.bit() != 0,
|
||||
"picked {} from advertised {advertised:#05b} (want {})",
|
||||
c.mode.name(),
|
||||
want.name()
|
||||
);
|
||||
// A downgrade is reported exactly when one happened.
|
||||
assert_eq!(c.wanted.is_some(), c.mode != want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_parses_the_spellings_we_document() {
|
||||
assert_eq!(parse_pin(""), Pin::Auto);
|
||||
assert_eq!(parse_pin("auto"), Pin::Auto);
|
||||
assert_eq!(parse_pin(" AUTO "), Pin::Auto);
|
||||
assert_eq!(parse_pin("embedded"), Pin::Mode(Mode::Embedded));
|
||||
assert_eq!(parse_pin("Embedded"), Pin::Mode(Mode::Embedded));
|
||||
assert_eq!(parse_pin("metadata"), Pin::Mode(Mode::Metadata));
|
||||
assert_eq!(parse_pin("hidden"), Pin::Mode(Mode::Hidden));
|
||||
assert_eq!(parse_pin("2"), Pin::Unrecognised);
|
||||
assert_eq!(parse_pin("yes"), Pin::Unrecognised);
|
||||
}
|
||||
|
||||
/// The hatch pins a PREFERENCE, not the request: pinning metadata at a portal without it must
|
||||
/// still come out embedded rather than re-closing the session.
|
||||
#[test]
|
||||
fn a_pin_still_runs_the_ladder() {
|
||||
let c = pick(Mode::Hidden.bit() | Mode::Embedded.bit(), Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Embedded);
|
||||
}
|
||||
}
|
||||
@@ -55,12 +55,17 @@ fn chooser_cmd() -> String {
|
||||
/// The wlroots/Sway virtual-display driver. Stateless — each [`create`](VirtualDisplay::create)
|
||||
/// adds one headless output and spins up a portal thread owning the cast on it.
|
||||
pub struct WlrootsDisplay {
|
||||
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal
|
||||
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): PREFER portal
|
||||
/// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the
|
||||
/// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the
|
||||
/// pointer into frames, zero host-side cursor work (the pre-channel default this backend
|
||||
/// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the
|
||||
/// channel isn't silently dead here; KWin/Mutter are the validated legs).
|
||||
/// composite blend. Off (every non-channel session): prefer `Embedded` — the compositor paints
|
||||
/// the pointer into frames, zero host-side cursor work (the pre-channel default this backend
|
||||
/// always had).
|
||||
///
|
||||
/// Both are only a PREFERENCE: [`crate::portal_cursor`] settles it against what xdpw actually
|
||||
/// advertises, because requesting an unadvertised mode closes the session outright. xdpw
|
||||
/// refuses metadata by construction (see the portal thread), so on this backend the channel can
|
||||
/// never be served out-of-band: it now degrades to `Embedded` and streams, where it used to
|
||||
/// cancel the cast and hand the client a black screen.
|
||||
hw_cursor: bool,
|
||||
}
|
||||
|
||||
@@ -512,13 +517,7 @@ fn portal_thread(
|
||||
stop: Arc<AtomicBool>,
|
||||
hw_cursor: bool,
|
||||
) {
|
||||
// Portal cursor mode per the session's channel negotiation (see the struct doc).
|
||||
let cursor_mode = if hw_cursor {
|
||||
CursorMode::Metadata
|
||||
} else {
|
||||
CursorMode::Embedded
|
||||
};
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
@@ -542,6 +541,14 @@ fn portal_thread(
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
|
||||
)?;
|
||||
// NEGOTIATED against what xdpw advertises, never asserted from `hw_cursor` alone — see
|
||||
// the xdph copy in `hyprland.rs` for the incident. xdpw is the sharper case: its
|
||||
// screencast.c refuses the mode outright —
|
||||
// if (sess->screencast_data.cursor_mode & METADATA) {
|
||||
// logprint(ERROR, "dbus: unsupported cursor mode requested, cancelling");
|
||||
// — so EVERY cursor-forward session on this backend asked for a mode that cancelled the
|
||||
// cast. Different wording from xdph's "unavailable cursor mode 4", same dead session.
|
||||
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdpw").await;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
|
||||
@@ -17,7 +17,19 @@ parse_deps = false
|
||||
# imports and their #[repr(C)] structs into the header, where socklen_t/ssize_t/iovec/msghdr are
|
||||
# undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs
|
||||
# `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module).
|
||||
exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
|
||||
#
|
||||
# `SOFT_LIMIT_KNEE` is host-side CAPTURE processing (the operator gain's soft knee, applied before
|
||||
# the encoder). No C embedder can act on it — they receive already-gained audio — so exporting it
|
||||
# would add a bare `#define` to the ABI surface, against R21 below, for a constant with no meaning
|
||||
# on that side of the boundary. Excluded rather than renamed: the header stays byte-identical.
|
||||
exclude = [
|
||||
"MsghdrX",
|
||||
"recvmsg_x",
|
||||
"mmsghdr",
|
||||
"sendmmsg",
|
||||
"recvmmsg",
|
||||
"SOFT_LIMIT_KNEE",
|
||||
]
|
||||
# Reached by no exported SIGNATURE, so cbindgen's sweep misses it — but a C embedder needs the
|
||||
# vocabulary: `punktfunk_connection_end_reason` writes one of these as a bare byte (deliberately,
|
||||
# so the JNI/Swift sides can marshal a `u8` rather than an enum), which without this would leave
|
||||
|
||||
@@ -3826,6 +3826,42 @@ fn build_clip_event(
|
||||
out
|
||||
}
|
||||
|
||||
/// The host's management-API port, from this session's `Welcome` — where its game library is
|
||||
/// served (distinct from the streaming ports). `0` means the host did not advertise one: an older
|
||||
/// host, or the standalone `punktfunk1-host` binary, which has no management API. Treat `0` as
|
||||
/// "unknown" and fall back to your own default (47990), never as a port to dial.
|
||||
///
|
||||
/// This exists so a client does NOT need mDNS to find the library. The port used to live only in
|
||||
/// the host's mDNS TXT, so a host that had moved it off 47990 — the supported way to coexist with
|
||||
/// a Sunshine fork, whose web UI owns that port — was reachable only where multicast worked. Read
|
||||
/// this after connect and prefer it over any cached or default value. Safe any time after connect.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `port` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_mgmt_port(
|
||||
c: *const PunktfunkConnection,
|
||||
port: *mut u16,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
// SAFETY: per the ABI contract - the out-param is OPTIONAL, so it is null-checked before
|
||||
// it is written; a non-null one is a caller-owned writable slot.
|
||||
unsafe {
|
||||
if !port.is_null() {
|
||||
*port = c.inner.mgmt_port();
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// The host capability bitfield the session's `Welcome` carried — a bitfield of
|
||||
/// `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / `PUNKTFUNK_HOST_CAP_CLIPBOARD` /
|
||||
/// `PUNKTFUNK_HOST_CAP_PEN`. A client tests `caps & PUNKTFUNK_HOST_CAP_CLIPBOARD` to decide
|
||||
|
||||
@@ -955,6 +955,68 @@ pub fn crossfade_drop(ring: &mut std::collections::VecDeque<f32>, drop: usize, f
|
||||
ring.drain(..drop);
|
||||
}
|
||||
|
||||
/// Where [`apply_gain`]'s soft knee begins, in linear amplitude (≈ −3.1 dBFS). Below this the
|
||||
/// gained signal is passed through EXACTLY — a boost whose peaks never reach the knee is plain
|
||||
/// multiplication, sample for sample, so the limiter costs nothing on material that does not need
|
||||
/// it.
|
||||
pub const SOFT_LIMIT_KNEE: f32 = 0.7;
|
||||
|
||||
/// Multiply `samples` by `gain`, bending anything that would overshoot full scale into a soft knee
|
||||
/// instead of slicing it flat.
|
||||
///
|
||||
/// **Why this is not a `clamp`.** The GameStream plane's gain was `(s * gain).clamp(-1.0, 1.0)`,
|
||||
/// which is a hard clip: the waveform's peaks are replaced by literal flat tops, and a flat top is
|
||||
/// a discontinuity in the first derivative. That radiates high-order harmonics — the harsher and
|
||||
/// more aliasing-prone the higher they go — which is why a field report of "+18 dB and everything
|
||||
/// warbles" is the expected outcome of that code and not a bug in anything downstream. Any operator
|
||||
/// who set `PUNKTFUNK_AUDIO_GAIN` much above ~1.5 was hearing this.
|
||||
///
|
||||
/// The curve here is `tanh`-based and chosen for three properties, in this order:
|
||||
///
|
||||
/// 1. **C¹-continuous at the knee.** The shaped branch's slope at `m == KNEE` is
|
||||
/// `(1-K) · sech²(0) · 1/(1-K) == 1`, exactly the slope of the linear branch it meets. There is
|
||||
/// no corner in the transfer curve, so the onset of limiting is not itself an audible event —
|
||||
/// the failure mode of a naïve piecewise limiter, which trades one discontinuity for another.
|
||||
/// 2. **Bounded by construction.** `tanh` is asymptotic to 1, so the output approaches but never
|
||||
/// exceeds full scale for any finite input, and `±inf` maps to `±1.0`. No sample can leave here
|
||||
/// out of range, which is what the encoder downstream assumes.
|
||||
/// 3. **Odd-symmetric.** `f(-x) == -f(x)`, so the distortion it does introduce is odd-harmonic and
|
||||
/// adds no DC offset — the benign, "saturating" flavour rather than the rectifying one.
|
||||
///
|
||||
/// Callers gate on `gain != 1.0`, so the default path is untouched and the wire stays byte-for-byte
|
||||
/// identical to a build without this. Note this is a WAVESHAPER, not a lookahead limiter: it is
|
||||
/// memoryless and therefore costs zero latency, which is the trade that makes it acceptable in the
|
||||
/// realtime encode path. It raises headroom; it does not raise *loudness* the way a compressor
|
||||
/// with a real time constant would, and it should not be sold as one.
|
||||
pub fn apply_gain(samples: &mut [f32], gain: f32) {
|
||||
// Unity is a no-op, not "multiply by one and shape": the shaper is only correct to apply to a
|
||||
// signal somebody asked to boost. Without this, calling at unity would bend every peak above
|
||||
// the knee — a silent quality change for anyone who forgot to gate the call, and the reason
|
||||
// the callers' `gain != 1.0` guards are a convenience rather than a load-bearing contract.
|
||||
if gain == 1.0 {
|
||||
return;
|
||||
}
|
||||
for s in samples {
|
||||
*s = soft_limit(*s * gain);
|
||||
}
|
||||
}
|
||||
|
||||
/// The waveshaper behind [`apply_gain`]: identity below [`SOFT_LIMIT_KNEE`], asymptotic to ±1.0
|
||||
/// above it. Exposed so the clients can mirror the curve if they ever grow a gain of their own.
|
||||
pub fn soft_limit(x: f32) -> f32 {
|
||||
let m = x.abs();
|
||||
if m <= SOFT_LIMIT_KNEE {
|
||||
return x;
|
||||
}
|
||||
let head = 1.0 - SOFT_LIMIT_KNEE;
|
||||
let shaped = SOFT_LIMIT_KNEE + head * ((m - SOFT_LIMIT_KNEE) / head).tanh();
|
||||
if x < 0.0 {
|
||||
-shaped
|
||||
} else {
|
||||
shaped
|
||||
}
|
||||
}
|
||||
|
||||
// ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
|
||||
|
||||
/// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout.
|
||||
@@ -2432,4 +2494,77 @@ mod tests {
|
||||
assert!(s.audible_tail <= 4, "{s:?}");
|
||||
assert!(s.audible <= 12, "{s:?}");
|
||||
}
|
||||
|
||||
/// Unity must be bit-exact. The callers gate on `gain != 1.0` anyway, but if this ever stopped
|
||||
/// holding, every default session's wire would shift and the "byte-for-byte identical" claim
|
||||
/// the tier machinery rests on would quietly become false.
|
||||
#[test]
|
||||
fn unity_gain_is_bit_exact() {
|
||||
let src: Vec<f32> = (0..512).map(|i| (i as f32 / 512.0) * 2.0 - 1.0).collect();
|
||||
let mut got = src.clone();
|
||||
apply_gain(&mut got, 1.0);
|
||||
assert_eq!(got, src, "unity gain must not touch a single sample");
|
||||
}
|
||||
|
||||
/// Below the knee the limiter is not in circuit at all: a boost whose peaks stay under
|
||||
/// `SOFT_LIMIT_KNEE` must be plain multiplication, or quiet material pays for a limiter it
|
||||
/// never needed.
|
||||
#[test]
|
||||
fn below_the_knee_is_plain_multiplication() {
|
||||
let mut got = vec![0.0, 0.1, -0.2, 0.34, -0.05];
|
||||
apply_gain(&mut got, 2.0);
|
||||
for (i, (g, s)) in got.iter().zip([0.0f32, 0.1, -0.2, 0.34, -0.05]).enumerate() {
|
||||
assert_eq!(*g, s * 2.0, "sample {i} must be untouched below the knee");
|
||||
}
|
||||
}
|
||||
|
||||
/// The property the hard `clamp` violated and this exists to restore: no input, however
|
||||
/// absurdly gained, may leave the shaper out of range — and non-finite input must not escape
|
||||
/// as something the encoder would choke on.
|
||||
#[test]
|
||||
fn nothing_escapes_full_scale() {
|
||||
for gain in [1.5f32, 4.0, 8.0, 64.0, 1000.0] {
|
||||
let mut got: Vec<f32> = (0..401).map(|i| (i as f32 - 200.0) / 200.0).collect();
|
||||
apply_gain(&mut got, gain);
|
||||
for s in &got {
|
||||
assert!(s.abs() <= 1.0, "gain {gain} produced {s}");
|
||||
}
|
||||
}
|
||||
assert_eq!(soft_limit(f32::INFINITY), 1.0);
|
||||
assert_eq!(soft_limit(f32::NEG_INFINITY), -1.0);
|
||||
}
|
||||
|
||||
/// Monotonic and odd-symmetric. Monotonicity is what keeps the shaper a limiter rather than a
|
||||
/// fold-back distortion; odd symmetry is what keeps its harmonics benign and its DC at zero.
|
||||
#[test]
|
||||
fn the_curve_is_monotonic_and_odd() {
|
||||
let mut prev = f32::NEG_INFINITY;
|
||||
for i in 0..=4000 {
|
||||
let x = (i as f32 - 2000.0) / 500.0; // -4.0 ..= 4.0
|
||||
let y = soft_limit(x);
|
||||
assert!(y >= prev, "not monotonic at {x}: {y} < {prev}");
|
||||
prev = y;
|
||||
assert!(
|
||||
(soft_limit(-x) + y).abs() < 1e-6,
|
||||
"not odd-symmetric at {x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The knee must not itself be an audible event. Both branches meet at the same value AND the
|
||||
/// same slope, so the transfer curve has no corner — a piecewise limiter that gets this wrong
|
||||
/// just swaps the clip's discontinuity for a softer one.
|
||||
#[test]
|
||||
fn the_knee_has_no_corner() {
|
||||
let k = SOFT_LIMIT_KNEE;
|
||||
assert!((soft_limit(k) - k).abs() < 1e-6, "value jumps at the knee");
|
||||
let h = 1e-4;
|
||||
let below = (soft_limit(k) - soft_limit(k - h)) / h;
|
||||
let above = (soft_limit(k + h) - soft_limit(k)) / h;
|
||||
assert!((below - 1.0).abs() < 1e-2, "linear side slope {below}");
|
||||
assert!(
|
||||
(above - below).abs() < 1e-2,
|
||||
"slope jumps at the knee: {below} -> {above}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,4 +73,8 @@ pub(crate) struct Negotiated {
|
||||
/// [`crate::quic::HOST_CAP_GAMEPAD_STATE`], [`crate::quic::HOST_CAP_CLIPBOARD`]. Exposed to the
|
||||
/// embedder via [`NativeClient::host_caps`] so a native client greys out unsupported toggles.
|
||||
pub(crate) host_caps: u8,
|
||||
/// The host's management-API port ([`crate::quic::Welcome::mgmt_port`]), `0` when it did not
|
||||
/// advertise one. Surfaced to the embedder via [`crate::NativeClient::mgmt_port`] so a client
|
||||
/// can reach the game library without ever having seen an mDNS advert.
|
||||
pub(crate) mgmt_port: u16,
|
||||
}
|
||||
|
||||
@@ -268,6 +268,9 @@ pub struct NativeClient {
|
||||
/// The host capability bitfield ([`crate::quic::Welcome::host_caps`]) — see
|
||||
/// [`NativeClient::host_caps`].
|
||||
pub host_caps: u8,
|
||||
/// The host's management-API port ([`crate::quic::Welcome::mgmt_port`]), or `0` when the host
|
||||
/// did not advertise one — see [`NativeClient::mgmt_port`].
|
||||
pub mgmt_port: u16,
|
||||
/// Speed-test accumulator, shared with the data-plane pump + control task.
|
||||
probe: Arc<Mutex<ProbeState>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
@@ -723,6 +726,7 @@ impl NativeClient {
|
||||
next_xfer_id: AtomicU32::new(1),
|
||||
pen_seq: AtomicU16::new(0),
|
||||
host_caps: negotiated.host_caps,
|
||||
mgmt_port: negotiated.mgmt_port,
|
||||
probe,
|
||||
shutdown,
|
||||
end_reason,
|
||||
@@ -1378,6 +1382,18 @@ impl NativeClient {
|
||||
self.host_caps
|
||||
}
|
||||
|
||||
/// The host's management-API port, from this session's [`crate::quic::Welcome`] — where its
|
||||
/// game library is served. `0` when the host did not advertise one (an older host, or the
|
||||
/// standalone `punktfunk1-host` binary, which has no management API); the caller then keeps
|
||||
/// its own default.
|
||||
///
|
||||
/// This is the mDNS-free answer to "where is the library": it arrives over the connection the
|
||||
/// client has already authenticated, so a host reached by IP over a VPN — or on any network
|
||||
/// where multicast never worked — no longer has to be assumed to be on 47990.
|
||||
pub fn mgmt_port(&self) -> u16 {
|
||||
self.mgmt_port
|
||||
}
|
||||
|
||||
/// Enable or disable the shared clipboard for this session (`design/clipboard-and-file-transfer.md`
|
||||
/// §3.1). Opt-in: nothing is announced or served until this crosses with `enabled = true`.
|
||||
/// `flags` carries [`crate::quic::CLIP_FLAG_FILES`]. Non-blocking; the host replies with a
|
||||
|
||||
@@ -255,6 +255,7 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
codec: welcome.codec,
|
||||
shard_payload: welcome.shard_payload,
|
||||
host_caps: welcome.host_caps,
|
||||
mgmt_port: welcome.mgmt_port,
|
||||
},
|
||||
welcome.host_caps,
|
||||
))
|
||||
|
||||
@@ -176,7 +176,16 @@ pub use stats::Stats;
|
||||
/// is unchanged (it simply keeps the double-arm race the pair exists to close). Additive and
|
||||
/// client-local: nothing new goes on the wire — the width is computed from frame indices the client
|
||||
/// already receives — so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 19;
|
||||
/// v20: `punktfunk_connection_mgmt_port` — reads the host's management-API port out of the
|
||||
/// session's `Welcome`, so a client can find the game library WITHOUT mDNS. The port previously
|
||||
/// existed only in the host's mDNS TXT, which made a host that had moved it off 47990 (the
|
||||
/// supported way to share a machine with a Sunshine fork, whose web UI owns that port) reachable
|
||||
/// only where multicast worked — over a VPN, a routed subnet, or for a host added by IP, the
|
||||
/// library silently fell back to a port nothing was listening on. A NEW symbol, not a widened one:
|
||||
/// every existing function keeps its signature and behaviour, and an embedder that never calls it
|
||||
/// is unchanged. The `Welcome` grew a trailing field, which older peers skip in both directions
|
||||
/// (see `Welcome::encode`), so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 20;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -340,6 +340,7 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
|
||||
mgmt_port: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
};
|
||||
|
||||
@@ -211,6 +211,22 @@ pub struct Welcome {
|
||||
/// advertised, so an unknown id reaching us is a bug, and falling back would yield an
|
||||
/// undecryptable session with a confusing failure signature.
|
||||
pub cipher: u8,
|
||||
/// The host's management-API port — where its game library is served, distinct from every
|
||||
/// other port here (`udp_port` is the data plane; the control plane is the QUIC port the
|
||||
/// client already dialed). `0` = not advertised (an older host), and the client falls back to
|
||||
/// the compiled-in 47990.
|
||||
///
|
||||
/// **Why this is on the wire at all:** the port was previously discoverable ONLY from the
|
||||
/// mDNS `mgmt` TXT. A host that moved it off 47990 — the supported way to share a machine with
|
||||
/// a Sunshine fork, whose web UI owns that port — therefore had a working library only where
|
||||
/// multicast worked. Carrying it in the `Welcome` means the client learns it over the
|
||||
/// connection it has already authenticated, so a VPN-only, routed-subnet or manually-added
|
||||
/// host needs no discovery at all.
|
||||
///
|
||||
/// Appended AFTER the cipher block (offset 69, or 101 when a ChaCha key precedes it) rather
|
||||
/// than at the next free fixed offset, and emitting it forces the `cipher` placeholder — see
|
||||
/// the note in [`Welcome::encode`]. `0` when an older host omitted it.
|
||||
pub mgmt_port: u16,
|
||||
/// The 256-bit ChaCha20-Poly1305 session key (RFC 8439 requires the full 32 bytes; wire
|
||||
/// cost is once per handshake) — present iff `cipher == 1`, at offsets 69..101. The legacy
|
||||
/// 16-byte `key` keeps its offset and stays independently random, so nothing downstream
|
||||
@@ -473,11 +489,24 @@ impl Welcome {
|
||||
self.key_chacha.is_some(),
|
||||
"key_chacha present iff cipher == 1"
|
||||
);
|
||||
if self.cipher != CIPHER_AES_128_GCM {
|
||||
//
|
||||
// ⚠ `mgmt_port` follows the cipher block, so emitting it FORCES the cipher byte even for
|
||||
// an AES session — the placeholder discipline `Hello::encode` already uses for
|
||||
// `audio_channels`/`preferred_codec`. Without that, an AES Welcome carrying a mgmt port
|
||||
// would put the port's low byte at offset 68, exactly where every 0.28.x client reads
|
||||
// `cipher` — and that decode is deliberately fail-closed on an unknown id, so the whole
|
||||
// handshake would break against currently-shipped clients. An explicit `cipher = 0` is
|
||||
// harmless by comparison: a current client reads AES (correct), and a pre-cipher client
|
||||
// stops before 68 regardless.
|
||||
let mgmt_present = self.mgmt_port != 0;
|
||||
if self.cipher != CIPHER_AES_128_GCM || mgmt_present {
|
||||
b.push(self.cipher);
|
||||
if let Some(k) = &self.key_chacha {
|
||||
b.extend_from_slice(k);
|
||||
}
|
||||
if mgmt_present {
|
||||
b.extend_from_slice(&self.mgmt_port.to_le_bytes());
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -488,9 +517,12 @@ impl Welcome {
|
||||
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
|
||||
// bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63]
|
||||
// chroma_format[64] audio_channels[65] codec[66] host_caps[67] cipher[68]
|
||||
// key_chacha[69..101] (everything from compositor on is an optional trailing byte; an
|
||||
// older host stops earlier; cipher/key_chacha are present only when ChaCha was
|
||||
// negotiated).
|
||||
// key_chacha[69..101] mgmt_port[69..71 | 101..103] (everything from compositor on is an
|
||||
// optional trailing byte; an older host stops earlier; cipher/key_chacha are present only
|
||||
// when ChaCha was negotiated). `mgmt_port` is the one field whose offset is NOT fixed: it
|
||||
// follows the cipher block, so it starts at 69 for an AES session and 101 when a 32-byte
|
||||
// ChaCha key precedes it. Emitting it forces the cipher byte (see `encode`), so "cipher
|
||||
// absent" and "mgmt_port present" can never both hold.
|
||||
if b.len() < 53 || &b[0..4] != MAGIC {
|
||||
return Err(PunktfunkError::InvalidArg("bad Welcome"));
|
||||
}
|
||||
@@ -518,6 +550,18 @@ impl Welcome {
|
||||
}
|
||||
_ => return Err(PunktfunkError::InvalidArg("bad Welcome")),
|
||||
};
|
||||
// The mgmt port sits after the cipher block, so its offset depends on whether a ChaCha key
|
||||
// preceded it. Absent (an older host, or one that did not advertise) → `0` = unknown, and
|
||||
// the client falls back to the compiled-in default.
|
||||
let mgmt_off = if cipher == CIPHER_CHACHA20_POLY1305 {
|
||||
101
|
||||
} else {
|
||||
69
|
||||
};
|
||||
let mgmt_port = b
|
||||
.get(mgmt_off..mgmt_off + 2)
|
||||
.map(|s| u16::from_le_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0);
|
||||
Ok(Welcome {
|
||||
abi_version: u32at(4),
|
||||
udp_port: u16at(8),
|
||||
@@ -585,6 +629,7 @@ impl Welcome {
|
||||
// Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state
|
||||
// snapshots; the client keeps sending legacy per-transition events).
|
||||
host_caps: b.get(67).copied().unwrap_or(0),
|
||||
mgmt_port,
|
||||
cipher,
|
||||
key_chacha,
|
||||
})
|
||||
@@ -671,6 +716,7 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||
mgmt_port: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
};
|
||||
@@ -736,6 +782,7 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: 0,
|
||||
mgmt_port: 0,
|
||||
cipher: CIPHER_AES_128_GCM,
|
||||
key_chacha: None,
|
||||
};
|
||||
@@ -779,6 +826,48 @@ mod tests {
|
||||
let cha_cfg = cha.session_config(Role::Client);
|
||||
assert_eq!(cha_cfg.key, SessionKey::ChaCha20Poly1305(k32));
|
||||
cha_cfg.validate().expect("ChaCha config validates");
|
||||
|
||||
// ── mgmt_port, the trailing field after the cipher block ──────────────────────────────
|
||||
//
|
||||
// ⚠ THE HAZARD THIS PINS: `mgmt_port` follows `cipher`, and `cipher` is emitted only when
|
||||
// non-default. Appending the port to an AES Welcome without forcing the cipher byte would
|
||||
// land the port's LOW BYTE at offset 68 — exactly where every shipped client reads
|
||||
// `cipher`, whose decode is fail-closed on an unknown id. 47991 is 0xBB57, so byte 68
|
||||
// would read 0x57 = 87, an unknown id, and EVERY 0.28.x client would fail the handshake
|
||||
// against a host that had merely moved its mgmt port. Assert the placeholder is there.
|
||||
let mgmt = Welcome {
|
||||
mgmt_port: 47991,
|
||||
..base
|
||||
};
|
||||
let menc = mgmt.encode();
|
||||
assert_eq!(menc.len(), 68 + 1 + 2, "cipher placeholder + LE u16 port");
|
||||
assert_eq!(
|
||||
menc[68], CIPHER_AES_128_GCM,
|
||||
"the cipher byte MUST be present (as 0) so a current client still reads AES here"
|
||||
);
|
||||
assert_eq!(&menc[69..71], &47991u16.to_le_bytes());
|
||||
assert_eq!(Welcome::decode(&menc).unwrap(), mgmt);
|
||||
|
||||
// With ChaCha the port sits after the 32-byte key instead, at 101..103.
|
||||
let both = Welcome {
|
||||
mgmt_port: 47991,
|
||||
cipher: CIPHER_CHACHA20_POLY1305,
|
||||
key_chacha: Some(k32),
|
||||
..base
|
||||
};
|
||||
let benc = both.encode();
|
||||
assert_eq!(benc.len(), 68 + 1 + 32 + 2);
|
||||
assert_eq!(&benc[101..103], &47991u16.to_le_bytes());
|
||||
assert_eq!(Welcome::decode(&benc).unwrap(), both);
|
||||
|
||||
// A host that advertises no mgmt port emits nothing extra — an AES Welcome stays exactly
|
||||
// 68 bytes, so this field costs the common case zero and cannot perturb an old client.
|
||||
assert_eq!(base.encode().len(), 68);
|
||||
// ...and an old host's Welcome decodes to 0 = unknown, never to a port we might dial.
|
||||
assert_eq!(Welcome::decode(&enc).unwrap().mgmt_port, 0);
|
||||
assert_eq!(Welcome::decode(&cenc).unwrap().mgmt_port, 0);
|
||||
// A truncated tail (one byte of the port) is not half a port: it reads as unknown.
|
||||
assert_eq!(Welcome::decode(&menc[..70]).unwrap().mgmt_port, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -873,6 +962,7 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_PYROWAVE,
|
||||
host_caps: 0,
|
||||
mgmt_port: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
}
|
||||
@@ -947,6 +1037,7 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_H264,
|
||||
host_caps: 0,
|
||||
mgmt_port: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
}
|
||||
@@ -1058,6 +1149,7 @@ mod tests {
|
||||
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||
mgmt_port: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,54 @@ pub const SAMPLE_RATE: u32 = 48_000;
|
||||
/// Stereo channel count — the default and the punktfunk/1 audio plane's fixed layout.
|
||||
pub const CHANNELS: usize = 2;
|
||||
|
||||
/// Highest boost `PUNKTFUNK_AUDIO_GAIN` will honour (+18 dB). Past this the soft knee is doing
|
||||
/// essentially all the work and the result is a squashed signal, not a louder one — so a runaway
|
||||
/// value (a stray `180` for `1.8`) is capped and said out loud rather than silently shipped.
|
||||
const MAX_CAPTURE_GAIN: f32 = 8.0;
|
||||
|
||||
/// The operator's capture gain, shared by BOTH audio planes (`PUNKTFUNK_AUDIO_GAIN`, default
|
||||
/// `1.0` = untouched).
|
||||
///
|
||||
/// **Why the host needs one at all.** WASAPI loopback is tapped UPSTREAM of the endpoint's master
|
||||
/// volume, so turning the host's speaker slider up does nothing whatsoever to the level a client
|
||||
/// receives. Before this, the native `punktfunk/1` plane had no gain of any kind, which left no
|
||||
/// host-side way to raise a quiet desktop mix — the GameStream plane's knob was the only one, and
|
||||
/// it applied to the wrong protocol.
|
||||
///
|
||||
/// Applied through [`punktfunk_core::audio::apply_gain`], whose soft knee replaces the hard
|
||||
/// `clamp(-1.0, 1.0)` this used to be. That clamp is why boosting was a trap: it flat-tops peaks,
|
||||
/// and flat tops are audible as harsh distortion long before the operator reaches the level they
|
||||
/// were chasing.
|
||||
///
|
||||
/// ⚠ This is headroom, not loudness. It cannot close a peak-to-loudness gap against
|
||||
/// already-limited broadcast content — that needs a real compressor with a time constant, which is
|
||||
/// deliberately NOT what this is.
|
||||
pub fn capture_gain() -> f32 {
|
||||
let raw: f32 = std::env::var("PUNKTFUNK_AUDIO_GAIN")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1.0);
|
||||
// A negative or non-finite gain is a typo, never an intent: it would invert or poison every
|
||||
// sample. Fall back to unity rather than shipping it.
|
||||
if !raw.is_finite() || raw <= 0.0 {
|
||||
if std::env::var("PUNKTFUNK_AUDIO_GAIN").is_ok() {
|
||||
tracing::warn!(
|
||||
"PUNKTFUNK_AUDIO_GAIN must be a positive number (1.0 = unchanged) — ignoring"
|
||||
);
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
if raw > MAX_CAPTURE_GAIN {
|
||||
tracing::warn!(
|
||||
requested = raw,
|
||||
capped = MAX_CAPTURE_GAIN,
|
||||
"PUNKTFUNK_AUDIO_GAIN is above the +18 dB ceiling — capping"
|
||||
);
|
||||
return MAX_CAPTURE_GAIN;
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
/// Produces interleaved `f32` PCM at [`SAMPLE_RATE`] in the channel count it was opened
|
||||
/// with. Lives on its own thread; never blocks the capture loop (drops if the consumer
|
||||
/// falls behind).
|
||||
|
||||
@@ -682,6 +682,10 @@ fn pw_thread(
|
||||
use pw::{properties::properties, spa};
|
||||
use spa::param::audio::{AudioFormat, AudioInfoRaw};
|
||||
use spa::pod::Pod;
|
||||
// The stream's `process` callbacks run ON this mainloop thread (we never hand PipeWire a
|
||||
// separate data loop), so PipeWire's own client `module-rt` boost of its data loops does not
|
||||
// cover it — the ~2.7 ms capture quantum lives or dies by this thread's scheduling.
|
||||
pf_frame::thread_qos::boost_thread_priority(true);
|
||||
|
||||
// Setup errors funnel through the ready handshake (mirrors mic_pw_thread's IIFE).
|
||||
let result = (|| -> Result<()> {
|
||||
|
||||
@@ -26,13 +26,22 @@
|
||||
//! mixing mono or at 24 kHz) loses to real hardware; see [`super::wiring_plan`]. **Never** the
|
||||
//! Steam Streaming Speakers, whose loopback is silent — validated live;
|
||||
//! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps
|
||||
//! record the client's mic by default.
|
||||
//! record the client's mic by default — applied, like the playback default, ONLY while a
|
||||
//! desktop-audio capture is open. It used to be asserted on EVERY wiring pass, mic pump at boot
|
||||
//! included, which left an IDLE box's default recording/communication device parked on a virtual
|
||||
//! microphone nothing feeds — and games bind the default microphone at launch (`SetDefaultEndpoint`
|
||||
//! covers eCommunications, so in-game voice binds it too). The 2026-08 Helldivers 2 field reports
|
||||
//! measured that as 1% lows of 2–5 FPS in a LOCALLY played game while the host sat idle (HD2 is
|
||||
//! Wwise + always-on voice, exactly the "finicky with audio devices" case its own wiki warns
|
||||
//! about). An idle host must leave the box's audio defaults exactly as the operator set them.
|
||||
//!
|
||||
//! Because the playback default is *parked* on a silent sink during a stream, it is remembered
|
||||
//! ([`park_default_playback`], plus an on-disk crash marker) and put back when the capture closes
|
||||
//! ([`restore_default_playback`]) or, after a crash, on the next process's first wiring pass — an
|
||||
//! operator must never be stranded with silent speakers. A default the operator changed themselves
|
||||
//! mid-stream is respected (no restore over their choice).
|
||||
//! Because both defaults are *parked* during a stream — playback on a silent sink, recording on the
|
||||
//! virtual mic — the operator's devices are remembered ([`park_default_playback`] /
|
||||
//! [`park_default_recording`], plus on-disk crash markers) and put back when the capture closes
|
||||
//! ([`restore_default_playback`] / [`restore_default_recording`]) or, after a crash, on the next
|
||||
//! process's first wiring pass — an operator must never be stranded with silent speakers or a dead
|
||||
//! mic. A default the operator changed themselves mid-stream is respected (no restore over their
|
||||
//! choice).
|
||||
//!
|
||||
//! The assignment rules are the PURE [`wiring_plan`](super::wiring_plan) module (unit-tested on every
|
||||
//! platform); this module only enumerates endpoints, applies the plan, and logs. [`wire_now`] runs on
|
||||
@@ -142,8 +151,8 @@ pub(crate) fn endpoint_fingerprint() -> u64 {
|
||||
}
|
||||
|
||||
/// [`wire_now_full`] for callers that only need the assignment (the mic paths).
|
||||
pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
wire_now_full(set_playback).wiring
|
||||
pub(crate) fn wire_now(park_defaults: bool) -> Wiring {
|
||||
wire_now_full(park_defaults).wiring
|
||||
}
|
||||
|
||||
/// The most recent wiring verdict, as the LAST wiring pass computed it (the mic pump wires
|
||||
@@ -170,13 +179,15 @@ fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
|
||||
|
||||
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
|
||||
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
|
||||
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
|
||||
/// parks the default PLAYBACK device on the plan's loopback endpoint for the capture's lifetime
|
||||
/// (the mic pump passes false: it runs while the host is idle and must not silence the box).
|
||||
/// Must run on a COM-initialized thread (the WASAPI worker threads all `initialize_mta` first).
|
||||
/// Logged only when the assignment changes, so per-open recomputation stays quiet in the steady
|
||||
/// state.
|
||||
pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
/// echo guard). `park_defaults` — true only from the desktop-audio capture open — additionally
|
||||
/// parks the default PLAYBACK device on the plan's loopback endpoint and the default RECORDING
|
||||
/// device on the virtual mic's capture side, both for the capture's lifetime (the mic pump passes
|
||||
/// false: it runs while the host is idle and must neither silence the box nor hold its default
|
||||
/// microphone — the idle-parked recording default is the 2026-08 Helldivers 2 tank, see the
|
||||
/// module docs). Must run on a COM-initialized thread (the WASAPI worker threads all
|
||||
/// `initialize_mta` first). Logged only when the assignment changes, so per-open recomputation
|
||||
/// stays quiet in the steady state.
|
||||
pub(crate) fn wire_now_full(park_defaults: bool) -> WiredPlan {
|
||||
recover_orphaned_default();
|
||||
let renders = list_endpoints(Direction::Render);
|
||||
let captures = list_endpoints(Direction::Capture);
|
||||
@@ -188,11 +199,11 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
// them out of every role. Identity is platform data (stamped container / devnode marker),
|
||||
// so it is collected HERE and passed in, like the candidate lists themselves.
|
||||
let pad_ids = pad_render_ids(&renders);
|
||||
// Mix formats are read only when we are actually going to park the playback default (i.e. a
|
||||
// Mix formats are read only when we are actually going to park the defaults (i.e. a
|
||||
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
|
||||
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
|
||||
// activation per render endpoint on every pass.
|
||||
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if set_playback {
|
||||
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if park_defaults {
|
||||
&mix_format_of
|
||||
} else {
|
||||
&wiring_plan::no_formats
|
||||
@@ -311,30 +322,44 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
}
|
||||
}
|
||||
}
|
||||
if set_playback {
|
||||
// Recording-default hygiene, IDLE passes only: builds before 2026-08-14 parked the default
|
||||
// recording on the virtual mic on EVERY wiring pass (boot included) and recorded nothing to
|
||||
// restore — so an upgraded box would otherwise sit wedged on a microphone nothing feeds
|
||||
// until the operator noticed (the Helldivers 2 idle tank; the session-scoped park below
|
||||
// can't heal it either: it remembers a previous default only when the default isn't already
|
||||
// ours). While nothing is parked, a default found sitting on the plan's mic capture moves to
|
||||
// the first real microphone. Session passes own the default and are exempt; a box with no
|
||||
// real microphone is left alone.
|
||||
if !park_defaults && PARKED_REC.lock().unwrap().is_none() {
|
||||
if let Some((mic_name, mic_id)) = &wiring.mic_capture {
|
||||
if default_capture_id().as_deref() == Some(mic_id.as_str()) {
|
||||
if let Some((name, id)) =
|
||||
wiring_plan::real_capture(&captures, Some(mic_id.as_str()))
|
||||
{
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => tracing::info!(from = %mic_name, device = %name,
|
||||
"default recording was left on the virtual mic outside a stream — \
|
||||
moved it back to a real microphone"),
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"failed to move the default recording off the virtual mic"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if park_defaults {
|
||||
if let Some((name, id)) = &wiring.loopback_render {
|
||||
let mic_id = wiring.mic_render.as_ref().map(|(_, m)| m.as_str());
|
||||
park_default_playback(name, id, changed, mic_id);
|
||||
}
|
||||
}
|
||||
if let Some((name, id)) = &wiring.mic_capture {
|
||||
// `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally
|
||||
// fires SetDefaultEndpoint for all three roles (an audio-policy write plus a
|
||||
// device-graph notification, each). Re-asserting on every wiring pass therefore both
|
||||
// churned the policy store AND silently stomped an operator's own recording-device
|
||||
// choice within one reopen cycle — write only when the plan changed or the default
|
||||
// actually drifted off the target.
|
||||
if changed || default_capture_id().as_deref() != Some(id.as_str()) {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => {
|
||||
if changed {
|
||||
tracing::info!(device = %name,
|
||||
"audio wiring: default recording = virtual mic (apps record the client's mic)");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"audio wiring: failed to set the default recording device"),
|
||||
}
|
||||
// The recording default is SESSION-SCOPED like the playback default, and for the same
|
||||
// reason inverted: parking it while idle handed the box's default microphone (and, via
|
||||
// eCommunications, every game's voice input) to a virtual mic nothing feeds — the
|
||||
// 2026-08 Helldivers 2 idle tank (see the module docs). A game launched DURING the
|
||||
// stream still binds the client's mic (this runs before the session's game does);
|
||||
// one launched before the stream keeps the operator's mic, which is the honest answer.
|
||||
if let Some((name, id)) = &wiring.mic_capture {
|
||||
park_default_recording(name, id, changed);
|
||||
}
|
||||
}
|
||||
done(wiring)
|
||||
@@ -350,6 +375,26 @@ fn park_marker_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("audio-default.prev")
|
||||
}
|
||||
|
||||
/// The operator's default recording endpoint while we have it parked on the virtual mic:
|
||||
/// `(previous_id, id_we_set)` — the recording-side twin of [`PARKED`].
|
||||
static PARKED_REC: Mutex<Option<(String, String)>> = Mutex::new(None);
|
||||
|
||||
/// On-disk crash marker mirroring [`PARKED_REC`] (two lines: previous id, set id).
|
||||
fn rec_marker_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("audio-default-rec.prev")
|
||||
}
|
||||
|
||||
/// Consume a park marker file: returns the PREVIOUS default's id when the marker existed AND the
|
||||
/// current default still is the endpoint we set — a default the operator changed since wins, like
|
||||
/// on every other restore path. The file is removed either way (it describes a park that is over).
|
||||
fn take_marker(path: &std::path::Path, current_default: Option<String>) -> Option<String> {
|
||||
let s = std::fs::read_to_string(path).ok()?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
let mut lines = s.lines();
|
||||
let (prev, set) = (lines.next()?, lines.next()?);
|
||||
(current_default.as_deref() == Some(set)).then(|| prev.to_string())
|
||||
}
|
||||
|
||||
/// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning
|
||||
/// uses it for its default-device guard (a freshly minted pad endpoint must never stay the
|
||||
/// default playback device).
|
||||
@@ -374,31 +419,28 @@ pub(crate) fn default_capture_id() -> Option<String> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Once per process: if a crash marker from a previous run exists, the host died while the
|
||||
/// playback default was parked — put the operator's device back, but only if the default still
|
||||
/// IS the endpoint we set (a manual change since the crash wins). Runs on the first wiring pass
|
||||
/// (the mic pump wires eagerly at host start, so this fires at boot, not at the first stream).
|
||||
/// Once per process: if a crash marker from a previous run exists, the host died while a default
|
||||
/// (playback and/or recording) was parked — put the operator's device back, but only if the
|
||||
/// default still IS the endpoint we set (a manual change since the crash wins). Runs on the first
|
||||
/// wiring pass (the mic pump wires eagerly at host start, so this fires at boot, not at the first
|
||||
/// stream).
|
||||
fn recover_orphaned_default() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
let path = park_marker_path();
|
||||
let Ok(s) = std::fs::read_to_string(&path) else {
|
||||
return;
|
||||
};
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut lines = s.lines();
|
||||
let (Some(prev), Some(set)) = (lines.next(), lines.next()) else {
|
||||
return;
|
||||
};
|
||||
if default_render_id().as_deref() != Some(set) {
|
||||
return;
|
||||
}
|
||||
match set_default_endpoint(prev) {
|
||||
Ok(()) => tracing::info!(
|
||||
"restored the default playback device a previous host run left parked"
|
||||
),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"),
|
||||
"failed to restore the default playback device left by a previous run"),
|
||||
for (path, current, what) in [
|
||||
(park_marker_path(), default_render_id(), "playback"),
|
||||
(rec_marker_path(), default_capture_id(), "recording"),
|
||||
] {
|
||||
let Some(prev) = take_marker(&path, current) else {
|
||||
continue;
|
||||
};
|
||||
match set_default_endpoint(&prev) {
|
||||
Ok(()) => tracing::info!(
|
||||
"restored the default {what} device a previous host run left parked"
|
||||
),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"),
|
||||
"failed to restore the default {what} device left by a previous run"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -415,20 +457,18 @@ fn recover_orphaned_default() {
|
||||
///
|
||||
/// Returns whether a device was actually put back — the caller only logs it.
|
||||
pub(crate) fn unpark_default_for_uninstall() -> bool {
|
||||
let path = park_marker_path();
|
||||
let Ok(s) = std::fs::read_to_string(&path) else {
|
||||
return false;
|
||||
};
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut lines = s.lines();
|
||||
let (Some(prev), Some(set)) = (lines.next(), lines.next()) else {
|
||||
return false;
|
||||
};
|
||||
// A default the operator changed by hand since the park wins, exactly as on the recovery path.
|
||||
if default_render_id().as_deref() != Some(set) {
|
||||
return false;
|
||||
let mut restored = false;
|
||||
for (path, current) in [
|
||||
(park_marker_path(), default_render_id()),
|
||||
(rec_marker_path(), default_capture_id()),
|
||||
] {
|
||||
// A default the operator changed by hand since the park wins, exactly as on the
|
||||
// recovery path (`take_marker` answers None then).
|
||||
if let Some(prev) = take_marker(&path, current) {
|
||||
restored |= set_default_endpoint(&prev).is_ok();
|
||||
}
|
||||
}
|
||||
set_default_endpoint(prev).is_ok()
|
||||
restored
|
||||
}
|
||||
|
||||
/// Make `id` the default playback device for the duration of the desktop-audio capture,
|
||||
@@ -469,6 +509,48 @@ fn park_default_playback(name: &str, id: &str, changed: bool, mic_id: Option<&st
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `id` the default recording device for the duration of the desktop-audio capture —
|
||||
/// [`park_default_playback`]'s recording twin, remembering the operator's current default (in
|
||||
/// memory + the crash marker) the FIRST time so [`restore_default_recording`] can put it back.
|
||||
/// Nothing is remembered when `id` already is the default — there is nothing to restore.
|
||||
fn park_default_recording(name: &str, id: &str, changed: bool) {
|
||||
let cur = default_capture_id();
|
||||
if cur.as_deref() != Some(id) {
|
||||
let mut parked = PARKED_REC.lock().unwrap();
|
||||
match parked.as_mut() {
|
||||
None => {
|
||||
if let Some(prev) = cur.clone() {
|
||||
let _ = std::fs::write(rec_marker_path(), format!("{prev}\n{id}"));
|
||||
*parked = Some((prev, id.to_string()));
|
||||
}
|
||||
}
|
||||
// Re-park onto a different endpoint mid-stream (plan changed): keep the ORIGINAL
|
||||
// previous default, update what we set.
|
||||
Some((prev, set)) if set != id => {
|
||||
let _ = std::fs::write(rec_marker_path(), format!("{prev}\n{id}"));
|
||||
*set = id.to_string();
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
// `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally fires
|
||||
// SetDefaultEndpoint for all three roles (an audio-policy write plus a device-graph
|
||||
// notification, each) — write only when the plan changed or the default actually drifted
|
||||
// off the target, or the policy store churns on every reopen.
|
||||
if changed || cur.as_deref() != Some(id) {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => {
|
||||
if changed {
|
||||
tracing::info!(device = %name,
|
||||
"audio wiring: default recording = virtual mic (apps record the client's mic)");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"audio wiring: failed to set the default recording device"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the default playback device back on the endpoint we are already capturing, WITHOUT a
|
||||
/// wiring pass (WP2.4).
|
||||
///
|
||||
@@ -507,6 +589,25 @@ pub(crate) fn restore_default_playback() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the operator's default recording device back after streaming — the inverse of
|
||||
/// [`park_default_recording`], with [`restore_default_playback`]'s exact rules: no-op if we never
|
||||
/// parked it, and a default the operator changed themselves mid-stream is left alone. Must run on
|
||||
/// a COM-initialized thread (called from the capture thread's exit path).
|
||||
pub(crate) fn restore_default_recording() {
|
||||
let Some((prev, set)) = PARKED_REC.lock().unwrap().take() else {
|
||||
return;
|
||||
};
|
||||
let _ = std::fs::remove_file(rec_marker_path());
|
||||
if default_capture_id().as_deref() != Some(set.as_str()) {
|
||||
return;
|
||||
}
|
||||
match set_default_endpoint(&prev) {
|
||||
Ok(()) => tracing::info!("default recording device restored after streaming"),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"),
|
||||
"failed to restore the default recording device after streaming"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a device by endpoint id, with a name for error context.
|
||||
///
|
||||
/// Resolves through [`super::pad_endpoint::open_wasapi_device`] rather than the `wasapi` crate's
|
||||
@@ -518,10 +619,11 @@ pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
|
||||
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
|
||||
}
|
||||
|
||||
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
|
||||
// --- IPolicyConfig (undocumented): default-endpoint and endpoint-visibility writes. ---
|
||||
|
||||
/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` is called; the 10 methods between `Release`
|
||||
/// and it (`GetMixFormat` … `SetPropertyValue`) are placeholders so the slot offset is correct.
|
||||
/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` and `SetEndpointVisibility` are called;
|
||||
/// the 10 methods between `Release` and them (`GetMixFormat` … `SetPropertyValue`) are
|
||||
/// placeholders so the slot offsets are correct.
|
||||
#[repr(C)]
|
||||
struct IPolicyConfigVtbl {
|
||||
query_interface: unsafe extern "system" fn(
|
||||
@@ -537,7 +639,11 @@ struct IPolicyConfigVtbl {
|
||||
windows::core::PCWSTR,
|
||||
u32,
|
||||
) -> windows::core::HRESULT,
|
||||
// SetEndpointVisibility follows — unused.
|
||||
set_endpoint_visibility: unsafe extern "system" fn(
|
||||
*mut c_void,
|
||||
windows::core::PCWSTR,
|
||||
i32,
|
||||
) -> windows::core::HRESULT,
|
||||
}
|
||||
|
||||
// This mirrors the vtable of the UNDOCUMENTED `IPolicyConfig` COM interface, so there is no header
|
||||
@@ -546,18 +652,21 @@ struct IPolicyConfigVtbl {
|
||||
// table" — so a field added, removed or resized above it does not fail to compile: it silently calls
|
||||
// a DIFFERENT function through a mismatched signature, which is arbitrary-code territory rather
|
||||
// than a wrong answer. The `_reserved` gap is what makes that easy to get wrong, since its ten slots
|
||||
// carry no names to anchor a review. These assertions pin the two things the call actually depends
|
||||
// on: the slot index of `set_default_endpoint`, and the size of the table up to it.
|
||||
// carry no names to anchor a review. These assertions pin the things the calls actually depend
|
||||
// on: the slot indexes of `set_default_endpoint` and `set_endpoint_visibility`, and the size of
|
||||
// the table up to them.
|
||||
const _: () = {
|
||||
use std::mem::{offset_of, size_of};
|
||||
type P = *const c_void;
|
||||
// 3 IUnknown slots + 10 reserved = `set_default_endpoint` is slot 13 (0-based).
|
||||
// 3 IUnknown slots + 10 reserved = `set_default_endpoint` is slot 13 (0-based),
|
||||
// `set_endpoint_visibility` the slot after.
|
||||
assert!(offset_of!(IPolicyConfigVtbl, query_interface) == 0);
|
||||
assert!(offset_of!(IPolicyConfigVtbl, add_ref) == size_of::<P>());
|
||||
assert!(offset_of!(IPolicyConfigVtbl, release) == 2 * size_of::<P>());
|
||||
assert!(offset_of!(IPolicyConfigVtbl, _reserved) == 3 * size_of::<P>());
|
||||
assert!(offset_of!(IPolicyConfigVtbl, set_default_endpoint) == 13 * size_of::<P>());
|
||||
assert!(size_of::<IPolicyConfigVtbl>() == 14 * size_of::<P>());
|
||||
assert!(offset_of!(IPolicyConfigVtbl, set_endpoint_visibility) == 14 * size_of::<P>());
|
||||
assert!(size_of::<IPolicyConfigVtbl>() == 15 * size_of::<P>());
|
||||
};
|
||||
|
||||
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
|
||||
@@ -603,3 +712,41 @@ pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Show or hide an audio endpoint via the undocumented `IPolicyConfig::SetEndpointVisibility` —
|
||||
/// the exact call behind mmsys.cpl's "Disable"/"Enable" device menu. A hidden endpoint drops to
|
||||
/// `DEVICE_STATE_DISABLED`: it vanishes from every ACTIVE enumeration and cannot be opened, but
|
||||
/// its devnode, driver binding and stamped identity all stay put — showing it again is instant
|
||||
/// and raises no PnP traffic. pub(crate): the pad-endpoint provider parks its "Wireless
|
||||
/// Controller" speaker hidden while no client pad is attached (a visible idle pad speaker makes
|
||||
/// libScePad titles engage their DualSense-haptics path against an endpoint nothing services —
|
||||
/// the 2026-08-14 Helldivers 2 field confirmation).
|
||||
pub(crate) fn set_endpoint_visibility(device_id: &str, visible: bool) -> Result<()> {
|
||||
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
|
||||
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
|
||||
|
||||
const CLSID_POLICY_CONFIG: GUID = GUID::from_u128(0x870af99c_171d_4f9e_af0d_e63df40c2bc9);
|
||||
const IID_IPOLICY_CONFIG: GUID = GUID::from_u128(0xf8679f50_850a_41cf_9c72_430f290290c8);
|
||||
|
||||
let wide: Vec<u16> = device_id.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
// SAFETY: same contract as `set_default_endpoint` — owned IUnknown from CoCreateInstance,
|
||||
// QI'd pointer checked non-null, the call goes through the assertion-pinned vtable slot with
|
||||
// a NUL-terminated UTF-16 id and an INT bool, and the QI'd pointer is Released before return.
|
||||
unsafe {
|
||||
let unk: IUnknown = CoCreateInstance(&CLSID_POLICY_CONFIG, None, CLSCTX_ALL)
|
||||
.map_err(|e| anyhow!("CoCreateInstance(PolicyConfig): {e}"))?;
|
||||
let mut raw: *mut c_void = std::ptr::null_mut();
|
||||
unk.query(&IID_IPOLICY_CONFIG, &mut raw)
|
||||
.ok()
|
||||
.map_err(|e| anyhow!("QueryInterface(IPolicyConfig): {e}"))?;
|
||||
if raw.is_null() {
|
||||
bail!("IPolicyConfig QueryInterface returned null");
|
||||
}
|
||||
let vtbl = *(raw as *const *const IPolicyConfigVtbl);
|
||||
let hr = ((*vtbl).set_endpoint_visibility)(raw, PCWSTR(wide.as_ptr()), visible as i32);
|
||||
((*vtbl).release)(raw);
|
||||
hr.ok()
|
||||
.map_err(|e| anyhow!("SetEndpointVisibility({visible}): {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ pub(crate) struct Removed {
|
||||
pub endpoint_records: usize,
|
||||
}
|
||||
|
||||
/// Restore the default playback device if we left it parked, then remove every audio devnode
|
||||
/// this product minted, newest registry record and all.
|
||||
/// Restore the default playback/recording devices if we left them parked, then remove every
|
||||
/// audio devnode this product minted, newest registry record and all.
|
||||
///
|
||||
/// Best-effort throughout, like the rest of the (un)install path: a devnode that refuses to go
|
||||
/// is counted and reported, never fatal — a non-zero exit here would abort the whole uninstaller
|
||||
@@ -59,7 +59,7 @@ pub(crate) fn purge() -> Result<Removed> {
|
||||
// what the operator had. Putting it back is the difference between "the box works again"
|
||||
// and "the box works again, on the device it started with".
|
||||
if audio_control::unpark_default_for_uninstall() {
|
||||
println!("restored the default playback device this host had parked");
|
||||
println!("restored the default audio device(s) this host had parked");
|
||||
}
|
||||
|
||||
let mut out = Removed::default();
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
//! behind the measured MMDevices ACL repair (see [`grant_system_full_control`]).
|
||||
//! 3. **Capture**: sessions loopback-capture the endpoint ([`PadLoopbackCapturer`], 4 ch f32
|
||||
//! interleaved) and ship the PCM to the client's pad speaker/haptics.
|
||||
//! 4. **Visibility** ([`set_visibility`]): the endpoint parks HIDDEN (`DEVICE_STATE_DISABLED`)
|
||||
//! whenever no client pad is attached — provisioning hides it at startup, the per-pad
|
||||
//! streamer shows it for exactly the pad's lifetime. The DualSense disguise that makes games
|
||||
//! route haptics at it during a session makes idle libScePad titles STALL on it otherwise
|
||||
//! (Helldivers 2, field-confirmed 2026-08-14: 2–5 FPS 1% lows with the host idle). The
|
||||
//! devnode, driver binding and stamps stay put, so flips raise no PnP traffic.
|
||||
//!
|
||||
//! The wiring plan must never route desktop audio or the virtual mic onto these endpoints —
|
||||
//! [`audio_control`](super::audio_control) collects the exclusion ids via
|
||||
@@ -1484,6 +1490,10 @@ static PROVISIONING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBo
|
||||
pub(crate) fn provision_at_startup() {
|
||||
if !pad_audio_enabled() {
|
||||
tracing::info!("pad audio disabled (PUNKTFUNK_PAD_AUDIO=0)");
|
||||
// Endpoints a previous run provisioned persist and stay VISIBLE — and a visible idle
|
||||
// pad speaker is exactly what libScePad titles stall on (see [`set_visibility`]).
|
||||
// Turning the feature off must also park the leftovers.
|
||||
hide_leftover_endpoints();
|
||||
return;
|
||||
}
|
||||
if PROVISIONED.get().is_some() {
|
||||
@@ -1531,6 +1541,17 @@ pub(crate) fn provision_at_startup() {
|
||||
stored-but-not-served until the next reboot"),
|
||||
}
|
||||
}
|
||||
// Park every provisioned endpoint HIDDEN until a client pad actually attaches. The
|
||||
// expensive work (devnode, driver bind, stamps, the AEB kick above) stays at boot —
|
||||
// the #185 lesson: no PnP traffic at session boundaries — but the ENDPOINT must not
|
||||
// sit visible on an idle box: libScePad titles (Helldivers 2, field-confirmed
|
||||
// 2026-08-14) find the "Wireless Controller" speaker BY IDENTITY, engage their
|
||||
// DualSense-haptics path against it, and stall on an endpoint nothing services —
|
||||
// 1% lows of 2–5 FPS with the host completely idle. The per-pad streamer shows it
|
||||
// for exactly the pad's lifetime, like a real DualSense arriving.
|
||||
for pe in &eps {
|
||||
set_visibility(&pe.endpoint_id, pe.pad_index, false);
|
||||
}
|
||||
// R5: latch the result ONLY if we actually provisioned something. This used to store
|
||||
// whatever `eps` held even when the loop broke on the first error — an empty vec —
|
||||
// and `OnceLock` made that permanent: one transient failure (a busy audio stack, a
|
||||
@@ -1570,6 +1591,54 @@ pub(crate) fn ensure_provisioned() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show or hide a pad endpoint (best-effort, logged). Hidden = `DEVICE_STATE_DISABLED` via
|
||||
/// [`audio_control::set_endpoint_visibility`] — the endpoint keeps its devnode, driver binding
|
||||
/// and DualSense stamps, but vanishes from every ACTIVE enumeration and cannot be opened.
|
||||
///
|
||||
/// WHY pad endpoints park hidden: the stamp set exists so libScePad titles read the endpoint as
|
||||
/// a real DualSense speaker and route haptics audio at it — during a pad session that is the
|
||||
/// feature, on an idle box it is a trap. Helldivers 2 (field-confirmed 2026-08-14) finds the
|
||||
/// idle "Wireless Controller" speaker, engages its DualSense-haptics path against an endpoint
|
||||
/// nothing services, and drops to 2–5 FPS 1% lows with the host completely idle; the manual
|
||||
/// community remedy is disabling the device in mmsys.cpl — this is that remedy, automated and
|
||||
/// scoped to "no pad attached". Visibility flips raise no PnP traffic (the #185 lesson), only
|
||||
/// an endpoint state notification — the same event a real pad's arrival/departure raises.
|
||||
pub(crate) fn set_visibility(endpoint_id: &str, pad_index: u8, visible: bool) {
|
||||
match audio_control::set_endpoint_visibility(endpoint_id, visible) {
|
||||
Ok(()) => tracing::info!(pad = pad_index, endpoint = %endpoint_id,
|
||||
state = if visible { "shown (client pad attached)" } else { "hidden (no pad attached)" },
|
||||
"pad-audio endpoint visibility"),
|
||||
Err(e) => tracing::warn!(pad = pad_index, endpoint = %endpoint_id, visible,
|
||||
error = %format!("{e:#}"),
|
||||
"pad-audio endpoint visibility change failed — an idle visible pad speaker can \
|
||||
stall libScePad titles (disable it in mmsys.cpl as a manual fallback)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide any pad endpoints a previous run left behind — the `PUNKTFUNK_PAD_AUDIO=0` path, where
|
||||
/// the provisioning worker never runs but persisted endpoints would otherwise stay visible (and
|
||||
/// stall idle libScePad titles) forever.
|
||||
fn hide_leftover_endpoints() {
|
||||
let spawned = thread::Builder::new()
|
||||
.name("punktfunk-pad-audio-hide".into())
|
||||
.spawn(|| {
|
||||
if wasapi::initialize_mta().ok().is_err() {
|
||||
return;
|
||||
}
|
||||
for idx in 0..4u8 {
|
||||
match find(idx) {
|
||||
Ok(Some(pe)) if !pe.endpoint_id.is_empty() => {
|
||||
set_visibility(&pe.endpoint_id, idx, false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
tracing::warn!(error = %e, "could not spawn the pad-endpoint hide sweep");
|
||||
}
|
||||
}
|
||||
|
||||
/// The provisioned endpoint for one pad slot — what a session queries when a client pad with
|
||||
/// speaker support arrives, to attach a [`PadLoopbackCapturer`].
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
//! the set changes — the thread says why once, then parks on a cheap fingerprint poll and
|
||||
//! re-plans the instant the set moves (the 2026-08 field case hammered a full wiring pass —
|
||||
//! IPolicyConfig writes included — every 2 s for 8+ minutes without ever being able to
|
||||
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback
|
||||
//! device is restored.
|
||||
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback AND
|
||||
//! recording devices are restored — both defaults are strictly session-scoped.
|
||||
|
||||
use super::capture_policy::{CaptureStats, FightDamper, FIGHT_BACKOFF, STATS_EVERY};
|
||||
use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE};
|
||||
@@ -290,9 +290,13 @@ fn capture_thread(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hand the default playback device back to the operator (no-op if we never parked it, or if
|
||||
// they changed it themselves mid-stream). COM is initialized on this thread.
|
||||
// Hand the default playback AND recording devices back to the operator (no-ops if we never
|
||||
// parked them, or if they changed them themselves mid-stream). COM is initialized on this
|
||||
// thread. The recording restore is what keeps the parked default session-scoped — an idle
|
||||
// box holding the default microphone on a virtual mic nothing feeds is the 2026-08
|
||||
// Helldivers 2 tank (see `audio_control`'s module docs).
|
||||
audio_control::restore_default_playback();
|
||||
audio_control::restore_default_recording();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -261,8 +261,10 @@ fn resolve_target() -> Result<(wasapi::Device, String)> {
|
||||
// on the cable while later plans paired the default recording with the minted microphone
|
||||
// nothing wrote into (see `minted::ensure_blocking`). Instant once latched.
|
||||
super::minted::ensure_blocking();
|
||||
// set_playback=false: the mic pump runs while the host is idle — only the desktop-audio
|
||||
// capture may park the playback default (on the silent sink) for a stream's lifetime.
|
||||
// park_defaults=false: the mic pump runs while the host is idle — only the desktop-audio
|
||||
// capture may park the box's defaults (playback on the silent sink, recording on the virtual
|
||||
// mic) for a stream's lifetime. An idle box must keep the operator's own devices default —
|
||||
// an idle-parked recording default is the 2026-08 Helldivers 2 tank (`audio_control` docs).
|
||||
let mut wiring = audio_control::wire_now(false);
|
||||
if wiring.mic_render.is_none() && !wiring.mic_withheld {
|
||||
// A WITHHELD mic skips the install attempt: the Streaming Microphone exists — the plan
|
||||
|
||||
@@ -241,6 +241,30 @@ pub(crate) fn silent_sink(lname: &str) -> bool {
|
||||
lname.contains("steam streaming microphone")
|
||||
}
|
||||
|
||||
/// A capture endpoint that surfaces a VIRTUAL device's audio (cables, streaming mics, mixer
|
||||
/// strips, the host's own minted "Punktfunk" microphone) rather than a real microphone. The
|
||||
/// recording-default hygiene pass must never move the box's default onto one of these.
|
||||
pub(crate) fn virtual_capture(lname: &str) -> bool {
|
||||
lname.contains("cable output")
|
||||
|| lname.contains("steam streaming")
|
||||
|| lname.contains("voicemeeter")
|
||||
|| lname.contains("virtual")
|
||||
|| lname.contains("punktfunk")
|
||||
}
|
||||
|
||||
/// The first REAL capture endpoint (skipping `avoid_id` and every [`virtual_capture`]) — where
|
||||
/// the recording-default hygiene sends a default an earlier build left parked on the virtual mic
|
||||
/// while the host is idle. `None` on a box with no real microphone: nothing sane to move to, so
|
||||
/// the default is left alone.
|
||||
pub(crate) fn real_capture<'a>(
|
||||
captures: &'a [Endpoint],
|
||||
avoid_id: Option<&str>,
|
||||
) -> Option<&'a Endpoint> {
|
||||
captures
|
||||
.iter()
|
||||
.find(|(n, id)| Some(id.as_str()) != avoid_id && !virtual_capture(&n.to_lowercase()))
|
||||
}
|
||||
|
||||
/// A known-virtual device (cables/streaming endpoints). A render WITHOUT these markers is real
|
||||
/// hardware — the best loopback source (apps render there by default and the operator can also
|
||||
/// hear it).
|
||||
@@ -1137,6 +1161,29 @@ mod tests {
|
||||
assert!(both.contains("16000") && both.contains("channel"), "{both}");
|
||||
}
|
||||
|
||||
/// The recording-default hygiene picker: skips every virtual capture (cable, streaming mic,
|
||||
/// the minted "Punktfunk" pair, VoiceMeeter) and lands on the real microphone — the exact
|
||||
/// recording-tab zoo of the 2026-08-14 Helldivers 2 field box.
|
||||
#[test]
|
||||
fn recording_hygiene_picks_the_real_microphone() {
|
||||
let captures = [
|
||||
ep("Microphone (2- Punktfunk)"),
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
ep("Microphone (Steam Streaming Microphone)"),
|
||||
ep("VoiceMeeter Output (VB-Audio VoiceMeeter VAIO)"),
|
||||
ep("Desktop Microphone (2- Microsoft LifeCam HD-3000)"),
|
||||
];
|
||||
assert_eq!(
|
||||
real_capture(&captures, None).unwrap().0,
|
||||
"Desktop Microphone (2- Microsoft LifeCam HD-3000)"
|
||||
);
|
||||
// `avoid_id` guards the plan's own mic capture even when its name would pass the
|
||||
// virtual test; with nothing else real, the answer is honestly None.
|
||||
let only = [ep("Desk Mic (USB)")];
|
||||
assert!(real_capture(&only, Some("id-desk mic (usb)")).is_none());
|
||||
assert!(real_capture(&[], None).is_none());
|
||||
}
|
||||
|
||||
/// Operator override beats the candidate order.
|
||||
#[test]
|
||||
fn env_override_wins() {
|
||||
|
||||
@@ -623,12 +623,15 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
|
||||
/// Windows: pad-audio endpoint provisioning — `pad-endpoint
|
||||
/// ensure|remove|status|tone|capture|show|hide [--index N]`.
|
||||
/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam
|
||||
/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether
|
||||
/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served
|
||||
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
|
||||
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
|
||||
/// hatch only, endpoints are persistent by design; `show`/`hide` flip the endpoint's
|
||||
/// visibility (the host parks it hidden while no client pad is attached — show it before
|
||||
/// `tone`/`capture`). Stamping needs SYSTEM (the MMDevices ACL);
|
||||
/// run `ensure` under the service account or PsExec when the property-store route is denied.
|
||||
/// Windows: the audio-substrate toolbox (`windows-audio-endpoints-and-vbcable.md`) —
|
||||
/// `audio-probe ssm|sink|sss-primary|mint|plan|cleanup [--keep]`. The S1–S3 spikes (`ssm` =
|
||||
@@ -744,7 +747,29 @@ pub fn pad_endpoint(args: &[String]) -> Result<()> {
|
||||
pe::capture_probe(&endpoint_id, secs)
|
||||
}
|
||||
Some("status") => pe::print_status(idx),
|
||||
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
|
||||
// `show`/`hide` — flip the endpoint's visibility (DEVICE_STATE_DISABLED). The host parks
|
||||
// pad endpoints hidden while no client pad is attached (idle libScePad titles stall on a
|
||||
// visible one — the 2026-08-14 Helldivers 2 field case); `tone`/`capture` need the
|
||||
// endpoint SHOWN first, and `hide` puts the box back to the idle-safe state after.
|
||||
Some(verb @ ("show" | "hide")) => {
|
||||
let endpoint_id = match endpoint_override {
|
||||
Some(id) => id,
|
||||
None => match pe::find(idx)? {
|
||||
Some(ep) if !ep.endpoint_id.is_empty() => ep.endpoint_id,
|
||||
_ => {
|
||||
println!("pad-endpoint {verb}: pad {idx} has no endpoint — run `ensure`");
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
pe::set_visibility(&endpoint_id, idx, verb == "show");
|
||||
println!("pad-endpoint {verb}: {endpoint_id}");
|
||||
Ok(())
|
||||
}
|
||||
_ => anyhow::bail!(
|
||||
"usage: punktfunk-host pad-endpoint \
|
||||
<ensure|remove|status|tone|capture|show|hide> [--index N]"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -397,11 +397,9 @@ fn audio_body(
|
||||
// stays small.
|
||||
let start = Instant::now();
|
||||
let mut frame_no: u64 = 0;
|
||||
// Optional linear gain for quiet capture sources (PUNKTFUNK_AUDIO_GAIN, default 1.0).
|
||||
let gain: f32 = std::env::var("PUNKTFUNK_AUDIO_GAIN")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1.0);
|
||||
// Optional gain for quiet capture sources (PUNKTFUNK_AUDIO_GAIN, default 1.0). Soft-limited
|
||||
// rather than clamped — see `crate::audio::capture_gain`.
|
||||
let gain = crate::audio::capture_gain();
|
||||
tracing::info!(
|
||||
channels = layout.channels,
|
||||
streams = layout.streams,
|
||||
@@ -418,9 +416,7 @@ fn audio_body(
|
||||
while acc.len() >= frame_len {
|
||||
let mut frame: Vec<f32> = acc.drain(..frame_len).collect();
|
||||
if gain != 1.0 {
|
||||
for s in &mut frame {
|
||||
*s = (*s * gain).clamp(-1.0, 1.0);
|
||||
}
|
||||
punktfunk_core::audio::apply_gain(&mut frame, gain);
|
||||
}
|
||||
let n = enc.encode_float(&frame, &mut out)?;
|
||||
// AES-128-CBC the Opus payload (RTP header stays plaintext). Per-packet IV =
|
||||
|
||||
@@ -442,6 +442,35 @@ pub fn validate_store_claim(store: &str) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every `launcher_ui` entry naming a launcher this host cannot actually open, returning the
|
||||
/// `(title, value)` pairs removed.
|
||||
///
|
||||
/// The launch-side counterpart to [`sanitize_art_paths`], and it exists for the same reason: a
|
||||
/// plugin reconciles its **whole** entry set at once, so anything that fails the payload costs the
|
||||
/// operator every game in it. The Playnite plugin appends one launcher tile beside the games, so a
|
||||
/// host that could not resolve `Playnite.FullscreenApp.exe` refused the lot — the operator saw an
|
||||
/// empty grid and a `HostRequestError` naming `entries[9]`, with nothing to say the other entries
|
||||
/// were fine.
|
||||
///
|
||||
/// Only the *unresolvable* case is dropped. A value outside the platform's vocabulary is still a
|
||||
/// hard 400 in [`validate_provider_payload`]: that one is a bug in the plugin, and silently
|
||||
/// swallowing it would leave the author with a tile that never appears and no reason why.
|
||||
///
|
||||
/// Dropping the whole entry rather than clearing its `launch` is deliberate — a launcher tile with
|
||||
/// no launch is a dead tile, which is strictly worse than no tile.
|
||||
pub fn sanitize_launcher_entries(inputs: &mut Vec<ProviderEntryInput>) -> Vec<(String, String)> {
|
||||
let mut dropped = Vec::new();
|
||||
inputs.retain(|e| {
|
||||
let Some(launch) = &e.launch else { return true };
|
||||
if launch.kind != "launcher_ui" || resolvable_launcher_ui(&launch.value) {
|
||||
return true;
|
||||
}
|
||||
dropped.push((e.title.clone(), launch.value.clone()));
|
||||
false
|
||||
});
|
||||
dropped
|
||||
}
|
||||
|
||||
/// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the
|
||||
/// diff key — a duplicate would make ownership of the surviving entry ambiguous).
|
||||
pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> {
|
||||
@@ -467,12 +496,13 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
|
||||
"entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`"
|
||||
));
|
||||
}
|
||||
// Refused rather than silently accepted, because the failure is otherwise invisible
|
||||
// until a user clicks the tile: an unresolvable value yields no command at launch time.
|
||||
if launch.kind == "launcher_ui" && !valid_launcher_ui(&launch.value) {
|
||||
// Only the VOCABULARY is refused here. Whether the launcher is actually installed on
|
||||
// this box is not the payload's fault, and 400ing over it threw away every game in the
|
||||
// reconcile — see `sanitize_launcher_entries`, which drops just the tile instead.
|
||||
if launch.kind == "launcher_ui" && !known_launcher_ui(&launch.value) {
|
||||
return Err(format!(
|
||||
"entries[{i}]: `launch.value` for kind `launcher_ui` names a launcher this host \
|
||||
cannot open (`{}`)",
|
||||
"entries[{i}]: `launch.value` for kind `launcher_ui` is not a launcher this \
|
||||
host's platform supports (`{}`)",
|
||||
launch.value
|
||||
));
|
||||
}
|
||||
@@ -1065,6 +1095,14 @@ mod tests {
|
||||
// Other kinds are unconstrained here (the host validates them per-kind at launch).
|
||||
assert!(validate_provider_payload(&[with_launch("command", "anything")]).is_ok());
|
||||
|
||||
// `launcher_ui` is checked for VOCABULARY only. A launcher that is merely not installed
|
||||
// must pass here and be dropped later — see `an_unopenable_launcher_tile_costs_only_itself`.
|
||||
assert!(validate_provider_payload(&[with_launch("launcher_ui", "nonesuch")]).is_err());
|
||||
#[cfg(windows)]
|
||||
assert!(validate_provider_payload(&[with_launch("launcher_ui", "playnite")]).is_ok());
|
||||
#[cfg(target_os = "linux")]
|
||||
assert!(validate_provider_payload(&[with_launch("launcher_ui", "lutris")]).is_ok());
|
||||
|
||||
let with_env = |key: &str, value: Option<&str>| {
|
||||
let mut i = input("a", "A");
|
||||
i.detect.env_marker = Some(EnvMarker {
|
||||
@@ -1129,4 +1167,40 @@ mod tests {
|
||||
"duplicate external_id"
|
||||
);
|
||||
}
|
||||
|
||||
/// The regression `sanitize_launcher_entries` exists for: a launcher tile this host cannot open
|
||||
/// must cost that tile, not the games reconciled beside it.
|
||||
///
|
||||
/// Field shape — the Playnite plugin appends exactly one `launcher_ui` tile after its games, so
|
||||
/// `entries[N]` failing validation used to refuse the entire payload and leave the operator with
|
||||
/// an empty grid and a `HostRequestError` that named only the index.
|
||||
#[test]
|
||||
fn an_unopenable_launcher_tile_costs_only_itself() {
|
||||
let mut tile = input("launcher", "Playnite");
|
||||
tile.role = GameRole::Launcher;
|
||||
tile.launch = Some(LaunchSpec {
|
||||
kind: "launcher_ui".into(),
|
||||
value: "playnite".into(),
|
||||
});
|
||||
|
||||
let mut inputs = vec![input("a", "A"), tile, input("b", "B")];
|
||||
let dropped = sanitize_launcher_entries(&mut inputs);
|
||||
|
||||
if resolvable_launcher_ui("playnite") {
|
||||
// A Windows box with Playnite actually installed keeps all three.
|
||||
assert!(dropped.is_empty());
|
||||
assert_eq!(inputs.len(), 3);
|
||||
} else {
|
||||
// Everywhere else the tile goes and both games survive — the whole point of the split.
|
||||
assert_eq!(dropped.len(), 1);
|
||||
assert_eq!(dropped[0].1, "playnite");
|
||||
assert_eq!(inputs.len(), 2);
|
||||
assert!(inputs.iter().all(|e| e.external_id != "launcher"));
|
||||
}
|
||||
|
||||
// A payload of nothing but games is untouched on every OS.
|
||||
let mut only_games = vec![input("a", "A"), input("b", "B")];
|
||||
assert!(sanitize_launcher_entries(&mut only_games).is_empty());
|
||||
assert_eq!(only_games.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,13 +478,31 @@ fn launcher_ui_stores() -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this a `launcher_ui` value this host can resolve?
|
||||
/// Is `value` a launcher this host's platform knows about at all?
|
||||
///
|
||||
/// On Windows, Playnite is validated by *resolution* rather than by being on the list: a host
|
||||
/// without Playnite installed refuses the entry (a 400 the plugin author can act on) instead of
|
||||
/// publishing a tile that does nothing when a user clicks it.
|
||||
pub(crate) fn valid_launcher_ui(value: &str) -> bool {
|
||||
if !launcher_ui_stores().contains(&value) {
|
||||
/// The *vocabulary* half of the old `valid_launcher_ui`. A value outside this set is a plugin
|
||||
/// author's mistake — a typo, or a launcher this OS has no support for — and no amount of
|
||||
/// installing things on the box will make it resolve, so the reconcile refuses the payload.
|
||||
pub(crate) fn known_launcher_ui(value: &str) -> bool {
|
||||
launcher_ui_stores().contains(&value)
|
||||
}
|
||||
|
||||
/// Can this host open `value`'s launcher **right now**?
|
||||
///
|
||||
/// The *environment* half. Deliberately separate from [`known_launcher_ui`], because the two
|
||||
/// failures are not the same kind of thing and must not get the same answer:
|
||||
///
|
||||
/// - an unknown value is a bug in the plugin, and a 400 is the only way its author finds out;
|
||||
/// - a known value that will not resolve means the launcher simply is not installed here, which is
|
||||
/// an ordinary fact about the box, not a defect in the payload.
|
||||
///
|
||||
/// Conflating them cost a real library: the Playnite plugin publishes one launcher tile alongside
|
||||
/// every game, so a host that could not resolve Playnite 400'd the whole reconcile and the operator
|
||||
/// got **no games at all** — the same shape as the unservable-cover bug that
|
||||
/// [`super::sanitize_art_paths`] was introduced to fix. The tile is dropped now (see
|
||||
/// [`super::sanitize_launcher_entries`]) and the games sync.
|
||||
pub(crate) fn resolvable_launcher_ui(value: &str) -> bool {
|
||||
if !known_launcher_ui(value) {
|
||||
return false;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
@@ -502,36 +520,141 @@ pub(crate) fn valid_launcher_ui(value: &str) -> bool {
|
||||
/// directly, which is also why nothing here is interpolated from the entry: the whole value is the
|
||||
/// literal `"playnite"`.
|
||||
///
|
||||
/// Playnite installs per-user by default, so the install directory comes from its own uninstall
|
||||
/// entry (HKCU first, then HKLM for a machine-wide install), falling back to the default
|
||||
/// `%LOCALAPPDATA%\Playnite`. `None` when nothing resolves, which is what refuses the tile.
|
||||
/// `None` when nothing resolves, which is what drops the tile.
|
||||
#[cfg(windows)]
|
||||
fn playnite_fullscreen_exe() -> Option<std::path::PathBuf> {
|
||||
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
|
||||
use winreg::RegKey;
|
||||
const KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Playnite";
|
||||
const EXE: &str = "Playnite.FullscreenApp.exe";
|
||||
|
||||
let from_registry = [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE]
|
||||
playnite_install_dirs()
|
||||
.into_iter()
|
||||
.find_map(|root| {
|
||||
RegKey::predef(root)
|
||||
.open_subkey(KEY)
|
||||
.ok()?
|
||||
.get_value::<String, _>("InstallLocation")
|
||||
.ok()
|
||||
})
|
||||
.map(std::path::PathBuf::from);
|
||||
|
||||
from_registry
|
||||
.into_iter()
|
||||
.chain(
|
||||
std::env::var_os("LOCALAPPDATA").map(|l| std::path::PathBuf::from(l).join("Playnite")),
|
||||
)
|
||||
.map(|dir| dir.join(EXE))
|
||||
.find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Windows: every directory that might hold a Playnite install, best candidates first.
|
||||
///
|
||||
/// **Playnite installs per-user by default, and this host is a LocalSystem service** — which
|
||||
/// invalidates all three of the obvious lookups, and is why this is not a two-liner:
|
||||
///
|
||||
/// - `HKEY_CURRENT_USER` is *SYSTEM's own* hive (`S-1-5-18`), never the person's, so a per-user
|
||||
/// install is invisible there. Every **loaded** hive under `HKEY_USERS` is read instead: only
|
||||
/// logged-on users' hives are loaded, which is exactly the set that can be streaming, and it
|
||||
/// avoids a `WTSQueryUserToken` dance for what is a best-effort probe. Same trade-off
|
||||
/// [`crate::procscan::steam_running_hint`] makes, for the same reason.
|
||||
/// - The uninstall subkey is matched by its **`DisplayName`**, not by key name. Playnite ships an
|
||||
/// Inno Setup installer and Inno registers `<AppId>_is1` — measured on a Windows box where Git
|
||||
/// and Inno itself appear as `Git_is1` and `Inno Setup 6_is1`. The hardcoded
|
||||
/// `…\Uninstall\Playnite` this replaced matched nothing on any box.
|
||||
/// - `%LOCALAPPDATA%` for a SYSTEM service is `C:\Windows\System32\config\systemprofile\AppData\
|
||||
/// Local`, so the default-install fallback cannot trust the variable — it enumerates the profiles
|
||||
/// under the users base instead, the same breadth [`super::art::art_roots`] already allows.
|
||||
///
|
||||
/// Order matters only as a preference: a registry `InstallLocation` is what the installer actually
|
||||
/// did, so it is consulted before the conventional path. Every candidate is probed for the exe, so
|
||||
/// a stale entry costs one `is_file` and nothing else.
|
||||
#[cfg(windows)]
|
||||
fn playnite_install_dirs() -> Vec<std::path::PathBuf> {
|
||||
use winreg::enums::{HKEY_LOCAL_MACHINE, HKEY_USERS, KEY_READ};
|
||||
use winreg::RegKey;
|
||||
|
||||
// 64-bit and 32-bit views. HKCU/HKU `Software` is not redirected (only `Software\Classes` is),
|
||||
// so the WOW view is a machine-hive concern only.
|
||||
const UNINSTALL: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
const UNINSTALL_WOW: &str = r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
|
||||
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
|
||||
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
playnite_dirs_from_uninstall(&hklm, UNINSTALL, &mut dirs);
|
||||
playnite_dirs_from_uninstall(&hklm, UNINSTALL_WOW, &mut dirs);
|
||||
|
||||
let users = RegKey::predef(HKEY_USERS);
|
||||
for sid in users.enum_keys().flatten() {
|
||||
// The `…_Classes` companion hives carry file associations, never uninstall entries.
|
||||
if sid.ends_with("_Classes") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(hive) = users.open_subkey_with_flags(&sid, KEY_READ) {
|
||||
playnite_dirs_from_uninstall(&hive, UNINSTALL, &mut dirs);
|
||||
}
|
||||
}
|
||||
|
||||
// The conventional per-user location, for every profile on the box — this is where Playnite's
|
||||
// own default install lands, and it covers a user whose hive is not currently loaded.
|
||||
for profile in windows_user_profiles() {
|
||||
push_unique(&mut dirs, profile.join(r"AppData\Local\Playnite"));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Collect `InstallLocation` from every Playnite-looking uninstall entry under `root\path`.
|
||||
///
|
||||
/// Matched on `DisplayName` because the key name is the installer's `AppId` (see
|
||||
/// [`playnite_install_dirs`]). `starts_with` rather than equality so a versioned or suffixed display
|
||||
/// name still counts; the value is only ever used as a directory to probe for the exe, so a false
|
||||
/// positive costs one failed `is_file`.
|
||||
#[cfg(windows)]
|
||||
fn playnite_dirs_from_uninstall(
|
||||
root: &winreg::RegKey,
|
||||
path: &str,
|
||||
out: &mut Vec<std::path::PathBuf>,
|
||||
) {
|
||||
use winreg::enums::KEY_READ;
|
||||
|
||||
let Ok(uninstall) = root.open_subkey_with_flags(path, KEY_READ) else {
|
||||
return;
|
||||
};
|
||||
for name in uninstall.enum_keys().flatten() {
|
||||
let Ok(entry) = uninstall.open_subkey_with_flags(&name, KEY_READ) else {
|
||||
continue;
|
||||
};
|
||||
let display: String = entry.get_value("DisplayName").unwrap_or_default();
|
||||
if !display.starts_with("Playnite") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(location) = entry.get_value::<String, _>("InstallLocation") {
|
||||
let location = location.trim();
|
||||
if !location.is_empty() {
|
||||
push_unique(out, std::path::PathBuf::from(location));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every user profile directory on the box (`C:\Users\*`), minus the shared `Public` pseudo-profile.
|
||||
///
|
||||
/// `%PUBLIC%`'s parent is the users base on every supported Windows — the same derivation
|
||||
/// [`super::art::art_roots`] uses — with `%SystemDrive%\Users` as the fallback when the variable is
|
||||
/// missing from a service's environment.
|
||||
#[cfg(windows)]
|
||||
fn windows_user_profiles() -> Vec<std::path::PathBuf> {
|
||||
let base = std::env::var_os("PUBLIC")
|
||||
.map(std::path::PathBuf::from)
|
||||
.and_then(|p| p.parent().map(std::path::Path::to_path_buf))
|
||||
.or_else(|| {
|
||||
std::env::var_os("SystemDrive").map(|d| std::path::PathBuf::from(d).join("Users"))
|
||||
});
|
||||
let Some(base) = base else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&base) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir() && !p.ends_with("Public"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Push `path` unless an equal one is already there — the candidate lists are a handful of entries,
|
||||
/// so a linear check beats carrying a set around.
|
||||
#[cfg(windows)]
|
||||
fn push_unique(out: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
|
||||
if !out.contains(&path) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
|
||||
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
|
||||
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
|
||||
@@ -800,33 +923,38 @@ mod tests {
|
||||
fn launcher_ui_accepts_only_launchers_this_host_can_open() {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
assert!(valid_launcher_ui("heroic"));
|
||||
assert!(valid_launcher_ui("lutris"));
|
||||
// Not wired on this OS — refused inbound rather than becoming a tile that does nothing.
|
||||
assert!(!valid_launcher_ui("gog"));
|
||||
assert!(known_launcher_ui("heroic"));
|
||||
assert!(known_launcher_ui("lutris"));
|
||||
// Not wired on this OS — outside the vocabulary, so it is refused inbound rather than
|
||||
// becoming a tile that does nothing.
|
||||
assert!(!known_launcher_ui("gog"));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Playnite is accepted only when this host can actually FIND its Fullscreen app:
|
||||
// validation is resolution, so a box without Playnite refuses the entry rather than
|
||||
// publishing a tile that does nothing when clicked.
|
||||
// Playnite is in the vocabulary unconditionally — whether this particular box has it
|
||||
// installed is a separate question, answered by `resolvable_launcher_ui` below. Keeping
|
||||
// them separate is the fix for the reconcile that 400'd a whole library over one tile.
|
||||
assert!(known_launcher_ui("playnite"));
|
||||
assert_eq!(
|
||||
valid_launcher_ui("playnite"),
|
||||
resolvable_launcher_ui("playnite"),
|
||||
playnite_fullscreen_exe().is_some()
|
||||
);
|
||||
// The Linux launchers, and the Windows ones whose activation is still unverified
|
||||
// (Epic, GOG Galaxy, the Xbox app), stay refused.
|
||||
assert!(!valid_launcher_ui("heroic"));
|
||||
assert!(!valid_launcher_ui("gog"));
|
||||
assert!(!known_launcher_ui("heroic"));
|
||||
assert!(!known_launcher_ui("gog"));
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
{
|
||||
// No launcher UIs are wired on this OS, so every value is refused.
|
||||
assert!(!valid_launcher_ui("heroic"));
|
||||
assert!(!valid_launcher_ui("gog"));
|
||||
assert!(!known_launcher_ui("heroic"));
|
||||
assert!(!known_launcher_ui("gog"));
|
||||
}
|
||||
assert!(!valid_launcher_ui(""));
|
||||
assert!(!valid_launcher_ui("lutris; rm -rf ~"));
|
||||
// Junk is outside the vocabulary on every OS, so it never reaches a resolver.
|
||||
assert!(!known_launcher_ui(""));
|
||||
assert!(!known_launcher_ui("lutris; rm -rf ~"));
|
||||
assert!(!resolvable_launcher_ui(""));
|
||||
assert!(!resolvable_launcher_ui("lutris; rm -rf ~"));
|
||||
}
|
||||
|
||||
/// The `xbox` kind is what a library PLUGIN can publish: the runner's principal cannot read
|
||||
|
||||
@@ -761,6 +761,9 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
|
||||
// paired clients can browse the game library out of the box (the bearer admin surface stays
|
||||
// loopback-gated in `mgmt::require_auth` regardless of the bind).
|
||||
let mut mgmt_bind_explicit = false;
|
||||
// Same question for the native port: an explicit `--native-port` out-ranks
|
||||
// `PUNKTFUNK_NATIVE_PORT` from host.env, resolved after the loop.
|
||||
let mut native_port_explicit = false;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let arg = args[i].as_str();
|
||||
@@ -793,7 +796,8 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
|
||||
"--native-port" => {
|
||||
native_port = next()?
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?
|
||||
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?;
|
||||
native_port_explicit = true;
|
||||
}
|
||||
"--data-port" => {
|
||||
data_port = Some(
|
||||
@@ -844,9 +848,34 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
|
||||
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
|
||||
// is confined to loopback peers in `mgmt::require_auth`, so binding wide adds no admin exposure.
|
||||
// An operator who pinned `--mgmt-bind` (e.g. `127.0.0.1:47990` to restore loopback-only) keeps it.
|
||||
//
|
||||
// Same two-source shape as `--gamestream` / `PUNKTFUNK_GAMESTREAM` below, and for the same
|
||||
// reason: the packaged units ship a fixed ExecStart, so `host.env` is the only route a package
|
||||
// user has to move this that an upgrade won't overwrite. CLI wins — it is the more explicit of
|
||||
// the two and the one a support instruction reaches for.
|
||||
if !mgmt_bind_explicit {
|
||||
opts.bind = std::net::SocketAddr::from(([0, 0, 0, 0], mgmt::DEFAULT_PORT));
|
||||
opts.bind = match pf_host_config::config().mgmt_bind.as_deref() {
|
||||
Some(s) => s
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad PUNKTFUNK_MGMT_BIND '{s}' (want IP:PORT)"))?,
|
||||
None => std::net::SocketAddr::from(([0, 0, 0, 0], mgmt::DEFAULT_PORT)),
|
||||
};
|
||||
}
|
||||
// Same two-source resolution as the mgmt bind above. A bad value is FATAL rather than ignored:
|
||||
// silently serving on 9777 while host.env says otherwise is the failure that reads as "I moved
|
||||
// the port and the client still can't reach me".
|
||||
if !native_port_explicit {
|
||||
if let Some(s) = pf_host_config::config().native_port.as_deref() {
|
||||
native_port = s
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad PUNKTFUNK_NATIVE_PORT '{s}' (want a port)"))?;
|
||||
}
|
||||
}
|
||||
// Publish the resolved port for the console, right here rather than inside `serve`: the
|
||||
// console's unit gates on `mgmt-token` (persisted a few lines above), so writing the endpoint
|
||||
// in the same function keeps the two files effectively simultaneous. A console that still wins
|
||||
// that race falls back to 47990 and its `Restart=always` retry picks the file up.
|
||||
mgmt::publish_endpoint(opts.bind);
|
||||
let native = native::NativeServe {
|
||||
port: native_port,
|
||||
require_pairing: !open,
|
||||
@@ -999,10 +1028,13 @@ USAGE:
|
||||
punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool)
|
||||
|
||||
SERVE OPTIONS:
|
||||
--mgmt-bind <IP:PORT> management API address (default: 0.0.0.0:47990 — paired clients
|
||||
--mgmt-bind <IP:PORT> management API address (or PUNKTFUNK_MGMT_BIND in host.env, which
|
||||
this flag overrides). Default: 0.0.0.0:47990 — paired clients
|
||||
reach the read-only surface, incl. the game library, over mTLS;
|
||||
the bearer admin API stays loopback-only. Pin 127.0.0.1:47990 to
|
||||
bind loopback only)
|
||||
bind loopback only. Move the PORT (e.g. 0.0.0.0:47991) to share a
|
||||
machine with Sunshine/Apollo/Vibeshine, whose web UI owns 47990 —
|
||||
clients follow via mDNS and the console via mgmt-endpoint
|
||||
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN); the
|
||||
admin endpoints it guards are honored only from a loopback peer
|
||||
(the co-located web console), never over the LAN
|
||||
@@ -1013,7 +1045,9 @@ SERVE OPTIONS:
|
||||
Also PUNKTFUNK_GAMESTREAM=1 in host.env (how a packaged install
|
||||
opts in — the shipped units run native-only)
|
||||
--native no-op (the native punktfunk/1 plane always runs in `serve` now)
|
||||
--native-port <PORT> native QUIC port (default 9777)
|
||||
--native-port <PORT> native QUIC port (or PUNKTFUNK_NATIVE_PORT in host.env, which
|
||||
this flag overrides). Default 9777. Clients follow via mDNS, and
|
||||
a manually-added host keeps whatever port it was added with
|
||||
--data-port <PORT> pin the per-session video data plane to this fixed UDP port and
|
||||
stream direct (no hole-punch) — open exactly this port in a host
|
||||
firewall to avoid the ~2.5 s punch-timeout. Default (unset) or
|
||||
|
||||
@@ -57,8 +57,98 @@ pub(crate) use plugins::ui_credential;
|
||||
|
||||
/// Default management port — adjacent to the GameStream block (47984…48010), and the same
|
||||
/// number Sunshine users already associate with "the config UI".
|
||||
///
|
||||
/// ⚠ That last part is also why it is the ONE port a Sunshine fork and a GameStream-off Punktfunk
|
||||
/// still collide on (47990 is their web UI). Moving it is supported — see [`publish_endpoint`] and
|
||||
/// `PUNKTFUNK_MGMT_BIND` — and every consumer derives the real port rather than assuming this one.
|
||||
pub const DEFAULT_PORT: u16 = 47990;
|
||||
|
||||
/// The file [`publish_endpoint`] writes the effective mgmt URL to, next to `mgmt-token`.
|
||||
const ENDPOINT_FILE: &str = "mgmt-endpoint";
|
||||
|
||||
/// The port the management API actually bound, recorded once by [`publish_endpoint`].
|
||||
static EFFECTIVE_PORT: std::sync::OnceLock<u16> = std::sync::OnceLock::new();
|
||||
|
||||
/// The mgmt port this process is serving on, or `0` when there is no management API at all — the
|
||||
/// standalone `punktfunk1-host` binary, which never calls [`publish_endpoint`].
|
||||
///
|
||||
/// The native handshake reads this to put the port in every session's `Welcome`, so a client learns
|
||||
/// it over the connection it has already authenticated instead of needing the mDNS advert. Resolved
|
||||
/// ONCE, from the same value the endpoint file carries, so the wire, the file and the advert cannot
|
||||
/// disagree — the whole point of this being a lookup rather than a fourth place to compute a port.
|
||||
///
|
||||
/// ⚠ `0` matters: advertising 47990 from a host with no mgmt API would point clients at a port
|
||||
/// nothing is listening on, which is strictly worse than saying nothing and letting them fall back.
|
||||
pub fn effective_port() -> u16 {
|
||||
EFFECTIVE_PORT.get().copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Publish the mgmt API's *effective* loopback URL to `<config-dir>/mgmt-endpoint`, in the same
|
||||
/// `KEY=VALUE` form as `mgmt-token` so the bundled console can source it directly as a systemd
|
||||
/// `EnvironmentFile` (and `windows::service::spawn_web` can read it with `read_env_file_value`).
|
||||
///
|
||||
/// **Why this exists:** the port used to be a literal `47990` in five places — this constant, the
|
||||
/// Windows service's console launch, `scripts/punktfunk-web.service`, the NixOS module, and the
|
||||
/// console's own default. Moving the listener therefore silently broke the console, because nothing
|
||||
/// downstream had any way to learn the new port. Now the host is the single source of truth and
|
||||
/// publishes what it actually bound; consumers keep a 47990 fallback purely so an OLD host with a
|
||||
/// NEW console still works.
|
||||
///
|
||||
/// Always loopback, never `bind`'s own address: the console proxies over loopback by design (see
|
||||
/// the module docs — the bearer-token admin surface is confined to loopback peers), so a wide
|
||||
/// `0.0.0.0` bind must not be echoed here as a LAN URL.
|
||||
///
|
||||
/// Best-effort: a console that cannot read this simply falls back to 47990, which is strictly what
|
||||
/// it did before, so a write failure must not stop the host from serving.
|
||||
pub fn publish_endpoint(bind: SocketAddr) {
|
||||
// Record it for [`effective_port`] BEFORE the write: the native handshake reads that to put the
|
||||
// port in every Welcome, and a failed file write must not also cost us the in-band answer.
|
||||
let _ = EFFECTIVE_PORT.set(bind.port());
|
||||
let dir = pf_paths::config_dir();
|
||||
if let Err(e) = pf_paths::create_private_dir(&dir) {
|
||||
tracing::warn!(error = %e, "could not create the config dir to publish the mgmt endpoint");
|
||||
return;
|
||||
}
|
||||
match write_endpoint(&dir, bind.port()) {
|
||||
Ok(path) => {
|
||||
tracing::debug!(path = %path.display(), port = bind.port(), "published mgmt endpoint")
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
dir = %dir.display(),
|
||||
error = %e,
|
||||
"could not publish the mgmt endpoint — a console on another port will fall back to 47990"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The IO half of [`publish_endpoint`], taking the directory so it is testable without touching
|
||||
/// `PUNKTFUNK_CONFIG_DIR` (which every other test in this process shares).
|
||||
///
|
||||
/// Deliberately NOT `pf_paths::write_secret_file`: this is not a secret — the same port is already
|
||||
/// in the mDNS TXT record — and locking it to SYSTEM/Administrators on Windows would keep a
|
||||
/// user-session console from reading the very thing it is published for. The 0700 config dir is the
|
||||
/// access control that matters.
|
||||
fn write_endpoint(dir: &std::path::Path, port: u16) -> std::io::Result<std::path::PathBuf> {
|
||||
let path = dir.join(ENDPOINT_FILE);
|
||||
// Write-then-rename rather than a plain truncating write: the console's systemd unit may source
|
||||
// this file at any moment, including while the host is restarting and rewriting it. A torn read
|
||||
// would hand systemd an EMPTY `PUNKTFUNK_MGMT_URL`, which is worse than a missing file — the
|
||||
// built-in default only applies to an UNSET variable, not a set-but-blank one. `rename` over an
|
||||
// existing path is atomic on Unix and replaces on Windows, so a reader sees old or new, never
|
||||
// half. (The consumers treat blank as unset too — this is the belt to that pair of braces.)
|
||||
let tmp = dir.join(format!("{ENDPOINT_FILE}.tmp"));
|
||||
std::fs::write(&tmp, endpoint_line(port))?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The published line. Must stay valid as BOTH a systemd `EnvironmentFile` entry and input to
|
||||
/// `windows::service::read_env_file_value` — i.e. exactly one `KEY=VALUE` line, no quoting, and no
|
||||
/// `=` inside the value (a URL has none).
|
||||
fn endpoint_line(port: u16) -> String {
|
||||
format!("PUNKTFUNK_MGMT_URL=https://127.0.0.1:{port}\n")
|
||||
}
|
||||
|
||||
/// Management server options (CLI: `serve --mgmt-bind ADDR --mgmt-token TOKEN`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Options {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user