Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f9c1f4d3 | ||
|
|
78efedc0d8 | ||
|
|
8c6099da2a | ||
|
|
2b066b3e11 | ||
|
|
c3c24b5855 | ||
|
|
0d4f878f32 | ||
|
|
d886cd0124 | ||
|
|
2832b5d0f6 | ||
|
|
6863f8141a | ||
|
|
cf4c12ea52 | ||
|
|
5e5d6904d3 | ||
|
|
9ce347e4c0 | ||
|
|
dea6395772 | ||
|
|
55dbb14cf4 | ||
|
|
f0b35de92a | ||
|
|
588962f696 | ||
|
|
91b8f1a939 | ||
|
|
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 | ||
|
|
d0a3eca7b8 | ||
|
|
f033d3f5df | ||
|
|
bf741f8693 | ||
|
|
e3443da108 | ||
|
|
5a4dd7423e | ||
|
|
0a468c96da |
@@ -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:
|
||||
@@ -51,7 +56,21 @@ on:
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/android.yml'
|
||||
# Manual runs are BUILD-ONLY by default. The escape hatch below exists because a push run can
|
||||
# go missing entirely: merge two PRs seconds apart and Gitea attributes the window's runs to the
|
||||
# newer head, so the older merge sha gets no run at all — its android change then sits on main
|
||||
# having never been built, let alone published (2026-08-14: `1e5dca4c`, PR #235, lost its run to
|
||||
# `b5cace3a` 12 s later). Re-running the PR run does NOT recover it: a re-run replays the original
|
||||
# `pull_request` event, so every gate below stays false. Only a dispatch with publish=true can
|
||||
# ship that commit without inventing a filler push.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
# String, not a boolean: matches apple.yml's `testflight` input, which is the form proven
|
||||
# to evaluate correctly on this Gitea. Compared as `inputs.publish == 'true'` below.
|
||||
description: "Also publish this build (registry + Google Play). main -> beta+alpha, vX.Y.Z tag -> production at 100%. Default false: a stray click must not reach testers."
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
|
||||
# unbound). The NDK clang targets get their own key universes automatically (keys embed
|
||||
@@ -94,8 +113,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: |
|
||||
@@ -222,15 +242,18 @@ jobs:
|
||||
# Single source of the version name + the Play track for the release steps below. versionCode
|
||||
# stays github.run_number (monotonic across both tracks; Play rejects a regressed code).
|
||||
- name: Version + channel
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: >-
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
|
||||
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
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,10 +263,12 @@ 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'))
|
||||
if: >-
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
|
||||
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
working-directory: clients/android
|
||||
env:
|
||||
VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV)
|
||||
@@ -278,7 +303,9 @@ jobs:
|
||||
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
|
||||
# to the unified Gitea Release.
|
||||
- name: Publish to generic registry + attach to Gitea release
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: >-
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
|
||||
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
@@ -312,7 +339,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
|
||||
@@ -320,13 +348,16 @@ jobs:
|
||||
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
|
||||
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
|
||||
- name: Upload to Google Play
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: >-
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
|
||||
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
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 "$@"
|
||||
|
||||
@@ -755,7 +755,7 @@ jobs:
|
||||
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 05-settings 03-pair" \
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,12 +70,18 @@
|
||||
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
|
||||
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||
#
|
||||
# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD
|
||||
# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the
|
||||
# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public
|
||||
# .cer is published next to the .msix (users import it to Trusted People before install).
|
||||
# Signing (clients/windows/packaging/pack-msix.ps1), first match wins:
|
||||
# 1. Azure Artifact Signing — what this workflow always takes, since the AZURE_CODESIGNING_*
|
||||
# endpoint/account/profile are literals below and only the AZURE_TENANT_ID / AZURE_CLIENT_ID /
|
||||
# AZURE_CLIENT_SECRET secrets are needed. Publicly trusted, so NO .cer is emitted or published
|
||||
# and users import nothing. NOTE the Publisher DN is the Azure profile's verified subject, and
|
||||
# MSIX identity is name + publisher: moving to it changed the package identity, so installs
|
||||
# predating it need an uninstall, not an upgrade.
|
||||
# 2. MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD — the older self-signed .pfx, kept as a fallback.
|
||||
# 3. an ephemeral self-signed cert. Modes 2 and 3 DO emit a .cer next to the .msix, which users
|
||||
# would have to import into Trusted People before Windows will install the package.
|
||||
#
|
||||
# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
|
||||
# Modes 2 and 3 are for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
|
||||
# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that
|
||||
# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself.
|
||||
name: windows-client
|
||||
@@ -257,6 +263,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 +294,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
|
||||
|
||||
+209
-1
@@ -12,7 +12,183 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.28.1
|
||||
## v0.29.0
|
||||
|
||||
53 commits since v0.28.1 (36 non-merge).
|
||||
|
||||
The headline contract change is one **additive** C ABI bump: the host now tells the client, in-band,
|
||||
where its management API lives, and the connection grew an accessor for it. The wire protocol, the
|
||||
driver protocol and the plugin contract do not move; every 0.28.x host, client, driver and plugin
|
||||
keeps interoperating with 0.29.0 in both directions, with no re-pairing. The one thing that needs an
|
||||
operator's hand is on Windows: the MSIX package identity changed with the move to a publicly
|
||||
trusted signing certificate, so that install path needs a one-time uninstall + reinstall.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.28.1 | v0.29.0 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged — `Welcome` grew a trailing field older peers never read (below) |
|
||||
| C ABI | 19 | **20** | one symbol added: `punktfunk_connection_mgmt_port` (below) |
|
||||
| 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); `pf-driver-proto` shows no diff against the v0.28.1 tag |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| `api/openapi.json` | 0.28.0 | **0.28.0** | the management API surface did not change; the file keeps the stamp it was regenerated under |
|
||||
| gamescope patch level (`+pfhdrN`) | 7 | **7** | unchanged — the patch series is untouched |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
|
||||
| `@punktfunk/plugin-kit` | 0.4.1 | **0.4.1** | unchanged |
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
- **C ABI 19 → 20, addition only.** `include/punktfunk_core.h` gains exactly one declaration,
|
||||
`punktfunk_connection_mgmt_port(const PunktfunkConnection *, uint16_t *)` — the management-API
|
||||
port the host advertised in its `Welcome`, or the documented default when it advertised none.
|
||||
Nothing is removed or reshaped; an embedder that compares `PUNKTFUNK_ABI_VERSION` at build time
|
||||
rebuilds against the new header and is done. Nothing in-tree compares it at runtime.
|
||||
- **The Windows MSIX package identity changed.** Releases are now signed by Azure Artifact Signing
|
||||
(below), and the MSIX manifest `Publisher` must equal the signer subject byte-for-byte — so it
|
||||
moved from the self-signed `CN=unom` to the verified subject. Package identity is Name +
|
||||
Publisher: Windows treats the new package as a different app, and an in-place upgrade is
|
||||
impossible by design. One-time uninstall + reinstall for MSIX installs; the `.exe` installer and
|
||||
winget-via-installer paths upgrade normally.
|
||||
- **Android embedder edge, additive:** `NativeBridge` gains `nativeHostMgmtPort`, and the native
|
||||
discovery record gains its 9th field, `mgmt` (the record's append-only rule; 0, non-numeric and
|
||||
out-of-range all parse as unknown). Out-of-tree JNI callers are unaffected unless they want the
|
||||
value.
|
||||
|
||||
### The management port is movable, survives, and is learned in-band
|
||||
|
||||
47990 is the management API's port and also the web-UI port of Sunshine and its forks — with the
|
||||
GameStream planes off, the only port the two still contend for. Moving it now actually works, end
|
||||
to end:
|
||||
|
||||
- **`PUNKTFUNK_MGMT_BIND` joins `host.env`** (the `PUNKTFUNK_GAMESTREAM` shape: env or CLI flag,
|
||||
the flag wins), so the choice survives package upgrades that rewrite the unit file. `serve`
|
||||
publishes the port it *actually bound* to `~/.config/punktfunk/mgmt-endpoint` (KEY=VALUE, written
|
||||
write-then-rename), and the console, the Windows service and the unit files all derive from that
|
||||
one file; the six hardcoded 47990 literals survive only as the old-host fallback.
|
||||
- **`Welcome.mgmt_port`** — a trailing `u16` after the cipher block, the same additive discipline
|
||||
as the eight fields before it, so `WIRE_VERSION` stays 2 and an older peer stops earlier and uses
|
||||
the default. ⚠ One encode subtlety, pinned by test: `cipher` used to be emitted only when
|
||||
non-default, and appending the port to an AES `Welcome` would land its low byte at offset 68 —
|
||||
exactly where every shipped 0.28.x client reads `cipher`, fail-closed. `encode` therefore writes
|
||||
an explicit cipher byte whenever a port rides along; a host advertising no port still emits
|
||||
exactly 68 bytes. The standalone `punktfunk1-host` binary advertises `0` (it has no management
|
||||
API).
|
||||
- **Clients persist it**: `KnownHost.mgmt_port` + `effective_mgmt_port()` across the Rust clients
|
||||
(three-rung: live advert → stored → default), the session console, Android (through
|
||||
`DiscoveredHost`), and Apple — where `StoredHost.mgmtPort` had existed all along but nothing ever
|
||||
wrote it, so every Apple client resolved 47990 regardless. A host that has never been seen over
|
||||
mDNS (VPN, routed subnet, multicast-dead network) now learns the port from the authenticated
|
||||
connection itself.
|
||||
- **`PUNKTFUNK_NATIVE_PORT`** completes the pair for the data plane — `--native-port` was CLI-only
|
||||
and died on upgrade. A bad value is a startup **error**, not a silent fall back to 9777.
|
||||
- The Windows shell's half of the client-side learn landed separately (#241): `trust.rs` re-exports
|
||||
`learn_mgmt_port`, the shell's own mDNS browser parses the `mgmt` TXT, and `HostTarget` carries
|
||||
the port like the mac client's target does.
|
||||
|
||||
### Linux thread priority: the renice was a no-op on every install to date
|
||||
|
||||
`boost_thread_priority`'s `setpriority()` needs `CAP_SYS_NICE` or a raised `RLIMIT_NICE`; no
|
||||
channel granted either, and the host binary can never carry a file capability (a capped process's
|
||||
`/proc/<pid>/exe` is unreadable to KWin — the 0.26.0-1 incident). So capture/encode/send ran at
|
||||
nice 0, and a shader-compile storm could deschedule them hard enough to stutter audio and drag ABR
|
||||
to its floor at zero loss. Now:
|
||||
|
||||
- **RealtimeKit fallback** — `MakeThreadHighPriorityWithPID`, the same unprivileged broker
|
||||
PipeWire clients use. Only the nice verb, never `MakeThreadRealtime`; nothing enters the
|
||||
permitted set, KWin identification is untouched.
|
||||
- **The audio plane is boosted at all, for the first time**: the 5 ms Opus
|
||||
capture→encode→send loop, the PipeWire capture mainloop, and the pad-audio streamer (on Windows
|
||||
too, via the existing `SetThreadPriority` arm).
|
||||
- **Packaging ships headroom for rtkit-less boxes**: `packaging/linux/50-punktfunk-nice.conf`
|
||||
(`user@.service.d`, `LimitNICE=-15` — a limit, not a grant; effective from next login) on rpm,
|
||||
Arch and deb, written to `/etc/systemd/system/user@.service.d` by the Steam Deck installer; deb
|
||||
and rpm gain a weak `Recommends: rtkit`, Arch an optdepends hint, and the NixOS module sets
|
||||
`security.rtkit.enable = mkDefault true`.
|
||||
|
||||
### Host capture gain works on `punktfunk/1`, and boosting no longer hard-clips
|
||||
|
||||
`PUNKTFUNK_AUDIO_GAIN` existed only on the GameStream plane, and where it applied it was a hard
|
||||
`clamp(-1.0, 1.0)` — flat-topping, so pushing past ~1.5× sounded broken long before it got loud
|
||||
(WASAPI loopback taps upstream of the endpoint's master volume, so the host's own slider never
|
||||
changes the sent level either). `punktfunk_core::audio::apply_gain` now serves **both planes** with
|
||||
a tanh soft knee above `SOFT_LIMIT_KNEE` (0.7, ≈−3.1 dBFS): C1-continuous, bounded by
|
||||
construction, odd-symmetric, memoryless (zero added latency). Unity is a no-op inside the function
|
||||
itself, so the default wire stays byte-for-byte identical. `capture_gain` rejects non-positive
|
||||
values and caps at 8.0 (+18 dB). This buys headroom, not loudness — it is deliberately not a
|
||||
compressor, and the docs say so. `SOFT_LIMIT_KNEE` is excluded from cbindgen on purpose.
|
||||
|
||||
### Windows binaries are signed by Azure Artifact Signing
|
||||
|
||||
Account `unomsigning`, profile `unom-io`, signed by a service principal holding only the
|
||||
profile-scoped signer role. Azure mints a **per-request leaf that expires in ~3 days**, which
|
||||
changes two rules: a timestamped countersignature is now *mandatory* (the old retry-without-
|
||||
timestamp fallback is a hard failure in Azure mode — it would ship an artifact that goes untrusted
|
||||
days later, everywhere at once), and leaf pinning is structurally impossible (the updater's
|
||||
`AUTHENTICODE_SHA256` note claiming otherwise is corrected). `pack-msix.ps1` reads the signature
|
||||
back off the packed `.msix` and fails on Publisher drift. Driver catalogs are deliberately
|
||||
untouched: they keep the `DRIVER_CERT_*` cert and the installer still plants it as a machine root
|
||||
(PnP trust is independent of SmartScreen/UAC trust). Canary and fork builds keep the `.pfx` and
|
||||
ephemeral fallbacks.
|
||||
|
||||
### Library: a launcher the host cannot open no longer costs the whole sync
|
||||
|
||||
`valid_launcher_ui` conflated vocabulary with environment. It is now split: `known_launcher_ui`
|
||||
(an unknown launcher kind is a plugin bug — still a hard 400) and `resolvable_launcher_ui` (the
|
||||
launcher just is not installed on this box — the entry is dropped with one warn and the games
|
||||
sync). Same shape as the unservable-cover fix, on the launch side. And Playnite is actually
|
||||
findable now: the old lookup read the LocalSystem service's own HKCU and `%LOCALAPPDATA%` (the
|
||||
SYSTEM profile — a per-user Playnite is invisible there) and matched a registry key name Inno Setup
|
||||
never writes. Now: every loaded hive under `HKEY_USERS` plus both HKLM views, matched on
|
||||
`DisplayName`, then `C:\Users\*\AppData\Local\Playnite`.
|
||||
|
||||
### Hyprland/sway capture: six defects, all ours, and streaming now survives past one session
|
||||
|
||||
The wlr portal route looked environmental and never was. Measured on Hyprland 0.55.4 +
|
||||
xdg-desktop-portal-hyprland 1.3.12, fixed in one arc (#240):
|
||||
|
||||
- **The dmabuf pod offered `BGRx`; xdph offers `BGRA`.** The modifier lists intersect perfectly,
|
||||
the fourcc never does, so PipeWire failed the link itself (`no more input formats`) — and the
|
||||
pods live only in the PipeWire *daemon's* log, which is why it read as a GPU/modifier problem.
|
||||
- **A per-cast tokio runtime orphaned ashpd's process-global D-Bus connection.** ashpd caches its
|
||||
connection in a `OnceLock`; the first cast's runtime hosted zbus's reader task and then died
|
||||
with the cast, so the first stream of a host process worked and every later one went black.
|
||||
Both wlr backends now share one long-lived portal runtime.
|
||||
- **Teardown removed the captured output before closing the cast**, and xdph spun on the wreckage;
|
||||
the order is now cast-then-output.
|
||||
- **A hung portal handshake leaked its thread** and the leak poisoned every later cast; the
|
||||
handshake is now bounded.
|
||||
- **The wlr absolute-motion injector aimed at the operator's head**, never the streamed one; the
|
||||
pointer is now bound to the streamed output.
|
||||
- **The cursor park schedule read a missing cursor overlay as a lost pointer** — an Embedded-mode
|
||||
portal never sends one.
|
||||
|
||||
### Everything else an integrator might notice
|
||||
|
||||
- **vdisplay/KDE:** a bare-spawn gamescope session under an exclusive topology now darkens the
|
||||
physical panels over `org_kde_kwin_dpms` (new in-process `kwin_dpms` module,
|
||||
`kscreen-doctor --dpms` fallback), refcounted host-wide so concurrent spawns compose; DPMS is
|
||||
non-persistent, so a dead host leaves nothing to journal. Managed and Attach routes untouched.
|
||||
- **macOS client:** `Settings::inhibit_shortcuts` is finally implemented on Apple — a local
|
||||
keyDown monitor claims every ⌘ chord while input is captured and forwards it host-side (AppKit
|
||||
dispatches menu key equivalents before the stream view sees them, so ⌘Q used to quit the
|
||||
client). ⌘⎋ and ⌃⌘F stay client-side; ⌘Tab/⌘Space/Mission Control are out of reach without a
|
||||
CGEventTap. Chord matching no longer compares Caps Lock and `.function`/`.numericPad` bits raw.
|
||||
- **Android client:** `Gamepad.padButtonBit` resolves a gamepad-sourced `KEYCODE_BACK` to
|
||||
`BTN_BACK` — pads that report Select as plain BACK (the Android-TV shape) no longer quit the
|
||||
stream on one press, and the Select chords (exit chord, mic mute, stats tier) become reachable
|
||||
on exactly those pads. `FLAG_FALLBACK` events stay excluded.
|
||||
- **CI:** Android canaries now feed Play **open testing (beta) and closed testing (alpha)** from
|
||||
one Play edit (`play-upload.py --also-track`); tags still publish production only, and a manual
|
||||
`android.yml` dispatch can now opt into publishing (`publish=true`), so a lost merge run is no
|
||||
longer a dead end. Windows
|
||||
runners provision the .NET 8 runtime and a machine-wide signing client (a mixed-mode dlib with
|
||||
no runtime makes signtool exit 3 in silence).
|
||||
|
||||
|
||||
|
||||
60 commits since v0.28.0.
|
||||
|
||||
@@ -642,6 +818,38 @@ CONTRIBUTING.md) and nothing in CI enforces it.** Three drifts in two release cy
|
||||
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.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
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.1"
|
||||
version = "0.29.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3031,7 +3031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3049,7 +3049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3071,7 +3071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3081,7 +3081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3107,7 +3107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
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.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3133,11 +3134,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3166,14 +3167,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3188,7 +3189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3196,7 +3197,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3208,7 +3209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3217,7 +3218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3250,7 +3251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3261,7 +3262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3272,7 +3273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3484,7 +3485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3494,7 +3495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3512,7 +3513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3529,7 +3530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
@@ -3543,7 +3544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3561,7 +3562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3593,7 +3594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3602,7 +3603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3672,7 +3673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3686,7 +3687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3709,7 +3710,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
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.**
|
||||
|
||||
@@ -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,35 @@
|
||||
package io.unom.punktfunk.screenshots
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BlendMode
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RadialGradient
|
||||
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 +56,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 +110,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 +595,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 +605,358 @@ 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", poster(context, "AURORA DRIFT", ::drawAurora))
|
||||
.intercept("shot://art/starfall", poster(context, "STARFALL VALE", ::drawStarfall))
|
||||
.intercept("shot://art/neon", poster(context, "NEON CIRCUIT", ::drawNeon))
|
||||
.intercept("shot://art/ember", poster(context, "EMBER PEAKS", ::drawEmber))
|
||||
.default(ColorDrawable(0xFF221E44.toInt()))
|
||||
.build()
|
||||
return ImageLoader.Builder(context).components { add(engine) }.build()
|
||||
}
|
||||
|
||||
// The four shelf posters, drawn procedurally at capture time — the same designs the Apple
|
||||
// harness draws with CoreGraphics (`ShotPosterArt.swift`), so both listings show the same shelf.
|
||||
// All geometry below is in a 600×900, y-UP space (matching the CG source); `posterY()` flips it.
|
||||
|
||||
private const val POSTER_W = 600
|
||||
private const val POSTER_H = 900
|
||||
|
||||
private fun posterY(v: Float) = POSTER_H - v
|
||||
|
||||
/** Deterministic LCG (same constants and seeds as the Swift twin) so every capture is identical. */
|
||||
private class ShotRand(var state: ULong) {
|
||||
fun next(): Float {
|
||||
state = state * 6364136223846793005UL + 1442695040888963407UL
|
||||
return (state shr 33).toFloat() / (1L shl 31).toFloat()
|
||||
}
|
||||
fun range(lo: Float, hi: Float) = lo + next() * (hi - lo)
|
||||
}
|
||||
|
||||
private fun poster(context: Context, title: String, draw: (Canvas) -> Unit): Drawable {
|
||||
val bmp = Bitmap.createBitmap(POSTER_W, POSTER_H, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bmp)
|
||||
draw(canvas)
|
||||
posterTitle(canvas, title)
|
||||
return BitmapDrawable(context.resources, bmp)
|
||||
}
|
||||
|
||||
/** Vertical gradient over the full canvas; stops bottom-to-top as (location, color). */
|
||||
private fun sky(canvas: Canvas, stops: List<Pair<Float, Int>>) {
|
||||
canvas.drawRect(
|
||||
0f, 0f, POSTER_W.toFloat(), POSTER_H.toFloat(),
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = LinearGradient(
|
||||
0f, POSTER_H.toFloat(), 0f, 0f,
|
||||
stops.map { it.second }.toIntArray(),
|
||||
stops.map { it.first }.toFloatArray(),
|
||||
Shader.TileMode.CLAMP,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun glowDot(canvas: Canvas, x: Float, y: Float, radius: Float, color: Int) {
|
||||
canvas.drawCircle(
|
||||
x, posterY(y), radius,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = RadialGradient(
|
||||
x, posterY(y), radius, color, color and 0x00FFFFFF, Shader.TileMode.CLAMP,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun shotAlpha(color: Int, a: Float) = (color and 0x00FFFFFF) or ((a * 255).toInt() shl 24)
|
||||
|
||||
/** Three strokes, wide-and-faint to thin-and-bright, in screen blend — the cheap neon glow. */
|
||||
private fun glowStroke(canvas: Canvas, path: Path, width: Float, color: Int) {
|
||||
for ((mult, a) in listOf(2.6f to 0.12f, 1.3f to 0.28f, 0.55f to 0.85f)) {
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
strokeWidth = width * mult
|
||||
this.color = shotAlpha(color, a)
|
||||
blendMode = BlendMode.SCREEN
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun posterTitle(canvas: Canvas, title: String) {
|
||||
sky(canvas, listOf(0f to shotAlpha(0x000000, 0.55f), 0.22f to shotAlpha(0x000000, 0f)))
|
||||
canvas.drawText(
|
||||
title, POSTER_W / 2f, posterY(72f),
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = shotAlpha(0xFFFFFF, 0.94f)
|
||||
textSize = 46f
|
||||
letterSpacing = 5f / 46f
|
||||
typeface = Typeface.create("sans-serif-condensed", Typeface.BOLD)
|
||||
textAlign = Paint.Align.CENTER
|
||||
setShadowLayer(8f, 0f, 2f, shotAlpha(0x000000, 0.6f))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawAurora(canvas: Canvas) {
|
||||
sky(canvas, listOf(0f to 0xFF221E5C.toInt(), 0.45f to 0xFF141040.toInt(), 1f to 0xFF0B0830.toInt()))
|
||||
val rng = ShotRand(11UL)
|
||||
repeat(48) {
|
||||
val x = rng.range(0f, 600f)
|
||||
val y = rng.range(300f, 890f)
|
||||
val r = rng.range(1.4f, 3.2f)
|
||||
glowDot(canvas, x, y, r, shotAlpha(0xFFFFFF, rng.range(0.25f, 0.8f)))
|
||||
}
|
||||
data class Ribbon(
|
||||
val base: Float, val amp: Float, val freq: Float,
|
||||
val phase: Float, val w: Float, val c: Int,
|
||||
)
|
||||
for (r in listOf(
|
||||
Ribbon(700f, 55f, 1.15f, 0.4f, 30f, 0xFF6656F2.toInt()),
|
||||
Ribbon(615f, 70f, 1.4f, 2.2f, 24f, 0xFF8F7BFF.toInt()),
|
||||
Ribbon(530f, 45f, 0.95f, 4.1f, 18f, 0xFF35D0C5.toInt()),
|
||||
)) {
|
||||
val path = Path()
|
||||
for (i in 0..60) {
|
||||
val t = i / 60f
|
||||
val x = t * 600f
|
||||
val y = r.base + r.amp * kotlin.math.sin(t * Math.PI.toFloat() * r.freq + r.phase) + 40f * t
|
||||
if (i == 0) path.moveTo(x, posterY(y)) else path.lineTo(x, posterY(y))
|
||||
}
|
||||
glowStroke(canvas, path, r.w, r.c)
|
||||
}
|
||||
// A low ridge grounds the scene — without it the poster's bottom half is bare sky.
|
||||
for ((fill, baseline, rough) in listOf(
|
||||
Triple(0xFF191345.toInt(), 212f, 30f),
|
||||
Triple(0xFF0E0A2E.toInt(), 148f, 38f),
|
||||
)) {
|
||||
val path = Path()
|
||||
path.moveTo(0f, posterY(0f))
|
||||
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
|
||||
for (i in 1..9) {
|
||||
val x = i / 9f * 600f
|
||||
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
|
||||
}
|
||||
path.lineTo(600f, posterY(0f))
|
||||
path.close()
|
||||
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawStarfall(canvas: Canvas) {
|
||||
sky(
|
||||
canvas,
|
||||
listOf(
|
||||
0f to 0xFF2A0C24.toInt(), 0.35f to 0xFF7A2B58.toInt(),
|
||||
0.8f to 0xFFE86FA8.toInt(), 1f to 0xFFF7A8C8.toInt(),
|
||||
),
|
||||
)
|
||||
val rng = ShotRand(23UL)
|
||||
repeat(6) {
|
||||
val hx = rng.range(60f, 560f)
|
||||
val hy = rng.range(420f, 840f)
|
||||
val len = rng.range(90f, 170f)
|
||||
val dx = kotlin.math.cos(2.15f)
|
||||
val dy = kotlin.math.sin(2.15f)
|
||||
val path = Path()
|
||||
path.moveTo(hx, posterY(hy))
|
||||
path.lineTo(hx + dx * len, posterY(hy + dy * len))
|
||||
glowStroke(canvas, path, 4f, 0xFFFFE3EF.toInt())
|
||||
glowDot(canvas, hx, hy, 11f, shotAlpha(0xFFFFFF, 0.9f))
|
||||
}
|
||||
for ((fill, baseline, rough) in listOf(
|
||||
Triple(0xFF3A1430.toInt(), 300f, 26f),
|
||||
Triple(0xFF1D0818.toInt(), 216f, 34f),
|
||||
)) {
|
||||
val path = Path()
|
||||
path.moveTo(0f, posterY(0f))
|
||||
path.lineTo(0f, posterY(baseline))
|
||||
for (i in 1..8) {
|
||||
val x = i / 8f * 600f
|
||||
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
|
||||
}
|
||||
path.lineTo(600f, posterY(0f))
|
||||
path.close()
|
||||
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawNeon(canvas: Canvas) {
|
||||
sky(canvas, listOf(0f to 0xFF0A2A33.toInt(), 1f to 0xFF04161C.toInt()))
|
||||
val rng = ShotRand(7UL)
|
||||
val ring = Path().apply {
|
||||
addOval(300f - 105f, posterY(560f) - 105f, 300f + 105f, posterY(560f) + 105f, Path.Direction.CW)
|
||||
}
|
||||
glowStroke(canvas, ring, 10f, 0xFF35D0C5.toInt())
|
||||
val gateX = listOf(-105f, 105f, 0f, 0f)
|
||||
val gateY = listOf(0f, 0f, -105f, 105f)
|
||||
for (i in 0 until 9) {
|
||||
var px: Float
|
||||
var py: Float
|
||||
if (i < 4) {
|
||||
px = 300f + gateX[i]
|
||||
py = 560f + gateY[i]
|
||||
} else {
|
||||
px = 40f * kotlin.math.round(rng.range(1f, 14f))
|
||||
py = 40f * kotlin.math.round(rng.range(1f, 21f))
|
||||
}
|
||||
val path = Path()
|
||||
path.moveTo(px, posterY(py))
|
||||
var horizontal = rng.next() > 0.5f
|
||||
repeat(rng.range(3f, 6f).toInt()) {
|
||||
val step = 40f * kotlin.math.round(rng.range(1f, 4f)) * (if (rng.next() > 0.5f) 1f else -1f)
|
||||
if (horizontal) px = (px + step).coerceIn(20f, 580f) else py = (py + step).coerceIn(20f, 880f)
|
||||
path.lineTo(px, posterY(py))
|
||||
horizontal = !horizontal
|
||||
}
|
||||
val color = if (rng.next() > 0.6f) 0xFF7FE8DE.toInt() else 0xFF35D0C5.toInt()
|
||||
glowStroke(canvas, path, 5f, color)
|
||||
glowDot(canvas, px, py, 12f, shotAlpha(color, 0.9f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawEmber(canvas: Canvas) {
|
||||
sky(
|
||||
canvas,
|
||||
listOf(
|
||||
0f to 0xFF200A04.toInt(), 0.3f to 0xFF7A2E12.toInt(),
|
||||
0.42f to 0xFFEF8F4B.toInt(), 1f to 0xFF2A0E06.toInt(),
|
||||
),
|
||||
)
|
||||
glowDot(canvas, 300f, 385f, 160f, shotAlpha(0xFFC37A, 0.85f))
|
||||
val rng = ShotRand(41UL)
|
||||
for ((fill, baseline, rough) in listOf(
|
||||
Triple(0xFF5A2410.toInt(), 340f, 42f),
|
||||
Triple(0xFF401708.toInt(), 255f, 56f),
|
||||
Triple(0xFF200A04.toInt(), 165f, 48f),
|
||||
)) {
|
||||
val path = Path()
|
||||
path.moveTo(0f, posterY(0f))
|
||||
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
|
||||
for (i in 1..10) {
|
||||
val x = i / 10f * 600f
|
||||
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
|
||||
}
|
||||
path.lineTo(600f, posterY(0f))
|
||||
path.close()
|
||||
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
|
||||
}
|
||||
repeat(20) {
|
||||
val x = rng.range(30f, 570f)
|
||||
val y = rng.range(180f, 620f)
|
||||
val r = rng.range(2.5f, 6f)
|
||||
glowDot(canvas, x, y, r, shotAlpha(0xFFB067, rng.range(0.35f, 0.9f)))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
|
||||
@@ -50,6 +50,10 @@ class TvScreenshotTest {
|
||||
@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)
|
||||
|
||||
@@ -24,7 +24,7 @@ struct LibraryCoverflowView: View {
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
let games: [GameEntry]
|
||||
let artLoader: LibraryArtLoader?
|
||||
let artLoader: (any LibraryArtSource)?
|
||||
var onLaunch: ((String) -> Void)?
|
||||
/// Button B (back) — dismisses the library screen. No touch equivalent needed here (the toolbar
|
||||
/// Close button already covers that); this is what makes gamepad-only exit possible.
|
||||
|
||||
@@ -74,7 +74,7 @@ struct LibraryView: View {
|
||||
@State private var errorText: String?
|
||||
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
|
||||
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
|
||||
@State private var artLoader: LibraryArtLoader?
|
||||
@State private var artLoader: (any LibraryArtSource)?
|
||||
#if os(iOS) || os(macOS)
|
||||
/// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count
|
||||
/// is derived from. nil until the first arrow press, so a touch user never sees a selection
|
||||
@@ -409,7 +409,7 @@ private struct LibraryBackCatcher: View {
|
||||
/// (portrait → header → hero) and finally a text placeholder.
|
||||
private struct GameCard: View {
|
||||
let game: GameEntry
|
||||
let artLoader: LibraryArtLoader?
|
||||
let artLoader: (any LibraryArtSource)?
|
||||
/// The hardware-keyboard cursor is on this tile — drawn as an accent ring, since the plain
|
||||
/// grid has no other way to say "Return launches THIS one".
|
||||
var selected = false
|
||||
|
||||
@@ -70,7 +70,7 @@ private extension Image {
|
||||
struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let loader: LibraryArtLoader?
|
||||
let loader: (any LibraryArtSource)?
|
||||
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
|
||||
/// no cover art by design, so for those the mark IS the poster — see `placeholder`.
|
||||
var icon: String?
|
||||
|
||||
@@ -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,29 @@ 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. The `shot://art/…` posters are answered by [`ShotPosterArt.source`]
|
||||
/// (drawn at capture time), so the shot stays offline; the Steam launcher entry stays artless
|
||||
/// by design and renders its brand mark.
|
||||
static let games: [GameEntry] = {
|
||||
let json = """
|
||||
[
|
||||
{"id": "custom:aurora", "store": "custom", "title": "Aurora Drift",
|
||||
"art": {"portrait": "shot://art/aurora"}},
|
||||
{"id": "steam:starfall", "store": "steam", "title": "Starfall Vale",
|
||||
"art": {"portrait": "shot://art/starfall"}},
|
||||
{"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit",
|
||||
"art": {"portrait": "shot://art/neon"}},
|
||||
{"id": "gog:ember", "store": "gog", "title": "Ember Peaks",
|
||||
"art": {"portrait": "shot://art/ember"}},
|
||||
{"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 +265,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: `ShotPosterArt` answers the mock entries' art immediately,
|
||||
/// so the cards swing in already carrying posters (the entrance waits on art settling).
|
||||
private struct ShotLibrary: View {
|
||||
var body: some View {
|
||||
LibraryCoverflowView(
|
||||
games: ShotMock.games, artLoader: ShotPosterArt.source,
|
||||
onLaunch: { _ in }, onDismiss: {}, controllerActive: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gamepad-mode console screens (dev-only glass preview)
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
@@ -311,6 +359,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)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// Procedural cover art for the screenshot shelf. The store's library frames used to render the
|
||||
// deterministic text-placeholder posters (`artLoader: nil`), which read as an empty library next
|
||||
// to the Android listing's populated one. These four posters are drawn with CoreGraphics at
|
||||
// capture time — no bundled assets, nothing in a release build, and the same designs the Android
|
||||
// harness draws in Canvas, so the two listings show the same shelf.
|
||||
|
||||
#if DEBUG
|
||||
import CoreText
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import PunktfunkKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// A canned `LibraryArtSource`: poster bytes by URL, no network. What the screenshot shelf hands
|
||||
/// the real coverflow in place of the paired-host loader.
|
||||
struct ShotArtSource: LibraryArtSource {
|
||||
let fixtures: [String: Data]
|
||||
|
||||
func data(for url: URL) async throws -> Data {
|
||||
guard let data = fixtures[url.absoluteString] else {
|
||||
throw CocoaError(.fileNoSuchFile)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func close() async {}
|
||||
}
|
||||
|
||||
enum ShotPosterArt {
|
||||
/// Art for `ShotMock.games` — keyed by the `shot://art/…` URLs those entries carry.
|
||||
static let source = ShotArtSource(fixtures: [
|
||||
"shot://art/aurora": poster("AURORA DRIFT", draw: drawAurora),
|
||||
"shot://art/starfall": poster("STARFALL VALE", draw: drawStarfall),
|
||||
"shot://art/neon": poster("NEON CIRCUIT", draw: drawNeon),
|
||||
"shot://art/ember": poster("EMBER PEAKS", draw: drawEmber),
|
||||
])
|
||||
|
||||
private static let W = 600
|
||||
private static let H = 900
|
||||
|
||||
// MARK: - Canvas plumbing
|
||||
|
||||
private static func poster(_ title: String, draw: (CGContext) -> Void) -> Data {
|
||||
let space = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
let ctx = CGContext(
|
||||
data: nil, width: W, height: H, bitsPerComponent: 8, bytesPerRow: 0,
|
||||
space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
|
||||
draw(ctx)
|
||||
drawTitle(ctx, title)
|
||||
let image = ctx.makeImage()!
|
||||
let out = NSMutableData()
|
||||
let dest = CGImageDestinationCreateWithData(
|
||||
out, UTType.png.identifier as CFString, 1, nil)!
|
||||
CGImageDestinationAddImage(dest, image, nil)
|
||||
CGImageDestinationFinalize(dest)
|
||||
return out as Data
|
||||
}
|
||||
|
||||
private static func rgb(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
|
||||
CGColor(
|
||||
srgbRed: CGFloat((hex >> 16) & 0xff) / 255,
|
||||
green: CGFloat((hex >> 8) & 0xff) / 255,
|
||||
blue: CGFloat(hex & 0xff) / 255, alpha: alpha)
|
||||
}
|
||||
|
||||
/// Vertical gradient over the full canvas; `stops` bottom-to-top as (location, color).
|
||||
private static func sky(_ ctx: CGContext, _ stops: [(CGFloat, CGColor)]) {
|
||||
let gradient = CGGradient(
|
||||
colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!,
|
||||
colors: stops.map(\.1) as CFArray,
|
||||
locations: stops.map(\.0))!
|
||||
ctx.drawLinearGradient(
|
||||
gradient, start: .zero, end: CGPoint(x: 0, y: CGFloat(H)), options: [])
|
||||
}
|
||||
|
||||
private static func glowDot(
|
||||
_ ctx: CGContext, at center: CGPoint, radius: CGFloat, color: CGColor
|
||||
) {
|
||||
let clear = color.copy(alpha: 0)!
|
||||
let gradient = CGGradient(
|
||||
colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!,
|
||||
colors: [color, clear] as CFArray, locations: [0, 1])!
|
||||
ctx.drawRadialGradient(
|
||||
gradient, startCenter: center, startRadius: 0,
|
||||
endCenter: center, endRadius: radius, options: [])
|
||||
}
|
||||
|
||||
/// Stroke `path` three times, wide-and-faint to thin-and-bright, in screen blend — the cheap
|
||||
/// neon-glow trick every one of these posters leans on.
|
||||
private static func glowStroke(
|
||||
_ ctx: CGContext, _ path: CGPath, width: CGFloat, color: CGColor
|
||||
) {
|
||||
ctx.saveGState()
|
||||
ctx.setBlendMode(.screen)
|
||||
ctx.setLineCap(.round)
|
||||
ctx.setLineJoin(.round)
|
||||
for (mult, alpha) in [(2.6, 0.12), (1.3, 0.28), (0.55, 0.85)] {
|
||||
ctx.addPath(path)
|
||||
ctx.setLineWidth(width * mult)
|
||||
ctx.setStrokeColor(color.copy(alpha: alpha)!)
|
||||
ctx.strokePath()
|
||||
}
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
private static func drawTitle(_ ctx: CGContext, _ title: String) {
|
||||
// A soft floor behind the caption keeps it legible over any art.
|
||||
sky(ctx, [(0, rgb(0x000000, 0.55)), (0.22, rgb(0x000000, 0))])
|
||||
let font = CTFontCreateWithName("HelveticaNeue-CondensedBold" as CFString, 46, nil)
|
||||
let text = NSAttributedString(string: title, attributes: [
|
||||
.font: font, .kern: 5, .foregroundColor: rgb(0xFFFFFF, 0.94),
|
||||
] as [NSAttributedString.Key: Any])
|
||||
let line = CTLineCreateWithAttributedString(text)
|
||||
let bounds = CTLineGetBoundsWithOptions(line, [])
|
||||
ctx.saveGState()
|
||||
ctx.setShadow(offset: CGSize(width: 0, height: -2), blur: 8, color: rgb(0x000000, 0.6))
|
||||
ctx.textPosition = CGPoint(x: (CGFloat(W) - bounds.width) / 2, y: 72)
|
||||
CTLineDraw(line, ctx)
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
/// Deterministic LCG so every capture draws the identical poster.
|
||||
private struct Rand {
|
||||
var state: UInt64
|
||||
mutating func next() -> CGFloat {
|
||||
state = state &* 6364136223846793005 &+ 1442695040888963407
|
||||
return CGFloat(state >> 33) / CGFloat(UInt64(1) << 31)
|
||||
}
|
||||
mutating func in_(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { lo + next() * (hi - lo) }
|
||||
}
|
||||
|
||||
// MARK: - The four posters
|
||||
|
||||
private static func drawAurora(_ ctx: CGContext) {
|
||||
sky(ctx, [(0, rgb(0x221E5C)), (0.45, rgb(0x141040)), (1, rgb(0x0B0830))])
|
||||
var rng = Rand(state: 11)
|
||||
for _ in 0..<48 {
|
||||
let p = CGPoint(x: rng.in_(0, 600), y: rng.in_(300, 890))
|
||||
glowDot(ctx, at: p, radius: rng.in_(1.4, 3.2), color: rgb(0xFFFFFF, rng.in_(0.25, 0.8)))
|
||||
}
|
||||
let ribbons: [(base: CGFloat, amp: CGFloat, freq: CGFloat, phase: CGFloat, w: CGFloat, c: UInt32)] = [
|
||||
(700, 55, 1.15, 0.4, 30, 0x6656F2),
|
||||
(615, 70, 1.4, 2.2, 24, 0x8F7BFF),
|
||||
(530, 45, 0.95, 4.1, 18, 0x35D0C5),
|
||||
]
|
||||
for r in ribbons {
|
||||
let path = CGMutablePath()
|
||||
for i in 0...60 {
|
||||
let t = CGFloat(i) / 60
|
||||
let p = CGPoint(
|
||||
x: t * 600,
|
||||
y: r.base + r.amp * sin(t * .pi * r.freq + r.phase) + 40 * t)
|
||||
if i == 0 { path.move(to: p) } else { path.addLine(to: p) }
|
||||
}
|
||||
glowStroke(ctx, path, width: r.w, color: rgb(r.c))
|
||||
}
|
||||
// A low ridge grounds the scene — without it the poster's bottom half is bare sky.
|
||||
for (fill, baseline, rough) in [
|
||||
(rgb(0x191345), CGFloat(212), CGFloat(30)),
|
||||
(rgb(0x0E0A2E), CGFloat(148), CGFloat(38)),
|
||||
] {
|
||||
let path = CGMutablePath()
|
||||
path.move(to: CGPoint(x: 0, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough)))
|
||||
for i in 1...9 {
|
||||
let x = CGFloat(i) / 9 * 600
|
||||
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
|
||||
}
|
||||
path.addLine(to: CGPoint(x: 600, y: 0))
|
||||
path.closeSubpath()
|
||||
ctx.setFillColor(fill)
|
||||
ctx.addPath(path)
|
||||
ctx.fillPath()
|
||||
}
|
||||
}
|
||||
|
||||
private static func drawStarfall(_ ctx: CGContext) {
|
||||
sky(ctx, [(0, rgb(0x2A0C24)), (0.35, rgb(0x7A2B58)), (0.8, rgb(0xE86FA8)), (1, rgb(0xF7A8C8))])
|
||||
var rng = Rand(state: 23)
|
||||
for _ in 0..<6 {
|
||||
let head = CGPoint(x: rng.in_(60, 560), y: rng.in_(420, 840))
|
||||
let len = rng.in_(90, 170)
|
||||
let dir = CGVector(dx: cos(2.15), dy: sin(2.15)) // ~123° — up-left tails
|
||||
let path = CGMutablePath()
|
||||
path.move(to: head)
|
||||
path.addLine(to: CGPoint(x: head.x + dir.dx * len, y: head.y + dir.dy * len))
|
||||
glowStroke(ctx, path, width: 4, color: rgb(0xFFE3EF))
|
||||
glowDot(ctx, at: head, radius: 11, color: rgb(0xFFFFFF, 0.9))
|
||||
}
|
||||
for (fill, baseline, rough) in [
|
||||
(rgb(0x3A1430), CGFloat(300), CGFloat(26)),
|
||||
(rgb(0x1D0818), CGFloat(216), CGFloat(34)),
|
||||
] {
|
||||
let path = CGMutablePath()
|
||||
path.move(to: CGPoint(x: 0, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: baseline))
|
||||
for i in 1...8 {
|
||||
let x = CGFloat(i) / 8 * 600
|
||||
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
|
||||
}
|
||||
path.addLine(to: CGPoint(x: 600, y: 0))
|
||||
path.closeSubpath()
|
||||
ctx.setFillColor(fill)
|
||||
ctx.addPath(path)
|
||||
ctx.fillPath()
|
||||
}
|
||||
}
|
||||
|
||||
private static func drawNeon(_ ctx: CGContext) {
|
||||
sky(ctx, [(0, rgb(0x0A2A33)), (1, rgb(0x04161C))])
|
||||
var rng = Rand(state: 7)
|
||||
let ring = CGPath(
|
||||
ellipseIn: CGRect(x: 300 - 105, y: 560 - 105, width: 210, height: 210), transform: nil)
|
||||
glowStroke(ctx, ring, width: 10, color: rgb(0x35D0C5))
|
||||
for i in 0..<9 {
|
||||
// Right-angle traces on a 40 px grid, some feeding out of the ring's four gates.
|
||||
var p = i < 4
|
||||
? CGPoint(x: 300 + [-105, 105, 0, 0][i], y: 560 + [0, 0, -105, 105][i])
|
||||
: CGPoint(x: 40 * (rng.in_(1, 14)).rounded(), y: 40 * (rng.in_(1, 21)).rounded())
|
||||
let path = CGMutablePath()
|
||||
path.move(to: p)
|
||||
var horizontal = rng.next() > 0.5
|
||||
for _ in 0..<Int(rng.in_(3, 6)) {
|
||||
let step = 40 * rng.in_(1, 4).rounded() * (rng.next() > 0.5 ? 1 : -1)
|
||||
p = horizontal ? CGPoint(x: min(max(p.x + step, 20), 580), y: p.y)
|
||||
: CGPoint(x: p.x, y: min(max(p.y + step, 20), 880))
|
||||
path.addLine(to: p)
|
||||
horizontal.toggle()
|
||||
}
|
||||
let color = rng.next() > 0.6 ? rgb(0x7FE8DE) : rgb(0x35D0C5)
|
||||
glowStroke(ctx, path, width: 5, color: color)
|
||||
glowDot(ctx, at: p, radius: 12, color: color.copy(alpha: 0.9)!)
|
||||
}
|
||||
}
|
||||
|
||||
private static func drawEmber(_ ctx: CGContext) {
|
||||
sky(ctx, [(0, rgb(0x200A04)), (0.3, rgb(0x7A2E12)), (0.42, rgb(0xEF8F4B)), (1, rgb(0x2A0E06))])
|
||||
glowDot(ctx, at: CGPoint(x: 300, y: 385), radius: 160, color: rgb(0xFFC37A, 0.85))
|
||||
var rng = Rand(state: 41)
|
||||
for (fill, baseline, rough) in [
|
||||
(rgb(0x5A2410), CGFloat(340), CGFloat(42)),
|
||||
(rgb(0x401708), CGFloat(255), CGFloat(56)),
|
||||
(rgb(0x200A04), CGFloat(165), CGFloat(48)),
|
||||
] {
|
||||
let path = CGMutablePath()
|
||||
path.move(to: CGPoint(x: 0, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough)))
|
||||
for i in 1...10 {
|
||||
let x = CGFloat(i) / 10 * 600
|
||||
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
|
||||
}
|
||||
path.addLine(to: CGPoint(x: 600, y: 0))
|
||||
path.closeSubpath()
|
||||
ctx.setFillColor(fill)
|
||||
ctx.addPath(path)
|
||||
ctx.fillPath()
|
||||
}
|
||||
for _ in 0..<20 {
|
||||
let p = CGPoint(x: rng.in_(30, 570), y: rng.in_(180, 620))
|
||||
glowDot(ctx, at: p, radius: rng.in_(2.5, 6), color: rgb(0xFFB067, rng.in_(0.35, 0.9)))
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -4,6 +4,11 @@
|
||||
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
|
||||
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
|
||||
//
|
||||
// Every card renders a plain value model (`ShotPad` / `InputSnapshot`) that the live path samples
|
||||
// out of the real pad each timeline tick. A GCController cannot be constructed, and the App Store
|
||||
// screenshot harness needs this panel with pads the capture machine doesn't have — ShotScenes
|
||||
// injects them via `shotPads` (the same seam Android's ControllersScreen grew for its capture).
|
||||
//
|
||||
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
|
||||
// layout); macOS + iOS/iPadOS cover the validation need.
|
||||
|
||||
@@ -14,10 +19,63 @@ import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ControllerTestView: View {
|
||||
/// What one panel section says about a pad, as plain values. The live path flattens the
|
||||
/// active `DiscoveredController` into one; the screenshot harness hands the panel pads that
|
||||
/// were never connected. `input`/`rumbleBackend` are the harness's section knobs (nil hides
|
||||
/// that card) — the live path always shows both, fed from the live pad and tester.
|
||||
struct ShotPad: Identifiable {
|
||||
let name: String
|
||||
/// The header's second line. Production shows the GC product category; a shot packs
|
||||
/// transport/battery/player facts into it (the panel has no dedicated battery row).
|
||||
let detail: String
|
||||
let isDualSense: Bool
|
||||
let hasAdaptiveTriggers: Bool
|
||||
let hasLight: Bool
|
||||
var input: InputSnapshot? = nil
|
||||
var rumbleBackend: String? = nil
|
||||
var id: String { name }
|
||||
}
|
||||
|
||||
/// One frame of the input readout. The live path samples the real `GCExtendedGamepad` into
|
||||
/// one of these on every 30 Hz tick; the harness writes a mid-game frame by hand.
|
||||
struct InputSnapshot {
|
||||
struct Stick {
|
||||
var x: Float
|
||||
var y: Float
|
||||
var pressed = false
|
||||
}
|
||||
struct Touch {
|
||||
/// Finger position in GC's -1...1 axes; nil = lifted. (GC snaps a lifted finger to
|
||||
/// exactly (0, 0), so a real (0, 0) contact is indistinguishable anyway.)
|
||||
var primary: CGPoint?
|
||||
var secondary: CGPoint?
|
||||
var clicked = false
|
||||
}
|
||||
struct Motion {
|
||||
var gyro: SIMD3<Double>
|
||||
var accel: SIMD3<Double>
|
||||
}
|
||||
var leftStick: Stick
|
||||
var rightStick: Stick
|
||||
var leftTrigger: Float = 0
|
||||
var rightTrigger: Float = 0
|
||||
/// Grid order; label → pressed.
|
||||
var buttons: [(String, Bool)]
|
||||
var touchpad: Touch?
|
||||
var motion: Motion?
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
@StateObject private var tester = ControllerTester()
|
||||
|
||||
/// Screenshot-harness injection — nil (the app) renders the live active pad.
|
||||
private let shotPads: [ShotPad]?
|
||||
|
||||
init(shotPads: [ShotPad]? = nil) {
|
||||
self.shotPads = shotPads
|
||||
}
|
||||
|
||||
@State private var heavyOn = false
|
||||
@State private var lightOn = false
|
||||
@State private var intensity = 0.75
|
||||
@@ -62,12 +120,12 @@ struct ControllerTestView: View {
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let active = gamepads.active {
|
||||
header(active)
|
||||
inputCard
|
||||
rumbleCard()
|
||||
triggerCard(active)
|
||||
extrasCard(active)
|
||||
if let shotPads {
|
||||
ForEach(shotPads) { pad in
|
||||
shotPanel(pad)
|
||||
}
|
||||
} else if let active = gamepads.active {
|
||||
livePanel(active)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No controller",
|
||||
@@ -81,9 +139,10 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 420, minHeight: 540)
|
||||
.onAppear { tester.target(gamepads.active?.controller) }
|
||||
.onDisappear { tester.stop() }
|
||||
.onAppear { if shotPads == nil { tester.target(gamepads.active?.controller) } }
|
||||
.onDisappear { if shotPads == nil { tester.stop() } }
|
||||
.onChange(of: gamepads.active?.id) { _, _ in
|
||||
guard shotPads == nil else { return }
|
||||
heavyOn = false
|
||||
lightOn = false
|
||||
playerLED = -1
|
||||
@@ -91,16 +150,53 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Panels
|
||||
|
||||
@ViewBuilder
|
||||
private func livePanel(_ active: GamepadManager.DiscoveredController) -> some View {
|
||||
let pad = Self.describe(active)
|
||||
header(pad)
|
||||
liveInputCard
|
||||
rumbleCard(backend: tester.rumbleBackend, health: tester.rumbleHealth)
|
||||
triggerCard(pad)
|
||||
extrasCard(pad)
|
||||
}
|
||||
|
||||
/// An injected pad's cards, in the live panel's order. The adaptive-trigger card is skipped
|
||||
/// outright for a pad without them — the live path's "needs a DualSense" hint is a diagnosis,
|
||||
/// and a capture has nothing to diagnose.
|
||||
@ViewBuilder
|
||||
private func shotPanel(_ pad: ShotPad) -> some View {
|
||||
header(pad)
|
||||
if let input = pad.input {
|
||||
card("Input") { inputReadout(input) }
|
||||
}
|
||||
if let backend = pad.rumbleBackend {
|
||||
rumbleCard(backend: backend, health: nil)
|
||||
}
|
||||
if pad.hasAdaptiveTriggers {
|
||||
triggerCard(pad)
|
||||
}
|
||||
extrasCard(pad)
|
||||
}
|
||||
|
||||
/// The live pad, flattened to what the panel renders about it.
|
||||
private static func describe(_ c: GamepadManager.DiscoveredController) -> ShotPad {
|
||||
ShotPad(
|
||||
name: c.name, detail: c.productCategory, isDualSense: c.isDualSense,
|
||||
hasAdaptiveTriggers: c.hasAdaptiveTriggers, hasLight: c.hasLight)
|
||||
}
|
||||
|
||||
// MARK: Header
|
||||
|
||||
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
private func header(_ pad: ShotPad) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||
Image(systemName: pad.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
Text(pad.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(pad.detail).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -108,13 +204,13 @@ struct ControllerTestView: View {
|
||||
|
||||
// MARK: Input
|
||||
|
||||
private var inputCard: some View {
|
||||
private var liveInputCard: some View {
|
||||
card("Input") {
|
||||
// Poll the live controller at 30 Hz — no handlers installed, so nothing else's
|
||||
// capture is disturbed.
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
|
||||
if let gp = gamepads.active?.controller.extendedGamepad {
|
||||
inputReadout(gp, controller: gamepads.active?.controller)
|
||||
inputReadout(Self.snapshot(gp, controller: gamepads.active?.controller))
|
||||
} else {
|
||||
Text("Not an extended gamepad").foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -122,40 +218,82 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// One readout frame off the live pad.
|
||||
private static func snapshot(
|
||||
_ g: GCExtendedGamepad, controller: GCController?
|
||||
) -> InputSnapshot {
|
||||
var buttons: [(String, Bool)] = [
|
||||
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
||||
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
||||
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
||||
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
||||
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
||||
("Menu", g.buttonMenu.isPressed),
|
||||
("Opts", g.buttonOptions?.isPressed ?? false),
|
||||
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
||||
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
||||
]
|
||||
let tp = touchpad(g)
|
||||
if let tp { buttons.append(("Pad", tp.button.isPressed)) }
|
||||
return InputSnapshot(
|
||||
leftStick: .init(
|
||||
x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
||||
pressed: g.leftThumbstickButton?.isPressed ?? false),
|
||||
rightStick: .init(
|
||||
x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
||||
pressed: g.rightThumbstickButton?.isPressed ?? false),
|
||||
leftTrigger: g.leftTrigger.value, rightTrigger: g.rightTrigger.value,
|
||||
buttons: buttons,
|
||||
touchpad: tp.map {
|
||||
.init(primary: finger($0.primary), secondary: finger($0.secondary),
|
||||
clicked: $0.button.isPressed)
|
||||
},
|
||||
motion: controller?.motion.map { m -> InputSnapshot.Motion in
|
||||
let a = totalAccel(m)
|
||||
return .init(
|
||||
gyro: .init(m.rotationRate.x, m.rotationRate.y, m.rotationRate.z),
|
||||
accel: .init(a.0, a.1, a.2))
|
||||
})
|
||||
}
|
||||
|
||||
private static func finger(_ pad: GCControllerDirectionPad) -> CGPoint? {
|
||||
let x = pad.xAxis.value, y = pad.yAxis.value
|
||||
// GC snaps a lifted finger to exactly (0, 0).
|
||||
return (x == 0 && y == 0) ? nil : CGPoint(x: CGFloat(x), y: CGFloat(y))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
|
||||
private func inputReadout(_ s: InputSnapshot) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
||||
pressed: g.leftThumbstickButton?.isPressed ?? false)
|
||||
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
||||
pressed: g.rightThumbstickButton?.isPressed ?? false)
|
||||
stick("L", s.leftStick)
|
||||
stick("R", s.rightStick)
|
||||
VStack(spacing: 8) {
|
||||
triggerBar("L2", value: g.leftTrigger.value)
|
||||
triggerBar("R2", value: g.rightTrigger.value)
|
||||
triggerBar("L2", value: s.leftTrigger)
|
||||
triggerBar("R2", value: s.rightTrigger)
|
||||
}
|
||||
}
|
||||
buttonGrid(g)
|
||||
if let tp = Self.touchpad(g) {
|
||||
buttonGrid(s.buttons)
|
||||
if let tp = s.touchpad {
|
||||
touchpadView(tp)
|
||||
}
|
||||
if let m = controller?.motion {
|
||||
if let m = s.motion {
|
||||
motionReadout(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
|
||||
private func stick(_ label: String, _ s: InputSnapshot.Stick) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.secondary.opacity(0.3))
|
||||
Circle()
|
||||
.fill(pressed ? Color.accentColor : Color.secondary)
|
||||
.fill(s.pressed ? Color.accentColor : Color.secondary)
|
||||
.frame(width: 12, height: 12)
|
||||
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
|
||||
.offset(x: CGFloat(s.x) * 22, y: CGFloat(-s.y) * 22) // GC y is +up
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||||
Text("\(label) \(sgn(s.x)),\(sgn(s.y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,20 +313,8 @@ struct ControllerTestView: View {
|
||||
.frame(width: 150)
|
||||
}
|
||||
|
||||
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
|
||||
var items: [(String, Bool)] = [
|
||||
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
||||
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
||||
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
||||
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
||||
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
||||
("Menu", g.buttonMenu.isPressed),
|
||||
("Opts", g.buttonOptions?.isPressed ?? false),
|
||||
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
||||
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
||||
]
|
||||
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
|
||||
return LazyVGrid(
|
||||
private func buttonGrid(_ items: [(String, Bool)]) -> some View {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
|
||||
) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
@@ -203,12 +329,9 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func touchpadView(
|
||||
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||
button: GCControllerButtonInput)
|
||||
) -> some View {
|
||||
private func touchpadView(_ tp: InputSnapshot.Touch) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||
Text("Touchpad\(tp.clicked ? " — click" : "")")
|
||||
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||
@@ -219,29 +342,25 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
|
||||
let x = pad.xAxis.value, y = pad.yAxis.value
|
||||
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
|
||||
return Circle().fill(color).frame(width: 10, height: 10)
|
||||
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
|
||||
.opacity(active ? 1 : 0)
|
||||
private func fingerDot(_ p: CGPoint?, color: Color) -> some View {
|
||||
Circle().fill(color).frame(width: 10, height: 10)
|
||||
.offset(x: (p?.x ?? 0) * 71, y: -(p?.y ?? 0) * 33)
|
||||
.opacity(p == nil ? 0 : 1)
|
||||
}
|
||||
|
||||
private func motionReadout(_ m: GCMotion) -> some View {
|
||||
let a = Self.totalAccel(m)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
private func motionReadout(_ m: InputSnapshot.Motion) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f", m.gyro.x, m.gyro.y, m.gyro.z))
|
||||
.font(.caption2.monospaced())
|
||||
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
|
||||
Text(String(format: "accel %+.2f %+.2f %+.2f", m.accel.x, m.accel.y, m.accel.z))
|
||||
.font(.caption2.monospaced())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Rumble
|
||||
|
||||
private func rumbleCard() -> some View {
|
||||
private func rumbleCard(backend: String, health: String?) -> some View {
|
||||
card("Rumble") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Strength", selection: $intensity) {
|
||||
@@ -253,9 +372,9 @@ struct ControllerTestView: View {
|
||||
.pickerStyle(.segmented)
|
||||
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||
Toggle("Light motor (right)", isOn: $lightOn)
|
||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||
Label("Backend: \(backend)", systemImage: "waveform")
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
if let problem = tester.rumbleHealth {
|
||||
if let problem = health {
|
||||
Label(problem, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.orange)
|
||||
}
|
||||
@@ -276,9 +395,9 @@ struct ControllerTestView: View {
|
||||
|
||||
// MARK: Adaptive triggers
|
||||
|
||||
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
private func triggerCard(_ pad: ShotPad) -> some View {
|
||||
card("Adaptive triggers") {
|
||||
if c.hasAdaptiveTriggers {
|
||||
if pad.hasAdaptiveTriggers {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Apply to", selection: $triggerTarget) {
|
||||
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
|
||||
@@ -315,8 +434,8 @@ struct ControllerTestView: View {
|
||||
// MARK: Lightbar + player LED
|
||||
|
||||
@ViewBuilder
|
||||
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
if c.hasLight {
|
||||
private func extrasCard(_ pad: ShotPad) -> some View {
|
||||
if pad.hasLight {
|
||||
card("Lightbar & player LED") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -228,6 +228,16 @@ extension Artwork {
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything that answers poster bytes for a cover-art URL. The production implementation is
|
||||
/// [`LibraryArtLoader`]; the screenshot harness substitutes a canned source so store frames carry
|
||||
/// artwork without a host on the network.
|
||||
public protocol LibraryArtSource: Sendable {
|
||||
func data(for url: URL) async throws -> Data
|
||||
/// Release pooled connections when the owning screen goes away. Sources without connections
|
||||
/// have nothing to do.
|
||||
func close() async
|
||||
}
|
||||
|
||||
/// Loads cover art for the library UI, routing each URL to the transport that suits its origin.
|
||||
///
|
||||
/// A `GameEntry`'s art candidates mix two very different things: the host's own art proxy
|
||||
@@ -242,7 +252,7 @@ extension Artwork {
|
||||
/// TLS handshake per tile.
|
||||
///
|
||||
/// Built once per library screen and reused across a whole grid's worth of posters.
|
||||
public final class LibraryArtLoader: @unchecked Sendable {
|
||||
public final class LibraryArtLoader: LibraryArtSource, @unchecked Sendable {
|
||||
private let address: String
|
||||
private let port: UInt16
|
||||
private let identity: SecIdentity
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,6 +50,7 @@ fn plan_for(req: &ConnectRequest, fp_hex: &str, tofu: bool, opts: &SpawnOpts) ->
|
||||
fp_hex: Some(fp_hex.to_string()),
|
||||
mac: req.mac.clone(),
|
||||
id: None,
|
||||
mgmt_port: None, // this shell resolves the library port itself (`mgmt_port_for`)
|
||||
},
|
||||
req.launch.as_ref().map(|(id, _)| id.clone()),
|
||||
// A plain card click carries no one-off: the resolver honors the host's own binding
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,8 @@ x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-win
|
||||
cross-compiled (the x64 MSVC toolset ships the ARM64 cross compiler; since M10 nothing in the
|
||||
package links FFmpeg, so neither arch needs a per-arch `FFMPEG_DIR` tree staged on the runner —
|
||||
one less thing the ARM64 leg can be missing). Artifacts are arch-suffixed
|
||||
(`..._x64.msix` / `..._arm64.msix`, each with its matching `.cer`); `pack-msix.ps1 -Arch x64|arm64`
|
||||
(`..._x64.msix` / `..._arm64.msix`, plus a matching `.cer` only in the fallback signing modes 2 and 3
|
||||
— Azure signing emits none); `pack-msix.ps1 -Arch x64|arm64`
|
||||
stamps the manifest `ProcessorArchitecture` and names the output. See
|
||||
[`windows-client.yml`](../../../.gitea/workflows/windows-client.yml) for the cross-build rationale.
|
||||
|
||||
@@ -52,38 +53,50 @@ low-level input hooks, WASAPI and SDL3.
|
||||
MSIX requires a strictly 4-part numeric version. The workflow computes:
|
||||
- `vX.Y.Z` tag → `X.Y.Z.0` (THE release; any `-rc`/`+meta` suffix is dropped for MSIX). Published to
|
||||
the stable `latest/` alias and attached to the unified Gitea Release.
|
||||
- `main` push / `workflow_dispatch` → `0.3.<run_number>.0` (canary, climbs by run number; `canary/` alias).
|
||||
- `main` push / `workflow_dispatch` → `X.<Y+1>.<run_number>.0` (canary — the minor *after* the latest
|
||||
`v*` tag, per `scripts/ci/pf-version.ps1`, climbing by run number; `canary/` alias).
|
||||
|
||||
## 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(),
|
||||
};
|
||||
|
||||
@@ -28,6 +28,11 @@ pub struct DiscoveredHost {
|
||||
/// `linux[/<family>][/<id>]`), sanitized — drives the host tile's OS mark and is
|
||||
/// persisted like `mac`. Empty if absent (older host).
|
||||
pub os: String,
|
||||
/// The management API's port from the mDNS `mgmt` TXT — where the game library is served.
|
||||
/// Persisted like `mac` (`trust::learn_mgmt_port`), and load-bearing rather than cosmetic:
|
||||
/// a host moved off 47990 loses its library once mDNS is gone unless we write this down.
|
||||
/// `None` if absent (older host) — resolve via `library::DEFAULT_MGMT_PORT`.
|
||||
pub mgmt_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// Forces the running browse to re-query now — the hosts page's Refresh. Mirrors
|
||||
@@ -124,6 +129,7 @@ pub fn browse() -> (async_channel::Receiver<DiscoveredHost>, Rescan) {
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
os: pf_client_core::os::sanitize_os(&val("os")),
|
||||
mgmt_port: val("mgmt").parse().ok(),
|
||||
};
|
||||
if tx.send_blocking(host).is_err() {
|
||||
break; // UI gone — stop browsing
|
||||
|
||||
@@ -160,6 +160,7 @@ pub(crate) fn spawn_session(
|
||||
fp_hex: Some(fp_hex.to_string()),
|
||||
mac: Vec::new(), // wake ran before this spawn (initiate_waking) — not the plan's job
|
||||
id: None,
|
||||
mgmt_port: None, // the library fetch runs in the shell (`Target`), never off a spawn plan
|
||||
},
|
||||
launch.map(str::to_string),
|
||||
profile.map(str::to_string),
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
//! still load via a serde alias in core.
|
||||
|
||||
pub use pf_client_core::trust::{
|
||||
hex, learn_mac, learn_os, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
|
||||
KnownHosts, Settings,
|
||||
hex, learn_mac, learn_mgmt_port, learn_os, load_or_create_identity, pair_error_message,
|
||||
parse_hex32, KnownHost, KnownHosts, Settings,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
@@ -1450,16 +1450,21 @@ pub fn pipewire_thread(
|
||||
RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)"
|
||||
);
|
||||
}
|
||||
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
|
||||
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
|
||||
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
|
||||
// radeonsi/iHD import it and any compositor can allocate it.
|
||||
let mut modifiers = importer
|
||||
.as_mut()
|
||||
.map(|i| i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap()))
|
||||
.unwrap_or_default();
|
||||
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
|
||||
modifiers.push(0); // DRM_FORMAT_MOD_LINEAR
|
||||
// Modifiers our import stack handles, enumerated PER FOURCC. `XR24` (BGRx) and `AR24` (BGRA)
|
||||
// are asked separately on purpose: EGL/libva answer per format, and nothing entitles us to
|
||||
// assume a driver that imports one imports the other. Keeping them apart is also what makes
|
||||
// the BGRA pod below correct on AMD and Intel rather than an NVIDIA-shaped guess — each list
|
||||
// is whatever THIS GPU's stack actually said.
|
||||
//
|
||||
// To each list we add LINEAR (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's
|
||||
// only offer) import via CUDA external memory instead. For the VAAPI passthrough path there is
|
||||
// no importer at all, so the lists start empty and LINEAR is all we advertise: radeonsi/iHD
|
||||
// import it and any compositor can allocate it.
|
||||
let mut modifiers = Vec::new();
|
||||
let mut modifiers_bgra = Vec::new();
|
||||
if let Some(i) = importer.as_mut() {
|
||||
modifiers = i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap());
|
||||
modifiers_bgra = i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgra).unwrap());
|
||||
}
|
||||
// PyroWave passthrough: the encoder imports through Vulkan, not libva — extend the
|
||||
// advertisement with every modifier its device samples from, so compositors that
|
||||
@@ -1468,12 +1473,20 @@ pub fn pipewire_thread(
|
||||
// the host's `pyrowave` feature is on AND the session (or the global encoder pref) is
|
||||
// PyroWave — so capture never calls back into `encode` and needs no feature gate of its
|
||||
// own (the emptiness check gates it).
|
||||
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
|
||||
for &m in &policy.pyrowave_modifiers {
|
||||
if !modifiers.contains(&m) {
|
||||
modifiers.push(m);
|
||||
let extend_pyrowave = vaapi_passthrough && !policy.pyrowave_modifiers.is_empty();
|
||||
for list in [&mut modifiers, &mut modifiers_bgra] {
|
||||
if (importer.is_some() || vaapi_passthrough) && !list.contains(&0) {
|
||||
list.push(0); // DRM_FORMAT_MOD_LINEAR
|
||||
}
|
||||
if extend_pyrowave {
|
||||
for &m in &policy.pyrowave_modifiers {
|
||||
if !list.contains(&m) {
|
||||
list.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if extend_pyrowave {
|
||||
tracing::info!(
|
||||
count = modifiers.len(),
|
||||
"zero-copy: advertising the PyroWave device's Vulkan-importable dmabuf modifiers"
|
||||
@@ -1540,9 +1553,14 @@ pub fn pipewire_thread(
|
||||
);
|
||||
} else if want_dmabuf {
|
||||
tracing::info!(
|
||||
count = modifiers.len(),
|
||||
bgrx_count = modifiers.len(),
|
||||
bgra_count = modifiers_bgra.len(),
|
||||
// `sample` is TRUNCATED to 6, and LINEAR is pushed last — so reading the sample as the
|
||||
// whole list makes a perfectly good offer look tiled-only. That misreading cost a full
|
||||
// debugging session on 2026-08-14, hence stating the one bit that was actually wanted.
|
||||
linear_offered = modifiers.contains(&0),
|
||||
sample = ?&modifiers[..modifiers.len().min(6)],
|
||||
"zero-copy: advertising EGL-importable dmabuf modifiers"
|
||||
"zero-copy: advertising EGL-importable dmabuf modifiers (BGRx + BGRA pods)"
|
||||
);
|
||||
} else if consumer.cpu_is_downgrade() {
|
||||
// Reached only when no dmabuf is advertised at all (every arm above rules out a
|
||||
@@ -2094,17 +2112,39 @@ pub fn pipewire_thread(
|
||||
.map(|fmt| build_hdr_dmabuf_format(*fmt, preferred))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
} else if want_dmabuf {
|
||||
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
|
||||
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 3 } else { 2 });
|
||||
if prefer_native_nv12 {
|
||||
// First compatible consumer pod wins. Gamescope advertises NV12 and BGRx; pinning
|
||||
// BT.709 limited here selects its RGB→NV12 shader with our bitstream colorimetry.
|
||||
pods.push(build_dmabuf_format(VideoFormat::NV12, &[0], preferred)?);
|
||||
}
|
||||
pods.push(build_dmabuf_format(
|
||||
VideoFormat::BGRx,
|
||||
&modifiers,
|
||||
preferred,
|
||||
)?);
|
||||
if !modifiers.is_empty() {
|
||||
pods.push(build_dmabuf_format(
|
||||
VideoFormat::BGRx,
|
||||
&modifiers,
|
||||
preferred,
|
||||
)?);
|
||||
}
|
||||
// xdph (Hyprland/sway) offers ONLY **BGRA** on its dmabuf EnumFormat — it lists BGRA *and*
|
||||
// BGRx on the SHM pod, so a BGRx-only dmabuf offer intersects with nothing and PipeWire
|
||||
// fails the link outright:
|
||||
// pw.link: negotiating -> error no more input formats (-22)
|
||||
// Measured 2026-08-14 on Hyprland 0.55.4 + xdph 1.3.12: the 12 tiled modifiers matched on
|
||||
// both sides perfectly — only the fourcc never did, which is why the failure reads like a
|
||||
// GPU/modifier problem and is not one.
|
||||
//
|
||||
// BGRA and BGRx are the same 32-bit layout; the alpha byte is ignored the whole way to the
|
||||
// encoder (`vk_util` maps both to `B8G8R8A8_UNORM`, VAAPI both to `Pixel::BGRA`), and the
|
||||
// dmabuf import is driven by the NEGOTIATED format's fourcc, so an AR24 frame imports as
|
||||
// AR24. Listed AFTER BGRx so a producer offering both still lands on the pre-existing path
|
||||
// — first compatible consumer pod wins, so this is purely additive.
|
||||
if !modifiers_bgra.is_empty() {
|
||||
pods.push(build_dmabuf_format(
|
||||
VideoFormat::BGRA,
|
||||
&modifiers_bgra,
|
||||
preferred,
|
||||
)?);
|
||||
}
|
||||
pods
|
||||
} else {
|
||||
vec![serialize_pod(obj)?]
|
||||
|
||||
@@ -38,6 +38,12 @@ pub struct HostTarget {
|
||||
pub fp_hex: Option<String>,
|
||||
pub mac: Vec<String>,
|
||||
pub id: Option<String>,
|
||||
/// The host's management-API port (saved store or live advert) — where the library is
|
||||
/// served, distinct from `port` (the native QUIC plane). Carried on the target for the same
|
||||
/// reason as `mac`: a front-end holding a plan has no `KnownHost` in hand, and resolving to
|
||||
/// [`crate::library::DEFAULT_MGMT_PORT`] there is what made a moved mgmt port work on the
|
||||
/// LAN but not over a VPN. `None` = unknown, fall back to the constant.
|
||||
pub mgmt_port: Option<u16>,
|
||||
}
|
||||
|
||||
impl From<&KnownHost> for HostTarget {
|
||||
@@ -49,6 +55,7 @@ impl From<&KnownHost> for HostTarget {
|
||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||
mac: h.mac.clone(),
|
||||
id: h.id.clone(),
|
||||
mgmt_port: h.mgmt_port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -374,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),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! The compositor output absolute coordinates belong to, by NAME — the Linux counterpart of the
|
||||
//! Windows `stream_target` slot, and what the wlroots virtual-pointer backend aims at.
|
||||
//!
|
||||
//! `MouseMoveAbs` carries its own reference extent (`w`/`h` — the client's letterboxed video rect
|
||||
//! in ITS window, not the streamed mode), and the wlr protocol normalizes `x`/`y` against it and
|
||||
//! maps the result onto whichever `wl_output` the virtual pointer was **created with**. So the
|
||||
//! extent takes care of itself and the OUTPUT is the whole question. The injector used to pass the
|
||||
//! first `wl_output` the registry advertised, which is the oldest global — on any multi-head box
|
||||
//! the operator's physical head, never the per-session headless output the client is looking at.
|
||||
//! On the EXTEND backends (Hyprland, wlroots/sway) the streamed head sits *beside* the operator's,
|
||||
//! so absolute samples landed on a screen no session was streaming. Reported from the field as
|
||||
//! "no cursor was visible in the session", and later as a cursor pinned near the left edge that
|
||||
//! vanished part-way across.
|
||||
//!
|
||||
//! The host publishes the streamed output's compositor name at capture bring-up
|
||||
//! ([`set_stream_output`]) — Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`, or a mirrored head's
|
||||
//! connector — and the wlr backend re-creates its virtual pointer bound to the matching `wl_output`
|
||||
//! (`wl_output.name`, protocol v4; the name is explicitly "the same for all clients", so the name
|
||||
//! `hyprctl`/`swaymsg` minted is the name we can match here).
|
||||
//!
|
||||
//! **One slot per process**, exactly like the Windows original: the injector is host-lifetime and
|
||||
//! every concurrent session's input flows through it, so with parallel sessions the LAST capture
|
||||
//! bring-up wins for every session's absolute input. Per-session routing needs source-tagged input
|
||||
//! events (the injector has to become session-aware first — see [`crate::set_absolute_anchor`]'s
|
||||
//! note), and the single slot is never worse than what it replaces: today EVERY session's absolute
|
||||
//! input lands on a head that no session is streaming.
|
||||
//!
|
||||
//! With nothing published — before the first bring-up, or on a compositor whose `wl_output` is
|
||||
//! older than v4 and therefore nameless — the pointer is bound to NO output, which maps absolute
|
||||
//! coordinates over the whole layout. On a single-output compositor that is identical to binding
|
||||
//! that output; on a multi-head one it is at least *reachable*, unlike a pin to the wrong head.
|
||||
|
||||
use std::sync::RwLock;
|
||||
|
||||
/// The streamed output's compositor name, or `None` when nothing has been published yet.
|
||||
static STREAM_OUTPUT: RwLock<Option<String>> = RwLock::new(None);
|
||||
|
||||
/// Publish the compositor output (by name) that absolute input maps into. The host calls this at
|
||||
/// capture bring-up, and ONLY there: nothing clears it at teardown, because an output that goes
|
||||
/// away simply stops resolving (the backend falls back to whole-layout mapping, and between
|
||||
/// sessions nothing injects anyway). A later bring-up is what rewrites it — including to `None`,
|
||||
/// which a backend that needs no named binding passes so a stale name cannot outlive its
|
||||
/// compositor. See the module doc for the one-slot-per-process trade with parallel sessions.
|
||||
pub fn set_stream_output(name: Option<String>) {
|
||||
let mut cur = STREAM_OUTPUT.write().unwrap_or_else(|e| e.into_inner());
|
||||
if *cur != name {
|
||||
tracing::info!(output = ?name, "absolute-input stream output set");
|
||||
*cur = name;
|
||||
}
|
||||
}
|
||||
|
||||
/// The streamed output's compositor name, if one has been published.
|
||||
pub fn stream_output() -> Option<String> {
|
||||
STREAM_OUTPUT
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// ONE test on purpose, like the libei anchor's: the slot is process-wide and cargo runs
|
||||
/// tests on threads in one process, so splitting this into several would let them race.
|
||||
#[test]
|
||||
fn publishes_clears_and_round_trips() {
|
||||
set_stream_output(Some("PF-1643-1".into()));
|
||||
assert_eq!(stream_output().as_deref(), Some("PF-1643-1"));
|
||||
// Re-publishing the same name is a no-op, not a second "set" (the backend keys its
|
||||
// pointer re-creation off the resolved name, but the log line should not repeat).
|
||||
set_stream_output(Some("PF-1643-1".into()));
|
||||
assert_eq!(stream_output().as_deref(), Some("PF-1643-1"));
|
||||
set_stream_output(Some("HEADLESS-2".into()));
|
||||
assert_eq!(stream_output().as_deref(), Some("HEADLESS-2"));
|
||||
set_stream_output(None);
|
||||
assert_eq!(stream_output(), None);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,11 @@
|
||||
//! virtual keyboard (the host's layout via the standard `XKB_DEFAULT_LAYOUT` et al., defaulting
|
||||
//! to evdev/US), and translate events into virtual pointer/keyboard requests, tracking modifier
|
||||
//! state so the compositor resolves shifted keysyms correctly.
|
||||
//!
|
||||
//! **Absolute** motion is mapped by the compositor onto the `wl_output` the virtual pointer was
|
||||
//! CREATED with, so which output that is decides where every absolute sample lands. We aim it at
|
||||
//! the head the session is actually streaming — published by name in [`crate::stream_output`] and
|
||||
//! re-resolved (re-creating the pointer) whenever it changes; see [`WlrootsInjector::retarget`].
|
||||
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||||
use anyhow::{bail, Context, Result};
|
||||
@@ -12,7 +17,12 @@ use punktfunk_core::input::InputKind;
|
||||
use std::io::Write;
|
||||
use std::os::fd::{AsFd, FromRawFd};
|
||||
use std::time::Instant;
|
||||
use wayland_client::protocol::{wl_output::WlOutput, wl_pointer, wl_registry, wl_seat::WlSeat};
|
||||
use wayland_client::backend::WaylandError;
|
||||
use wayland_client::protocol::{
|
||||
wl_output::{self, WlOutput},
|
||||
wl_pointer, wl_registry,
|
||||
wl_seat::WlSeat,
|
||||
};
|
||||
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
|
||||
use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
|
||||
zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1,
|
||||
@@ -27,13 +37,65 @@ use xkbcommon::xkb;
|
||||
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input`).
|
||||
const SCROLL_HORIZONTAL: u32 = 1;
|
||||
|
||||
/// `wl_output.name` — the connector name we match the streamed head on — arrived in v4. Nothing
|
||||
/// else we ask of an output needs more than v1, so a lower advert only costs us the names (and
|
||||
/// with them the ability to aim absolute input; see [`index_named`]). Same constant, same reason,
|
||||
/// as `pf_vdisplay`'s `kwin_dpms`.
|
||||
const WL_OUTPUT_MAX: u32 = 4;
|
||||
|
||||
/// One `wl_output` the compositor has advertised.
|
||||
struct Output {
|
||||
/// The registry global name — the key `wl_registry.global_remove` reports, and the user data
|
||||
/// each `wl_output` event carries back so we know which head it describes.
|
||||
global: u32,
|
||||
proxy: WlOutput,
|
||||
/// `wl_output.name` (protocol v4): the compositor's own name for the head — `HDMI-A-1`,
|
||||
/// Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`. The protocol guarantees this is "the same
|
||||
/// output name for all clients", which is what lets us match the name `hyprctl`/`swaymsg`
|
||||
/// minted on the vdisplay side. `None` on a compositor stuck at v3, which has no name event at
|
||||
/// all — then there is nothing to match on and the pointer stays unbound.
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// Globals bound from the registry (the Wayland dispatch state).
|
||||
#[derive(Default)]
|
||||
struct Globals {
|
||||
pointer_mgr: Option<ZwlrVirtualPointerManagerV1>,
|
||||
keyboard_mgr: Option<ZwpVirtualKeyboardManagerV1>,
|
||||
seat: Option<WlSeat>,
|
||||
output: Option<WlOutput>,
|
||||
/// EVERY advertised output, in advertisement order — not just the first. The streamed head is
|
||||
/// created per session, so it is never the first one advertised (that is the operator's
|
||||
/// oldest physical head), and binding only the first is what aimed absolute input at the
|
||||
/// wrong screen on every EXTEND box.
|
||||
outputs: Vec<Output>,
|
||||
}
|
||||
|
||||
/// Which advertised output — by position in `names`, which is advertisement order — the virtual
|
||||
/// pointer should bind to for the published target `want`.
|
||||
///
|
||||
/// The rule has **no fallback on purpose**, and that absence is the fix: what this replaced was a
|
||||
/// fallback ("bind whatever `wl_output` came first"), and the first-advertised output is the oldest
|
||||
/// global, i.e. the operator's physical head — never the per-session headless one the client is
|
||||
/// looking at. A target that matches nothing therefore yields `None`, which binds the pointer to no
|
||||
/// output and maps absolute coordinates over the whole layout: wrong-ish, but reachable, where a
|
||||
/// pin to the wrong head is unreachable.
|
||||
///
|
||||
/// Split out of [`Globals::output_named`] so the rule is testable — a `WlOutput` proxy cannot be
|
||||
/// constructed without a live Wayland connection, but the decision it feeds can.
|
||||
fn index_named<'a>(
|
||||
names: impl IntoIterator<Item = Option<&'a str>>,
|
||||
want: Option<&str>,
|
||||
) -> Option<usize> {
|
||||
let want = want?;
|
||||
names.into_iter().position(|n| n == Some(want))
|
||||
}
|
||||
|
||||
impl Globals {
|
||||
/// The `wl_output` whose compositor name is `want`, if it is currently advertised.
|
||||
fn output_named(&self, want: &str) -> Option<WlOutput> {
|
||||
index_named(self.outputs.iter().map(|o| o.name.as_deref()), Some(want))
|
||||
.map(|i| self.outputs[i].proxy.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||
@@ -45,13 +107,12 @@ impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
{
|
||||
match interface.as_str() {
|
||||
match event {
|
||||
wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} => match interface.as_str() {
|
||||
"zwlr_virtual_pointer_manager_v1" => {
|
||||
state.pointer_mgr = Some(registry.bind(name, version.min(2), qh, ()));
|
||||
}
|
||||
@@ -61,16 +122,52 @@ impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||
"wl_seat" => {
|
||||
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
|
||||
}
|
||||
"wl_output" if state.output.is_none() => {
|
||||
state.output = Some(registry.bind(name, version.min(3), qh, ()));
|
||||
"wl_output" => {
|
||||
// The `name` event is the only thing that tells the streamed head from the
|
||||
// operator's. Older compositors bind lower and stay nameless (harmless:
|
||||
// `output_named` then matches nothing and the pointer maps over the layout).
|
||||
// The registry global name rides along as user data so the events that follow
|
||||
// land on the right entry.
|
||||
let proxy = registry.bind(name, version.min(WL_OUTPUT_MAX), qh, name);
|
||||
state.outputs.push(Output {
|
||||
global: name,
|
||||
proxy,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// A head went away — a session's headless output being torn down is the common case,
|
||||
// and the pointer must stop being aimed at a dead object (`retarget` re-resolves and
|
||||
// falls back to the whole layout on the next absolute sample).
|
||||
wl_registry::Event::GlobalRemove { name } => {
|
||||
state.outputs.retain(|o| o.global != name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlOutput, u32> for Globals {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlOutput,
|
||||
event: wl_output::Event,
|
||||
global: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
// Only the name matters here: geometry/mode/scale are the compositor's problem, because
|
||||
// binding the pointer to an output makes IT do the mapping (see `retarget`).
|
||||
if let wl_output::Event::Name { name } = event {
|
||||
if let Some(o) = state.outputs.iter_mut().find(|o| o.global == *global) {
|
||||
o.name = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The managers, the two virtual devices, the seat and the output emit no events we use.
|
||||
// The managers, the two virtual devices and the seat emit no events we use.
|
||||
macro_rules! ignore_events {
|
||||
($($t:ty),* $(,)?) => {$(
|
||||
impl Dispatch<$t, ()> for Globals {
|
||||
@@ -80,7 +177,6 @@ macro_rules! ignore_events {
|
||||
}
|
||||
ignore_events!(
|
||||
WlSeat,
|
||||
WlOutput,
|
||||
ZwlrVirtualPointerManagerV1,
|
||||
ZwlrVirtualPointerV1,
|
||||
ZwpVirtualKeyboardManagerV1,
|
||||
@@ -92,6 +188,14 @@ pub struct WlrootsInjector {
|
||||
queue: EventQueue<Globals>,
|
||||
globals: Globals,
|
||||
pointer: ZwlrVirtualPointerV1,
|
||||
/// The compositor name of the output `pointer` is bound to, or `None` when it is bound to no
|
||||
/// output (absolute coordinates then span the whole layout). Compared against
|
||||
/// [`crate::stream_output`] on every absolute sample; a difference re-creates the pointer.
|
||||
bound_output: Option<String>,
|
||||
/// evdev codes of the mouse buttons currently held on `pointer`, so re-creating the device
|
||||
/// can release them first — the compositor has no reason to, and a virtual pointer destroyed
|
||||
/// mid-press leaves the host with a stuck mouse button.
|
||||
pressed: Vec<u32>,
|
||||
keyboard: ZwpVirtualKeyboardV1,
|
||||
xkb_state: xkb::State,
|
||||
_keymap_file: std::fs::File, // keep the memfd alive for the compositor's mmap
|
||||
@@ -100,6 +204,25 @@ pub struct WlrootsInjector {
|
||||
start: Instant,
|
||||
}
|
||||
|
||||
/// Resolve the published stream output ([`crate::stream_output`]) against the outputs this
|
||||
/// connection has bound: `(proxy, name)` when the target is live, `(None, None)` otherwise.
|
||||
///
|
||||
/// `(None, None)` covers three cases that all want the same answer — nothing published yet (before
|
||||
/// the first capture bring-up), the target's `wl_output` global not advertised yet (the injector
|
||||
/// opens on the first input event, which can beat the session's display), and the target torn down
|
||||
/// (session end). A pointer bound to no output maps absolute coordinates over the whole layout,
|
||||
/// which on a single-output compositor is exactly that output and on a multi-head one at least
|
||||
/// keeps the streamed head reachable — unlike a pin to a head nobody is streaming.
|
||||
fn resolve_target(globals: &Globals) -> (Option<WlOutput>, Option<String>) {
|
||||
let Some(want) = crate::stream_output() else {
|
||||
return (None, None);
|
||||
};
|
||||
match globals.output_named(&want) {
|
||||
Some(proxy) => (Some(proxy), Some(want)),
|
||||
None => (None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap on distinct characters the dynamic text keymap holds before it restarts from scratch
|
||||
/// (keycodes grow upward from 9; xkb tops out at 255, so stay well under).
|
||||
const TEXT_KEYMAP_MAX: usize = 200;
|
||||
@@ -140,12 +263,16 @@ impl WlrootsInjector {
|
||||
.clone()
|
||||
.context("compositor advertised no wl_seat")?;
|
||||
|
||||
let pointer = pointer_mgr.create_virtual_pointer_with_output(
|
||||
Some(&seat),
|
||||
globals.output.as_ref(),
|
||||
&qh,
|
||||
(),
|
||||
);
|
||||
// A second roundtrip: the first only said WHICH globals exist. The `wl_output.name` events
|
||||
// that identify each head are emitted on the objects we bound *during* that roundtrip, so
|
||||
// they only land now — and the pointer's output has to be resolved before we create it.
|
||||
queue
|
||||
.roundtrip(&mut globals)
|
||||
.context("Wayland output-name roundtrip")?;
|
||||
|
||||
let (target, bound_output) = resolve_target(&globals);
|
||||
let pointer =
|
||||
pointer_mgr.create_virtual_pointer_with_output(Some(&seat), target.as_ref(), &qh, ());
|
||||
let keyboard = keyboard_mgr.create_virtual_keyboard(&seat, &qh, ());
|
||||
|
||||
// The keymap the compositor resolves our raw evdev keycodes with. Empty names defer to
|
||||
@@ -174,7 +301,9 @@ impl WlrootsInjector {
|
||||
conn.flush().ok();
|
||||
|
||||
tracing::info!(
|
||||
output = globals.output.is_some(),
|
||||
outputs = globals.outputs.len(),
|
||||
want = ?crate::stream_output(),
|
||||
bound = ?bound_output,
|
||||
"wlroots virtual input ready (pointer + keyboard)"
|
||||
);
|
||||
Ok(Self {
|
||||
@@ -182,6 +311,8 @@ impl WlrootsInjector {
|
||||
queue,
|
||||
globals,
|
||||
pointer,
|
||||
bound_output,
|
||||
pressed: Vec::new(),
|
||||
keyboard,
|
||||
xkb_state,
|
||||
_keymap_file: file,
|
||||
@@ -190,6 +321,90 @@ impl WlrootsInjector {
|
||||
})
|
||||
}
|
||||
|
||||
/// Aim the virtual pointer at the output the session is streaming, re-creating it when that
|
||||
/// changes — the fix for absolute input landing on the operator's screen.
|
||||
///
|
||||
/// The wlr protocol maps `motion_absolute` onto the output the pointer was **created with**
|
||||
/// and offers no way to re-aim one, so a change means destroy + create. Cheap and rare: the
|
||||
/// host publishes the target once per capture bring-up, so a re-create fires at most a couple
|
||||
/// of times per session. The no-change path — every other absolute sample — costs one `RwLock`
|
||||
/// read and a scan of the output list, which has one entry per head.
|
||||
///
|
||||
/// Called from the `MouseMoveAbs` arm immediately BEFORE the motion is sent, so a re-created
|
||||
/// pointer gets its first position in the same batch rather than sitting wherever the
|
||||
/// compositor puts a brand-new device.
|
||||
///
|
||||
/// Resolution is by NAME, never by size: `MouseMoveAbs`'s extent is the client's letterboxed
|
||||
/// content rect in ITS window, not the streamed mode, so no size ladder could identify the
|
||||
/// head. Falling back to no output at all (whole-layout mapping) when the target is unknown is
|
||||
/// deliberate — see [`crate::stream_output`]'s module doc.
|
||||
fn retarget(&mut self) {
|
||||
let (target, want) = resolve_target(&self.globals);
|
||||
if want == self.bound_output {
|
||||
return;
|
||||
}
|
||||
let (Some(mgr), Some(seat)) = (self.globals.pointer_mgr.clone(), self.globals.seat.clone())
|
||||
else {
|
||||
return; // cannot re-create without the manager/seat; keep the pointer we have
|
||||
};
|
||||
// Never destroy a device with a button held: nothing else will release it.
|
||||
if !self.pressed.is_empty() {
|
||||
let t = self.now_ms();
|
||||
for btn in std::mem::take(&mut self.pressed) {
|
||||
self.pointer
|
||||
.button(t, btn, wl_pointer::ButtonState::Released);
|
||||
}
|
||||
self.pointer.frame();
|
||||
}
|
||||
self.pointer.destroy();
|
||||
self.pointer = mgr.create_virtual_pointer_with_output(
|
||||
Some(&seat),
|
||||
target.as_ref(),
|
||||
&self.queue.handle(),
|
||||
(),
|
||||
);
|
||||
tracing::info!(
|
||||
from = ?self.bound_output,
|
||||
to = ?want,
|
||||
"wlroots virtual pointer re-aimed (absolute input now maps into this output)"
|
||||
);
|
||||
self.bound_output = want;
|
||||
}
|
||||
|
||||
/// Drain the compositor's half of the connection, then push our batch to it — run after every
|
||||
/// injected event.
|
||||
///
|
||||
/// The **read** is the load-bearing half, and it used to be missing: `dispatch_pending`'s own
|
||||
/// documentation says it "will not perform reads on the Wayland socket", so the queue only
|
||||
/// ever held what [`Self::open`]'s roundtrips put there. Two consequences, both real. The
|
||||
/// injector could never learn about a `wl_output` created AFTER it opened — which is exactly
|
||||
/// the ordering the field report was captured in, and would have left [`Self::retarget`] with
|
||||
/// nothing to resolve. And everything the compositor sent us piled up unread in the socket
|
||||
/// buffer for the host's lifetime, including the protocol errors the code here claimed to be
|
||||
/// surfacing but structurally could not.
|
||||
///
|
||||
/// Non-blocking by construction: `read()` is documented to answer `WouldBlock` when the socket
|
||||
/// has nothing for us, which is the common case at input rates and is not an error.
|
||||
fn pump(&mut self) -> Result<()> {
|
||||
// `prepare_read` will not hand out a guard while events are still queued, so dispatch first.
|
||||
self.queue
|
||||
.dispatch_pending(&mut self.globals)
|
||||
.context("wayland dispatch")?;
|
||||
if let Some(guard) = self.conn.prepare_read() {
|
||||
match guard.read() {
|
||||
Ok(_) => {
|
||||
self.queue
|
||||
.dispatch_pending(&mut self.globals)
|
||||
.context("wayland dispatch (post-read)")?;
|
||||
}
|
||||
Err(WaylandError::Io(e)) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(e) => return Err(e).context("wayland read"),
|
||||
}
|
||||
}
|
||||
self.conn.flush().context("wayland flush")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_ms(&self) -> u32 {
|
||||
self.start.elapsed().as_millis() as u32
|
||||
}
|
||||
@@ -271,6 +486,12 @@ impl InputInjector for WlrootsInjector {
|
||||
let w = (event.flags >> 16) & 0xffff;
|
||||
let h = event.flags & 0xffff;
|
||||
if w > 0 && h > 0 {
|
||||
// The compositor maps these onto the pointer's bound output, so make sure that
|
||||
// is the head this session streams before sending any. Checked here rather
|
||||
// than per inject: only absolute motion depends on the binding, and a pointer
|
||||
// swapped mid-drag is the one thing `retarget` has to work to be safe about.
|
||||
self.retarget();
|
||||
let t = self.now_ms(); // `retarget` may have consumed time releasing buttons
|
||||
let x = event.x.clamp(0, w as i32) as u32;
|
||||
let y = event.y.clamp(0, h as i32) as u32;
|
||||
self.pointer.motion_absolute(t, x, y, w, h);
|
||||
@@ -280,8 +501,12 @@ impl InputInjector for WlrootsInjector {
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
|
||||
if let Some(btn) = gs_button_to_evdev(event.code) {
|
||||
let st = if event.kind == InputKind::MouseButtonDown {
|
||||
if !self.pressed.contains(&btn) {
|
||||
self.pressed.push(btn);
|
||||
}
|
||||
wl_pointer::ButtonState::Pressed
|
||||
} else {
|
||||
self.pressed.retain(|&b| b != btn);
|
||||
wl_pointer::ButtonState::Released
|
||||
};
|
||||
self.pointer.button(t, btn, st);
|
||||
@@ -328,12 +553,7 @@ impl InputInjector for WlrootsInjector {
|
||||
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
|
||||
}
|
||||
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
||||
self.queue
|
||||
.dispatch_pending(&mut self.globals)
|
||||
.context("wayland dispatch")?;
|
||||
self.conn.flush().context("wayland flush")?;
|
||||
Ok(())
|
||||
self.pump()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,3 +603,40 @@ fn memfd_with(s: &str) -> Result<std::fs::File> {
|
||||
f.write_all(&[0]).context("write keymap NUL")?;
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The live-box layout the field report came from: the operator's `HDMI-A-1` is advertised
|
||||
/// FIRST (it exists from compositor start), and the session's headless head is added later —
|
||||
/// so "first advertised" is always the wrong answer, whichever order the injector and the
|
||||
/// display happen to come up in.
|
||||
const HYPRLAND_BOX: [Option<&str>; 2] = [Some("HDMI-A-1"), Some("PF-87756-3")];
|
||||
|
||||
#[test]
|
||||
fn binds_the_streamed_head_not_the_first_advertised_one() {
|
||||
assert_eq!(index_named(HYPRLAND_BOX, Some("PF-87756-3")), Some(1));
|
||||
assert_eq!(index_named(HYPRLAND_BOX, Some("HDMI-A-1")), Some(0));
|
||||
// sway's own naming, and a mirrored physical head, resolve the same way.
|
||||
let sway = [Some("HEADLESS-1"), Some("DP-2"), Some("HEADLESS-2")];
|
||||
assert_eq!(index_named(sway, Some("HEADLESS-2")), Some(2));
|
||||
assert_eq!(index_named(sway, Some("DP-2")), Some(1));
|
||||
}
|
||||
|
||||
/// Every "we don't know" must land on NO output (whole-layout mapping), never on a guess —
|
||||
/// the regression this whole change exists to prevent.
|
||||
#[test]
|
||||
fn an_unknown_target_binds_nothing_rather_than_falling_back() {
|
||||
// Published but not advertised (yet, or any more — the injector opens on the first input
|
||||
// event, which can beat the display, and the head goes away at session end).
|
||||
assert_eq!(index_named(HYPRLAND_BOX, Some("PF-87756-9")), None);
|
||||
// Nothing published at all — before the first capture bring-up.
|
||||
assert_eq!(index_named(HYPRLAND_BOX, None), None);
|
||||
// A compositor older than wl_output v4 emits no `name` event, so nothing is matchable.
|
||||
assert_eq!(index_named([None, None], Some("PF-87756-3")), None);
|
||||
// …and a compositor advertising no outputs at all cannot resolve anything either.
|
||||
let headless: [Option<&str>; 0] = [];
|
||||
assert_eq!(index_named(headless, Some("PF-87756-3")), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,15 @@ static ABSOLUTE_ANCHOR: std::sync::RwLock<Option<AbsoluteAnchor>> = std::sync::R
|
||||
/// record in `design/per-monitor-portal-capture.md` §5.3) and wrong for anything per-client. A
|
||||
/// per-session anchor needs the injector to become session-aware first; don't call this from a
|
||||
/// session path until it is.
|
||||
///
|
||||
/// The wlroots backend does **not** consult this — it aims at a named output via
|
||||
/// `stream_output::set_stream_output` (Linux), which the host DOES publish per session and which
|
||||
/// therefore takes exactly the last-bring-up-wins trade this warning describes: on purpose, and
|
||||
/// stated in the open in that module's doc, matching the Windows `stream_target` slot that already
|
||||
/// made the same call. The two are separate slots because they answer different questions and are
|
||||
/// written by different owners: this anchor is the operator's host-wide capture pin, recomputed
|
||||
/// from policy whenever the console writes it — which would wipe a per-session value written here —
|
||||
/// while the stream output is whatever head the session's capture actually attached to.
|
||||
pub fn set_absolute_anchor(anchor: Option<AbsoluteAnchor>) {
|
||||
let anchor = anchor.filter(|a| !a.is_empty());
|
||||
tracing::debug!(?anchor, "input: absolute-coordinate anchor set");
|
||||
@@ -529,6 +538,14 @@ pub mod pen;
|
||||
pub mod stream_target;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use stream_target::set_stream_target;
|
||||
/// Linux: the streamed compositor output (by name) that absolute coordinates map into — the
|
||||
/// counterpart of the Windows `stream_target` module, published by the host at capture bring-up and
|
||||
/// consumed by the wlroots virtual-pointer backend, which binds its pointer to that `wl_output`.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/stream_output.rs"]
|
||||
pub mod stream_output;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use stream_output::{set_stream_output, stream_output};
|
||||
/// Stub — pen injection needs the Linux uinput tablet or Windows synthetic pointers;
|
||||
/// `pen_supported()` is false here, so no host advertises the cap and no batches arrive.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -79,6 +79,10 @@ pub(crate) fn emit_display_event(ev: DisplayEvent) {
|
||||
#[path = "vdisplay/backend.rs"]
|
||||
pub(crate) mod backend;
|
||||
pub use backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
|
||||
/// The NEGOTIATED ScreenCast cursor mode of a portal-backed output, reported per session by
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`]. (The module itself stays private — the ladder that
|
||||
/// picks the mode is this crate's business; the verdict is the caller's.)
|
||||
pub use portal_cursor::Mode as PortalCursorMode;
|
||||
|
||||
/// Time-bounded child-process helpers — every compositor query shells out, and an unbounded one
|
||||
/// can wedge the calling (session) thread forever.
|
||||
@@ -833,6 +837,21 @@ mod portal_config;
|
||||
#[path = "vdisplay/linux/portal_cursor.rs"]
|
||||
mod portal_cursor;
|
||||
|
||||
/// The line fed to xdph's custom picker to select an output headlessly.
|
||||
///
|
||||
/// Declared unconditionally for the same reason again: it is a wire format with no schema and no
|
||||
/// error report, so the transcribed-parser tests are the only place a malformed line is visible
|
||||
/// without a compositor. That is not hypothetical — a missing separator shipped, and the one
|
||||
/// assertion that existed for it passed throughout.
|
||||
#[path = "vdisplay/linux/portal_picker.rs"]
|
||||
mod portal_picker;
|
||||
|
||||
/// The single, never-dropped tokio runtime the portal handshakes run on. Linux-only: it exists to
|
||||
/// outlive ashpd's process-global cached D-Bus connection, and only the Linux backends speak to it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/portal_rt.rs"]
|
||||
mod portal_rt;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/hyprland.rs"]
|
||||
mod hyprland;
|
||||
|
||||
@@ -76,6 +76,20 @@ pub struct VirtualOutput {
|
||||
/// capturer must hold frames until that renegotiation lands. Linux-only.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub expect_exact_dims: bool,
|
||||
/// The compositor's own name for this output (Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`,
|
||||
/// a mirrored head's connector) — the Linux answer to what `win_capture` carries on Windows:
|
||||
/// the identity the host needs to aim **absolute input** at the head it is streaming
|
||||
/// (`pf_inject::set_stream_output`, called from `capture::capture_virtual_output`).
|
||||
///
|
||||
/// It is the `wl_output.name` of that head, which the protocol guarantees is the same string
|
||||
/// for every client — so the injector can match it on its own Wayland connection. `None` on
|
||||
/// the backends whose absolute mapping does not need it (KWin/Mutter inject through libei,
|
||||
/// which selects by region; gamescope owns its whole seat).
|
||||
///
|
||||
/// This crate must not depend on pf-inject (see the crate doc), so the name is only CARRIED
|
||||
/// here — the host publishes it.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub output_name: Option<String>,
|
||||
}
|
||||
|
||||
impl VirtualOutput {
|
||||
@@ -101,6 +115,8 @@ impl VirtualOutput {
|
||||
pool_gen: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
expect_exact_dims: false,
|
||||
#[cfg(target_os = "linux")]
|
||||
output_name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +200,33 @@ pub trait VirtualDisplay: Send {
|
||||
fn hw_cursor(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// The ScreenCast cursor mode the backend's portal actually NEGOTIATED for the most recent
|
||||
/// [`create`](Self::create) — the answer to [`set_hw_cursor`](Self::set_hw_cursor), which is
|
||||
/// only ever a *request*.
|
||||
///
|
||||
/// This is the difference between the two that matters downstream: on the whole wlr family
|
||||
/// (xdph, xdpw) `AvailableCursorModes` is `Hidden|Embedded`, so a session that asked for
|
||||
/// metadata is served **`Embedded`** — the compositor paints the pointer into the frames and
|
||||
/// sends no `SPA_META_Cursor`, ever, wherever the pointer is. A consumer that reads "no cursor
|
||||
/// overlay" as a symptom (the host's park schedule reads it as "the seat pointer has not
|
||||
/// reached the streamed output" — true on Mutter, which suppresses metadata while the pointer
|
||||
/// is off the recorded view) is then acting on noise; see
|
||||
/// [`PortalCursorMode::delivers_metadata`](crate::PortalCursorMode::delivers_metadata).
|
||||
///
|
||||
/// `None` — the default, and what every non-portal backend reports — means "nothing was
|
||||
/// negotiated through the xdg ScreenCast portal here, so this says nothing at all": KWin
|
||||
/// (`zkde_screencast` `pointer` mode), Mutter (`RecordVirtual` `cursor-mode`), gamescope (no
|
||||
/// pointer either way) and Windows (IddCx) all get exactly what they ask for through their own
|
||||
/// protocols, and their consumers must keep behaving as they always did. It is also `None`
|
||||
/// before the first `create`.
|
||||
///
|
||||
/// Reported by the wlr-family backends (`hyprland`, `wlroots`) and by the monitor
|
||||
/// [`mirror`](crate::open_mirror) when it delegates to one. Those outputs are never registry-
|
||||
/// pooled (`remote_fd.is_some()` — the portal fd cannot be re-opened per attach), so a reused
|
||||
/// kept display can never hand back a *stale* answer here.
|
||||
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
|
||||
None
|
||||
}
|
||||
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) —
|
||||
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
|
||||
/// registry reads it right after `create` to key the display's group **arrangement** (manual
|
||||
|
||||
@@ -525,6 +525,9 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// gamescope owns its own seat and injects through its EIS socket, not the wlr
|
||||
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
|
||||
output_name: None,
|
||||
});
|
||||
}
|
||||
check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions
|
||||
@@ -718,6 +721,9 @@ fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<Virtual
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// gamescope owns its own seat and injects through its EIS socket, not the wlr
|
||||
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
|
||||
output_name: None,
|
||||
});
|
||||
}
|
||||
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
|
||||
@@ -834,6 +840,9 @@ fn managed_output(node_id: u32, mode: Mode) -> VirtualOutput {
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// gamescope owns its own seat and injects through its EIS socket, not the wlr
|
||||
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
|
||||
output_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3595,6 +3604,9 @@ pub(crate) fn stream_existing_output(
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
// No xdg portal in this path at all (gamescope publishes the node itself), and no pointer
|
||||
// in the node either way — nothing to report.
|
||||
cursor_mode: None,
|
||||
keepalive: Box::new(()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
//! 3. The xdg ScreenCast portal (served by **xdph**) yields the output's PipeWire node. There is
|
||||
//! no GUI to pick an output headlessly, so xdph is steered through its **custom picker**: a
|
||||
//! managed config (`~/.config/hypr/xdph.conf`) points `screencopy:custom_picker_binary` at a tiny
|
||||
//! installed shim that cats a per-session selection file we write (`[SELECTION]screen:<NAME>`)
|
||||
//! right before the handshake — byte-for-byte the xdpw pattern, xdph's picker wire format.
|
||||
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and runs
|
||||
//! `hyprctl output remove NAME`.
|
||||
//! installed shim that cats a per-session selection file we write right before the handshake —
|
||||
//! `[SELECTION]/screen:<NAME>`, whose leading `/` is xdph's mandatory empty-flags separator (see
|
||||
//! [`crate::portal_picker`], which owns the format and its tests).
|
||||
//! 4. Teardown is RAII **and ordered**: drop closes the ScreenCast session and WAITS for the portal
|
||||
//! to confirm it, and only then runs `hyprctl output remove NAME`. Removing the output first is
|
||||
//! what made every stream after the first one fail on Hyprland — see [`StopGuard`].
|
||||
//!
|
||||
//! Requirements: the host runs inside (or can reach) the Hyprland session — either
|
||||
//! `HYPRLAND_INSTANCE_SIGNATURE` is inherited, or [`is_available`] discovers it from
|
||||
@@ -27,7 +29,8 @@
|
||||
//! the ScreenCast interface routed to xdph (`scripts/headless/portals.conf`).
|
||||
//!
|
||||
//! Contracts verified on **Hyprland 0.55.4 + xdph 1.3.x** (`design/hyprland-support.md` Phase 0):
|
||||
//! `hyprctl` subcommands / JSON shapes, the `[SELECTION]screen:<name>` picker format, the
|
||||
//! `hyprctl` subcommands / JSON shapes, the `[SELECTION]/screen:<name>` picker format (re-derived
|
||||
//! from xdph 1.3.12's own parser on 2026-08-14, which is when the missing `/` turned up), the
|
||||
//! `~/.config/hypr/xdph.conf` path + `screencopy:custom_picker_binary` key, and that `eval` needs
|
||||
//! the Lua config manager. Not yet exercised end-to-end on real DRM hardware: a headless output's
|
||||
//! GBM/dmabuf allocation (fails on a nested/NVIDIA test box — Sunshine#4197); `set_monitor_rule`
|
||||
@@ -44,7 +47,7 @@ use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Per-session file the xdph custom picker reads the selected output from. We write
|
||||
/// `screen:<NAME>\n` here right before the portal handshake selects sources. Lives under
|
||||
/// [`picker_selection_line`] here right before the portal handshake selects sources. Lives under
|
||||
/// `$XDG_RUNTIME_DIR` (per-user, 0700) — NOT a world-writable /tmp path another local user could
|
||||
/// pre-create or rewrite between our write and xdph's read (steer capture elsewhere). Mirrors the
|
||||
/// wlroots chooser file.
|
||||
@@ -61,13 +64,11 @@ fn picker_shim_path() -> String {
|
||||
format!("{dir}/punktfunk-xdph-picker.sh")
|
||||
}
|
||||
|
||||
/// The picker line for output `name`. Verified against xdph 1.3.x / hyprland-share-picker on
|
||||
/// Hyprland 0.55.4: xdph reads the custom picker's stdout and requires the `[SELECTION]` marker
|
||||
/// followed by `screen:<name>` (or `window:<addr>` / `region:…`); anything else is rejected as
|
||||
/// "strange output" and falls back to the interactive picker. So a monitor selection is
|
||||
/// `[SELECTION]screen:<name>`.
|
||||
/// The picker line for output `name` — `[SELECTION]/screen:<name>`, whose every byte is load-bearing.
|
||||
/// Lives in [`crate::portal_picker`] with a transcription of xdph's parser, because it is a wire
|
||||
/// format with no error report and this file only compiles on Linux.
|
||||
fn picker_selection_line(name: &str) -> String {
|
||||
format!("[SELECTION]screen:{name}\n")
|
||||
crate::portal_picker::selection_line(name)
|
||||
}
|
||||
|
||||
/// Monotonic per-process counter for headless output names (`PF-<pid>-1`, `PF-<pid>-2`, …). Named
|
||||
@@ -131,11 +132,18 @@ pub struct HyprlandDisplay {
|
||||
/// 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,
|
||||
/// What the portal actually gave us on the most recent [`create`](VirtualDisplay::create) — see
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
|
||||
/// overlay is never coming instead of inferring it from an absence.
|
||||
last_cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
}
|
||||
|
||||
impl HyprlandDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(HyprlandDisplay { hw_cursor: false })
|
||||
Ok(HyprlandDisplay {
|
||||
hw_cursor: false,
|
||||
last_cursor_mode: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +209,10 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
self.hw_cursor
|
||||
}
|
||||
|
||||
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
|
||||
self.last_cursor_mode
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Log the permission-system caveat once per process (silent black frames otherwise).
|
||||
preflight_once();
|
||||
@@ -224,16 +236,21 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
// thread (it parks to keep the cast alive, like the other backends). Serialized: the
|
||||
// selection is one per-user file, so a concurrent session's write between ours and xdph's
|
||||
// read would silently capture the wrong output (see `SELECTION_LOCK`).
|
||||
let (fd, node_id, stop) = {
|
||||
let (fd, node_id, cursor_mode, stop) = {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
select_and_cast(&name, self.hw_cursor)?
|
||||
};
|
||||
// Latched for `last_portal_cursor_mode`: on today's xdph this is `embedded` whatever we
|
||||
// asked for, and the session's whole cursor behaviour follows from that fact rather than
|
||||
// from `hw_cursor`.
|
||||
self.last_cursor_mode = Some(cursor_mode);
|
||||
tracing::info!(
|
||||
node_id,
|
||||
output = %name,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz,
|
||||
cursor = cursor_mode.name(),
|
||||
"hyprland headless output ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
@@ -251,24 +268,100 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// Hyprland is an EXTEND topology: this head sits BESIDE the operator's, so absolute
|
||||
// input has to be aimed at it by name or it lands on their screen. `hyprctl`'s monitor
|
||||
// name is the head's `wl_output.name`, which is what the injector matches.
|
||||
output_name: Some(name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast), then
|
||||
/// remove the output (fields drop in declaration order).
|
||||
/// Drop order matters, and it is the whole fix: [`StopGuard`] **blocks until the ScreenCast session
|
||||
/// is actually closed**, and only then does [`OutputGuard`] remove the compositor output (fields drop
|
||||
/// in declaration order).
|
||||
///
|
||||
/// 🛑 THIS ORDERING USED TO BE A LIE. `StopGuard::drop` only set an atomic and returned, while the
|
||||
/// portal thread noticed it 200 ms later — so `OutputGuard::drop` ran `hyprctl output remove` on an
|
||||
/// output xdph was still actively capturing, every single teardown. See [`StopGuard`] for what that
|
||||
/// did to xdph.
|
||||
struct Keepalive {
|
||||
_stop: StopGuard,
|
||||
_output: OutputGuard,
|
||||
}
|
||||
|
||||
/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal then
|
||||
/// tears the screencast session down.
|
||||
struct StopGuard(Arc<AtomicBool>);
|
||||
/// How long teardown waits for the portal to confirm the ScreenCast session is closed before giving
|
||||
/// up and removing the output anyway. One D-Bus round trip through xdg-desktop-portal to xdph; three
|
||||
/// seconds is generous. Bounded on purpose: a portal that has already wedged must not be able to
|
||||
/// wedge the host's teardown with it — every other blocking helper on this path is bounded the same
|
||||
/// way (see [`HYPRCTL_BUDGET`]).
|
||||
const CAST_CLOSE_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Ends the cast: signals the portal thread, then **waits for it to have closed the ScreenCast
|
||||
/// session**, so the caller may safely remove the output afterwards.
|
||||
///
|
||||
/// 🛑 THE WAIT IS THE POINT — "only the first stream after a portal start works" on Hyprland was
|
||||
/// this, root-caused 2026-08-14 against Hyprland 0.55.4 + xdph 1.3.12 + xdg-desktop-portal 1.20.4.
|
||||
///
|
||||
/// This used to be a bare `AtomicBool` that `drop` merely SET. The portal thread polled it every
|
||||
/// 200 ms and then just dropped its zbus connection, and xdph destroys a session on exactly one
|
||||
/// event — an explicit `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`,
|
||||
/// `onCloseSession`); it has no peer-vanished watcher of its own. The frontend does have one
|
||||
/// (`xdg-desktop-portal.c:230` `peer_died_cb` → `close_sessions_for_sender`), but it only fires once
|
||||
/// our unique bus name goes away, which is *after* the 200 ms poll, and it runs asynchronously on a
|
||||
/// GTask thread. Meanwhile `OutputGuard::drop` had already removed the output — synchronously,
|
||||
/// microseconds after the flag was set.
|
||||
///
|
||||
/// So every teardown destroyed the `wl_output` out from under a live screencopy session. xdph's next
|
||||
/// `Start` then built a PipeWire stream against that wreckage and fell into
|
||||
///
|
||||
/// ```text
|
||||
/// while (pSession->sharingData.nodeID == SPA_ID_INVALID) { // Screencopy.cpp:307-313
|
||||
/// int ret = pw_loop_iterate(g_pPortalManager->m_sPipewire.loop, 0); // timeout 0 = NON-blocking
|
||||
/// ```
|
||||
///
|
||||
/// — an unbounded hot spin on xdph's ONLY event-loop thread, inside the `Start` handler, holding its
|
||||
/// `m_mEventLock`. From that moment xdph answers no D-Bus, no Wayland and no PipeWire, ever again, and
|
||||
/// every later `select_and_cast` dies on our 20 s timeout. MEASURED on the box: the wedged instance's
|
||||
/// unit reported `Consumed 3min 51.971s CPU time over 23min 41.092s wall clock`, and there were
|
||||
/// exactly 232.7 s of wall clock between its last log flush and its restart — 231.971 s of CPU
|
||||
/// against 232.7 s of wall, i.e. one core pinned solid for precisely the wedged interval.
|
||||
///
|
||||
/// Waiting here closes that window: `Session.Close` is answered synchronously by the frontend
|
||||
/// (`xdp-session.c:217` `handle_close` → `xdp_session_close` →
|
||||
/// `xdp_dbus_impl_session_call_close_sync`), so by the time `close()` returns, xdph has already run
|
||||
/// `destroyStream` and logged `Session destroyed`. The output we remove next is one nobody is
|
||||
/// capturing.
|
||||
struct StopGuard {
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Signalled by the portal thread once it has closed the ScreenCast session.
|
||||
///
|
||||
/// `None` on every path where no cast was ever established (a rejected or timed-out handshake):
|
||||
/// there is nothing to close, and a portal that just failed to answer for 20 s is precisely the
|
||||
/// one that would burn the whole budget here for nothing.
|
||||
closed: Option<std::sync::mpsc::Receiver<()>>,
|
||||
}
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
let Some(closed) = self.closed.take() else {
|
||||
return;
|
||||
};
|
||||
match closed.recv_timeout(CAST_CLOSE_BUDGET) {
|
||||
// Closed — xdph has torn the capture down, the output is safe to remove.
|
||||
Ok(()) => {}
|
||||
// The thread is gone without confirming (it panicked, or the runtime died). Nothing is
|
||||
// holding the cast either way, so there is nothing left to wait for.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {}
|
||||
// Still going after the budget. Fall through and remove the output anyway — a leaked
|
||||
// output is worse than a racy one — but say so, because this is the state that wedges
|
||||
// xdph and the next session will be the one that pays for it.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => tracing::warn!(
|
||||
budget_s = CAST_CLOSE_BUDGET.as_secs(),
|
||||
"the ScreenCast session did not close in time — removing the output underneath it, \
|
||||
which is what wedges xdph's frame loop; the next cast may find the portal busy"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +442,12 @@ impl Drop for OutputGuard {
|
||||
/// stream thread, whose only way to end a session is to return, so one hung query used to wedge the
|
||||
/// session for good. Generous next to a healthy call (single-digit milliseconds), and every call
|
||||
/// site already has a failed-query path.
|
||||
/// Ceiling on the whole ScreenCast handshake (`create_session` → `select_sources` → `start` →
|
||||
/// `open_pipe_wire_remote`). Deliberately under [`select_and_cast`]'s 20 s wait so a stuck portal is
|
||||
/// reported by the thread that owns it, with a reason, instead of the caller timing out on it — and,
|
||||
/// far more importantly, so that thread EXITS. See the note at the handshake itself.
|
||||
const HANDSHAKE_BUDGET: Duration = Duration::from_secs(15);
|
||||
|
||||
const HYPRCTL_BUDGET: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Budget for the one-shot xdph restart. `systemctl --user try-restart` waits for the user manager's
|
||||
@@ -401,19 +500,31 @@ impl Drop for SelectionFile {
|
||||
|
||||
/// Point xdph's custom picker at `output` and run the ScreenCast handshake, returning the portal fd
|
||||
/// + node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopGuard)> {
|
||||
fn select_and_cast(
|
||||
output: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<(OwnedFd, u32, crate::portal_cursor::Mode, StopGuard)> {
|
||||
ensure_xdph_config()?;
|
||||
let sel = selection_file();
|
||||
std::fs::write(&sel, picker_selection_line(output)).with_context(|| format!("write {sel}"))?;
|
||||
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
|
||||
// only thing that reads it.
|
||||
let _sel_file = SelectionFile(sel);
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
// The NEGOTIATED cursor mode rides back with the fd and node id: it is decided inside the
|
||||
// portal thread (only there is the proxy to ask), and nothing downstream can re-derive it —
|
||||
// `hw_cursor` is the request, not the answer.
|
||||
let (setup_tx, setup_rx) =
|
||||
std::sync::mpsc::channel::<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>();
|
||||
// The teardown handshake: the thread signals this once it has closed the ScreenCast session, and
|
||||
// `StopGuard::drop` waits on it before the output is removed (see `StopGuard`). Kept a SEPARATE
|
||||
// channel from the setup one above — it fires at the other end of the cast's life, long after
|
||||
// `setup_rx` has been consumed.
|
||||
let (closed_tx, closed_rx) = std::sync::mpsc::channel::<()>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-hypr-cast".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.spawn(move || portal_thread(setup_tx, closed_tx, stop_thread, hw_cursor))
|
||||
.context("spawn hyprland portal thread")?;
|
||||
// Built BEFORE the wait so EVERY error arm below sets the flag on its way out — as Mutter's
|
||||
// `create` does. Returning the bare `Arc` and letting the CALLER wrap it left the two failure
|
||||
@@ -422,9 +533,14 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
|
||||
// parks forever on `while !stop`, holding a live ScreenCast session, its zbus connection, an
|
||||
// `OwnedFd` and a 2-worker tokio runtime — one more set per slow-portal connect, for the host's
|
||||
// lifetime, against an output that no longer exists.
|
||||
let guard = StopGuard(stop);
|
||||
let mut guard = StopGuard { stop, closed: None };
|
||||
match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, guard)),
|
||||
Ok(Ok((fd, node_id, cursor_mode))) => {
|
||||
// A cast exists now, so teardown has something to close and must wait for it. Only this
|
||||
// arm arms the wait: see the field note on `StopGuard::closed`.
|
||||
guard.closed = Some(closed_rx);
|
||||
Ok((fd, node_id, cursor_mode, guard))
|
||||
}
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
|
||||
}
|
||||
@@ -440,10 +556,11 @@ pub(crate) fn stream_existing_output(
|
||||
hw_cursor: bool,
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
let (fd, node_id, cursor_mode, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
cursor_mode: Some(cursor_mode),
|
||||
keepalive: Box::new(stop),
|
||||
})
|
||||
}
|
||||
@@ -793,7 +910,8 @@ fn ensure_xdph_config() -> Result<()> {
|
||||
/// custom picker, no dialog. (Kept separate from wlroots' copy so each wlr-family backend stays
|
||||
/// self-owned per D1; unify if they ever diverge no further.)
|
||||
fn portal_thread(
|
||||
setup_tx: Sender<Result<(OwnedFd, u32), String>>,
|
||||
setup_tx: Sender<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>,
|
||||
closed_tx: Sender<()>,
|
||||
stop: Arc<AtomicBool>,
|
||||
hw_cursor: bool,
|
||||
) {
|
||||
@@ -801,16 +919,15 @@ fn portal_thread(
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
// Multi-thread runtime: the zbus background reader must be pumped across the
|
||||
// create_session → select_sources → start handshake (see capture/linux.rs).
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
// 🛑 The SHARED, never-dropped runtime — NOT a per-cast one. ashpd caches its D-Bus connection
|
||||
// process-globally, and a per-cast runtime takes that connection's background reader down with
|
||||
// it when the cast ends, leaving every later handshake in this process awaiting a reply nothing
|
||||
// is alive to read. That is the whole "the first stream works, the rest are black" bug. See
|
||||
// [`crate::portal_rt`] for the measurement.
|
||||
let rt = match crate::portal_rt::portal_runtime() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
let _ = setup_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -818,9 +935,21 @@ fn portal_thread(
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
|
||||
)?;
|
||||
// Inside the bound below, deliberately: when the cached connection was orphaned this is
|
||||
// where the thread hung — `Screencast::new()` itself, before a single handshake call —
|
||||
// and a bound that started after it reported the caller's generic timeout instead.
|
||||
let connect = async {
|
||||
Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
|
||||
)
|
||||
};
|
||||
let proxy = match tokio::time::timeout(HANDSHAKE_BUDGET, connect).await {
|
||||
Ok(v) => v?,
|
||||
Err(_) => bail!(
|
||||
"connecting to the ScreenCast portal did not return within {}s",
|
||||
HANDSHAKE_BUDGET.as_secs()
|
||||
),
|
||||
};
|
||||
// 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.
|
||||
@@ -829,51 +958,99 @@ fn portal_thread(
|
||||
// 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
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode)
|
||||
// xdph offers MONITOR; the custom picker selects our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (custom picker declined? check the xdph config/shim/selection file)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
// 🛑 BOUNDED, and that bound is load-bearing. `select_sources`/`start` await a D-Bus
|
||||
// reply a wedged portal never sends, and an await that never returns CANNOT be cancelled
|
||||
// by the `stop` flag — the thread never reaches the park loop that reads it. That is how
|
||||
// one host accumulated NINE live cast threads (28 tokio workers) on 2026-08-14: each
|
||||
// timed-out attempt left one behind holding a half-created portal session on this
|
||||
// process's shared D-Bus connection, and from the first hang onwards EVERY later request
|
||||
// from this process hung too — while a freshly-spawned process talking to the very same
|
||||
// portal completed the identical handshake fine. Shorter than the caller's 20 s wait, so
|
||||
// the failure is reported HERE with a reason instead of surfacing as a bare timeout.
|
||||
let handshake = async {
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode.to_ashpd())
|
||||
// xdph offers MONITOR; the custom picker selects our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (custom picker declined? check the xdph config/shim/selection file)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
Ok::<_, anyhow::Error>((session, fd, node_id))
|
||||
};
|
||||
let (session, fd, node_id) =
|
||||
match tokio::time::timeout(HANDSHAKE_BUDGET, handshake).await {
|
||||
Ok(v) => v?,
|
||||
Err(_) => bail!(
|
||||
"the ScreenCast portal did not complete the handshake within {}s — \
|
||||
abandoning it instead of parking this thread on it forever (a hung \
|
||||
request poisons every later one from this process)",
|
||||
HANDSHAKE_BUDGET.as_secs()
|
||||
),
|
||||
};
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.send(Ok((fd, node_id, cursor_mode)))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
// Park, keeping `proxy` + `session` (the zbus connection) alive until stopped — the cast
|
||||
// is torn down when the connection drops.
|
||||
// Park, keeping `proxy` + `session` alive until stopped. Polled at 20 ms rather than the
|
||||
// 200 ms this used to use, because the teardown now WAITS on what follows — every
|
||||
// millisecond here is a millisecond of stream teardown.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
|
||||
// 🛑 CLOSE THE SESSION, AND CLOSE IT *BEFORE* THE OUTPUT GOES AWAY. Dropping the
|
||||
// connection and trusting the peer to notice is what this used to do, and it is not the
|
||||
// contract: xdph destroys a session only on an explicit
|
||||
// `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`). The caller is blocked
|
||||
// in `StopGuard::drop` waiting for the signal below, and only removes the compositor
|
||||
// output afterwards — that ordering is the whole fix; see `StopGuard`.
|
||||
//
|
||||
// Bounded: `close()` goes through xdg-desktop-portal to xdph, and an already-wedged xdph
|
||||
// never answers. Timing out here still signals, so teardown pays the budget once and
|
||||
// moves on rather than hanging on a portal that is already gone.
|
||||
match tokio::time::timeout(CAST_CLOSE_BUDGET, session.close()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => tracing::warn!(
|
||||
error = %e,
|
||||
"closing the ScreenCast session failed — the next cast may find xdph busy"
|
||||
),
|
||||
Err(_) => tracing::warn!(
|
||||
budget_s = CAST_CLOSE_BUDGET.as_secs(),
|
||||
"the ScreenCast portal did not answer Session.Close in time — it is probably \
|
||||
already wedged"
|
||||
),
|
||||
}
|
||||
// Release the teardown. Best-effort: the receiver is gone if the caller already gave up.
|
||||
let _ = closed_tx.send(());
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
@@ -929,9 +1106,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend hands the picker exactly what [`crate::portal_picker`] says — that module owns the
|
||||
/// format and its xdph-parser tests, which run on every platform rather than only this leg.
|
||||
#[test]
|
||||
fn picker_line_carries_the_selection_marker() {
|
||||
// xdph requires the `[SELECTION]` prefix; a bare `screen:NAME` is rejected as strange output.
|
||||
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]screen:PF-1\n");
|
||||
fn picker_line_is_the_shared_selection_format() {
|
||||
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]/screen:PF-1\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1467,6 +1467,9 @@ pub(crate) fn stream_existing_output(
|
||||
node_id,
|
||||
// KWin publishes on the user's own PipeWire daemon — no portal remote to carry.
|
||||
remote_fd: None,
|
||||
// Not an xdg-portal session either: the `zkde_screencast` pointer mode was asked of KWin
|
||||
// directly and KWin honours it, so the request IS the answer.
|
||||
cursor_mode: None,
|
||||
keepalive: Box::new(StopOnDrop(stop)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -602,6 +602,9 @@ pub(crate) fn stream_existing_output(
|
||||
node_id,
|
||||
// Mutter's RecordMonitor node lives on the user's PipeWire daemon (like RecordVirtual).
|
||||
remote_fd: None,
|
||||
// Not an xdg-portal session: `cursor-mode` was set directly on `RecordMonitor` and Mutter
|
||||
// honours it, so the request IS the answer and there is nothing to report back.
|
||||
cursor_mode: None,
|
||||
keepalive: Box::new(guard),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,8 +33,15 @@
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Public because the NEGOTIATED mode is a per-session fact the consumer needs: the host's stream
|
||||
/// loop reads it back off the backend ([`VirtualDisplay::last_portal_cursor_mode`]) to know whether
|
||||
/// `SPA_META_Cursor` can ever arrive on this output. Re-exported as
|
||||
/// [`crate::PortalCursorMode`](crate::PortalCursorMode).
|
||||
///
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`]: crate::VirtualDisplay::last_portal_cursor_mode
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Mode {
|
||||
pub enum Mode {
|
||||
/// No pointer in the cast at all.
|
||||
Hidden = 1,
|
||||
/// The compositor paints the pointer into the frames it hands us.
|
||||
@@ -52,7 +59,7 @@ impl Mode {
|
||||
}
|
||||
|
||||
/// The spelling used in logs and in `PUNKTFUNK_PORTAL_CURSOR_MODE`.
|
||||
pub(crate) const fn name(self) -> &'static str {
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Hidden => "hidden",
|
||||
Mode::Embedded => "embedded",
|
||||
@@ -60,6 +67,20 @@ impl Mode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Can `SPA_META_Cursor` EVER arrive under this mode? Only under [`Metadata`](Mode::Metadata) —
|
||||
/// and this is the whole point of surfacing the negotiated mode.
|
||||
///
|
||||
/// Under `Embedded` the compositor paints the pointer into the frames and sends no cursor
|
||||
/// metadata **regardless of where the pointer is**, so on such a session the absence of a cursor
|
||||
/// overlay carries NO information: not about the pointer's position, not about whether the
|
||||
/// capture is healthy. Consumers that treat "no overlay" as a symptom (the host's seat-pointer
|
||||
/// park schedule, which reads it as "the pointer has not reached the streamed output" — true on
|
||||
/// Mutter, which suppresses metadata while the pointer is off the recorded view) must ask this
|
||||
/// first. Under `Hidden` there is no pointer at all, so the same holds.
|
||||
pub const fn delivers_metadata(self) -> bool {
|
||||
matches!(self, Mode::Metadata)
|
||||
}
|
||||
|
||||
/// What to ask for instead, best first, when this mode is not advertised.
|
||||
const fn fallbacks(self) -> [Mode; 2] {
|
||||
match self {
|
||||
@@ -185,7 +206,7 @@ pub(crate) fn want(hw_cursor: bool, backend: &str) -> Mode {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Mode {
|
||||
fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
|
||||
pub(crate) fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
|
||||
use ashpd::desktop::screencast::CursorMode;
|
||||
match self {
|
||||
Mode::Hidden => CursorMode::Hidden,
|
||||
@@ -198,12 +219,17 @@ impl Mode {
|
||||
/// 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.
|
||||
///
|
||||
/// Returns OUR [`Mode`], not ashpd's — the caller converts with [`Mode::to_ashpd`] for the request
|
||||
/// and carries the value out of the portal thread, because what was negotiated (as opposed to
|
||||
/// asked for) governs how the session's cursor behaves for its whole life. See
|
||||
/// [`Mode::delivers_metadata`].
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) async fn negotiate(
|
||||
proxy: &ashpd::desktop::screencast::Screencast,
|
||||
hw_cursor: bool,
|
||||
backend: &str,
|
||||
) -> ashpd::desktop::screencast::CursorMode {
|
||||
) -> Mode {
|
||||
let want = want(hw_cursor, backend);
|
||||
let advertised = match proxy.available_cursor_modes().await {
|
||||
Ok(avail) => avail.bits(),
|
||||
@@ -216,7 +242,7 @@ pub(crate) async fn negotiate(
|
||||
error = %e,
|
||||
"ScreenCast: AvailableCursorModes query failed — requesting Embedded cursor"
|
||||
);
|
||||
return Mode::Embedded.to_ashpd();
|
||||
return Mode::Embedded;
|
||||
}
|
||||
};
|
||||
let choice = pick(advertised, want);
|
||||
@@ -238,7 +264,7 @@ pub(crate) async fn negotiate(
|
||||
(requesting it anyway would close the session)"
|
||||
),
|
||||
}
|
||||
choice.mode.to_ashpd()
|
||||
choice.mode
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -286,6 +312,21 @@ mod tests {
|
||||
assert_eq!(c.wanted, Some(Mode::Metadata));
|
||||
}
|
||||
|
||||
/// The consumer-facing half of the same incident: xdph negotiates `3` down to `Embedded`, and
|
||||
/// under Embedded no `SPA_META_Cursor` ever arrives — so a host that reads "no cursor overlay"
|
||||
/// as "the pointer has not reached the streamed output" (true on Mutter, which suppresses
|
||||
/// metadata off-view) re-centres the user's pointer forever. Field report 2026-08-14: the seat
|
||||
/// pointer warped to centre once a second for the full park cap on a working Hyprland stream.
|
||||
#[test]
|
||||
fn only_metadata_can_deliver_a_cursor_overlay() {
|
||||
assert!(Mode::Metadata.delivers_metadata());
|
||||
assert!(!Mode::Embedded.delivers_metadata());
|
||||
assert!(!Mode::Hidden.delivers_metadata());
|
||||
// The negotiated mode is what governs, not the wanted one: this is the exact ladder result
|
||||
// on xdph/xdpw, and it says "no overlay is ever coming" even though metadata was requested.
|
||||
assert!(!pick(3, Mode::Metadata).mode.delivers_metadata());
|
||||
}
|
||||
|
||||
/// The same portal, a session with no cursor channel: already asking for what exists, so the
|
||||
/// fix must not perturb it.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! The line we feed xdg-desktop-portal-hyprland's **custom picker** to select an output headlessly.
|
||||
//!
|
||||
//! xdph has no headless source-selection API: it runs `screencopy:custom_picker_binary` and parses
|
||||
//! one line from its stdout. The Hyprland backend points that at a shim which cats a per-session
|
||||
//! file, and this module is the format of what goes in the file — a wire format with no schema, no
|
||||
//! validation and no error report, whose only observable failure is a line in the portal's log.
|
||||
//!
|
||||
//! Declared unconditionally although only `hyprland.rs` calls it, for the reason `portal_config` and
|
||||
//! `portal_cursor` give: this is pure string handling whose tests are the only place its behaviour
|
||||
//! is checkable without a compositor in front of you, so they run on every platform's CI rather than
|
||||
//! only on the leg that compiles `mod hyprland`. That is not hypothetical here — the bug below
|
||||
//! shipped, and the one test that existed for it passed the whole time.
|
||||
|
||||
/// The picker line selecting monitor `name`: `[SELECTION]<flags>/<selection>`, with **empty flags**.
|
||||
///
|
||||
/// 🛑 THE `/` IS MANDATORY AND WE USED TO OMIT IT. xdph splits the line on the FIRST `/` into flags
|
||||
/// and selection ([xdph 1.3.12] `src/shared/ScreencopyShared.cpp:86-87`):
|
||||
///
|
||||
/// ```text
|
||||
/// const auto FLAGS = SELECTION.substr(0, SELECTION.find_first_of('/'));
|
||||
/// const auto SEL = SELECTION.substr(SELECTION.find_first_of('/') + 1);
|
||||
/// ```
|
||||
///
|
||||
/// With no `/` anywhere, `find_first_of` returns `npos`, so `FLAGS` becomes the WHOLE payload — and
|
||||
/// `SEL` becomes the whole payload too, purely because `npos + 1` wraps to `0`. The output name
|
||||
/// therefore still parsed correctly, which is exactly why this survived: the only thing it broke was
|
||||
/// the flag loop (`:89-94`), which then walked `screen:<name>` one character at a time —
|
||||
///
|
||||
/// ```text
|
||||
/// [screencopy] unknown flag from share-picker: s
|
||||
/// [screencopy] unknown flag from share-picker: c
|
||||
/// [screencopy] unknown flag from share-picker: e … one line per character
|
||||
/// ```
|
||||
///
|
||||
/// — and, because `sc*r*een` contains an `r`, which is xdph's "allow restore token" flag, set
|
||||
/// `data.allowToken = true`. xdph then answered every `Start` with a `restore_data` +
|
||||
/// `persist_mode: 2` we never asked for (we request `PersistMode::DoNot`), which is the
|
||||
/// `[screencopy] Sent restore token to …` on every single session in the field log.
|
||||
///
|
||||
/// The reference picker prints the separator unconditionally
|
||||
/// (`hyprland-share-picker/main.cpp:133-136`):
|
||||
///
|
||||
/// ```text
|
||||
/// std::cout << "[SELECTION]";
|
||||
/// std::cout << (ALLOWTOKENBUTTON->isChecked() ? "r" : "");
|
||||
/// std::cout << "/";
|
||||
/// std::cout << "screen:" << outputName.toStdString() << "\n";
|
||||
/// ```
|
||||
///
|
||||
/// so empty flags are spelled as a bare leading `/`, not as nothing at all.
|
||||
///
|
||||
/// ⚠️ This was NOT the cause of the "only the first stream works" stall — see `hyprland.rs`'s
|
||||
/// `StopGuard` for that. The sessions that streamed fine logged the identical flag spam and the
|
||||
/// identical restore token, so it never discriminated. It is a real bug on its own terms and nothing
|
||||
/// more.
|
||||
///
|
||||
/// The trailing newline is equally load-bearing: xdph does `data.output.pop_back()` unconditionally
|
||||
/// after `SEL.substr(7)` (`:96-100`), so without it the last character of the output name is eaten.
|
||||
///
|
||||
/// [xdph 1.3.12]: https://github.com/hyprwm/xdg-desktop-portal-hyprland/blob/v1.3.12/src/shared/ScreencopyShared.cpp
|
||||
pub(crate) fn selection_line(name: &str) -> String {
|
||||
format!("[SELECTION]/screen:{name}\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// xdph 1.3.12's parser (`ScreencopyShared.cpp:82-100`), transcribed — including the `npos`
|
||||
/// arithmetic, which is the entire subtlety. Returns `(flags, output)`, or `None` where xdph
|
||||
/// would fall through to its interactive picker.
|
||||
///
|
||||
/// Transcribed rather than asserted on the string, because the bug this catches is invisible in
|
||||
/// the string: the old line yielded the RIGHT OUTPUT NAME while handing xdph the whole selection
|
||||
/// as a FLAG STRING. Only running its parser tells the two apart.
|
||||
fn xdph_parse(picker_stdout: &str) -> Option<(String, String)> {
|
||||
// `if (!RETVAL.contains("[SELECTION]")) return data;` — a default `SSelectionData`, i.e.
|
||||
// TYPE_INVALID, which makes `SelectSources` fail.
|
||||
let marker = picker_stdout.find("[SELECTION]")?;
|
||||
let selection = &picker_stdout[marker + "[SELECTION]".len()..];
|
||||
// `substr(0, npos)` is the whole string, and `substr(npos + 1)` is `substr(0)` — also the
|
||||
// whole string. Unsigned wraparound, not a special case in xdph.
|
||||
let (flags, sel) = match selection.find('/') {
|
||||
Some(i) => (&selection[..i], &selection[i + 1..]),
|
||||
None => (selection, selection),
|
||||
};
|
||||
let name = sel.strip_prefix("screen:")?;
|
||||
// `data.output.pop_back()` — unconditional, hence the mandatory trailing newline.
|
||||
let mut output = name.to_string();
|
||||
output.pop();
|
||||
Some((flags.to_string(), output))
|
||||
}
|
||||
|
||||
/// The three load-bearing parts of the line, pinned as bytes.
|
||||
#[test]
|
||||
fn the_line_carries_marker_empty_flags_separator_and_newline() {
|
||||
assert_eq!(selection_line("PF-1"), "[SELECTION]/screen:PF-1\n");
|
||||
}
|
||||
|
||||
/// What xdph actually makes of our line: the exact output, and NO flags.
|
||||
#[test]
|
||||
fn xdph_reads_our_line_as_an_output_with_no_flags() {
|
||||
for name in ["PF-1", "PF-1620-1", "HDMI-A-1", "DP-2"] {
|
||||
let (flags, output) = xdph_parse(&selection_line(name)).expect("xdph parses our line");
|
||||
assert_eq!(output, name, "xdph must recover the exact output name");
|
||||
assert_eq!(flags, "", "we ask for no flags at all");
|
||||
assert!(
|
||||
!flags.contains('r'),
|
||||
"an `r` in the flags makes xdph hand back restore_data + persist_mode=2 we never \
|
||||
requested (Screencopy.cpp:261-267)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The regression itself, so it cannot come back by "simplifying" the leading `/` away: the line
|
||||
/// we used to send parsed the whole selection as flags, `r` included.
|
||||
#[test]
|
||||
fn without_the_separator_the_whole_selection_becomes_flags() {
|
||||
let (flags, output) = xdph_parse("[SELECTION]screen:PF-1620-1\n").expect("still parses");
|
||||
assert_eq!(
|
||||
output, "PF-1620-1",
|
||||
"the output name did survive — which is precisely why this hid for so long"
|
||||
);
|
||||
assert_eq!(
|
||||
flags, "screen:PF-1620-1\n",
|
||||
"…while the entire selection was handed to the flag loop"
|
||||
);
|
||||
assert!(
|
||||
flags.contains('r'),
|
||||
"the `r` of `sc*r*een` is xdph's allow-restore-token flag"
|
||||
);
|
||||
}
|
||||
|
||||
/// Without the trailing newline xdph's unconditional `pop_back()` eats a character of the name —
|
||||
/// a silently wrong output, not an error.
|
||||
#[test]
|
||||
fn the_trailing_newline_is_what_pop_back_consumes() {
|
||||
assert!(selection_line("PF-1620-1").ends_with('\n'));
|
||||
let (_, truncated) = xdph_parse("[SELECTION]/screen:PF-1620-1").expect("parses");
|
||||
assert_eq!(
|
||||
truncated, "PF-1620-",
|
||||
"pop_back() takes the last real character"
|
||||
);
|
||||
}
|
||||
|
||||
/// A line with no marker at all is xdph's documented empty-read fallback: it prompts instead. The
|
||||
/// shim relies on this when no session has written the selection file.
|
||||
#[test]
|
||||
fn an_empty_read_is_not_a_selection() {
|
||||
assert!(xdph_parse("").is_none());
|
||||
assert!(xdph_parse("screen:PF-1\n").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! The ONE tokio runtime every portal handshake runs on, for the life of the process.
|
||||
//!
|
||||
//! 🛑🛑🛑 This exists because of a lifetime bug that cost a full day of misdiagnosis, so the reason
|
||||
//! is written down rather than left to be rediscovered.
|
||||
//!
|
||||
//! ashpd caches its D-Bus connection **process-globally** — `static SESSION: OnceLock<Connection>`
|
||||
//! (ashpd 0.13.13, `src/proxy.rs:27`). The first `Screencast::new()` in the process creates that
|
||||
//! connection, and zbus spawns the connection's background reader as a task **on whichever tokio
|
||||
//! runtime happens to be current at that moment**.
|
||||
//!
|
||||
//! Each backend used to build its own multi-thread runtime per cast and drop it at teardown. So the
|
||||
//! FIRST cast of a host process created the cached connection on a runtime that was then destroyed
|
||||
//! when that cast ended — and the `OnceLock` went on handing the same, now-executor-less connection
|
||||
//! to every later `Screencast::new()`, which then awaited a reply nothing was left alive to read.
|
||||
//!
|
||||
//! MEASURED 2026-08-14 (Hyprland 0.55.4 + xdph 1.3.12): the first cast of a host process streamed;
|
||||
//! every cast after it hung, in a process whose surviving cast thread sat in `futex_do_wait` inside
|
||||
//! runtime shutdown. The discriminator that pins it on us rather than on the compositor stack: a
|
||||
//! freshly spawned process completed the identical handshake against the identical xdph, repeatedly,
|
||||
//! while the long-lived host could complete none — and xdph itself was idle (28 ms of CPU).
|
||||
//!
|
||||
//! ⚠ Therefore: **never build a per-cast runtime, and never drop this one.** A `OnceLock` that is
|
||||
//! only ever read keeps the connection's reader alive for the process lifetime, which is exactly as
|
||||
//! long as the cached connection itself lives. `block_on` takes `&self`, so every cast thread can
|
||||
//! park on this one runtime concurrently.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
/// Build failures are reported to the caller rather than panicking: a host that cannot build a
|
||||
/// runtime should fail the cast with a reason, not abort the process.
|
||||
static PORTAL_RT: OnceLock<std::io::Result<Runtime>> = OnceLock::new();
|
||||
|
||||
/// The shared portal runtime, or the error from trying to build it.
|
||||
///
|
||||
/// Multi-thread with 2 workers: the zbus background reader must be pumped *across* the
|
||||
/// `create_session` → `select_sources` → `start` handshake while a cast thread blocks on it, which a
|
||||
/// current-thread runtime cannot do.
|
||||
pub(crate) fn portal_runtime() -> Result<&'static Runtime, String> {
|
||||
match PORTAL_RT.get_or_init(|| {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.thread_name("punktfunk-portal-rt")
|
||||
.enable_all()
|
||||
.build()
|
||||
}) {
|
||||
Ok(rt) => Ok(rt),
|
||||
Err(e) => Err(format!("build the shared portal runtime: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@
|
||||
//! (`~/.config/xdg-desktop-portal-wlr/config`, written once + portal restarted on change)
|
||||
//! sets `chooser_type=simple` with a `chooser_cmd` that cats the chooser file, which we
|
||||
//! write per session (`Monitor: <NAME>` — xdpw 0.8 parses that prefix strictly).
|
||||
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and
|
||||
//! runs `swaymsg output <NAME> unplug` (headless outputs support unplug since sway 1.8).
|
||||
//! 4. Teardown is RAII **and ordered**: drop closes the ScreenCast session and WAITS for the portal
|
||||
//! to confirm it, and only then runs `swaymsg output <NAME> unplug` (headless outputs support
|
||||
//! unplug since sway 1.8). See [`StopGuard`] — and the long root-cause note on `hyprland.rs`'s
|
||||
//! copy, which is where this was measured.
|
||||
//!
|
||||
//! Requirements: the host runs inside the sway session's environment (`SWAYSOCK` for swaymsg,
|
||||
//! and the portal activation env — `WAYLAND_DISPLAY`/`XDG_CURRENT_DESKTOP=sway` imported into
|
||||
@@ -67,11 +69,18 @@ pub struct WlrootsDisplay {
|
||||
/// 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,
|
||||
/// What the portal actually gave us on the most recent [`create`](VirtualDisplay::create) — see
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
|
||||
/// overlay is never coming instead of inferring it from an absence.
|
||||
last_cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
}
|
||||
|
||||
impl WlrootsDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(WlrootsDisplay { hw_cursor: false })
|
||||
Ok(WlrootsDisplay {
|
||||
hw_cursor: false,
|
||||
last_cursor_mode: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +109,10 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
self.hw_cursor
|
||||
}
|
||||
|
||||
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
|
||||
self.last_cursor_mode
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
warn_topology_is_extend_only();
|
||||
// Snapshot → create → identify, all under CREATE_LOCK. sway names the headless output
|
||||
@@ -146,16 +159,21 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
// its own thread (it parks to keep the cast alive, like the other backends). Serialized:
|
||||
// the chooser is one per-user file, so a concurrent session's write between ours and xdpw's
|
||||
// read would silently capture the wrong output (see `SELECTION_LOCK`).
|
||||
let (fd, node_id, stop) = {
|
||||
let (fd, node_id, cursor_mode, stop) = {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
select_and_cast(&name, self.hw_cursor)?
|
||||
};
|
||||
// Latched for `last_portal_cursor_mode`: xdpw refuses metadata by construction, so this is
|
||||
// `embedded` whatever we asked for, and the session's whole cursor behaviour follows from
|
||||
// that fact rather than from `hw_cursor`.
|
||||
self.last_cursor_mode = Some(cursor_mode);
|
||||
tracing::info!(
|
||||
node_id,
|
||||
output = %name,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz,
|
||||
cursor = cursor_mode.name(),
|
||||
"sway headless output ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
@@ -173,24 +191,75 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// Same EXTEND problem as Hyprland: on a sway session with real heads this `HEADLESS-N`
|
||||
// sits beside them, and absolute input must be aimed at it by name. `swaymsg`'s output
|
||||
// name is the head's `wl_output.name`, which is what the injector matches.
|
||||
output_name: Some(name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast),
|
||||
/// then unplug the output (fields drop in declaration order).
|
||||
/// Drop order matters, and it is the whole fix: [`StopGuard`] **blocks until the ScreenCast session
|
||||
/// is actually closed**, and only then does [`OutputGuard`] unplug the output (fields drop in
|
||||
/// declaration order). This used to unplug first — see [`StopGuard`].
|
||||
struct Keepalive {
|
||||
_stop: StopGuard,
|
||||
_output: OutputGuard,
|
||||
}
|
||||
|
||||
/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal
|
||||
/// then tears the screencast session down.
|
||||
struct StopGuard(Arc<AtomicBool>);
|
||||
/// How long teardown waits for the portal to confirm the ScreenCast session is closed before giving
|
||||
/// up and unplugging the output anyway. See `hyprland.rs`'s twin.
|
||||
const CAST_CLOSE_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Ceiling on the whole ScreenCast handshake, under the caller's 20 s wait — see the note at the
|
||||
/// handshake, and the longer one on `hyprland.rs`'s copy.
|
||||
const HANDSHAKE_BUDGET: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Ends the cast: signals the portal thread, then **waits for it to have closed the ScreenCast
|
||||
/// session**, so the caller may safely unplug the output afterwards.
|
||||
///
|
||||
/// 🛑 THE WAIT IS THE POINT. Root-caused on the Hyprland leg (see the long note on `hyprland.rs`'s
|
||||
/// `StopGuard`, which carries the measurements); the defect is the same here, and this is NOT an
|
||||
/// assumption of symmetry — xdpw was read to confirm it, against `emersion/xdg-desktop-portal-wlr`:
|
||||
///
|
||||
/// * **Only `Close` tears a session down.** `src/core/session.c` gives the session object exactly
|
||||
/// one method — `SD_BUS_METHOD("Close", …, method_close, …)` — and nothing else calls
|
||||
/// `xdpw_session_destroy` for a live cast. Like xdph, xdpw has no peer-vanished watcher of its own
|
||||
/// and depends entirely on xdg-desktop-portal's `peer_died_cb` calling `Close` for us, which
|
||||
/// happens only after our bus name goes away, asynchronously, and therefore after the old
|
||||
/// `StopGuard` had already let `OutputGuard` unplug the output.
|
||||
/// * **The same unbounded busy-wait is waiting for it.** `src/screencast/screencast.c:599-605`:
|
||||
/// `while (cast->node_id == SPA_ID_INVALID) { pw_loop_iterate(state->pw_loop, 0); }` — timeout 0,
|
||||
/// i.e. non-blocking, i.e. a hot spin on the portal's only loop with no escape if the stream never
|
||||
/// gets a node id. xdph's copy (`Screencopy.cpp:307-313`) is this code; that is the one measured
|
||||
/// pinning a core solid until it was restarted.
|
||||
///
|
||||
/// So sway's `output unplug` yanks a captured output out from under a live session exactly the way
|
||||
/// Hyprland's `output remove` did. Whether xdpw wedges *identically* has not been observed on glass
|
||||
/// — no sway box was available — but the two preconditions are present in its source, and closing
|
||||
/// the session before unplugging is the correct order regardless of what the backend does with it.
|
||||
struct StopGuard {
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Signalled by the portal thread once it has closed the ScreenCast session. `None` when no cast
|
||||
/// was ever established — nothing to close, and nothing worth spending the budget on.
|
||||
closed: Option<std::sync::mpsc::Receiver<()>>,
|
||||
}
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
let Some(closed) = self.closed.take() else {
|
||||
return;
|
||||
};
|
||||
match closed.recv_timeout(CAST_CLOSE_BUDGET) {
|
||||
// Closed, or the thread is gone without confirming — either way nothing holds the cast.
|
||||
Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => tracing::warn!(
|
||||
budget_s = CAST_CLOSE_BUDGET.as_secs(),
|
||||
"the ScreenCast session did not close in time — unplugging the output underneath \
|
||||
it; the next cast may find the portal busy"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +415,10 @@ impl Drop for ChooserFile {
|
||||
|
||||
/// Point xdpw's chooser at `output` and run the ScreenCast handshake, returning the portal fd +
|
||||
/// node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopGuard)> {
|
||||
fn select_and_cast(
|
||||
output: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<(OwnedFd, u32, crate::portal_cursor::Mode, StopGuard)> {
|
||||
ensure_xdpw_config()?;
|
||||
let chooser = chooser_file();
|
||||
std::fs::write(&chooser, format!("Monitor: {output}\n"))
|
||||
@@ -354,12 +426,21 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
|
||||
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
|
||||
// only thing that reads it.
|
||||
let _chooser = ChooserFile(chooser);
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
// The NEGOTIATED cursor mode rides back with the fd and node id: it is decided inside the
|
||||
// portal thread (only there is the proxy to ask), and nothing downstream can re-derive it —
|
||||
// `hw_cursor` is the request, not the answer.
|
||||
let (setup_tx, setup_rx) =
|
||||
std::sync::mpsc::channel::<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>();
|
||||
// The teardown handshake: the thread signals this once it has closed the ScreenCast session, and
|
||||
// `StopGuard::drop` waits on it before the output is unplugged (see `StopGuard`). Kept a
|
||||
// SEPARATE channel from the setup one above — it fires at the other end of the cast's life,
|
||||
// long after `setup_rx` has been consumed.
|
||||
let (closed_tx, closed_rx) = std::sync::mpsc::channel::<()>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-wlr-cast".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.spawn(move || portal_thread(setup_tx, closed_tx, stop_thread, hw_cursor))
|
||||
.context("spawn wlroots portal thread")?;
|
||||
// Built BEFORE the wait so EVERY error arm below sets the flag on its way out — as Mutter's
|
||||
// `create` does. Returning the bare `Arc` and letting the CALLER wrap it left the two failure
|
||||
@@ -368,9 +449,13 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
|
||||
// parks forever on `while !stop`, holding a live ScreenCast session, its zbus connection, an
|
||||
// `OwnedFd` and a 2-worker tokio runtime — one more set per slow-portal connect, for the host's
|
||||
// lifetime, against an output that no longer exists.
|
||||
let guard = StopGuard(stop);
|
||||
let mut guard = StopGuard { stop, closed: None };
|
||||
match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, guard)),
|
||||
Ok(Ok((fd, node_id, cursor_mode))) => {
|
||||
// A cast exists now, so teardown has something to close and must wait for it.
|
||||
guard.closed = Some(closed_rx);
|
||||
Ok((fd, node_id, cursor_mode, guard))
|
||||
}
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
|
||||
}
|
||||
@@ -387,10 +472,11 @@ pub(crate) fn stream_existing_output(
|
||||
hw_cursor: bool,
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
let (fd, node_id, cursor_mode, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
cursor_mode: Some(cursor_mode),
|
||||
keepalive: Box::new(stop),
|
||||
})
|
||||
}
|
||||
@@ -513,7 +599,8 @@ fn ensure_xdpw_config() -> Result<()> {
|
||||
/// reports the fd + node id and parks until stopped — the zbus connection is the cast's
|
||||
/// lifetime). xdpw answers the source selection via the chooser, no dialog.
|
||||
fn portal_thread(
|
||||
setup_tx: Sender<Result<(OwnedFd, u32), String>>,
|
||||
setup_tx: Sender<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>,
|
||||
closed_tx: Sender<()>,
|
||||
stop: Arc<AtomicBool>,
|
||||
hw_cursor: bool,
|
||||
) {
|
||||
@@ -523,14 +610,13 @@ fn portal_thread(
|
||||
|
||||
// Multi-thread runtime: the zbus background reader must be pumped across the
|
||||
// create_session → select_sources → start handshake (see capture/linux.rs).
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
// The SHARED, never-dropped runtime — see [`crate::portal_rt`] and the long note on
|
||||
// `hyprland.rs`'s copy: a per-cast runtime kills ashpd's process-global cached connection when
|
||||
// the cast ends, and every later handshake in the process then hangs.
|
||||
let rt = match crate::portal_rt::portal_runtime() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
let _ = setup_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -538,9 +624,20 @@ fn portal_thread(
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
|
||||
)?;
|
||||
// Bounded, like `hyprland.rs`'s copy: an orphaned cached connection hangs HERE, before
|
||||
// any handshake call, so a bound that starts later never fires.
|
||||
let connect = async {
|
||||
Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
|
||||
)
|
||||
};
|
||||
let proxy = match tokio::time::timeout(HANDSHAKE_BUDGET, connect).await {
|
||||
Ok(v) => v?,
|
||||
Err(_) => bail!(
|
||||
"connecting to the ScreenCast portal did not return within {}s",
|
||||
HANDSHAKE_BUDGET.as_secs()
|
||||
),
|
||||
};
|
||||
// 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 —
|
||||
@@ -549,51 +646,91 @@ fn portal_thread(
|
||||
// — 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
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode)
|
||||
// xdpw offers MONITOR only; the chooser picks our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (chooser declined? check the xdpw config/chooser file)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
// Bounded for the same reason as `hyprland.rs`'s copy (the long note lives there): an
|
||||
// await on a wedged portal never returns, the `stop` flag is only read by the park loop
|
||||
// further down, so the thread leaks — and a leaked half-handshake poisons every later
|
||||
// portal request from this process. xdpw has the identical unbounded node-id spin as
|
||||
// xdph (`screencast.c`), so it can wedge the same way.
|
||||
let handshake = async {
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode.to_ashpd())
|
||||
// xdpw offers MONITOR only; the chooser picks our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context(
|
||||
"start response (chooser declined? check the xdpw config/chooser file)",
|
||||
)?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
Ok::<_, anyhow::Error>((session, fd, node_id))
|
||||
};
|
||||
let (session, fd, node_id) =
|
||||
match tokio::time::timeout(HANDSHAKE_BUDGET, handshake).await {
|
||||
Ok(v) => v?,
|
||||
Err(_) => bail!(
|
||||
"the ScreenCast portal did not complete the handshake within {}s — \
|
||||
abandoning it instead of parking this thread on it forever (a hung \
|
||||
request poisons every later one from this process)",
|
||||
HANDSHAKE_BUDGET.as_secs()
|
||||
),
|
||||
};
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.send(Ok((fd, node_id, cursor_mode)))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
// Park, keeping `proxy` + `session` (the zbus connection) alive until stopped —
|
||||
// the cast is torn down when the connection drops.
|
||||
// Park, keeping `proxy` + `session` alive until stopped. Polled at 20 ms rather than the
|
||||
// 200 ms this used to use, because teardown now WAITS on what follows.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
|
||||
// 🛑 CLOSE THE SESSION, AND CLOSE IT *BEFORE* THE OUTPUT IS UNPLUGGED. `Session.Close` is
|
||||
// the only thing that ends an xdpw session (`src/core/session.c`); dropping the
|
||||
// connection and trusting the peer to notice is not the contract. The caller is blocked
|
||||
// in `StopGuard::drop` on the signal below — see `StopGuard`. Bounded, so an
|
||||
// already-wedged portal cannot hang teardown with it.
|
||||
match tokio::time::timeout(CAST_CLOSE_BUDGET, session.close()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => tracing::warn!(
|
||||
error = %e,
|
||||
"closing the ScreenCast session failed — the next cast may find the portal busy"
|
||||
),
|
||||
Err(_) => tracing::warn!(
|
||||
budget_s = CAST_CLOSE_BUDGET.as_secs(),
|
||||
"the ScreenCast portal did not answer Session.Close in time — it is probably \
|
||||
already wedged"
|
||||
),
|
||||
}
|
||||
// Release the teardown. Best-effort: the receiver is gone if the caller already gave up.
|
||||
let _ = closed_tx.send(());
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
@@ -31,6 +31,12 @@ use anyhow::{bail, Context, Result};
|
||||
pub(crate) struct MirrorStream {
|
||||
pub node_id: u32,
|
||||
pub remote_fd: Option<std::os::fd::OwnedFd>,
|
||||
/// The cursor mode the xdg ScreenCast portal NEGOTIATED for this recording, for the two
|
||||
/// portal-based backends; `None` for the compositor-protocol ones (KWin/Mutter/gamescope),
|
||||
/// which get what they ask for. Reported on to the host as
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`] — same split as `remote_fd` above, and for the
|
||||
/// same reason: only the portal path has an answer that can differ from the request.
|
||||
pub cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
/// Dropping this ends the recording. It never owns the monitor — we did not create it.
|
||||
pub keepalive: Box<dyn Send>,
|
||||
}
|
||||
@@ -40,6 +46,9 @@ pub struct MirrorDisplay {
|
||||
compositor: Compositor,
|
||||
connector: String,
|
||||
hw_cursor: bool,
|
||||
/// What the portal gave the most recent [`create`](VirtualDisplay::create), when this mirror
|
||||
/// delegated to a portal-based backend. See [`VirtualDisplay::last_portal_cursor_mode`].
|
||||
last_cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
}
|
||||
|
||||
impl MirrorDisplay {
|
||||
@@ -48,6 +57,7 @@ impl MirrorDisplay {
|
||||
compositor,
|
||||
connector,
|
||||
hw_cursor: false,
|
||||
last_cursor_mode: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,6 +75,10 @@ impl VirtualDisplay for MirrorDisplay {
|
||||
self.hw_cursor
|
||||
}
|
||||
|
||||
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
|
||||
self.last_cursor_mode
|
||||
}
|
||||
|
||||
fn poolable_now(&self) -> bool {
|
||||
// Never. `create` below always reports `DisplayOwnership::External` — we did not make this
|
||||
// head and must not keep it — so the registry never pools a mirror, and the trait's `true`
|
||||
@@ -125,10 +139,15 @@ impl VirtualDisplay for MirrorDisplay {
|
||||
),
|
||||
};
|
||||
|
||||
// Latched for `last_portal_cursor_mode` — the delegate's verdict is this mirror's verdict.
|
||||
self.last_cursor_mode = stream.cursor_mode;
|
||||
|
||||
// NOTE: aiming absolute input at this head is the HOST's job, not ours — this crate must
|
||||
// not depend on pf-inject (see the crate doc: "never on capture/inject"). The host sets the
|
||||
// anchor from the same pin at startup; §7.2 of the design doc explains why it is host-level
|
||||
// rather than set here per session.
|
||||
// rather than set here per session. We only CARRY the head's name out (`output_name`
|
||||
// below), which is what the wlr injector needs to bind its virtual pointer to this head —
|
||||
// the libei anchor above cannot serve it, because that backend selects by region.
|
||||
tracing::info!(
|
||||
connector = %target.connector,
|
||||
mode = %target.mode_label(),
|
||||
@@ -145,6 +164,9 @@ impl VirtualDisplay for MirrorDisplay {
|
||||
out.remote_fd = stream.remote_fd;
|
||||
// Never pooled, never lingered, never made primary/exclusive: we don't own this head.
|
||||
out.ownership = DisplayOwnership::External;
|
||||
// The head absolute input maps into is the one we mirror — its connector IS its
|
||||
// `wl_output.name` on the wlroots/Hyprland backends, where the injector matches on it.
|
||||
out.output_name = Some(target.connector.clone());
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,14 @@ mod pool {
|
||||
pub(super) keepalive: Box<dyn Send>,
|
||||
pub(super) node_id: u32,
|
||||
pub(super) preferred_mode: Option<(u32, u32, u32)>,
|
||||
/// The compositor's name for this output ([`VirtualOutput::output_name`]) — the identity the
|
||||
/// host aims absolute input with. Kept across a keep-alive reuse for the same reason
|
||||
/// `preferred_mode` is: the reused display IS the same head, so the output the caller is
|
||||
/// handed must answer with the same name a fresh create would. No poolable backend sets it
|
||||
/// today — the ones that do are all passed through unpooled (Hyprland/sway carry a portal
|
||||
/// fd, a mirror is `External`) — so this only exists so that stops being a silent trap the
|
||||
/// day one does.
|
||||
pub(super) output_name: Option<String>,
|
||||
pub(super) mode: Mode,
|
||||
pub(super) backend: &'static str,
|
||||
/// The identity slot the backend resolved for this display (KWin per-slot naming; `None` for
|
||||
@@ -601,6 +609,7 @@ mod pool {
|
||||
keepalive: Box::new(()),
|
||||
node_id: 0,
|
||||
preferred_mode: None,
|
||||
output_name: None,
|
||||
mode: Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
@@ -1083,6 +1092,7 @@ mod linux {
|
||||
fn output_for(
|
||||
node_id: u32,
|
||||
preferred_mode: Option<(u32, u32, u32)>,
|
||||
output_name: Option<String>,
|
||||
generation: u64,
|
||||
quit: Arc<AtomicBool>,
|
||||
reused: bool,
|
||||
@@ -1093,6 +1103,8 @@ mod linux {
|
||||
preferred_mode,
|
||||
Box::new(DisplayLease { generation, quit }),
|
||||
);
|
||||
// The head is the same one the entry was created for, so it answers with the same name.
|
||||
out.output_name = output_name;
|
||||
// A2: tell the pipeline builder this was a REUSED kept display, so a first-frame failure can
|
||||
// `mark_failed(generation)` (tear the corpse down) rather than re-wedge the retry loop on the same node.
|
||||
out.reused_gen = reused.then_some(generation);
|
||||
@@ -1176,6 +1188,7 @@ mod linux {
|
||||
let generation = r.generation.fetch_add(1, Ordering::Relaxed);
|
||||
es[idx].generation = generation;
|
||||
let preferred_mode = es[idx].preferred_mode;
|
||||
let output_name = es[idx].output_name.clone();
|
||||
tracing::info!(
|
||||
backend,
|
||||
node_id,
|
||||
@@ -1184,6 +1197,7 @@ mod linux {
|
||||
ReuseOutcome::Reused(output_for(
|
||||
node_id,
|
||||
preferred_mode,
|
||||
output_name,
|
||||
generation,
|
||||
quit.clone(),
|
||||
true,
|
||||
@@ -1279,6 +1293,7 @@ mod linux {
|
||||
|
||||
let node_id = real.node_id;
|
||||
let preferred_mode = real.preferred_mode;
|
||||
let output_name = real.output_name.clone();
|
||||
// Fresh creates only: the backend may have birthed the output at a sacrificial mode whose
|
||||
// stream must renegotiate before frames count (KWin >60 Hz — see backend.rs). A REUSED kept
|
||||
// display already renegotiated in its prior session (the producer's rebuilt offer persists
|
||||
@@ -1295,6 +1310,7 @@ mod linux {
|
||||
keepalive: real.keepalive,
|
||||
node_id,
|
||||
preferred_mode,
|
||||
output_name: output_name.clone(),
|
||||
mode,
|
||||
backend,
|
||||
identity_slot,
|
||||
@@ -1349,7 +1365,14 @@ mod linux {
|
||||
if (position.x, position.y) != (0, 0) {
|
||||
vd.apply_position(position.x, position.y);
|
||||
}
|
||||
let mut out = output_for(node_id, preferred_mode, generation, quit, false);
|
||||
let mut out = output_for(
|
||||
node_id,
|
||||
preferred_mode,
|
||||
output_name,
|
||||
generation,
|
||||
quit,
|
||||
false,
|
||||
);
|
||||
out.expect_exact_dims = expect_exact_dims;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -135,6 +135,17 @@ pub fn capture_virtual_output(
|
||||
// handshake already resolved that through [`capturer_supports_hdr_for`] before the Welcome,
|
||||
// so passing it through here is the whole of this arm's HDR logic. It used to be dropped on
|
||||
// the floor, which is what kept the Linux native plane at 8 bits.
|
||||
//
|
||||
// Aim the wlr injector's absolute mapping (abs-mouse, and `park_pointer`'s opening warp) at
|
||||
// THIS head — the Linux counterpart of the `set_stream_target` call in the Windows arm below.
|
||||
// The wlroots virtual pointer maps `motion_absolute` onto the `wl_output` it was created with,
|
||||
// and on the EXTEND backends (Hyprland, sway) the streamed head sits BESIDE the operator's, so
|
||||
// without this every absolute sample landed on their screen and the cursor never entered the
|
||||
// stream at all. `None` (KWin/Mutter/gamescope, none of which inject through that backend)
|
||||
// CLEARS the slot rather than leaving a stale name: one compositor serves the whole host, so a
|
||||
// `None` here means no session on this host wants a named binding — e.g. a Game-Mode switch
|
||||
// from a Hyprland desktop to gamescope, after which the old `PF-…` name means nothing.
|
||||
crate::inject::set_stream_output(vout.output_name.clone());
|
||||
pf_capture::open_virtual_output(
|
||||
vout.remote_fd,
|
||||
vout.node_id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user