Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6776bff82d | ||
|
|
f04da20f0d | ||
|
|
60b652d5e2 | ||
|
|
fb61e69a3e | ||
|
|
0e8149fa9c | ||
|
|
3fd0a94528 | ||
|
|
2e753fd84d | ||
|
|
08a06ff295 | ||
|
|
a7ef62241b | ||
|
|
ff513a18e1 | ||
|
|
e207d0d73e | ||
|
|
7d9d7c3814 | ||
|
|
424621f1ce | ||
|
|
925b07ec10 | ||
|
|
5e77ec42c6 | ||
|
|
704defe0a7 | ||
|
|
42a257e013 | ||
|
|
04b6f84fc3 | ||
|
|
054bf26393 | ||
|
|
5ae08ffaa5 | ||
|
|
a17571c6bf | ||
|
|
97f81a6ea6 | ||
|
|
b8ec8ea260 | ||
|
|
4aeec6051d | ||
|
|
da13d14159 | ||
|
|
bb781ca694 | ||
|
|
662df795b2 | ||
|
|
9a05750376 | ||
|
|
6cd25b4829 | ||
|
|
4369e3b2ec | ||
|
|
ff33eb872e | ||
|
|
6ada74066d | ||
|
|
59d8b8a677 | ||
|
|
6b3e793b39 | ||
|
|
ab55dd4e39 | ||
|
|
ab8fa46b66 | ||
|
|
c32fad8aee | ||
|
|
6753641c5e | ||
|
|
19411d8d6d | ||
|
|
b2e6debb22 | ||
|
|
1bed82423d | ||
|
|
ee6dff116c | ||
|
|
25487a8bd4 | ||
|
|
97fe3a0ff5 | ||
|
|
00f9c1f4d3 | ||
|
|
18f595698c | ||
|
|
8ae524d801 | ||
|
|
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 | ||
|
|
79114891df | ||
|
|
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
|
||||
|
||||
+16
-4
@@ -114,8 +114,14 @@ jobs:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
# Namespaced `-ci-` so a fork PR's cargo-home cache can never land in the `cargo-home-`
|
||||
# pool the SIGNED release builds (deb.yml / android.yml) restore: registry/src holds
|
||||
# already-extracted crate sources that cargo compiles WITHOUT re-checksumming past
|
||||
# `.cargo-ok`, so a poisoned entry would be arbitrary Rust source compiled into a release
|
||||
# artifact with no Cargo.lock diff. security-review 2026-08-15 finding 5. (The definitive
|
||||
# control is operator-side: Gitea's "require approval for fork PRs".)
|
||||
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-ci-
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
@@ -258,8 +264,14 @@ jobs:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
# Namespaced `-ci-` so a fork PR's cargo-home cache can never land in the `cargo-home-`
|
||||
# pool the SIGNED release builds (deb.yml / android.yml) restore: registry/src holds
|
||||
# already-extracted crate sources that cargo compiles WITHOUT re-checksumming past
|
||||
# `.cargo-ok`, so a poisoned entry would be arbitrary Rust source compiled into a release
|
||||
# artifact with no Cargo.lock diff. security-review 2026-08-15 finding 5. (The definitive
|
||||
# control is operator-side: Gitea's "require approval for fork PRs".)
|
||||
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-ci-
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
|
||||
@@ -363,6 +363,12 @@ jobs:
|
||||
envs: REGISTRY_TOKEN
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# Log out on EVERY exit path: unlike the ephemeral LAN-registry runners, this is a
|
||||
# long-lived internet-facing VM, so a `write:package` PAT left base64-encoded in
|
||||
# ~/.docker/config.json is credential-at-rest on the most exposed host in the estate.
|
||||
# The LAN jobs above already `docker logout`; this one omitted it. security-review
|
||||
# 2026-08-15 finding 14.
|
||||
trap 'docker logout git.unom.io || true' EXIT
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login git.unom.io -u enricobuehler --password-stdin
|
||||
cd ~/punktfunk-docs
|
||||
docker compose -f compose.production.yml pull docs
|
||||
|
||||
@@ -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
|
||||
@@ -291,9 +297,17 @@ jobs:
|
||||
# so the installer ships just bun + a ~75-file .output instead of node + a node_modules forest.
|
||||
$ver = 'bun-v1.3.14'
|
||||
$url = "https://github.com/oven-sh/bun/releases/download/$ver/bun-windows-x64.zip"
|
||||
# SHA-256 of this exact asset, pinned. GitHub release assets are MUTABLE at a fixed URL, so
|
||||
# the tag alone vouches for nothing — this binary is Authenticode-signed into our installer
|
||||
# and its hash published in the Ed25519 update manifest, i.e. our signature vouches for bytes
|
||||
# we downloaded. Verify them. On a bun bump, update BOTH $ver and $sha (compute:
|
||||
# `shasum -a 256 bun-windows-x64.zip`). security-review 2026-08-15 finding 12.
|
||||
$sha = '0a0620930b6675d7ba440e81f4e0e00d3cfbe096c4b140d3fff02205e9e18922'
|
||||
New-Item -ItemType Directory -Force -Path C:\t | Out-Null
|
||||
$zip = 'C:\t\bun.zip'; $dst = 'C:\t\bundist'
|
||||
Invoke-WebRequest -Uri $url -OutFile $zip
|
||||
$got = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower()
|
||||
if ($got -ne $sha) { throw "bun zip sha256 mismatch for ${ver}: got $got, pinned $sha" }
|
||||
if (Test-Path $dst) { Remove-Item $dst -Recurse -Force }
|
||||
Expand-Archive -Path $zip -DestinationPath $dst -Force
|
||||
$bun = (Get-ChildItem -Path $dst -Recurse -Filter bun.exe | Select-Object -First 1).FullName
|
||||
@@ -415,12 +429,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 +480,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.**
|
||||
|
||||
+834
-5
@@ -10,9 +10,242 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.28.0"
|
||||
"version": "0.29.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"logs"
|
||||
],
|
||||
"summary": "List uploaded client log bundles",
|
||||
"description": "Every stored bundle's metadata, newest first.",
|
||||
"operationId": "clientLogsList",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Stored bundles, newest first",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ClientLogMeta"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"logs"
|
||||
],
|
||||
"summary": "Upload a client log bundle",
|
||||
"description": "A PAIRED DEVICE posts its recent client log as plain text, authenticated by its streaming\ncertificate (the same mTLS identity it pairs and streams with) — no bearer token. Bundles are\ncapped at 1 MiB and only the newest few per device are kept. The operator downloads them from\nthe console's Logs page. This is deliberately write-only for devices: uploading grants no read.",
|
||||
"operationId": "clientLogsUpload",
|
||||
"requestBody": {
|
||||
"description": "The client's log text",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Bundle stored",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ClientLogUploaded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "No paired-device certificate on the connection",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "The device's access has expired (per-client access)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Bundle exceeds the size cap",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Empty body",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not store the bundle",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/client-logs/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"logs"
|
||||
],
|
||||
"summary": "Download a client log bundle",
|
||||
"description": "The bundle body as plain text, for saving or attaching to a report.",
|
||||
"operationId": "clientLogsGet",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The bundle id (its filename stem)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The bundle body",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No bundle with that id",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "The bundle file is unreadable",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"logs"
|
||||
],
|
||||
"summary": "Delete a client log bundle",
|
||||
"description": "Removes the bundle `id` from disk. `404` if there is no such bundle.",
|
||||
"operationId": "clientLogsDelete",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The bundle id (its filename stem)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Bundle deleted"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No bundle with that id",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not delete the bundle",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/clients": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -168,6 +401,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/diagnostics": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"diagnostics"
|
||||
],
|
||||
"summary": "Host health checks",
|
||||
"description": "Every verdict this host computes about its own health — group membership the managed takeover\nneeds, the input device nodes virtual controllers are built on, competing streaming servers —\nwith the impact and a copy-pasteable remedy for each.\n\nCached: the probes run once at startup and on demand via `POST /diagnostics/refresh`, so this is\ncheap to poll. Checks whose status is `ok` and `inapplicable` are included — a troubleshooting\npage needs to show what is working and to answer \"why isn't this check relevant here?\".\n\n`summary`, `impact` and `remedy.text` are always present in English. A console that recognizes\nthe check's `id` replaces them with a localized string interpolated from `params`; one that does\nnot renders the wire text as-is, which is what keeps a console paired with a newer host readable.",
|
||||
"operationId": "getDiagnostics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The current verdicts, worst-first",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DiagnosticsReport"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/diagnostics/refresh": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"diagnostics"
|
||||
],
|
||||
"summary": "Re-run the health checks",
|
||||
"description": "Runs every probe again and returns the refreshed verdicts. Most checks describe state that only\nchanges when an operator changes it (a group membership, an installed udev rule), so this exists\nfor exactly the moment after they have done so — a \"did that fix it?\" button, not a poll.",
|
||||
"operationId": "refreshDiagnostics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The refreshed verdicts, worst-first",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DiagnosticsReport"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/display/layout": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -1903,6 +2200,97 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"native"
|
||||
],
|
||||
"summary": "Update a native client's access",
|
||||
"description": "Partial edit of a paired device's grants/expiry (the console edit sheet: preset change,\nextend, \"expire now\", make permanent). Omitted fields keep their current value; the edit\nreaches the device's live sessions immediately. Not a way to pair a device (404 when the\nfingerprint isn't in the trust store).",
|
||||
"operationId": "updateNativeClientAccess",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "fingerprint",
|
||||
"in": "path",
|
||||
"description": "Hex SHA-256 of the client certificate (case-insensitive)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateNativeAccess"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Access updated; the stored record as now in force",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NativeClient"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Reserved grant bits set, or expires_in_secs together with clear_expiry",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No paired native client with that fingerprint",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the trust store",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Native host not enabled",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/native/pair": {
|
||||
@@ -1976,7 +2364,7 @@
|
||||
"native"
|
||||
],
|
||||
"summary": "Arm native pairing",
|
||||
"description": "Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list.",
|
||||
"description": "Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list. An access choice\n(`grants` / `expires_in_secs`) applies to whichever device completes this window's ceremony.",
|
||||
"operationId": "armNativePairing",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -1999,6 +2387,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Reserved grant bits set",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
@@ -2063,7 +2461,7 @@
|
||||
"native"
|
||||
],
|
||||
"summary": "Approve a pending device",
|
||||
"description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it via the body; send `{}` to keep the name it knocked with.",
|
||||
"description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it and/or choose its access via the body; send `{}` to keep the name it knocked with\nand its existing access (full/permanent for a first pairing). The response is the stored\nrecord — what is actually in force, not necessarily this request's inputs.",
|
||||
"operationId": "approvePendingDevice",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -2090,7 +2488,7 @@
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Device paired",
|
||||
"description": "Device paired; the stored record as now in force",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2099,6 +2497,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Reserved grant bits set",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
@@ -4128,8 +4536,28 @@
|
||||
},
|
||||
"ApprovePending": {
|
||||
"type": "object",
|
||||
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
|
||||
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name and — for a\nre-approved device — its existing access (the full/permanent default for a first pairing).",
|
||||
"properties": {
|
||||
"expires_in_secs": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Access expiry in seconds **from now** (relative — the host stores the absolute deadline\nand stamps the grant time). Alone, it means full control until then.",
|
||||
"example": 14400,
|
||||
"minimum": 0
|
||||
},
|
||||
"grants": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "Access choice: grant bitmask (`GRANT_*` bits 0–5). Reserved bits are a 400. Omitting BOTH\naccess fields keeps a re-approved device's stored access; `grants` without\n`expires_in_secs` grants permanently.",
|
||||
"example": 1,
|
||||
"minimum": 0
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4144,6 +4572,16 @@
|
||||
"type": "object",
|
||||
"description": "Arm-native-pairing request body.",
|
||||
"properties": {
|
||||
"expires_in_secs": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Optional access expiry for the pairing device, in seconds **from now** (relative — the\nhost stores the absolute deadline). NOT the pairing window's length; that is `ttl_secs`.\nOmit for permanent access (when `grants` is set) or preserved access (when neither is).",
|
||||
"example": 14400,
|
||||
"minimum": 0
|
||||
},
|
||||
"fingerprint": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4152,6 +4590,16 @@
|
||||
"description": "Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).\nWhen set, only a pairing attempt from that fingerprint consumes the window — so an unpaired\nLAN peer can neither pair nor burn a window armed for a specific device (security-review #9).\nOmit for an unbound window (any device may use the PIN — trusted-LAN only).",
|
||||
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||
},
|
||||
"grants": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "Optional access choice for whichever device completes this window's ceremony: a grant\nbitmask (`GRANT_*` bits 0–5). Reserved bits are a 400. Omit (with `expires_in_secs`) for\ntoday's behavior — a new device gets full control, a re-pairing device keeps what it has.",
|
||||
"example": 1,
|
||||
"minimum": 0
|
||||
},
|
||||
"ttl_secs": {
|
||||
"type": [
|
||||
"integer",
|
||||
@@ -4518,6 +4966,75 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CheckSource": {
|
||||
"type": "string",
|
||||
"description": "Where a verdict came from. `Event` is reserved for the live feeds (transitions push instead of\nwaiting for a refresh); v1 produces only `Startup` and `Refresh`.",
|
||||
"enum": [
|
||||
"startup",
|
||||
"event",
|
||||
"refresh"
|
||||
]
|
||||
},
|
||||
"CheckStatus": {
|
||||
"type": "string",
|
||||
"description": "What a probe found. `Inapplicable` is deliberately distinct from `Ok`: \"this box will never do\nthe thing\" and \"the thing works here\" are different answers, and the troubleshooting page shows\nthem differently.",
|
||||
"enum": [
|
||||
"ok",
|
||||
"warn",
|
||||
"fail",
|
||||
"inapplicable"
|
||||
]
|
||||
},
|
||||
"ClientLogMeta": {
|
||||
"type": "object",
|
||||
"description": "One stored bundle, as the console lists it.",
|
||||
"required": [
|
||||
"id",
|
||||
"device_name",
|
||||
"fingerprint_prefix",
|
||||
"received_ms",
|
||||
"size_bytes"
|
||||
],
|
||||
"properties": {
|
||||
"device_name": {
|
||||
"type": "string",
|
||||
"description": "The paired device's name at upload time (sanitized for the filesystem)."
|
||||
},
|
||||
"fingerprint_prefix": {
|
||||
"type": "string",
|
||||
"description": "First 16 hex chars of the device's pairing fingerprint — enough to correlate with the\npaired-devices roster without repeating the full identity in every filename."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The bundle id (its filename stem) — pass to the fetch/delete endpoints."
|
||||
},
|
||||
"received_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Upload time (unix ms, from the file's mtime).",
|
||||
"minimum": 0
|
||||
},
|
||||
"size_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Bundle size in bytes.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ClientLogUploaded": {
|
||||
"type": "object",
|
||||
"description": "Response to a successful upload.",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The stored bundle's id."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ClientRef": {
|
||||
"type": "object",
|
||||
"description": "The connecting/disconnecting client's identity.",
|
||||
@@ -4793,6 +5310,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DiagnosticsReport": {
|
||||
"type": "object",
|
||||
"description": "The `GET /diagnostics` body.",
|
||||
"required": [
|
||||
"ran_at_unix",
|
||||
"checks"
|
||||
],
|
||||
"properties": {
|
||||
"checks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/HostCheck"
|
||||
},
|
||||
"description": "Every registered check, worst-first. Includes `ok` and `inapplicable` rows — the console\ndecides what to hide, because \"what's working\" is the reassurance the dashboard omits."
|
||||
},
|
||||
"ran_at_unix": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "When the probes last ran (unix seconds).",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"DisconnectReason": {
|
||||
"type": "string",
|
||||
"description": "Why a client went away. `Quit` is a deliberate user \"stop\" (the typed close code);\n`Timeout` is a transport idle timeout (the client vanished); `Error` is everything else.",
|
||||
@@ -5231,6 +5771,91 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A device was granted access with an explicit operator choice — the approve dialog, the\narm window's carried choice, or any other `add_with_access(Some)` path\n(design/per-client-access.md §6). A plain pairing with no choice emits only\n`pairing.completed` (its access is the preserved/default record, nothing was *chosen*).",
|
||||
"required": [
|
||||
"device",
|
||||
"grants",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"device": {
|
||||
"$ref": "#/components/schemas/DeviceRef"
|
||||
},
|
||||
"expires_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Absolute expiry, host wall clock unix seconds; absent = permanent."
|
||||
},
|
||||
"grants": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "The granted mask (the `GRANT_*` bit vocabulary), reserved bits already cleared.",
|
||||
"minimum": 0
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"access.granted"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A paired device's access was edited after the fact (the console edit sheet / extend /\n\"expire now\") — the owner's hook can say \"the TV is view-only now\".",
|
||||
"required": [
|
||||
"device",
|
||||
"grants",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"device": {
|
||||
"$ref": "#/components/schemas/DeviceRef"
|
||||
},
|
||||
"expires_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64"
|
||||
},
|
||||
"grants": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"access.changed"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A device's temporary access reached its deadline and its live session was closed — \"guest\naccess ended\". Emitted at deadline fire by the expiring session (a device with no live\nsession expires silently; the console row flips to \"Expired\" either way).",
|
||||
"required": [
|
||||
"device",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"device": {
|
||||
"$ref": "#/components/schemas/DeviceRef"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"access.expired"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -5870,6 +6495,72 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"HostCheck": {
|
||||
"type": "object",
|
||||
"description": "One health verdict. This IS the wire shape.",
|
||||
"required": [
|
||||
"id",
|
||||
"status",
|
||||
"severity",
|
||||
"summary",
|
||||
"impact",
|
||||
"params",
|
||||
"source"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable snake_case machine code — the console's i18n key (see [`ids`])."
|
||||
},
|
||||
"impact": {
|
||||
"type": "string",
|
||||
"description": "What actually breaks, in the operator's terms. Empty only for `ok`/`inapplicable` rows."
|
||||
},
|
||||
"params": {
|
||||
"type": "object",
|
||||
"description": "Interpolation values for the console's localized strings (`{user}`, `{group}`, …). The\nconsole needs these because it cannot re-derive them: only the host can see the username.",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"remedy": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Remedy"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": {
|
||||
"$ref": "#/components/schemas/Severity",
|
||||
"description": "What a non-ok status means. Meaningless when `status` is `ok`/`inapplicable`; carried anyway\nso a check never changes shape as it flips."
|
||||
},
|
||||
"since_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "First non-ok observation in this host run. Per-run bookkeeping, not a time series — there is\nno history here by design.",
|
||||
"minimum": 0
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/CheckSource"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/CheckStatus"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "One line, English. The console replaces this with a localized message when it knows `id`."
|
||||
}
|
||||
}
|
||||
},
|
||||
"HostEvent": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -6497,10 +7188,44 @@
|
||||
"fingerprint"
|
||||
],
|
||||
"properties": {
|
||||
"access_level": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The preset this device's mask amounts to, for display: `full` | `controller` | `view` |\n`custom`. Derived from `grants` on the host; absent only on hosts older than the field.",
|
||||
"example": "controller"
|
||||
},
|
||||
"expires_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Absolute access expiry, unix seconds on the host's wall clock. `null` = permanent. Whether\nit has already passed is the reader's arithmetic — an expired device stays listed (shown\nas \"Expired\"), it just isn't authorized."
|
||||
},
|
||||
"fingerprint": {
|
||||
"type": "string",
|
||||
"description": "Hex SHA-256 of the client certificate — its stable id here."
|
||||
},
|
||||
"granted_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "When access was last granted, unix seconds — display/audit only, never enforced."
|
||||
},
|
||||
"grants": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "Grant bitmask (`GRANT_*` bits 0–5). `null` = a record from before grants existed, which\nmeans full control.",
|
||||
"example": 1,
|
||||
"minimum": 0
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name the client supplied when pairing.",
|
||||
@@ -6627,16 +7352,49 @@
|
||||
"age_secs"
|
||||
],
|
||||
"properties": {
|
||||
"access_level": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The stored mask's preset name (`full` | `controller` | `view` | `custom`) — `null` for a\ndevice with no stored record, unlike [`NativeClient`] where it is always derivable.",
|
||||
"example": "controller"
|
||||
},
|
||||
"age_secs": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Seconds since the device last knocked.",
|
||||
"minimum": 0
|
||||
},
|
||||
"expires_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "The stored record's absolute expiry (unix seconds; likely in the past — that's why it's\nknocking). `null` when unknown or permanent."
|
||||
},
|
||||
"fingerprint": {
|
||||
"type": "string",
|
||||
"description": "Hex SHA-256 of the device's certificate — what approval pins."
|
||||
},
|
||||
"granted_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "When the stored record's access was granted (unix seconds). `null` when unknown."
|
||||
},
|
||||
"grants": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The grant mask this fingerprint is ALREADY stored with, if it was paired before (the\nexpired-guest re-knock: the approve dialog can offer \"re-grant what they had\"). `null`\nwhen the device is unknown, or known with a pre-grants record (= full).",
|
||||
"minimum": 0
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
@@ -7055,6 +7813,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Remedy": {
|
||||
"type": "object",
|
||||
"description": "What the operator should do about it. Always copy-paste — the host runs unprivileged and the\nconsole must never trigger privileged mutation. The `punktfunk` group in particular is\ndeliberately opt-in: writing the vhci `attach` node materialises arbitrary emulated USB devices\n(security review 2026-08-05, M-4), so joining it stays a deliberate act with the caveat attached.",
|
||||
"required": [
|
||||
"text",
|
||||
"relogin_required"
|
||||
],
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "A single pasteable shell command, when one fixes it outright."
|
||||
},
|
||||
"relogin_required": {
|
||||
"type": "boolean",
|
||||
"description": "True when the fix only takes effect after logging out and back in — a `systemd --user`\nmanager keeps the supplementary group set it started with. This distinction is the\ndifference between \"I already added myself!\" and a working virtual pad."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Plain-language instruction. English fallback — the console overrides it by check id."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuntimeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -7375,6 +8158,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Severity": {
|
||||
"type": "string",
|
||||
"description": "How much a non-ok status matters. Orthogonal to [`CheckStatus`] on purpose: a check can be\n`warn` about something `critical` (degraded, not dead) and the console sorts by both.",
|
||||
"enum": [
|
||||
"info",
|
||||
"warning",
|
||||
"critical"
|
||||
]
|
||||
},
|
||||
"SourceInput": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -7856,6 +8648,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateNativeAccess": {
|
||||
"type": "object",
|
||||
"description": "PATCH body for a paired device's access (the console edit sheet: change the preset, extend,\n\"expire now\", make permanent). **Partial**: an omitted `grants` keeps the current grants, and\nomitted expiry fields keep the current expiry — send only what changes.",
|
||||
"properties": {
|
||||
"clear_expiry": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "`true` removes the expiry — access becomes permanent. Mutually exclusive with\n`expires_in_secs` (400)."
|
||||
},
|
||||
"expires_in_secs": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "New expiry in seconds **from now** (relative; the host stores the absolute deadline).\n`0` expires the device now. Omit to keep the current expiry. Mutually exclusive with\n`clear_expiry` (400).",
|
||||
"example": 14400,
|
||||
"minimum": 0
|
||||
},
|
||||
"grants": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "New grant bitmask (`GRANT_*` bits 0–5); reserved bits are a 400. Omit to keep the\ndevice's current grants.",
|
||||
"example": 1,
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateResultInfo": {
|
||||
"type": "object",
|
||||
"description": "Durable outcome of the most recent apply attempt (survives the host's own restart).",
|
||||
@@ -8028,6 +8853,10 @@
|
||||
"name": "host",
|
||||
"description": "Host identity, capabilities, and liveness"
|
||||
},
|
||||
{
|
||||
"name": "diagnostics",
|
||||
"description": "Host health checks: what is wrong, what it breaks, and how to fix it (admin lane only)"
|
||||
},
|
||||
{
|
||||
"name": "gpu",
|
||||
"description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,9 +28,14 @@
|
||||
<!-- Gamepad rumble feedback. -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<!-- Steam Controller 2 over direct BLE (Sc2BleLink talks Valve's vendor GATT service to the
|
||||
bonded pad). A RUNTIME permission (NEARBY_DEVICES group); the capture engages only when
|
||||
already granted — USB capture (wired / Puck dongle) needs no Bluetooth at all. -->
|
||||
bonded pad). A RUNTIME permission (NEARBY_DEVICES group) from API 31 — MainActivity asks
|
||||
for it when a BLE-paired SC2 is actually around, and the Controllers screen offers the
|
||||
grant outright. USB capture (wired / Puck dongle) needs no Bluetooth at all.
|
||||
Below API 31 the same two operations (the bonded list + connectGatt) are covered by the
|
||||
install-time legacy permission instead, which BLUETOOTH_CONNECT does NOT imply — without
|
||||
it every BLE capture on Android 11 and older throws SecurityException. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
|
||||
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
|
||||
devices without a gamepad. -->
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
@@ -14,6 +13,7 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@@ -206,7 +206,9 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
|
||||
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
|
||||
dismissOnOutsideTap = false,
|
||||
) {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
// MUST be the name the connect actually knocked with (`HostConnect`), or this sends the
|
||||
// user looking for a row the console does not show.
|
||||
val label = deviceName(LocalContext.current)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -222,7 +224,7 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
|
||||
)
|
||||
}
|
||||
PromptText(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
|
||||
"Open the host's console (or web UI) and approve “$label”. It connects " +
|
||||
"automatically once you approve — no PIN needed.",
|
||||
gamepadUi,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -31,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
@@ -137,7 +137,8 @@ internal fun PairPinDialog(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var pin by remember(pt) { mutableStateOf("") }
|
||||
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
||||
val context = LocalContext.current
|
||||
var name by remember(pt) { mutableStateOf(deviceName(context)) }
|
||||
var pairing by remember(pt) { mutableStateOf(false) }
|
||||
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||
AlertDialog(
|
||||
|
||||
@@ -53,6 +53,11 @@ object ConnectErrors {
|
||||
"on the host."
|
||||
"wire-version" -> "Client and host versions don't match — update both to the same release."
|
||||
"busy" -> "The host is busy with another session."
|
||||
"access-expired" ->
|
||||
"Your access to this host has expired — ask the host's owner to grant it again."
|
||||
"launch-not-permitted" ->
|
||||
"This device's access doesn't include launching games — connect to the desktop, " +
|
||||
"or ask the host's owner."
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,8 @@ import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -54,6 +56,7 @@ import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2BleLink
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@@ -69,7 +72,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 +80,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 +103,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 +148,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 +210,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()
|
||||
@@ -363,12 +376,16 @@ private fun ControllersBody(
|
||||
}
|
||||
val sc2Probe = remember { Sc2Capture(context) }
|
||||
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
|
||||
val sc2Ble = remember(usbGeneration) {
|
||||
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) sc2Probe.pairedBleAddress() else null
|
||||
}
|
||||
// Answers null without the Bluetooth grant (and logs why) — see Sc2BleLink.
|
||||
val sc2Ble = remember(usbGeneration) { sc2Probe.pairedBleAddress() }
|
||||
val sc2Present = sc2Usb != null || sc2Ble != null
|
||||
// A BLE-paired SC2 cannot be seen at all until Bluetooth is granted, so "no controller
|
||||
// detected" would be the wrong thing to print at someone who has one paired. This is the
|
||||
// screen a user opens when a pad is missing, so the grant belongs here — see
|
||||
// [sc2BluetoothGrantOffered] for when it is worth offering, and the lizard-mode
|
||||
// InputDevice probe (no permission of its own) for how we word it.
|
||||
val btPermitted = remember(usbGeneration) { Sc2BleLink.permissionGranted(context) }
|
||||
val sc2OnBluetooth = remember(usbGeneration) { Gamepad.sc2InputDevicePresent() }
|
||||
val dsUsb = remember(usbGeneration) {
|
||||
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
|
||||
.deviceList.values.firstOrNull {
|
||||
@@ -389,11 +406,24 @@ private fun ControllersBody(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// After that paragraph on purpose: when nothing was detected, this is the actionable
|
||||
// half of the same answer — the one pad we are blind to rather than one Android has
|
||||
// simply classified oddly.
|
||||
if (
|
||||
sc2BluetoothGrantOffered(
|
||||
permissionGranted = btPermitted,
|
||||
usbSc2 = sc2Usb != null,
|
||||
sc2Attached = sc2OnBluetooth,
|
||||
anyPadDetected = pads.isNotEmpty(),
|
||||
)
|
||||
) {
|
||||
Sc2BluetoothRow(attached = sc2OnBluetooth, activity = activity) { usbGeneration++ }
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,6 +481,90 @@ private fun ControllersBody(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to offer the Bluetooth grant for a directly-paired Steam Controller 2.
|
||||
*
|
||||
* Only when it could change the answer ([permissionGranted] false), and only when there is reason
|
||||
* to think it would: an SC2 is visibly attached in lizard mode ([sc2Attached] — the permission-free
|
||||
* probe), or nothing was detected at all ([anyPadDetected] false) and a Bluetooth SC2 is precisely
|
||||
* the pad this client cannot see without the grant. A [usbSc2] is already captured over USB and
|
||||
* needs no Bluetooth, and someone with working controllers and no sign of an SC2 is shown nothing.
|
||||
*/
|
||||
fun sc2BluetoothGrantOffered(
|
||||
permissionGranted: Boolean,
|
||||
usbSc2: Boolean,
|
||||
sc2Attached: Boolean,
|
||||
anyPadDetected: Boolean,
|
||||
): Boolean = !permissionGranted && !usbSc2 && (sc2Attached || !anyPadDetected)
|
||||
|
||||
/**
|
||||
* The Bluetooth grant for a directly-paired Steam Controller 2 — the card that exists because a
|
||||
* BLE SC2 is invisible without it.
|
||||
*
|
||||
* A wired or Puck SC2 is enumerated over USB with no permission at all, so it shows up in this
|
||||
* screen either way; the bonded list a BLE one lives in is behind `BLUETOOTH_CONNECT` from API 31
|
||||
* and answers "nothing is paired" rather than "ask me first" when the permission is missing. Until
|
||||
* this existed, nothing in the client ever requested it, so a Bluetooth SC2 was silently absent
|
||||
* everywhere — no capture, no controller layout, no forwarding — while the same pad over USB
|
||||
* worked (field report, 2026-08-15).
|
||||
*
|
||||
* [attached] distinguishes "we can see one sitting in lizard mode" from "you may have one paired",
|
||||
* which is the difference between a statement and a guess. [onGranted] re-probes the caller's
|
||||
* device state; the menu capture is engaged from here too, so the pad starts driving the UI on the
|
||||
* grant rather than at the next resume.
|
||||
*/
|
||||
@Composable
|
||||
private fun Sc2BluetoothRow(
|
||||
attached: Boolean,
|
||||
activity: MainActivity?,
|
||||
onGranted: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val settingOn = remember { SettingsStore(context).load().sc2Capture }
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
activity?.startSc2MenuNav()
|
||||
onGranted()
|
||||
}
|
||||
}
|
||||
val permission = Sc2BleLink.CONNECT_PERMISSION ?: return
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
if (attached) "Steam Controller 2" else "Steam Controller 2 over Bluetooth",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
when {
|
||||
!settingOn ->
|
||||
"Passthrough is disabled in Settings — enable \"Steam Controller 2 " +
|
||||
"passthrough\" to capture it."
|
||||
attached ->
|
||||
"Paired over Bluetooth. Punktfunk needs Bluetooth access to capture it — " +
|
||||
"until then it stays in its built-in keyboard/mouse mode and no game " +
|
||||
"sees a controller."
|
||||
else ->
|
||||
"A Steam Controller 2 paired over Bluetooth can't be detected without " +
|
||||
"Bluetooth access. Wired and Puck-dongle controllers need no " +
|
||||
"permission and are already listed above."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (settingOn) {
|
||||
OutlinedButton(onClick = { launcher.launch(permission) }) {
|
||||
Text("Grant Bluetooth access")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Steam Controller 2 card — capture-side state, since a (claimed or lizard-mode) SC2 never
|
||||
* appears as a gamepad InputDevice. Shows the transport, whether the capture is live (driving
|
||||
@@ -675,19 +789,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 +810,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 +825,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 +907,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) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
||||
/**
|
||||
* The name the user knows this device by — what a host shows in its pending-approval list (the web
|
||||
* console's outstanding-pairings view and the dialog that approves a knock) and files the device
|
||||
* under once approved.
|
||||
*
|
||||
* `Settings.Global.DEVICE_NAME` is the name the user typed in Settings ("Enrico's Pixel", "TV im
|
||||
* Wohnzimmer"); it is what every other protocol on the network already calls this device. Only when
|
||||
* it is unset does this fall back to [Build.MODEL], which names the *product* and so reads
|
||||
* identically on every unit of it — two of the same tablet pending approval are indistinguishable.
|
||||
* Available unconditionally here: `DEVICE_NAME` landed in API 25 and this app's floor is 28.
|
||||
*/
|
||||
internal fun deviceName(context: Context): String {
|
||||
val userNamed = runCatching {
|
||||
Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME)
|
||||
}.getOrNull()
|
||||
return userNamed?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: Build.MODEL?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: "Android"
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
@@ -44,6 +43,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -396,7 +396,8 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
|
||||
var pairing by remember(pt) { mutableStateOf(false) }
|
||||
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||
val name = remember { Build.MODEL ?: "Android" }
|
||||
val context = LocalContext.current
|
||||
val name = remember(context) { deviceName(context) }
|
||||
|
||||
fun pair() {
|
||||
val id = identity ?: return
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
@@ -82,8 +81,8 @@ suspend fun connectToHost(
|
||||
codecBits, preferredCodec, timeoutMs,
|
||||
launch,
|
||||
// The host's approval-list / trust-store label for this device — the same
|
||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||
Build.MODEL ?: "Android",
|
||||
// user-set device name the pairing dialogs offer for nativePair.
|
||||
deviceName(context),
|
||||
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
|
||||
// user with it off does not make the host provision endpoints it will never feed.
|
||||
settings.padHaptics || settings.padSpeaker,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -34,6 +34,8 @@ import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.Sc2BleLink
|
||||
import io.unom.punktfunk.kit.SessionAccess
|
||||
import io.unom.punktfunk.kit.link.DeepLinkResult
|
||||
import io.unom.punktfunk.kit.link.DeepLinks
|
||||
import io.unom.punktfunk.kit.link.HostResolution
|
||||
@@ -42,6 +44,9 @@ import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
|
||||
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
|
||||
|
||||
/** Request code for the SC2's Bluetooth grant (see [MainActivity.maybeAskSc2BtPermission]). */
|
||||
private const val REQ_SC2_BLUETOOTH = 0x5C2B
|
||||
|
||||
/**
|
||||
* Keeps ONE window-insets reader alive for as long as the app's UI exists — the fix for the menus
|
||||
* coming back from a stream laid out against the WRONG safe area.
|
||||
@@ -86,6 +91,16 @@ class MainActivity : ComponentActivity() {
|
||||
*/
|
||||
var streamHandle: Long = 0L
|
||||
|
||||
/**
|
||||
* The active session's access-grant mask ([SessionAccess] bits) — set with [streamHandle] by
|
||||
* StreamScreen and kept live by its access poll; back to [SessionAccess.ALL] when the stream
|
||||
* leaves. Consulted only while streaming: the VK keyboard path below goes inert without
|
||||
* [SessionAccess.KEYBOARD] (the keys are consumed, not sent — the host would drop them, and
|
||||
* letting them fall through would drive Android navigation under a live stream). Courtesy
|
||||
* gating; the host enforces regardless.
|
||||
*/
|
||||
var streamAccess: Int = SessionAccess.ALL
|
||||
|
||||
/**
|
||||
* Multi-controller router for the active session (built/released by StreamScreen): assigns each
|
||||
* connected pad a stable wire index, threads it onto every event, declares/removes pads on
|
||||
@@ -181,6 +196,9 @@ class MainActivity : ComponentActivity() {
|
||||
private var sc2Receiver: BroadcastReceiver? = null
|
||||
private var sc2PermissionAsked = false
|
||||
|
||||
/** Bluetooth asked once this process — a denial must not re-prompt on every resume. */
|
||||
private var sc2BtPermissionAsked = false
|
||||
|
||||
/** Sony-pad USB grant asked this attach — a deny doesn't re-nag until a fresh attach (or the
|
||||
* Controllers screen's explicit button). */
|
||||
private var dsPermissionAsked = false
|
||||
@@ -319,7 +337,8 @@ class MainActivity : ComponentActivity() {
|
||||
* Engage the menu-time SC2 capture if possible: setting on, not streaming, and a wired/Puck
|
||||
* pad attached (asking for USB permission at most once per attach — [forceAsk] re-arms the
|
||||
* dialog, for the Controllers screen's explicit grant button) — else an already-paired BLE
|
||||
* controller when BLUETOOTH_CONNECT is granted. Safe to call repeatedly.
|
||||
* controller, asking for Bluetooth access once if one appears to be attached
|
||||
* ([maybeAskSc2BtPermission]). Safe to call repeatedly.
|
||||
*/
|
||||
fun startSc2MenuNav(forceAsk: Boolean = false) {
|
||||
if (forceAsk) sc2PermissionAsked = false
|
||||
@@ -347,10 +366,46 @@ class MainActivity : ComponentActivity() {
|
||||
),
|
||||
)
|
||||
}
|
||||
dev == null && checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
PackageManager.PERMISSION_GRANTED -> {
|
||||
dev == null && Sc2BleLink.permissionGranted(this) -> {
|
||||
cap.pairedBleAddress()?.let { cap.startBle(it) }
|
||||
}
|
||||
dev == null -> maybeAskSc2BtPermission()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for Bluetooth access when a BLE-paired SC2 looks like it is attached and we cannot see
|
||||
* it — once per process, and never on a device that shows no sign of owning one.
|
||||
*
|
||||
* The permission is the whole reason a Bluetooth SC2 used to go unnoticed: the bonded list and
|
||||
* `connectGatt` both need it from API 31, nothing in the client had ever requested it, and the
|
||||
* bonded-list call answers an empty list rather than an error when it is missing — so the
|
||||
* capture stood down silently and the console UI never flipped to its controller layout, while
|
||||
* the same pad over USB worked (field report, 2026-08-15). Asking is gated on
|
||||
* [Gamepad.sc2InputDevicePresent] because an uncaptured SC2 sits in lizard mode as a
|
||||
* keyboard/mouse [android.view.InputDevice] — visible without any permission at all — so the
|
||||
* prompt reaches the people who have the hardware and nobody else.
|
||||
*/
|
||||
private fun maybeAskSc2BtPermission() {
|
||||
val permission = Sc2BleLink.CONNECT_PERMISSION ?: return // granted at install time here
|
||||
if (sc2BtPermissionAsked) return
|
||||
if (!Gamepad.sc2InputDevicePresent()) return
|
||||
sc2BtPermissionAsked = true
|
||||
requestPermissions(arrayOf(permission), REQ_SC2_BLUETOOTH)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
// Engage immediately on the grant — the pad is already paired, so there is nothing else to
|
||||
// wait for and the user just told us what they want it for.
|
||||
if (requestCode == REQ_SC2_BLUETOOTH &&
|
||||
grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
startSc2MenuNav()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,10 +581,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 +610,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 +626,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
|
||||
@@ -591,6 +651,10 @@ class MainActivity : ComponentActivity() {
|
||||
KeyEvent.ACTION_UP -> false
|
||||
else -> return super.dispatchKeyEvent(event)
|
||||
}
|
||||
// Without the KEYBOARD grant the key path is inert: consumed (so nothing
|
||||
// drives Android navigation under the stream) but never sent — the host
|
||||
// would drop it, and the Access chip is what says why. Courtesy gating.
|
||||
if (streamAccess and SessionAccess.KEYBOARD == 0) return true
|
||||
// Full-event overload: evdev scancode first (positional under ANY selected
|
||||
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
||||
val vk = Keymap.toVk(event)
|
||||
|
||||
@@ -44,6 +44,18 @@ class MouseForwarder(
|
||||
var onRequestCapture: (() -> Unit)? = null
|
||||
var onReleaseCapture: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Whether this session's access includes the POINTER grant ([io.unom.punktfunk.kit.SessionAccess.POINTER])
|
||||
* — seeded from the Welcome, kept live by StreamScreen's access poll. Without it the mouse
|
||||
* path goes inert: nothing forwards, and — the part that matters — the pointer is never
|
||||
* GRABBED, because a captured mouse that moves nothing is the "my mouse does nothing and
|
||||
* nobody says why" failure the grants UX exists to prevent (the Access chip says why
|
||||
* instead). Revocation mid-session releases an existing grab (StreamScreen calls [release]).
|
||||
* Volatile: set on the main thread, read wherever the dispatch path runs.
|
||||
*/
|
||||
@Volatile
|
||||
var pointerGranted: Boolean = true
|
||||
|
||||
/** Live capture state, updated from [android.app.Activity.onPointerCaptureChanged]. */
|
||||
var captured = false
|
||||
private set
|
||||
@@ -59,6 +71,7 @@ class MouseForwarder(
|
||||
|
||||
/** Uncaptured mouse events on the TOUCH stream (position while a button is down). */
|
||||
fun onTouchEvent(ev: MotionEvent): Boolean {
|
||||
if (!pointerGranted) return true // inert: consumed over the stream, nothing forwards
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
if (captureWanted && !captured && !userReleased) {
|
||||
@@ -80,6 +93,7 @@ class MouseForwarder(
|
||||
|
||||
/** Uncaptured mouse events on the GENERIC stream (hover motion, wheel, button edges). */
|
||||
fun onGenericMotion(ev: MotionEvent): Boolean {
|
||||
if (!pointerGranted) return true // inert: consumed over the stream, nothing forwards
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_HOVER_MOVE -> sendAbs(ev)
|
||||
MotionEvent.ACTION_SCROLL -> wheel(ev)
|
||||
@@ -98,6 +112,7 @@ class MouseForwarder(
|
||||
* gesture layer is the touchpad story); returning false leaves those to the framework.
|
||||
*/
|
||||
fun onCapturedPointer(ev: MotionEvent): Boolean {
|
||||
if (!pointerGranted) return true // a revocation is racing the release of the grab
|
||||
if (!ev.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
@@ -131,7 +146,7 @@ class MouseForwarder(
|
||||
if (captured) {
|
||||
userReleased = true
|
||||
onReleaseCapture?.invoke()
|
||||
} else {
|
||||
} else if (pointerGranted) { // never grab a pointer whose input can't land
|
||||
userReleased = false
|
||||
onRequestCapture?.invoke()
|
||||
}
|
||||
@@ -139,7 +154,7 @@ class MouseForwarder(
|
||||
|
||||
/** Auto-engage at stream start (setting on + a mouse actually present). */
|
||||
fun engageFromStart() {
|
||||
if (captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
|
||||
if (pointerGranted && captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
|
||||
onRequestCapture?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -204,7 +219,9 @@ class MouseForwarder(
|
||||
* input reader synthesizes them in), so both paths funnel into the same held-set and the
|
||||
* add/remove guard collapses the pair into a single wire press.
|
||||
*/
|
||||
fun sideButtonKey(back: Boolean, down: Boolean) = press(if (back) 4 else 5, down)
|
||||
fun sideButtonKey(back: Boolean, down: Boolean) {
|
||||
if (pointerGranted) press(if (back) 4 else 5, down)
|
||||
}
|
||||
|
||||
private fun button(actionButton: Int, down: Boolean) {
|
||||
val b = when (actionButton) {
|
||||
|
||||
@@ -44,6 +44,21 @@ class RemotePointer(
|
||||
var active = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether this session's access includes the POINTER grant — StreamScreen keeps it live from
|
||||
* the access poll. Ungranted, the SELECT long-press stops entering pointer mode (a mode whose
|
||||
* every action the host would drop; the Access chip says why), and a revocation while the
|
||||
* mode is on leaves it cleanly ([setGranted]). Everything else passes through untouched,
|
||||
* exactly as when the mode is off — the remote stays a remote.
|
||||
*/
|
||||
private var granted = true
|
||||
|
||||
/** Update the POINTER grant; revoking while pointer mode is on leaves the mode. Main thread. */
|
||||
fun setGranted(ok: Boolean) {
|
||||
granted = ok
|
||||
if (!ok && active) toggle()
|
||||
}
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val held = mutableSetOf<Int>() // D-pad keycodes currently down
|
||||
private var moveAccX = 0f
|
||||
@@ -169,6 +184,7 @@ class RemotePointer(
|
||||
}
|
||||
|
||||
private fun toggle() {
|
||||
if (!active && !granted) return // never enter a mode whose input can't land
|
||||
active = !active
|
||||
if (!active) {
|
||||
held.clear()
|
||||
|
||||
@@ -70,6 +70,7 @@ import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.PadSensors
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.SessionAccess
|
||||
import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
@@ -102,6 +103,20 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
|
||||
}
|
||||
|
||||
// The session's access level (the per-client grants of design/per-client-access.md), the
|
||||
// courtesy mirror of what the host enforces: seeded from the Welcome's advert here, kept live
|
||||
// by the 1 Hz poll below (the host's AccessUpdate messages fold latest-wins into the native
|
||||
// state). Full control + permanent — the only state an old host or an old native lib ever
|
||||
// reports — gates nothing and draws nothing: today's look, unchanged.
|
||||
val initialAccess = remember(handle) { NativeBridge.nativeAccessState(handle) }
|
||||
var accessGrants by remember(handle) {
|
||||
mutableStateOf(initialAccess?.getOrNull(0) ?: SessionAccess.ALL)
|
||||
}
|
||||
// Seconds until this session's access expires (0 = permanent), as last reported natively.
|
||||
var accessRemaining by remember(handle) {
|
||||
mutableStateOf(initialAccess?.getOrNull(1) ?: 0)
|
||||
}
|
||||
|
||||
// Start mic only if the user enabled it AND granted RECORD_AUDIO (else the AAudio input fails).
|
||||
val micWanted = micEnabled && ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
@@ -182,6 +197,34 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
NativeBridge.nativeSetMicMuted(handle, muted)
|
||||
}
|
||||
|
||||
// Push a grant mask into every gate that consults one — called at session start (once the
|
||||
// router/forwarders exist) and again whenever the poll sees the mask change (an AccessUpdate
|
||||
// revoked or restored something mid-session). A lambda, deliberately not a local fun — this
|
||||
// codebase has been burned by `::localFun` references in composable scopes. The gates it does
|
||||
// NOT reach (the Compose-side ones — the touch layer, the IME summon, the banner line, the
|
||||
// chip) key on `accessGrants` directly and re-run on the state write.
|
||||
val applyAccess: (Int) -> Unit = { grants ->
|
||||
activity?.streamAccess = grants
|
||||
activity?.gamepadRouter?.gamepadGranted = grants and SessionAccess.GAMEPAD != 0
|
||||
val pointerOk = grants and SessionAccess.POINTER != 0
|
||||
activity?.mouseForwarder?.let { m ->
|
||||
m.pointerGranted = pointerOk
|
||||
// A revocation must also let an existing grab go (and lift held buttons): a captured
|
||||
// mouse that moves nothing reads as a broken mouse, not a spectator session.
|
||||
if (!pointerOk) m.release()
|
||||
}
|
||||
activity?.remotePointer?.setGranted(pointerOk)
|
||||
// Mic revoked mid-session: stop the capture — the host detaches its end regardless, and
|
||||
// an open mic (with the platform's recording indicator lit) feeding a plane the host
|
||||
// drops would be the worst kind of lie. Not restarted on a re-grant: the host attaches
|
||||
// the mic service at session setup only, so a fresh session is the honest offer.
|
||||
if (grants and SessionAccess.MIC == 0 && micRunning) {
|
||||
releaseMicEffects(micEffects)
|
||||
NativeBridge.nativeStopMic(handle)
|
||||
micRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
|
||||
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
|
||||
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
|
||||
@@ -243,22 +286,62 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// open, so this fires only on a genuinely dead peer, never a false positive. Keyed on `handle`, so
|
||||
// it stops the moment we navigate away (the handle is only freed later, in onDispose).
|
||||
LaunchedEffect(handle) {
|
||||
var lastAccessSeq = initialAccess?.getOrNull(2) ?: 0
|
||||
while (true) {
|
||||
delay(1000)
|
||||
// Access first, ended second: a session about to close on its expiry gets its final
|
||||
// countdown read, which is what lets the ended branch word that close honestly.
|
||||
NativeBridge.nativeAccessState(handle)?.let { st ->
|
||||
val grants = st.getOrNull(0) ?: SessionAccess.ALL
|
||||
val seq = st.getOrNull(2) ?: 0
|
||||
if (grants != accessGrants) {
|
||||
accessGrants = grants
|
||||
applyAccess(grants)
|
||||
}
|
||||
accessRemaining = st.getOrNull(1) ?: 0
|
||||
if (seq != lastAccessSeq) {
|
||||
lastAccessSeq = seq
|
||||
// A fresh AccessUpdate close to the deadline is the host's T−5 m / T−1 m
|
||||
// courtesy warning — surface it. Grant edits (and a warning's grant echo)
|
||||
// otherwise just move the chip; a toast per edit would be noise.
|
||||
if (accessRemaining in 1..330) {
|
||||
val mins = (accessRemaining + 30) / 60
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (mins <= 1) {
|
||||
"Access expires in about a minute."
|
||||
} else {
|
||||
"Access expires in about $mins minutes."
|
||||
},
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (NativeBridge.nativeSessionEnded(handle)) {
|
||||
// WHY it ended decides what the user is told. This used to show the "host may be
|
||||
// asleep" line for EVERY ending — including a game the player had just quit and a
|
||||
// session the host ended on purpose — which reads as a failure report for
|
||||
// something nobody did wrong. Only a connection that actually died says that now.
|
||||
val reason = SessionEndReason.fromNative(NativeBridge.nativeEndReason(handle))
|
||||
when (reason) {
|
||||
SessionEndReason.LOST ->
|
||||
when {
|
||||
// The session died inside the access countdown's final stretch: that IS the
|
||||
// typed expiry close (ACCESS_EXPIRED), worded with the shared rejection
|
||||
// sentence rather than the generic host-ended silence. Recognized off the
|
||||
// countdown because the generic end-reason byte predates the expiry code.
|
||||
accessRemaining in 1..75 ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Your access to this host has expired.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
reason == SessionEndReason.LOST ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Connection lost — the host may be asleep. Wake it to reconnect.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
SessionEndReason.HOST_ERROR ->
|
||||
reason == SessionEndReason.HOST_ERROR ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"The host ended the session with an error.",
|
||||
@@ -266,10 +349,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
).show()
|
||||
// Deliberate endings — the player quit the game, the host was stopped, or we
|
||||
// closed it. Leaving the stream IS the feedback; a toast would only add noise.
|
||||
SessionEndReason.GAME_EXITED,
|
||||
SessionEndReason.HOST_ENDED,
|
||||
SessionEndReason.LOCAL,
|
||||
SessionEndReason.NONE -> {}
|
||||
else -> {}
|
||||
}
|
||||
onSessionEnded(reason)
|
||||
return@LaunchedEffect
|
||||
@@ -465,15 +545,32 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
handle,
|
||||
surfaceWidth = { videoView?.width?.takeIf { it > 0 } ?: decor?.width ?: 1920 },
|
||||
onActiveChanged = { on -> remotePointerOn = on },
|
||||
onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } },
|
||||
// The toggle TYPES — summoning also needs the KEYBOARD grant (hiding is free).
|
||||
onKeyboardToggle = {
|
||||
keyCapture?.let { v ->
|
||||
if (v.imeShown || accessGrants and SessionAccess.KEYBOARD != 0) {
|
||||
v.setImeVisible(!v.imeShown)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
activity?.remotePointer = remote
|
||||
// Shared clipboard (text v1): only when the user setting is on AND the host has a
|
||||
// working clipboard service. Protocol-level opt-in + the poll thread live in the sync.
|
||||
val clip = if (session.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
|
||||
// Everything the grant gates hang off now exists — apply the session's access level once
|
||||
// up front (the poll only re-applies on change, and a restricted session is restricted
|
||||
// from its first event, not from its first poll).
|
||||
applyAccess(accessGrants)
|
||||
// Shared clipboard (text v1): only when the user setting is on AND the session's access
|
||||
// includes the clipboard AND the host has a working clipboard service. Ungranted, the
|
||||
// host's policy resolution declines everything anyway (grants AND into it); not starting
|
||||
// the sync is the client-side mirror — no offers announced, no poll thread for a plane
|
||||
// that cannot move. Applied at session start only, like the host's own coordinator gate.
|
||||
val clip = if (session.clipboardSync &&
|
||||
accessGrants and SessionAccess.CLIPBOARD != 0 &&
|
||||
NativeBridge.nativeClipSupported(handle)
|
||||
) {
|
||||
ClipboardSync(context, handle).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
@@ -594,8 +691,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
),
|
||||
)
|
||||
}
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
PackageManager.PERMISSION_GRANTED -> {
|
||||
// No USB pad: fall back to a bonded BLE one. The Bluetooth-permission gate lives
|
||||
// inside pairedBleAddress() (it answers null, and says why, when the grant is
|
||||
// missing) rather than being restated here — the grant itself is asked for where
|
||||
// a user can act on it, in the console UI and the Controllers screen.
|
||||
else -> {
|
||||
sc2.pairedBleAddress()?.let { addr ->
|
||||
Log.i("punktfunk", "SC2: no USB pad — using the paired BLE controller $addr")
|
||||
sc2.startBle(addr)
|
||||
@@ -699,6 +799,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
activity?.remotePointer = null
|
||||
decor?.pointerIcon = priorPointerIcon
|
||||
activity?.streamHandle = 0L
|
||||
activity?.streamAccess = SessionAccess.ALL // grants are per session, like the handle
|
||||
activity?.requestStreamExit = null
|
||||
// Back in the menus: the SC2 (if present) resumes driving the console UI.
|
||||
activity?.startSc2MenuNav()
|
||||
@@ -817,7 +918,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
.roundToInt(),
|
||||
)
|
||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)
|
||||
if (micWanted) {
|
||||
// The MIC grant is read live (a surface recreate re-runs this, and
|
||||
// the mask may have changed since the last one): without it no
|
||||
// capture opens — the host never attached this session to its mic
|
||||
// service, so the platform's recording indicator would announce a
|
||||
// mic nobody can hear.
|
||||
if (micWanted && accessGrants and SessionAccess.MIC != 0) {
|
||||
val sessionId =
|
||||
NativeBridge.nativeStartMic(handle, initialSettings.echoCancel)
|
||||
if (initialSettings.echoCancel) {
|
||||
@@ -881,6 +987,22 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
)
|
||||
}
|
||||
}
|
||||
// The Access chip — what this session is allowed to do, said in the preset vocabulary
|
||||
// ("Controller only · 1 h 58 m left"), standing for the whole stream. Full control with
|
||||
// no expiry — every session against an old host, and most against a new one — shows
|
||||
// NOTHING: the chip exists for the sessions where input silently not landing needs an
|
||||
// explanation, not as new chrome on everyone's stream. TopEnd, in the shared pill family
|
||||
// (TopStart is the HUD's, TopCentre the transient cues', BottomCentre the banner's).
|
||||
val accessChip = when {
|
||||
accessGrants and SessionAccess.ALL == SessionAccess.ALL && accessRemaining == 0 -> null
|
||||
accessRemaining > 0 ->
|
||||
"${SessionAccess.label(accessGrants)} · " +
|
||||
"${SessionAccess.remainingLabel(accessRemaining)} left"
|
||||
else -> SessionAccess.label(accessGrants)
|
||||
}
|
||||
if (accessChip != null) {
|
||||
AccessChip(accessChip, Modifier.align(Alignment.TopEnd).padding(12.dp))
|
||||
}
|
||||
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
|
||||
// hold, so without this cue a couch user reads the (deliberately no-longer-instant) chord as
|
||||
// broken. Purely visual; it sits above the video and below the gesture layer.
|
||||
@@ -898,7 +1020,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// Above the video and below the gesture layer: it teaches touches, it must never eat one.
|
||||
//
|
||||
// Bottom-centre is the desktop's placement and the only edge left — TopStart is the HUD,
|
||||
// TopEnd the mic badge, TopCentre the three transient cues — but MotionUnreachableHint
|
||||
// TopEnd the Access chip, TopCentre the three transient cues — but MotionUnreachableHint
|
||||
// already owns it, and both of these can be up at t≈0. The banner YIELDS rather than
|
||||
// stacking or sliding off-centre: the notice reports something broken about THIS session
|
||||
// and names the setting that fixes it, while the banner repeats shortcuts that will be
|
||||
@@ -919,8 +1041,13 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// button — all land on the same BackHandler).
|
||||
add("Back leaves the stream")
|
||||
// The tap lives in the pointer touch models only — passthrough gives every
|
||||
// finger to the host verbatim — and needs a screen to put three fingers on.
|
||||
if (hasTouch && touchMode != TouchMode.TOUCH) add("three-finger tap for stats")
|
||||
// finger to the host verbatim — and needs a screen to put three fingers on,
|
||||
// plus the POINTER grant (without it the gesture layer is not installed).
|
||||
if (hasTouch && touchMode != TouchMode.TOUCH &&
|
||||
accessGrants and SessionAccess.POINTER != 0
|
||||
) {
|
||||
add("three-finger tap for stats")
|
||||
}
|
||||
}
|
||||
}.joinToString(" · "),
|
||||
alpha = bannerAlpha,
|
||||
@@ -951,23 +1078,35 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// Stylus lane (design/pen-tablet-input.md §7): against a HOST_CAP_PEN host a stylus
|
||||
// splits out of BOTH touch models onto the pen plane; its heartbeat coroutine keeps a
|
||||
// stationary held stroke alive (and its cancellation lifts everything on teardown).
|
||||
val stylus = remember(handle) {
|
||||
if (NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
|
||||
// The POINTER grant gates the whole touch/stylus capture layer — "don't capture what
|
||||
// can't land": ungranted, no gesture handler is installed at all (and no pen lane opens),
|
||||
// rather than fingers being read into events the host will drop. Keyed on the grant so an
|
||||
// AccessUpdate flipping it mid-session swaps the layer live.
|
||||
val pointerOk = accessGrants and SessionAccess.POINTER != 0
|
||||
val stylus = remember(handle, pointerOk) {
|
||||
if (pointerOk && NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
|
||||
}
|
||||
if (stylus != null) {
|
||||
LaunchedEffect(stylus) { stylus.heartbeatLoop() }
|
||||
}
|
||||
Box(
|
||||
videoFit.pointerInput(handle, touchMode) {
|
||||
when (touchMode) {
|
||||
TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
|
||||
videoFit.pointerInput(handle, touchMode, pointerOk) {
|
||||
when {
|
||||
!pointerOk -> {} // no capture — the Access chip is what says why
|
||||
touchMode == TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
|
||||
else -> streamTouchInput(
|
||||
handle,
|
||||
stylus,
|
||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||
invertScroll = initialSettings.invertScroll,
|
||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
|
||||
// The summon rides the pointer gesture but TYPES — so it also needs the
|
||||
// KEYBOARD grant (dismissing is always allowed).
|
||||
onKeyboard = { show ->
|
||||
if (!show || accessGrants and SessionAccess.KEYBOARD != 0) {
|
||||
keyCapture?.setImeVisible(show)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -1031,6 +1170,25 @@ private fun MicChordHint(text: String, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The standing Access chip — the session's access level in the preset vocabulary, with the live
|
||||
* countdown when the grant expires ("Controller only · 1 h 58 m left"). Same pill family as the
|
||||
* other in-stream overlays, sized down a step because it stands for the whole session rather than
|
||||
* flashing a moment's confirmation. Only composed when there is something to say: a full-control
|
||||
* permanent session — today's normal — shows nothing at all.
|
||||
*/
|
||||
@Composable
|
||||
private fun AccessChip(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text,
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "This pad's gyro can't reach the game" — shown briefly when a captured controller with motion
|
||||
* meets a session whose virtual pad has no motion plane (the X-Box classes have no gyro in their
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [sc2BluetoothGrantOffered] is pure — table-tested over its inputs.
|
||||
*
|
||||
* The rule exists because a Steam Controller 2 paired over Bluetooth is invisible to this client
|
||||
* until `BLUETOOTH_CONNECT` is granted (the bonded list answers "nothing is paired" rather than
|
||||
* refusing), and nothing ever asked for it — so the pad silently never engaged while the same
|
||||
* controller over USB worked. The offer has to reach those users without becoming a Bluetooth
|
||||
* prompt for everyone else, which is the whole content of these assertions.
|
||||
*/
|
||||
class Sc2BluetoothGrantTest {
|
||||
|
||||
/** The reported case: an SC2 sitting in lizard mode that we cannot capture. */
|
||||
@Test
|
||||
fun offeredWhenAnSc2IsAttachedButBluetoothIsNot() {
|
||||
assertTrue(
|
||||
sc2BluetoothGrantOffered(
|
||||
permissionGranted = false,
|
||||
usbSc2 = false,
|
||||
sc2Attached = true,
|
||||
anyPadDetected = false,
|
||||
),
|
||||
)
|
||||
// Still offered next to other working pads — the SC2 is the one we can't reach.
|
||||
assertTrue(
|
||||
sc2BluetoothGrantOffered(
|
||||
permissionGranted = false,
|
||||
usbSc2 = false,
|
||||
sc2Attached = true,
|
||||
anyPadDetected = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The probe reads an SC2's USB identity, which we cannot assume a BLE stack reports. When it
|
||||
* misses, "no controller detected" is exactly when a blind spot is worth naming.
|
||||
*/
|
||||
@Test
|
||||
fun offeredWhenNothingWasDetectedAtAll() {
|
||||
assertTrue(
|
||||
sc2BluetoothGrantOffered(
|
||||
permissionGranted = false,
|
||||
usbSc2 = false,
|
||||
sc2Attached = false,
|
||||
anyPadDetected = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Never a prompt for someone with working controllers and no sign of an SC2. */
|
||||
@Test
|
||||
fun notOfferedToUsersWithNoSignOfAnSc2() {
|
||||
assertFalse(
|
||||
sc2BluetoothGrantOffered(
|
||||
permissionGranted = false,
|
||||
usbSc2 = false,
|
||||
sc2Attached = false,
|
||||
anyPadDetected = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Granting it changes nothing that is already captured over USB — wired and Puck alike. */
|
||||
@Test
|
||||
fun notOfferedWhenTheSc2IsOnUsb() {
|
||||
assertFalse(
|
||||
sc2BluetoothGrantOffered(
|
||||
permissionGranted = false,
|
||||
usbSc2 = true,
|
||||
sc2Attached = true,
|
||||
anyPadDetected = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Nothing to ask for once it is held — including on releases that grant it at install time. */
|
||||
@Test
|
||||
fun notOfferedOncePermitted() {
|
||||
for (attached in listOf(true, false)) {
|
||||
for (pads in listOf(true, false)) {
|
||||
assertFalse(
|
||||
sc2BluetoothGrantOffered(
|
||||
permissionGranted = true,
|
||||
usbSc2 = false,
|
||||
sc2Attached = attached,
|
||||
anyPadDetected = pads,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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)
|
||||
|
||||
@@ -105,9 +105,12 @@ class DeviceGyro(
|
||||
for (i in 0..2) lastAccel[i] = Gamepad.motionAccelWire(v[i])
|
||||
}
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
// The write gate, per sample: pad 0 must exist (motion never creates a pad)
|
||||
// and must not be a capture link's (its own IMU is streaming).
|
||||
val write = router.padPresent(0) && !router.padHasOwnMotion(0)
|
||||
// The write gate, per sample: sends must be on at all (the forwarding
|
||||
// preference AND the session's GAMEPAD grant — an AccessUpdate can revoke it
|
||||
// mid-session), pad 0 must exist (motion never creates a pad) and must not be
|
||||
// a capture link's (its own IMU is streaming).
|
||||
val write = router.sendsEnabled() && router.padPresent(0) &&
|
||||
!router.padHasOwnMotion(0)
|
||||
if (!write) {
|
||||
// Stand-down edge: never leave the last angular velocity latched host-side.
|
||||
if (wasWriting) {
|
||||
|
||||
@@ -195,6 +195,29 @@ object Gamepad {
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? = pads().firstOrNull()
|
||||
|
||||
/**
|
||||
* True when a Steam Controller 2 is attached as an ORDINARY [InputDevice] — which, for a pad
|
||||
* this client wants to capture, means an uncaptured one still in lizard mode.
|
||||
*
|
||||
* Deliberately not filtered by [isPad]: lizard mode emulates a keyboard and mouse, so an SC2
|
||||
* is never a gamepad source and every other pad-shaped query in the client steps right past
|
||||
* it. That is also why this is worth having — a wired or Puck SC2 is found by enumerating USB
|
||||
* (no permission needed), but a BLE-paired one is invisible until `BLUETOOTH_CONNECT` is
|
||||
* granted, and asking for Bluetooth on the chance that someone might own one is not something
|
||||
* to put in front of every user. This is the permission-free signal that the pad is genuinely
|
||||
* there, so the request can be made to the people it helps and to nobody else.
|
||||
*
|
||||
* A false negative is survivable by design (the Controllers screen offers the grant outright),
|
||||
* so this matches only the identities we know rather than reaching for every Valve device — a
|
||||
* Steam Deck's own controller and a classic Steam Controller are not SC2s and must not
|
||||
* conjure a Bluetooth prompt.
|
||||
*/
|
||||
fun sc2InputDevicePresent(): Boolean =
|
||||
InputDevice.getDeviceIds().asSequence().mapNotNull { InputDevice.getDevice(it) }.any {
|
||||
it.vendorId == VID_VALVE &&
|
||||
(it.productId in PID_STEAMCONTROLLER2 || it.productId in PID_STEAMCONTROLLER2_PUCK)
|
||||
}
|
||||
|
||||
/**
|
||||
* The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A
|
||||
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
|
||||
@@ -230,6 +253,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
|
||||
|
||||
@@ -51,7 +51,7 @@ class GamepadRouter(
|
||||
* claimed by keeping a slot — the Android input stack shares controllers — unlike the USB
|
||||
* capture links, which `StreamScreen` does not start at all while this is off.
|
||||
*/
|
||||
private val forwarding: Boolean = true,
|
||||
forwarding: Boolean = true,
|
||||
/**
|
||||
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved — auto = forward on
|
||||
* Android, where the press reaches the app on most devices; `local` exists for
|
||||
@@ -70,6 +70,25 @@ class GamepadRouter(
|
||||
private val guideGesture: Boolean = false,
|
||||
) {
|
||||
|
||||
/** The ctor's forwarding preference, fixed for the session — one term of [forwarding]. */
|
||||
private val forwardingSetting = forwarding
|
||||
|
||||
/**
|
||||
* Whether this session's access includes the GAMEPAD grant ([SessionAccess.GAMEPAD]) —
|
||||
* seeded from the Welcome and kept live by `StreamScreen`'s access poll (an `AccessUpdate`
|
||||
* can revoke or restore it mid-session, latest-wins). Gates exactly what the forwarding
|
||||
* preference gates: the wire sends, never the slots — the exit/mic/stats chords must keep
|
||||
* working on a Controller-less access level too (they are local controls that happen to be
|
||||
* read off pad buttons). The host enforces regardless; this stops the client paying to send
|
||||
* events that will be dropped. Volatile: the sensor and USB-capture threads read it per
|
||||
* sample through [forwarding].
|
||||
*/
|
||||
@Volatile
|
||||
var gamepadGranted: Boolean = true
|
||||
|
||||
/** Send on the wire at all — the forwarding preference AND the session's GAMEPAD grant. */
|
||||
private val forwarding: Boolean get() = forwardingSetting && gamepadGranted
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(
|
||||
val index: Int,
|
||||
@@ -380,6 +399,13 @@ class GamepadRouter(
|
||||
/** Whether ANY live slot currently holds wire pad [pad]. Read from the phone-gyro thread. */
|
||||
fun padPresent(pad: Int): Boolean = slots.values.any { it.index == pad }
|
||||
|
||||
/**
|
||||
* Whether wire sends are on at all — the forwarding preference AND the session's GAMEPAD
|
||||
* grant. For the writers that ride the pad planes from OUTSIDE this router (the phone-gyro
|
||||
* mirror), which must stand down with it. Read from the sensor thread.
|
||||
*/
|
||||
fun sendsEnabled(): Boolean = forwarding
|
||||
|
||||
/**
|
||||
* Whether wire pad [pad]'s motion already comes from the controller's OWN IMU — either a
|
||||
* capture-link slot ([ExternalPad] — USB DualSense / SC2; synthetic ids are negative
|
||||
|
||||
@@ -99,6 +99,17 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeEndReason(handle: Long): Int
|
||||
|
||||
/**
|
||||
* The session's live access state as `[grants, remainingSecs, updateSeq]`, or `null` on a `0`
|
||||
* handle. `grants` is a [SessionAccess] bitmask; `remainingSecs` counts down to the access
|
||||
* expiry (`0` = permanent); `updateSeq` increments once per `AccessUpdate` the host sent
|
||||
* (latest-wins — the state IS the fold, this counter is how a poller tells a fresh T−5 m /
|
||||
* T−1 m warning arrived and owes a toast). Seeded from the Welcome's access advert; an old
|
||||
* host — or an old native lib — reads as full control, permanent, exactly what such a host
|
||||
* enforces. Poll ~1 Hz alongside [nativeSessionEnded]. Cheap; safe on the UI thread.
|
||||
*/
|
||||
external fun nativeAccessState(handle: Long): IntArray?
|
||||
|
||||
/**
|
||||
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
|
||||
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
|
||||
@@ -477,6 +488,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
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
@@ -9,6 +10,8 @@ import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -47,16 +50,33 @@ class Sc2BleLink(
|
||||
|
||||
@Volatile private var state = State.IDLE
|
||||
|
||||
/** Bonded devices that look like a Steam Controller (name heuristic — BLE exposes no PID here). */
|
||||
fun pairedControllers(): List<BluetoothDevice> = runCatching {
|
||||
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
|
||||
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
|
||||
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
|
||||
/**
|
||||
* Bonded devices that look like a Steam Controller (name heuristic — BLE exposes no PID here).
|
||||
*
|
||||
* Gates on [permissionGranted] itself rather than trusting callers to: without the permission
|
||||
* `bondedDevices` throws, and the `runCatching` below turns that into an empty list —
|
||||
* indistinguishable from "no controller is paired". A capture that never engaged for want of a
|
||||
* permission nobody had asked for is exactly the silence this logs its way out of.
|
||||
*/
|
||||
fun pairedControllers(): List<BluetoothDevice> {
|
||||
if (!permissionGranted(context)) {
|
||||
Log.i(TAG, "BLE controllers not enumerated: $CONNECT_PERMISSION not granted")
|
||||
return emptyList()
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
return runCatching {
|
||||
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
|
||||
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
|
||||
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
/** Connect to the bonded controller at [address]. Reports start flowing once READY. */
|
||||
fun start(address: String): Boolean {
|
||||
if (!permissionGranted(context)) {
|
||||
Log.i(TAG, "BLE capture not started: $CONNECT_PERMISSION not granted")
|
||||
return false
|
||||
}
|
||||
val adapter = manager.adapter ?: return false
|
||||
if (!adapter.isEnabled) return false
|
||||
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return false
|
||||
@@ -222,20 +242,50 @@ class Sc2BleLink(
|
||||
return s.substring(0, 8).toLongOrNull(16)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Sc2BleLink"
|
||||
companion object {
|
||||
private const val TAG = "Sc2BleLink"
|
||||
|
||||
val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
|
||||
const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
|
||||
const val NOTIFY_LOW = 0x100f6c75L
|
||||
const val NOTIFY_HIGH = 0x100f6c7aL
|
||||
const val WRITE_LOW = 0x100f6cb5L
|
||||
const val WRITE_HIGH = 0x100f6cbeL
|
||||
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
private val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
|
||||
private const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
|
||||
private const val NOTIFY_LOW = 0x100f6c75L
|
||||
private const val NOTIFY_HIGH = 0x100f6c7aL
|
||||
private const val WRITE_LOW = 0x100f6cb5L
|
||||
private const val WRITE_HIGH = 0x100f6cbeL
|
||||
private val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
val NAME_HINTS = listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
|
||||
private val NAME_HINTS =
|
||||
listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
|
||||
|
||||
/** Enough for a state payload (45 B) + ATT header with margin. */
|
||||
const val DESIRED_MTU = 100
|
||||
private const val DESIRED_MTU = 100
|
||||
|
||||
/**
|
||||
* The runtime permission this transport needs, or null where the platform grants Bluetooth
|
||||
* at install time.
|
||||
*
|
||||
* From API 31 both operations a capture makes — reading the bonded list and `connectGatt`
|
||||
* — sit behind the runtime `BLUETOOTH_CONNECT`. Below it the manifest's legacy `BLUETOOTH`
|
||||
* (normal-level, granted on install) covers exactly those two, and `BLUETOOTH_CONNECT` is
|
||||
* not a permission that platform version knows: `checkSelfPermission` answers DENIED for
|
||||
* it and a request is refused without a dialog. Gating on it unconditionally is therefore
|
||||
* not merely redundant on old releases — it is a permanent refusal, which is what this
|
||||
* null arm exists to avoid.
|
||||
*/
|
||||
val CONNECT_PERMISSION: String? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
Manifest.permission.BLUETOOTH_CONNECT
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a BLE capture may run: [CONNECT_PERMISSION] held, or not required on this
|
||||
* release. Callers that can offer the user a grant ask this first, so the offer appears
|
||||
* only when it would change something.
|
||||
*/
|
||||
fun permissionGranted(context: Context): Boolean {
|
||||
val permission = CONNECT_PERMISSION ?: return true
|
||||
return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* Per-client access grants — the Kotlin mirror of `punktfunk_core::quic::access` (bit-for-bit;
|
||||
* `design/per-client-access.md` §3), read per session via [NativeBridge.nativeAccessState].
|
||||
*
|
||||
* The host is the only enforcer: everything gated on these bits client-side is courtesy UX over
|
||||
* the same vocabulary — don't capture what can't land (a keyboard that silently does nothing is
|
||||
* the failure mode this exists to prevent), and say what this session is (the stream's Access
|
||||
* chip). The user-facing word is **"Access"**; the preset labels are *derived* from the mask,
|
||||
* never stored, so they can't drift from what the host actually granted.
|
||||
*/
|
||||
object SessionAccess {
|
||||
/** Controller input — gamepad events, rich pad input, pad audio, rumble return. */
|
||||
const val GAMEPAD = 1 shl 0
|
||||
|
||||
/** Pointing input — mouse rel/abs + buttons, scroll, touch, and the pen plane. */
|
||||
const val POINTER = 1 shl 1
|
||||
|
||||
/** Key input — key down/up and IME-committed text. */
|
||||
const val KEYBOARD = 1 shl 2
|
||||
|
||||
/** Shared clipboard (ANDed into the host's clipboard policy). */
|
||||
const val CLIPBOARD = 1 shl 3
|
||||
|
||||
/** Mic injection — the uplink plane + the per-session mic attach. */
|
||||
const val MIC = 1 shl 4
|
||||
|
||||
/** Library launch (`Hello.launch`). */
|
||||
const val LAUNCH = 1 shl 5
|
||||
|
||||
/** Every defined grant — full control, and what an old host's Welcome decodes to. */
|
||||
const val ALL = GAMEPAD or POINTER or KEYBOARD or CLIPBOARD or MIC or LAUNCH
|
||||
|
||||
/**
|
||||
* The preset name a mask displays as — §3.2's rule: three levels people actually reason
|
||||
* about, "Custom" for any other combination, never a raw bit list.
|
||||
*/
|
||||
fun label(grants: Int): String = when (grants and ALL) {
|
||||
ALL -> "Full control"
|
||||
GAMEPAD -> "Controller only"
|
||||
0 -> "View only"
|
||||
else -> "Custom"
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact time-left wording for the Access chip ("1 h 58 m", "12 m", "45 s") — hours and
|
||||
* minutes once the span has them, bare seconds only under a minute (the final countdown).
|
||||
*/
|
||||
fun remainingLabel(secs: Int): String {
|
||||
val h = secs / 3600
|
||||
val m = (secs % 3600) / 60
|
||||
return when {
|
||||
h > 0 && m > 0 -> "$h h $m m"
|
||||
h > 0 -> "$h h"
|
||||
m > 0 -> "$m m"
|
||||
else -> "${secs.coerceAtLeast(0)} s"
|
||||
}
|
||||
}
|
||||
}
|
||||
+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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of [SessionAccess] — the bit values are an ABI contract with
|
||||
* `punktfunk_core::quic::access` (wire == store == this mirror), and the preset labels are the
|
||||
* §3.2 naming rule the Access chip renders from: three levels people reason about, "Custom" for
|
||||
* anything else, derived from the mask so they cannot drift. Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class SessionAccessTest {
|
||||
|
||||
/** Bit-for-bit the core vocabulary — a reorder here would mislabel every session. */
|
||||
@Test
|
||||
fun `bits mirror punktfunk-core`() {
|
||||
assertEquals(1, SessionAccess.GAMEPAD)
|
||||
assertEquals(2, SessionAccess.POINTER)
|
||||
assertEquals(4, SessionAccess.KEYBOARD)
|
||||
assertEquals(8, SessionAccess.CLIPBOARD)
|
||||
assertEquals(16, SessionAccess.MIC)
|
||||
assertEquals(32, SessionAccess.LAUNCH)
|
||||
assertEquals(0x3F, SessionAccess.ALL)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preset labels derive from the mask`() {
|
||||
assertEquals("Full control", SessionAccess.label(SessionAccess.ALL))
|
||||
assertEquals("Controller only", SessionAccess.label(SessionAccess.GAMEPAD))
|
||||
assertEquals("View only", SessionAccess.label(0))
|
||||
// Any other combination is Custom — including controller + clipboard, the design's
|
||||
// media-remote example.
|
||||
assertEquals(
|
||||
"Custom",
|
||||
SessionAccess.label(SessionAccess.GAMEPAD or SessionAccess.CLIPBOARD),
|
||||
)
|
||||
assertEquals("Custom", SessionAccess.label(SessionAccess.ALL and SessionAccess.LAUNCH.inv()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remaining label is compact and never empty`() {
|
||||
assertEquals("1 h 58 m", SessionAccess.remainingLabel(7130))
|
||||
assertEquals("2 h", SessionAccess.remainingLabel(7200))
|
||||
assertEquals("12 m", SessionAccess.remainingLabel(725))
|
||||
assertEquals("45 s", SessionAccess.remainingLabel(45))
|
||||
assertEquals("0 s", SessionAccess.remainingLabel(0))
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
@@ -851,6 +851,15 @@ fn decode_loop(
|
||||
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
|
||||
let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
|
||||
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
|
||||
// WP-C1 — the drought half of concealment. The loop below already conceals a SEQ GAP, but only
|
||||
// when a later packet arrives to reveal it; when the wire simply goes quiet — Wi-Fi power-save
|
||||
// bunching, the shape this preset already runs deeper for — nothing arrives to reveal anything
|
||||
// and the ring drains into an underrun and a de-prime whose re-prime is a longer artifact than
|
||||
// the audio that was missing.
|
||||
let mut drought = punktfunk_core::audio::DroughtConceal::new(
|
||||
punktfunk_core::audio::JitterTuning::AAUDIO.plc_max_ms(),
|
||||
);
|
||||
let mut last_packet = std::time::Instant::now();
|
||||
|
||||
// A/V sync (audio latency overhaul). This thread is the only place holding all three
|
||||
// ingredients at once: the packet's host capture `pts_ns`, the ring depth (via the sync cell)
|
||||
@@ -894,10 +903,15 @@ fn decode_loop(
|
||||
sync.set_target(av.desired_depth(depth));
|
||||
av_offset_out.store(av.offset_ms() as i64, Ordering::Relaxed);
|
||||
}
|
||||
last_packet = std::time::Instant::now();
|
||||
// Anything the drought path already covered is audio the stream now has;
|
||||
// concealing it a second time here would insert samples it never carried and push
|
||||
// everything after them later.
|
||||
let already = drought.packet();
|
||||
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one that
|
||||
// arrived: empty input synthesizes `frame_samples` of interpolation per missing
|
||||
// packet — an inaudible fade instead of the click a hard gap makes in the ring.
|
||||
for _ in 0..gaps.missing_before(pkt.seq) {
|
||||
for _ in 0..gaps.missing_before(pkt.seq).saturating_sub(already) {
|
||||
let plc = frame_samples * channels;
|
||||
if plc == 0 {
|
||||
break; // no decoded frame yet to size the concealment from
|
||||
@@ -958,13 +972,17 @@ fn decode_loop(
|
||||
// the picture); 0 with sync off, or before it has a video reference.
|
||||
// Logged next to the depth because a deep ring on a jittery link is
|
||||
// correct and only the offset separates that from audio held late.
|
||||
// `plc_ms` is concealment synthesized for packet droughts: a healthy
|
||||
// `underruns` bought with a climbing `plc_ms` is a link in trouble,
|
||||
// not a link that is fine.
|
||||
log::info!(
|
||||
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} av_ms={} peak={window_peak:.3}",
|
||||
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} av_ms={} plc_ms={} peak={window_peak:.3}",
|
||||
counters.pcm_written.load(Ordering::Relaxed),
|
||||
counters.underruns.load(Ordering::Relaxed),
|
||||
(depth / ms.max(1)) as u64,
|
||||
counters.target_ms.load(Ordering::Relaxed),
|
||||
av.offset_ms(),
|
||||
drought.total_ms(),
|
||||
);
|
||||
window_peak = 0.0;
|
||||
}
|
||||
@@ -972,7 +990,31 @@ fn decode_loop(
|
||||
Err(e) => log::debug!("audio: opus decode: {e}"),
|
||||
}
|
||||
}
|
||||
Err(PunktfunkError::NoFrame) => {} // timeout
|
||||
Err(PunktfunkError::NoFrame) => {
|
||||
// Nothing on the wire. If the ring is draining with it, conceal from the decoder's
|
||||
// own state — the same libopus interpolation the loss path uses, bounded by this
|
||||
// preset's de-prime fuse so a genuinely dead stream is not papered over. ONE frame
|
||||
// per tick, not a burst: this arm fires every 5 ms, which is the rate the callback
|
||||
// drains at, so concealment keeps pace with playout instead of racing ahead of a
|
||||
// depth reading it has already invalidated. `frame_samples` is 0 until something
|
||||
// has decoded — there is no state to extrapolate from before then.
|
||||
let depth_ms = (sync.depth() / ms.max(1)) as u32;
|
||||
if frame_samples > 0 && drought.conceal(last_packet.elapsed(), depth_ms) {
|
||||
let plc = frame_samples * channels;
|
||||
if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
|
||||
let mut buf = free_rx
|
||||
.try_recv()
|
||||
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
|
||||
buf.clear();
|
||||
buf.extend_from_slice(&pcm[..samples * channels]);
|
||||
match tx.try_send(buf) {
|
||||
Ok(()) | Err(TrySendError::Full(_)) => {}
|
||||
Err(TrySendError::Disconnected(_)) => return DecodeExit::Shutdown,
|
||||
}
|
||||
}
|
||||
sync.publish_plc_ms(drought.total_ms());
|
||||
}
|
||||
}
|
||||
Err(_) => return DecodeExit::SessionClosed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ struct OutputReady {
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
decoded_mono_ns: i64,
|
||||
}
|
||||
|
||||
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
|
||||
@@ -51,11 +52,12 @@ enum DecodeEvent {
|
||||
Au(Frame, u32),
|
||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||
InputAvailable(usize),
|
||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamps).
|
||||
OutputAvailable {
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
decoded_mono_ns: i64,
|
||||
},
|
||||
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
||||
FormatChanged,
|
||||
@@ -126,6 +128,12 @@ pub(super) fn run_async(
|
||||
// decode stage ends when the frame actually became available — not after the
|
||||
// channel hop + whatever work the loop coalesces in front of presenting it.
|
||||
decoded_ns: now_realtime_ns(),
|
||||
// Its monotonic twin, from the same instant. The stats are REALTIME (they
|
||||
// fold the host's clock offset in), while the cadence loop and
|
||||
// `releaseOutputBufferAtTime` are both CLOCK_MONOTONIC — and the loop is fed
|
||||
// and read in one domain, never converted (`punktfunk_core::phase`: a
|
||||
// constant offset between domains is what its offset estimator absorbs).
|
||||
decoded_mono_ns: now_monotonic_ns(),
|
||||
});
|
||||
})),
|
||||
on_format_changed: Some(Box::new(move |_fmt| {
|
||||
@@ -232,7 +240,7 @@ pub(super) fn run_async(
|
||||
PresentPriority::Smooth { buffer } => format!("smoothness, buffer {buffer}"),
|
||||
}
|
||||
);
|
||||
Some(Presenter::new(priority))
|
||||
Some(Presenter::new(priority, mode.refresh_hz))
|
||||
};
|
||||
stats.set_presenter_active(presenter.is_some());
|
||||
// The vsync clock, started LAZILY on the first decoded frame (see `vsync.rs`); its ticks ride
|
||||
@@ -299,6 +307,7 @@ pub(super) fn run_async(
|
||||
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
|
||||
// `present_ready` (present), keyed by the codec-echoed pts.
|
||||
let mut gate = ReanchorGate::new(client.frames_dropped());
|
||||
let mut last_arms = gate.arms();
|
||||
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
||||
@@ -378,6 +387,19 @@ pub(super) fn run_async(
|
||||
&mut queued_stamps,
|
||||
&mut gate,
|
||||
);
|
||||
// The cadence loop's re-anchor seam. A fresh arm means a loss was detected: the gate is
|
||||
// about to freeze on the last good picture and the frames that reach the presenter on the
|
||||
// far side come through a decoder that has just recovered, so the source→presentable delay
|
||||
// the loop had measured is not the one it will see. Watched by ARM COUNT rather than at
|
||||
// each `gate.arm` site because those are spread across the dispatcher, the feeder and this
|
||||
// loop's own backstops, and the count catches every one of them — including the ones
|
||||
// `feed_ready` raises for an abandoned partial AU.
|
||||
if gate.arms() != last_arms {
|
||||
last_arms = gate.arms();
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
p.reset_cadence();
|
||||
}
|
||||
}
|
||||
let had_output = !ready.is_empty();
|
||||
let rendered_before = rendered;
|
||||
present_ready(
|
||||
@@ -414,7 +436,7 @@ pub(super) fn run_async(
|
||||
if let (Some(_), Some(c)) = (p.flush_log(&meter, clock), clock) {
|
||||
let period = c.panel_period_ns().max(c.period_ns());
|
||||
if period > 0 {
|
||||
if let Some(t) = c.next_target(now_monotonic_ns(), 0) {
|
||||
if let Some(t) = c.next_target(now_monotonic_ns()) {
|
||||
let mono_now = now_monotonic_ns();
|
||||
let real_now = now_realtime_ns();
|
||||
let leads_us: Vec<u64> = arrival_stamps
|
||||
@@ -737,10 +759,12 @@ fn dispatch_event(
|
||||
index,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
decoded_mono_ns,
|
||||
} => ready.push(OutputReady {
|
||||
index,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
decoded_mono_ns,
|
||||
}),
|
||||
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
||||
DecodeEvent::Vsync => *vsync_tick = true,
|
||||
@@ -998,7 +1022,7 @@ fn present_ready(
|
||||
for o in ready.drain(..) {
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
if gate.on_decoded(flags, false, now) == GateVerdict::Present {
|
||||
let dropped = p.submit(codec, o.index, o.pts_us, o.decoded_ns);
|
||||
let dropped = p.submit(codec, o.index, o.pts_us, o.decoded_ns, o.decoded_mono_ns);
|
||||
skipped += dropped;
|
||||
*discarded += dropped;
|
||||
} else {
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
|
||||
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
|
||||
//! identical to the legacy path — and only the budget prediction uses the measured period.
|
||||
//! * under the SMOOTHNESS intent only, a **due time** per frame from
|
||||
//! [`punktfunk_core::phase::CadenceClock`]: the release targets the first timeline at or after
|
||||
//! the instant the SOURCE's own timestamp says the frame is due, rather than the first one
|
||||
//! after it happened to decode, so the host's cadence reaches glass instead of the network's
|
||||
//! (`design/presenter-cadence-rework.md` D1). The latency intent holds no clock at all and
|
||||
//! stays arrival-driven by construction.
|
||||
//!
|
||||
//! The legacy behaviour (release the newest ready buffer immediately, unbudgeted) remains
|
||||
//! selectable at runtime: `adb shell setprop debug.punktfunk.presenter arrival` — the on-device
|
||||
@@ -21,6 +27,7 @@
|
||||
//! (off = the synchronous pre-overhaul loop, no presenter at all).
|
||||
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use punktfunk_core::phase::{CadenceClock, CadenceHealth, CadenceTuning};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::Mutex;
|
||||
@@ -103,8 +110,9 @@ const FALLBACK_PERIOD_NS: i64 = 8_333_333;
|
||||
pub(crate) enum PresentPriority {
|
||||
/// Newest-wins, release the instant the budget opens. The default.
|
||||
Latency,
|
||||
/// A small FIFO (1..=3 frames) drained one per vsync: jitter absorbed at one refresh of
|
||||
/// added display latency per slot, which the metrics show rather than hide.
|
||||
/// A small holding store (1..=3 frames) drained on each frame's DUE time from the source's own
|
||||
/// timestamps: transport jitter absorbed in the cushion the cadence loop sizes from its own
|
||||
/// measured residual, at a latency the metrics show rather than hide.
|
||||
Smooth { buffer: usize },
|
||||
}
|
||||
|
||||
@@ -129,6 +137,9 @@ struct HeldFrame {
|
||||
pts_us: u64,
|
||||
/// The output callback's `CLOCK_REALTIME` stamp — the pace metric's start (decoded→release).
|
||||
decoded_ns: i128,
|
||||
/// When the source says this frame is due, monotonic — [`CadenceClock::due_ns`] folded at
|
||||
/// submit. `None` under the latency intent, which has no clock.
|
||||
due_ns: Option<i64>,
|
||||
}
|
||||
|
||||
/// The one-in-flight glass budget.
|
||||
@@ -287,20 +298,33 @@ fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
|
||||
}
|
||||
|
||||
pub(super) struct Presenter {
|
||||
/// 0 = newest-wins; 1..=3 = smoothing FIFO capacity.
|
||||
/// 0 = newest-wins; 1..=3 = the holding store's capacity, a bound against a burst rather than
|
||||
/// a pacing depth (design §4.3).
|
||||
fifo_capacity: usize,
|
||||
frames: VecDeque<HeldFrame>,
|
||||
/// FIFO preroll: `take` withholds until the buffer filled to capacity once, re-armed on a dry
|
||||
/// run — the Apple `FrameStore` semantics (headroom never builds without it).
|
||||
prerolled: bool,
|
||||
/// The source-cadence loop, and the ONLY thing that makes this presenter anything but
|
||||
/// arrival-driven. `None` under the latency intent — not "a clock we choose to ignore" but no
|
||||
/// clock at all, so that path cannot drift into cadence targeting by accident (`design/
|
||||
/// presenter-cadence-rework.md` §5; the test below is what holds it there).
|
||||
cadence: Option<CadenceClock>,
|
||||
/// The source's nominal frame interval — [`CadenceClock`]'s cushion ceiling, which is a design
|
||||
/// invariant rather than a tunable (a cushion past one whole frame buys smoothness the source
|
||||
/// cannot supply). From the negotiated stream rate, which cannot change without tearing this
|
||||
/// loop down.
|
||||
frame_interval_ns: i64,
|
||||
inflight: Option<InFlight>,
|
||||
/// A vsync arrived since the last release — the FIFO's one-per-refresh drain pace.
|
||||
/// A vsync arrived since the last release — the retry beat for a parked frame, and what the
|
||||
/// empty-store readout below is counted against.
|
||||
vsync_tick: bool,
|
||||
// -- 1 Hz pf-present window, always on --
|
||||
released: u64,
|
||||
paced_drops: u64,
|
||||
no_budget: u64,
|
||||
forced: u64,
|
||||
/// Vsync ticks that found the store empty. Under cadence targeting an empty store is the
|
||||
/// ordinary state between a frame's decode and its due time, so this reads as supply depth
|
||||
/// rather than as the underflow alarm it was under the retired per-slot drain — the alarm is
|
||||
/// the loop's own late count.
|
||||
dry: u64,
|
||||
/// Pump passes that held a frame back because too many earlier releases were still
|
||||
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
|
||||
@@ -321,7 +345,12 @@ pub(super) struct Presenter {
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
pub(super) fn new(priority: PresentPriority) -> Presenter {
|
||||
/// `source_hz` is the NEGOTIATED STREAM rate, not the panel's — it sizes the cadence loop's
|
||||
/// cushion ceiling, and the quantity that must not be exceeded is one source frame. 0 (an
|
||||
/// unnegotiated rate) falls back to [`FALLBACK_PERIOD_NS`], the tighter of the two plausible
|
||||
/// answers: a ceiling set too low costs smoothness, one set too high costs latency the user
|
||||
/// never asked for.
|
||||
pub(super) fn new(priority: PresentPriority, source_hz: u32) -> Presenter {
|
||||
let pinned = latch_margin_ns();
|
||||
let (margin_ns, margin_pinned) = match pinned {
|
||||
Some(ns) => (ns, true),
|
||||
@@ -336,13 +365,27 @@ impl Presenter {
|
||||
" (adaptive — widens on latch misses)"
|
||||
}
|
||||
);
|
||||
let frame_interval_ns = match source_hz {
|
||||
0 => FALLBACK_PERIOD_NS,
|
||||
hz => 1_000_000_000 / i64::from(hz),
|
||||
};
|
||||
// `snapping()`, because every release here goes through the frame-timeline grid: the
|
||||
// snap-up carries roughly half a refresh of implicit slack, so the cushion is small.
|
||||
let cadence = matches!(priority, PresentPriority::Smooth { .. }).then(|| {
|
||||
log::info!(
|
||||
"presenter: cadence clock on the source's timeline, frame interval {:.2}ms",
|
||||
frame_interval_ns as f64 / 1e6
|
||||
);
|
||||
CadenceClock::new(CadenceTuning::snapping())
|
||||
});
|
||||
Presenter {
|
||||
fifo_capacity: match priority {
|
||||
PresentPriority::Latency => 0,
|
||||
PresentPriority::Smooth { buffer } => buffer,
|
||||
},
|
||||
frames: VecDeque::new(),
|
||||
prerolled: false,
|
||||
cadence,
|
||||
frame_interval_ns,
|
||||
inflight: None,
|
||||
vsync_tick: false,
|
||||
released: 0,
|
||||
@@ -359,8 +402,9 @@ impl Presenter {
|
||||
}
|
||||
}
|
||||
|
||||
/// A vsync pulse from the clock thread's event — the retry tick for a parked frame and the
|
||||
/// FIFO's drain pace.
|
||||
/// A vsync pulse from the clock thread's event — the retry tick for a frame parked on a closed
|
||||
/// budget, and the beat the empty-store readout is counted against. No longer a drain pace:
|
||||
/// under cadence targeting the due time is what releases a frame.
|
||||
pub(super) fn on_vsync(&mut self) {
|
||||
self.vsync_tick = true;
|
||||
}
|
||||
@@ -368,13 +412,21 @@ impl Presenter {
|
||||
/// Accept one decoded, gate-approved output buffer. Newest-wins evicts everything older
|
||||
/// (released unrendered — the explicit, counted drop); the FIFO evicts its oldest past
|
||||
/// capacity. Returns how many frames were dropped by the policy (the HUD's `skipped`).
|
||||
///
|
||||
/// `decoded_mono_ns` is the monotonic twin of `decoded_ns`, stamped at the same instant on the
|
||||
/// codec's callback thread: the cadence loop and `releaseOutputBufferAtTime` both live in
|
||||
/// `CLOCK_MONOTONIC`, while the latency stats live in `CLOCK_REALTIME`, and the loop is fed and
|
||||
/// read in ONE domain (`phase.rs`: a constant offset between domains is absorbed by the offset
|
||||
/// estimator, so no conversion belongs anywhere in this path).
|
||||
pub(super) fn submit(
|
||||
&mut self,
|
||||
codec: &MediaCodec,
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
decoded_mono_ns: i64,
|
||||
) -> u64 {
|
||||
let due_ns = self.due_at(pts_us, decoded_mono_ns);
|
||||
let mut dropped = 0u64;
|
||||
if self.fifo_capacity == 0 {
|
||||
while let Some(stale) = self.frames.pop_front() {
|
||||
@@ -386,6 +438,7 @@ impl Presenter {
|
||||
index,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
due_ns,
|
||||
});
|
||||
if self.fifo_capacity > 0 && self.frames.len() > self.fifo_capacity {
|
||||
if let Some(stale) = self.frames.pop_front() {
|
||||
@@ -397,6 +450,61 @@ impl Presenter {
|
||||
dropped
|
||||
}
|
||||
|
||||
/// When the source says this frame is due, or `None` under the latency intent.
|
||||
///
|
||||
/// `pts_us` IS the host's own stamp — it round-trips through the codec's presentation time, so
|
||||
/// the source timeline needs no plumbing of its own. The µs the codec API quantises it to
|
||||
/// costs ±0.5 µs of white noise on an 8.3 ms period, and that passes through to the due time
|
||||
/// like any other variation in the source's cadence: the loop smooths the offset, never the
|
||||
/// timestamps.
|
||||
fn due_at(&mut self, pts_us: u64, decoded_mono_ns: i64) -> Option<i64> {
|
||||
let interval_ns = self.frame_interval_ns;
|
||||
self.cadence
|
||||
.as_mut()
|
||||
.map(|c| c.due_ns(pts_us.saturating_mul(1_000), decoded_mono_ns, interval_ns))
|
||||
}
|
||||
|
||||
/// The earliest present a release may target: SurfaceFlinger's latch lead ahead of now, since
|
||||
/// a present it cannot latch in time is not a target, and never before the frame's own due
|
||||
/// time. That second half is the whole of the cadence change — `next_target(max(now, due))`
|
||||
/// where it used to be `next_target(now)` (design §4.2).
|
||||
fn not_before_ns(&self, now_mono_ns: i64, due_ns: Option<i64>) -> i64 {
|
||||
let submit_floor_ns = now_mono_ns + self.margin_ns;
|
||||
due_ns.map_or(submit_floor_ns, |due| due.max(submit_floor_ns))
|
||||
}
|
||||
|
||||
/// Whether the frame at the head of the smoothing store may leave it yet.
|
||||
///
|
||||
/// The store's job under cadence targeting is to HOLD WHAT IS NOT DUE YET (design §4.3): the
|
||||
/// due time paces, so capacity is a bound against a burst rather than the clock. A frame is
|
||||
/// releasable once the grid point it aims at is the next one this pump could still submit for
|
||||
/// — one `grid_period_ns` of reach past the submit margin. Sooner buys nothing, because the
|
||||
/// release is timed either way and holding keeps the store's own eviction policy live over the
|
||||
/// frame; later risks the loop's 5 ms housekeeping wake landing inside the submit lead, which
|
||||
/// costs the frame a whole refresh.
|
||||
fn head_is_releasable(&self, now_mono_ns: i64, grid_period_ns: i64) -> bool {
|
||||
let reach_ns = now_mono_ns + self.margin_ns + grid_period_ns;
|
||||
self.frames
|
||||
.front()
|
||||
.is_some_and(|f| f.due_ns.is_none_or(|due| due <= reach_ns))
|
||||
}
|
||||
|
||||
/// Force the cadence loop to re-anchor on the next frame — the discontinuity hook the clock
|
||||
/// asks its callers for. The Android seam is the re-anchor gate arming: a loss freezes the
|
||||
/// gate, the decoder recovers behind it, and what reaches this store on the far side comes
|
||||
/// through a pipeline whose delay is no longer the one the loop measured. Source-timestamp
|
||||
/// regressions and half-second gaps the loop catches by itself. No-op under latency.
|
||||
pub(super) fn reset_cadence(&mut self) {
|
||||
if let Some(c) = self.cadence.as_mut() {
|
||||
c.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// The cadence loop's health for the 1 Hz line, or `None` when there is no loop to read.
|
||||
fn cadence_health(&self) -> Option<CadenceHealth> {
|
||||
self.cadence.as_ref().map(CadenceClock::health)
|
||||
}
|
||||
|
||||
/// The present decision point — run on every loop pass (frame arrivals, vsync ticks, and the
|
||||
/// 5 ms housekeeping wake all land here). Releases AT MOST one frame (the budget). Returns
|
||||
/// `true` when a frame was released to glass this call.
|
||||
@@ -423,28 +531,28 @@ impl Presenter {
|
||||
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
|
||||
// rather than how often a frame happened to be ready.
|
||||
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
|
||||
// The grid a release snaps to, for the store's due-time reach below: the panel's own
|
||||
// period where it is known — the app's choreographer stream can be down-rated below it
|
||||
// (see `VsyncShared::next_target`) — and the measured callback period otherwise.
|
||||
let grid_period_ns = clock
|
||||
.map(|c| c.panel_period_ns().max(c.period_ns()))
|
||||
.filter(|&p| p > 0)
|
||||
.unwrap_or(FALLBACK_PERIOD_NS);
|
||||
// Pick the frame this pump may release.
|
||||
let frame = if self.fifo_capacity == 0 {
|
||||
self.frames.pop_back() // submit() kept it a single slot; back == the newest
|
||||
} else {
|
||||
// FIFO: drain exactly one frame per vsync tick, after preroll; a drain tick that
|
||||
// finds the buffer dry re-arms preroll (the Apple `FrameStore` underflow semantics —
|
||||
// the previous frame persists on glass, a repeat by omission, while headroom
|
||||
// rebuilds). Everything is gated on the tick so an idle stream neither counts
|
||||
// underflows nor churns the preroll flag 200×/s.
|
||||
if !self.vsync_tick {
|
||||
return false;
|
||||
}
|
||||
if !self.prerolled {
|
||||
if self.frames.len() < self.fifo_capacity {
|
||||
return false;
|
||||
// The smoothing store releases on the DUE time, so there is no drain tick to gate on
|
||||
// and no preroll beneath it. One-frame-per-slot was itself the defect: at 60 fps on a
|
||||
// 120 Hz panel it drains at twice supply, the store empties, preroll re-arms, and the
|
||||
// intervals become 1,3,1,3 where 2,2,2 is the correct answer — the smoothing mode
|
||||
// juddering by construction at exactly the rate mismatch it exists to smooth (design
|
||||
// §4.3, D3). Due times one source period apart snap to every second vblank instead.
|
||||
if !self.head_is_releasable(now_mono_ns, grid_period_ns) {
|
||||
if self.vsync_tick && self.frames.is_empty() {
|
||||
self.dry += 1;
|
||||
}
|
||||
self.prerolled = true;
|
||||
}
|
||||
if self.frames.is_empty() {
|
||||
self.prerolled = false;
|
||||
self.dry += 1;
|
||||
self.vsync_tick = false; // this tick's drain ran (and found nothing)
|
||||
self.vsync_tick = false; // this tick's evaluation ran (and released nothing)
|
||||
return false;
|
||||
}
|
||||
self.frames.pop_front()
|
||||
@@ -463,8 +571,11 @@ impl Presenter {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Release: timeline-timed when the clock has one, ASAP otherwise.
|
||||
let target = clock.and_then(|c| c.next_target(now_mono_ns, self.margin_ns));
|
||||
// Release: timeline-timed when the clock has one, ASAP otherwise. Under cadence targeting
|
||||
// the floor is the frame's due time rather than this instant — the source's grid rather
|
||||
// than the network's.
|
||||
let target =
|
||||
clock.and_then(|c| c.next_target(self.not_before_ns(now_mono_ns, frame.due_ns)));
|
||||
let released = match target {
|
||||
Some(t) => codec
|
||||
.release_output_buffer_at_time_by_index(frame.index, t.expected_present_ns)
|
||||
@@ -549,6 +660,12 @@ impl Presenter {
|
||||
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
|
||||
/// A/B headline) / `vsync` (the measured panel period).
|
||||
///
|
||||
/// Under the smoothness intent it carries the cadence loop's health as well — `late‰` of all
|
||||
/// frames folded (a due time already past when the frame became presentable: the direct signal
|
||||
/// the cushion is too small, and WP8's acceptance criterion), `jitter` (the loop residual's
|
||||
/// mean absolute deviation, our first honest per-stream jitter number), `cushion` and
|
||||
/// `reanchors`. Absent under latency, where there is no loop.
|
||||
///
|
||||
/// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period,
|
||||
/// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal
|
||||
/// (design/phase-locked-capture.md §6; the v1 median was immovable under jitter).
|
||||
@@ -577,6 +694,21 @@ impl Presenter {
|
||||
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
|
||||
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
|
||||
let (outstanding, _) = meter.outstanding();
|
||||
// Cumulative over the session rather than this window (the loop's counters survive
|
||||
// `reset`): `late` is a RATE question, and one second of it is too few frames to read a
|
||||
// sub-percent criterion off.
|
||||
let cadence = self
|
||||
.cadence_health()
|
||||
.map(|h| {
|
||||
format!(
|
||||
" late={}‰ jitterMs={:.2} cushionMs={:.2} reanchors={}",
|
||||
h.late.saturating_mul(1000) / h.frames.max(1),
|
||||
h.jitter_ns as f64 / 1e6,
|
||||
h.cushion_ns as f64 / 1e6,
|
||||
h.reanchors,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
log::info!(
|
||||
target: "pf.present",
|
||||
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
|
||||
@@ -584,7 +716,7 @@ impl Presenter {
|
||||
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
|
||||
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
|
||||
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
|
||||
vsyncMs={:.2} panelMs={:.2}",
|
||||
vsyncMs={:.2} panelMs={:.2}{}",
|
||||
self.released,
|
||||
displays,
|
||||
self.paced_drops,
|
||||
@@ -607,6 +739,7 @@ impl Presenter {
|
||||
circ.map(|(_, c)| c).unwrap_or(0),
|
||||
period_ms,
|
||||
panel_ns as f64 / 1e6,
|
||||
cadence,
|
||||
);
|
||||
self.released = 0;
|
||||
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
|
||||
@@ -671,3 +804,108 @@ pub(super) fn presenter_disabled_by_sysprop() -> bool {
|
||||
};
|
||||
n > 0 && &buf[..n as usize] == b"arrival"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SOURCE_HZ: u32 = 120;
|
||||
const P: i64 = 8_333_333; // one 120 Hz source interval, ns
|
||||
/// A host stamping realtime, played out against a monotonic present clock — two different
|
||||
/// eras, which is the whole reason the loop estimates the offset rather than being told it.
|
||||
const PTS0_NS: i64 = 1_786_000_000_000_000_000;
|
||||
const MONO0: i64 = 987_000_000_000;
|
||||
const PTS0_US: u64 = (PTS0_NS / 1000) as u64;
|
||||
|
||||
/// `k`'s pts as the CODEC echoes it — µs, truncated exactly as `feed_ready` queues it — and
|
||||
/// the monotonic instant it became presentable, with `jitter_ns` of transport noise on the
|
||||
/// arrival and none on the source stamp.
|
||||
fn frame(k: i64, jitter_ns: i64) -> (u64, i64) {
|
||||
(((PTS0_NS + k * P) / 1000) as u64, MONO0 + k * P + jitter_ns)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_latency_intent_never_consults_the_cadence_clock() {
|
||||
let mut p = Presenter::new(PresentPriority::Latency, SOURCE_HZ);
|
||||
for k in 0..600 {
|
||||
let (pts_us, ready) = frame(k, (k % 11) * 400_000);
|
||||
assert_eq!(
|
||||
p.due_at(pts_us, ready),
|
||||
None,
|
||||
"latency must produce no due time at all"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
p.cadence_health().is_none(),
|
||||
"latency holds no loop to have health"
|
||||
);
|
||||
// …so the target floor is exactly what it was before the clock existed: this instant plus
|
||||
// SurfaceFlinger's latch lead, and nothing else.
|
||||
assert_eq!(p.not_before_ns(MONO0, None), MONO0 + p.margin_ns);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_smooth_intent_puts_every_decoded_frame_through_the_loop() {
|
||||
let mut p = Presenter::new(PresentPriority::Smooth { buffer: 2 }, SOURCE_HZ);
|
||||
for k in 0..600 {
|
||||
let (pts_us, ready) = frame(k, (k % 11) * 400_000);
|
||||
assert!(p.due_at(pts_us, ready).is_some());
|
||||
}
|
||||
let h = p.cadence_health().expect("smooth holds a loop");
|
||||
assert_eq!(h.frames, 600);
|
||||
assert_eq!(
|
||||
h.reanchors, 1,
|
||||
"only the cold start anchors on a clean trace"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_due_time_ahead_of_now_moves_the_target_and_one_behind_it_does_not() {
|
||||
let p = Presenter::new(PresentPriority::Smooth { buffer: 2 }, SOURCE_HZ);
|
||||
let floor = MONO0 + p.margin_ns;
|
||||
// Due ahead: the release aims at the source's grid, which is the entire change.
|
||||
assert_eq!(p.not_before_ns(MONO0, Some(floor + P)), floor + P);
|
||||
// Due already past — a late frame, which the loop deliberately reports unclamped: present
|
||||
// at the next opportunity, never drag the grid back to the frame.
|
||||
assert_eq!(p.not_before_ns(MONO0, Some(floor - 5 * P)), floor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_store_holds_a_frame_that_is_not_due_yet_and_releases_it_within_reach_of_its_slot() {
|
||||
let mut p = Presenter::new(PresentPriority::Smooth { buffer: 2 }, SOURCE_HZ);
|
||||
let due = MONO0 + 4 * P;
|
||||
p.frames.push_back(HeldFrame {
|
||||
index: 0,
|
||||
pts_us: PTS0_US,
|
||||
decoded_ns: 0,
|
||||
due_ns: Some(due),
|
||||
});
|
||||
assert!(!p.head_is_releasable(MONO0, P), "four refreshes early");
|
||||
assert!(
|
||||
!p.head_is_releasable(due - P - p.margin_ns - 1, P),
|
||||
"one nanosecond outside the submit reach of its own slot"
|
||||
);
|
||||
assert!(
|
||||
p.head_is_releasable(due - P - p.margin_ns, P),
|
||||
"exactly one panel period of reach, the earliest that still buys nothing to wait"
|
||||
);
|
||||
assert!(
|
||||
p.head_is_releasable(due + 10 * P, P),
|
||||
"late frames go at once"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_carrying_no_due_time_is_always_releasable() {
|
||||
// `HeldFrame` is shared with the latency intent, where the due time is always absent, so
|
||||
// the predicate has to drain on a frame it cannot answer for rather than wedge behind it.
|
||||
let mut p = Presenter::new(PresentPriority::Smooth { buffer: 2 }, SOURCE_HZ);
|
||||
p.frames.push_back(HeldFrame {
|
||||
index: 0,
|
||||
pts_us: PTS0_US,
|
||||
decoded_ns: 0,
|
||||
due_ns: None,
|
||||
});
|
||||
assert!(p.head_is_releasable(MONO0, P));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,16 +93,24 @@ impl VsyncShared {
|
||||
self.panel_period_ns.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The release target for a frame submitted at `now`: the earliest stored timeline whose
|
||||
/// EXPECTED PRESENT is still `margin` away, extrapolated forward by whole periods once the
|
||||
/// stored set has aged out (timelines refresh once per vsync callback; a frame can decode
|
||||
/// anywhere inside that window). `None` on the 31/32 fallback — the caller releases ASAP.
|
||||
/// The release target for a frame that must not be presented before `not_before_ns`: the
|
||||
/// earliest stored timeline whose EXPECTED PRESENT is past that instant, extrapolated forward
|
||||
/// by whole periods once the stored set has aged out (timelines refresh once per vsync
|
||||
/// callback; a frame can decode anywhere inside that window). `None` on the 31/32 fallback —
|
||||
/// the caller releases ASAP.
|
||||
///
|
||||
/// The floor is the CALLER's to compose because two constraints meet in it and only the
|
||||
/// caller knows the second: SurfaceFlinger's latch lead (`now + margin`, always) and, under
|
||||
/// cadence targeting, the frame's own due time on the source's timeline. Adding the submit
|
||||
/// lead here to a due time that is already an absolute present instant would push a frame due
|
||||
/// just under a grid point onto the next one for some phases and not others — judder rather
|
||||
/// than latency, which is the defect the due time exists to remove.
|
||||
///
|
||||
/// Gated on `expected_present`, NOT the timeline's `deadline`, on purpose: the deadline
|
||||
/// budgets for GPU rendering the app has yet to submit (`presDeadline` — 11.3 ms on the
|
||||
/// A024, more than a full 120 Hz period), but a video buffer is already fully rendered —
|
||||
/// the only real constraint is SurfaceFlinger's own latch lead, which is what the caller's
|
||||
/// `margin` represents. Targeting by deadline cost every frame an extra refresh of waiting
|
||||
/// the only real constraint is SurfaceFlinger's own latch lead, which is what the caller
|
||||
/// folds into the floor. Targeting by deadline cost every frame an extra refresh of waiting
|
||||
/// (measured: latch p50 ~21 ms vs the ~2-interval floor); a mis-gamble here just means the
|
||||
/// frame presents one vsync later — exactly what the conservative gate always paid.
|
||||
///
|
||||
@@ -110,10 +118,9 @@ impl VsyncShared {
|
||||
/// at the app's assigned render rate, but the panel latches at its own — when the app is
|
||||
/// down-rated (60 Hz callbacks on a 120 Hz panel) the reported timelines are a whole panel
|
||||
/// period apart or more, and pacing to them would cap the video. Pulling the target earlier
|
||||
/// by whole panel periods (while its present still clears the margin) restores the true
|
||||
/// grid; when callbacks run at the panel rate the pull condition is never true and this is
|
||||
/// a no-op.
|
||||
pub(super) fn next_target(&self, now_ns: i64, margin_ns: i64) -> Option<FrameTimeline> {
|
||||
/// by whole panel periods (while its present still clears the floor) restores the true grid;
|
||||
/// when callbacks run at the panel rate the pull condition is never true and this is a no-op.
|
||||
pub(super) fn next_target(&self, not_before_ns: i64) -> Option<FrameTimeline> {
|
||||
let mut t = {
|
||||
let g = self
|
||||
.timelines
|
||||
@@ -121,7 +128,7 @@ impl VsyncShared {
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let found = g
|
||||
.iter()
|
||||
.find(|t| t.expected_present_ns > now_ns + margin_ns)
|
||||
.find(|t| t.expected_present_ns > not_before_ns)
|
||||
.copied();
|
||||
match found {
|
||||
Some(t) => t,
|
||||
@@ -132,8 +139,8 @@ impl VsyncShared {
|
||||
return None;
|
||||
}
|
||||
// All stored timelines have passed — step the last one forward whole
|
||||
// periods until its present clears `now + margin` again.
|
||||
let behind = (now_ns + margin_ns).saturating_sub(last.expected_present_ns);
|
||||
// periods until its present clears the floor again.
|
||||
let behind = not_before_ns.saturating_sub(last.expected_present_ns);
|
||||
let k = behind / period + 1;
|
||||
FrameTimeline {
|
||||
expected_present_ns: last.expected_present_ns + k * period,
|
||||
@@ -144,7 +151,7 @@ impl VsyncShared {
|
||||
};
|
||||
let panel = self.panel_period_ns.load(Ordering::Relaxed);
|
||||
if panel > 0 {
|
||||
while t.expected_present_ns - panel > now_ns + margin_ns {
|
||||
while t.expected_present_ns - panel > not_before_ns {
|
||||
t.deadline_ns -= panel;
|
||||
t.expected_present_ns -= panel;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! The session's access level over JNI — the Android leg of `design/per-client-access.md` §7.
|
||||
//!
|
||||
//! One poll shim: Kotlin reads `[grants, remainingSecs, updateSeq]` ~1 Hz (alongside its
|
||||
//! session-ended watchdog) instead of holding a blocking event thread — access news is a
|
||||
//! console edit or an expiry warning, a handful per session, and every gate the mask drives
|
||||
//! re-checks within a second anyway. The connector already folds each mid-session
|
||||
//! [`punktfunk_core::quic::AccessUpdate`] latest-wins into its live grants/deadline slots;
|
||||
//! the seq counter here only exists so the poller can tell a FRESH update arrived (the host's
|
||||
//! T−5 m / T−1 m warnings owe a toast) without diffing state that a warning doesn't change.
|
||||
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JIntArray, JObject};
|
||||
use jni::sys::jlong;
|
||||
use jni::EnvUnowned;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::SessionHandle;
|
||||
|
||||
/// `NativeBridge.nativeAccessState(handle): IntArray?` — the live access state as
|
||||
/// `[grants, remainingSecs, updateSeq]`; `null` on a `0` handle. `grants` is the
|
||||
/// `GRANT_GAMEPAD`-family bitmask, seeded from the Welcome's advert (an old host reads as
|
||||
/// `GRANT_ALL` — full control, today's behavior); `remainingSecs` counts down to the access
|
||||
/// deadline on the CLIENT's clock (`0` = permanent, clamped to ≥ 1 once a deadline exists so
|
||||
/// the sentinel can never be reached by counting); `updateSeq` increments once per
|
||||
/// `AccessUpdate` drained from the connector's event plane. Not android-gated — pure `jni` +
|
||||
/// connector reads, so it links on the host build too. Cheap; safe on the UI thread.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeAccessState<'local>(
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> JIntArray<'local> {
|
||||
env.with_env(|env| -> jni::errors::Result<JIntArray<'local>> {
|
||||
if handle == 0 {
|
||||
return Ok(JIntArray::default());
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
// Drain the event plane into the seq counter. The connector's grants/deadline slots
|
||||
// are already the latest-wins fold when an event lands — the events carry no state
|
||||
// this read doesn't get below, they are purely the "something arrived" cue. Zero
|
||||
// timeout: this is the UI thread's poll, it must never park.
|
||||
while h.client.next_access_update(Duration::ZERO).is_ok() {
|
||||
h.access_seq.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let remaining: u64 = match h.client.access_deadline_unix() {
|
||||
None => 0, // permanent
|
||||
Some(deadline) => {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
// ≥ 1 once a deadline exists: 0 is the "permanent" sentinel, and a deadline
|
||||
// already past with the session still up (the host's typed close is in
|
||||
// flight) must keep reading as "about to end", never flip to "forever".
|
||||
deadline.saturating_sub(now).max(1)
|
||||
}
|
||||
};
|
||||
let buf: [i32; 3] = [
|
||||
h.client.access_grants() as i32,
|
||||
remaining.min(i32::MAX as u64) as i32,
|
||||
h.access_seq.load(Ordering::Relaxed) as i32,
|
||||
];
|
||||
let arr = env.new_int_array(buf.len())?;
|
||||
arr.set_region(env, 0, &buf)?;
|
||||
Ok(arr)
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -315,6 +315,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
pad_audio: Mutex::new(None),
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
access_seq: std::sync::atomic::AtomicU32::new(0),
|
||||
};
|
||||
Box::into_raw(Box::new(handle)) as jlong
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
|
||||
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
||||
|
||||
mod access;
|
||||
mod clipboard;
|
||||
mod connect;
|
||||
mod input;
|
||||
@@ -25,7 +26,7 @@ mod probe;
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -82,6 +83,10 @@ pub(crate) struct SessionHandle {
|
||||
/// fresh capture could send an unmuted frame. Per session and never persisted: a new session
|
||||
/// starts unmuted.
|
||||
pub mic_muted: Arc<AtomicBool>,
|
||||
/// Count of `AccessUpdate`s drained from the connector's event plane, bumped by the
|
||||
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
|
||||
/// (the host's expiry warnings) arrived without holding a blocking event thread.
|
||||
pub(crate) access_seq: AtomicU32,
|
||||
}
|
||||
|
||||
struct VideoThread {
|
||||
|
||||
@@ -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:
|
||||
@@ -373,7 +378,11 @@ struct ContentView: View {
|
||||
#if !os(tvOS)
|
||||
.focusedSceneValue(\.sessionFocus, SessionFocus(
|
||||
isStreaming: model.connection != nil,
|
||||
clipboardAvailable: model.connection?.hostSupportsClipboard == true,
|
||||
// Host cap AND this device's CLIPBOARD grant (per-client access §7) — an
|
||||
// ungranted session's menu item greys out instead of inviting a refused enable.
|
||||
clipboardAvailable: model.connection.map {
|
||||
$0.hostSupportsClipboard && $0.canUseClipboard
|
||||
} == true,
|
||||
clipboardOn: model.clipboardEnabled,
|
||||
toggleClipboard: { model.toggleClipboardSync() },
|
||||
micAvailable: model.micAvailable,
|
||||
@@ -1058,7 +1067,25 @@ struct ContentView: View {
|
||||
MotionUnreachableBadge()
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
// The expiry-warning toast (T−5 m / T−1 m, per-client access §7) —
|
||||
// transient, every platform, every tier: "the pad just died" must
|
||||
// read as "the evening's access ended" while it can still be fixed.
|
||||
if captureEnabled, let warning = model.accessWarning {
|
||||
AccessWarningBadge(text: warning)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
#if !os(tvOS)
|
||||
// The access chip — up for the life of a LIMITED session ("Controller
|
||||
// only · ends in 1 h 58 m"), at every tier and with the overlay off.
|
||||
// Never mounted for a full-and-permanent session (every old host):
|
||||
// today's look must not change there. tvOS states it as a line in the
|
||||
// stats overlay instead (StreamHUDView).
|
||||
if captureEnabled && model.accessLimited {
|
||||
AccessChipBadge(
|
||||
label: model.accessLevel.label,
|
||||
remainingSecs: model.accessRemainingSecs)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
// Shown for as long as the mic is muted, at every stats tier and with the
|
||||
// overlay off — see MicMutedBadge. tvOS has no microphone to mute.
|
||||
if captureEnabled && model.micMuted {
|
||||
@@ -1078,6 +1105,8 @@ struct ContentView: View {
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
.animation(.easeOut(duration: 0.2), value: model.micMuted)
|
||||
.animation(.easeOut(duration: 0.2), value: model.accessWarning)
|
||||
.animation(.easeOut(duration: 0.2), value: model.accessLimited)
|
||||
}
|
||||
#if os(iOS)
|
||||
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||
@@ -1262,6 +1291,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 +1352,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)
|
||||
|
||||
@@ -551,8 +551,10 @@ struct GamepadHomeView: View {
|
||||
filled: true,
|
||||
// A pinned card reaches the library too, and gets its OWN shelf: browsing is
|
||||
// this card's connect with a title picked first, not a host-level action like
|
||||
// wake or forget.
|
||||
hasLibrary: true,
|
||||
// wake or forget. Gated on a pinned identity: the library plane's MgmtTransport
|
||||
// accepts any cert for an unpinned host, so an unpaired host must not expose a
|
||||
// library affordance a LAN MITM could answer. security-review 2026-08-15 #8.
|
||||
hasLibrary: host.pinnedSHA256 != nil,
|
||||
osChain: host.osChain,
|
||||
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||
&& !online && !host.wakeMacs.isEmpty,
|
||||
|
||||
@@ -268,7 +268,12 @@ struct HomeView: View {
|
||||
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
|
||||
// …and browsing is that same connect with a title picked first, so a pinned card opens its
|
||||
// OWN shelf: every launch off it carries the card's profile rather than the host's binding.
|
||||
let onBrowseLibrary: (() -> Void)? = libraryEnabled
|
||||
// Gated on a pinned identity, not just the feature toggle: the library plane's
|
||||
// MgmtTransport trust-on-first-use accepts ANY cert for a pin-less host (self-signed, no
|
||||
// SAN — system trust is bypassed), so browsing an unpinned host lets a LAN MITM serve a
|
||||
// forged catalog and harvest the device's pairing identity. Pair first, exactly as the
|
||||
// stream path already refuses an unpinned connect. security-review 2026-08-15 finding 8.
|
||||
let onBrowseLibrary: (() -> Void)? = (libraryEnabled && host.pinnedSHA256 != nil)
|
||||
? { libraryTarget = LibraryTarget(host: host, profile: selection) }
|
||||
: nil
|
||||
return HostCardView(
|
||||
|
||||
@@ -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
|
||||
@@ -357,6 +357,17 @@ struct LibraryView: View {
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
// Beyond the client identity, require the HOST's pinned fingerprint. MgmtTransport accepts
|
||||
// ANY cert for a pin-less host (self-signed, no SAN → system trust is bypassed), so browsing
|
||||
// one lets a LAN MITM serve a forged catalog and harvest this device's mTLS identity. A host
|
||||
// can hold a client identity yet no host pin (abandoned pairing, or after "Forget
|
||||
// Identity"), so this is a distinct check. security-review 2026-08-15 finding 8.
|
||||
guard current.pinnedSHA256 != nil else {
|
||||
games = []
|
||||
errorText = "Pair with this host before browsing its library."
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
do {
|
||||
// `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and
|
||||
// the gamepad coverflow both inherit the D4 ordering.
|
||||
@@ -409,7 +420,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
|
||||
@@ -262,6 +262,27 @@ final class SessionModel: ObservableObject {
|
||||
/// The host's last `ClipState.reason` (`CLIP_REASON_*`) — why an enable was refused
|
||||
/// (backend unavailable / policy disabled / …); 0 = OK.
|
||||
@Published private(set) var clipboardReason: UInt8 = 0
|
||||
|
||||
// MARK: - Per-client access (design/per-client-access.md §7)
|
||||
|
||||
/// The session's access preset, derived live from the grants mask (§3.2 — the label is
|
||||
/// never stored). `.fullControl` against every old host and for every full-grant device,
|
||||
/// so nothing below changes today's look there.
|
||||
@Published private(set) var accessLevel: PunktfunkConnection.AccessLevel = .fullControl
|
||||
/// Seconds until this session's access expires; `0` = permanent. Ticks down at the 1 Hz
|
||||
/// stats cadence — the chip's countdown renders straight from it.
|
||||
@Published private(set) var accessRemainingSecs: UInt32 = 0
|
||||
/// Anything about this session's access differs from full-and-permanent — the visibility
|
||||
/// gate for the chip (and the tvOS stats-overlay line). False = today's look, untouched.
|
||||
@Published private(set) var accessLimited = false
|
||||
/// The transient expiry-warning toast ("Access ends in 5 m") — non-nil for a few seconds
|
||||
/// around the T−5 m / T−1 m marks the host also warns at via `AccessUpdate`.
|
||||
@Published private(set) var accessWarning: String?
|
||||
/// One-shot latches for the two warning marks (reset per session).
|
||||
private var accessWarned5m = false
|
||||
private var accessWarned1m = false
|
||||
/// Auto-dismiss for `accessWarning` — held so a newer warning replaces a pending clear.
|
||||
private var accessWarningTimer: Task<Void, Never>?
|
||||
#if os(tvOS)
|
||||
/// Siri Remote → host pointer while streaming (touch surface moves, press = left click,
|
||||
/// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See
|
||||
@@ -566,7 +587,9 @@ final class SessionModel: ObservableObject {
|
||||
#if os(tvOS)
|
||||
return false // no app-accessible microphone — SessionAudio never opens an uplink either
|
||||
#else
|
||||
guard settings.micEnabled else { return false }
|
||||
// The session's grants must include MIC (per-client access §7 — hide the mic UI when
|
||||
// ungranted; a mute button over a mic the host drops would be a lie twice over).
|
||||
guard settings.micEnabled, connection?.canUseMic != false else { return false }
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized, .notDetermined: return true
|
||||
default: return false // denied / restricted — there is no uplink to mute
|
||||
@@ -613,6 +636,71 @@ final class SessionModel: ObservableObject {
|
||||
audio?.setMicMuted(micMuted || isBackgrounded)
|
||||
}
|
||||
|
||||
// MARK: - Per-client access (chip state + expiry warnings)
|
||||
|
||||
/// Refresh the published access state from the connection's LIVE grants + countdown —
|
||||
/// called by the 1 Hz stats tick, which is also what makes a mid-session `AccessUpdate`
|
||||
/// (a console edit) reach the chip and the capture gates within a second. The equality
|
||||
/// guards keep a full-and-permanent session (every old host) from publishing anything.
|
||||
private func updateAccessState() {
|
||||
guard let conn = connection else { return }
|
||||
let grants = conn.accessGrants
|
||||
let level = PunktfunkConnection.AccessLevel(grants: grants)
|
||||
let remaining = conn.accessExpiresInSeconds
|
||||
if accessLevel != level { accessLevel = level }
|
||||
if accessRemainingSecs != remaining { accessRemainingSecs = remaining }
|
||||
let limited = level != .fullControl || remaining != 0
|
||||
if accessLimited != limited { accessLimited = limited }
|
||||
// A mid-session edit that removed BOTH input classes releases an engaged capture:
|
||||
// holding a frozen cursor and swallowed keys over input the host now drops is
|
||||
// exactly the "keyboard does nothing and nobody says why" failure §7 exists to
|
||||
// prevent. (Engage is gated at the stream views; this is the live-revoke half.)
|
||||
if mouseCaptured,
|
||||
grants & (PunktfunkConnection.grantPointer | PunktfunkConnection.grantKeyboard) == 0 {
|
||||
NotificationCenter.default.post(name: .punktfunkReleaseCapture, object: nil)
|
||||
}
|
||||
// The T−5 m / T−1 m warning toasts (§7). Derived from the countdown CROSSING the
|
||||
// marks rather than from the AccessUpdate messages alone: the host's warnings
|
||||
// re-anchor the same countdown, so this shows them when they arrive AND still fires
|
||||
// on plain clock progress if a warning datagram never lands. One shot each; an edit
|
||||
// that extends the deadline back above a mark re-arms it.
|
||||
guard remaining != 0 else { return }
|
||||
if remaining > 300 {
|
||||
accessWarned5m = false
|
||||
accessWarned1m = false
|
||||
} else if remaining > 60 {
|
||||
accessWarned1m = false
|
||||
if !accessWarned5m {
|
||||
accessWarned5m = true
|
||||
showAccessWarning("Access ends in \(Self.accessCountdown(remaining))")
|
||||
}
|
||||
} else if !accessWarned1m {
|
||||
accessWarned1m = true
|
||||
accessWarned5m = true
|
||||
showAccessWarning("Access ends in under a minute")
|
||||
}
|
||||
}
|
||||
|
||||
/// Put one warning toast up for a few seconds (the motion hint's pattern: last one wins,
|
||||
/// its timer restarts, teardown cancels a pending clear).
|
||||
private func showAccessWarning(_ text: String) {
|
||||
accessWarning = text
|
||||
accessWarningTimer?.cancel()
|
||||
accessWarningTimer = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(Self.motionHintSeconds))
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.accessWarning = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// "1 h 58 m" / "12 m" / "45 s" — the countdown wording the chip and the warnings share.
|
||||
static func accessCountdown(_ secs: UInt32) -> String {
|
||||
let s = Int(secs)
|
||||
if s >= 3600 { return "\(s / 3600) h \((s % 3600) / 60) m" }
|
||||
if s >= 60 { return "\(s / 60) m" }
|
||||
return "\(s) s"
|
||||
}
|
||||
|
||||
/// Follow a live stats-overlay cycle (⌃⌥⇧S, the three-finger tap, the Stream menu). Those
|
||||
/// surfaces write the GLOBAL setting as they always have; this moves the session's own tier
|
||||
/// with it, so cycling still works in a session a profile put on a different tier.
|
||||
@@ -658,6 +746,16 @@ final class SessionModel: ObservableObject {
|
||||
motionHintTimer?.cancel()
|
||||
motionHintTimer = nil
|
||||
motionUnreachableKind = nil
|
||||
// Access state is per-session: back to the invisible full-and-permanent default, and
|
||||
// no warning latch may carry into the next stream (same discipline as the mic mute).
|
||||
accessWarningTimer?.cancel()
|
||||
accessWarningTimer = nil
|
||||
accessWarning = nil
|
||||
accessLevel = .fullControl
|
||||
accessRemainingSecs = 0
|
||||
accessLimited = false
|
||||
accessWarned5m = false
|
||||
accessWarned1m = false
|
||||
let audio = self.audio
|
||||
self.audio = nil
|
||||
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
||||
@@ -732,6 +830,10 @@ final class SessionModel: ObservableObject {
|
||||
let name = activeHost?.displayName ?? "host"
|
||||
// WHY it ended, asked while the connection is still up — `disconnect` tears it down.
|
||||
let reason = conn.sessionEndReason
|
||||
// A typed mid-session rejection outranks the coarse reason: an access-expiry close
|
||||
// (per-client access §4) files under `.hostError` there, and "ended with an error"
|
||||
// is the wrong sentence for "your access expired".
|
||||
let rejection = conn.endRejection
|
||||
// Where a game exit sends us: back into the library this title was launched from, so the
|
||||
// next one is a tap away. Only for a launch that CAME from the library — a game exiting in
|
||||
// a plain desktop session has no library to return to.
|
||||
@@ -741,6 +843,11 @@ final class SessionModel: ObservableObject {
|
||||
// without naming one, which is what that launch effectively browsed.
|
||||
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
|
||||
disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect
|
||||
if let rejection {
|
||||
// The shared typed-rejection wording ("Your access to this host has expired…").
|
||||
errorMessage = "\(name): \(rejection.userMessage)"
|
||||
return
|
||||
}
|
||||
switch reason {
|
||||
case .gameExited:
|
||||
// The player quit their own game. Not a failure, and they are probably after the next
|
||||
@@ -795,7 +902,9 @@ final class SessionModel: ObservableObject {
|
||||
speakerUID: settings.speakerUID,
|
||||
micUID: settings.micUID,
|
||||
micChannel: settings.micChannel,
|
||||
micEnabled: settings.micEnabled,
|
||||
// Deny-at-setup for an ungranted mic (per-client access §5): no MIC bit, no
|
||||
// uplink at all — a capture the host would only drop is pure privacy downside.
|
||||
micEnabled: settings.micEnabled && conn.canUseMic,
|
||||
echoCancel: settings.echoCancel,
|
||||
// The A/V sync reference: `endToEnd` is capture→on-glass, the one figure that says
|
||||
// where the picture actually IS, and the audio ring steers its depth to land with it.
|
||||
@@ -833,9 +942,11 @@ final class SessionModel: ObservableObject {
|
||||
gamepadFeedback = feedback
|
||||
#if os(macOS)
|
||||
// Shared clipboard: opt-in per host AND host-advertised (older hosts / operator-disabled
|
||||
// hosts never see a ClipControl). Same trust gate as audio — nothing is announced
|
||||
// hosts never see a ClipControl) AND granted to this device (per-client access §5 —
|
||||
// without the bit the host would refuse with CLIP_REASON_NOT_PERMITTED anyway; not
|
||||
// asking keeps the UI honest). Same trust gate as audio — nothing is announced
|
||||
// during the trust prompt.
|
||||
if activeHost?.clipboardSync == true, conn.hostSupportsClipboard {
|
||||
if activeHost?.clipboardSync == true, conn.hostSupportsClipboard, conn.canUseClipboard {
|
||||
startClipboardSync(conn)
|
||||
}
|
||||
#endif
|
||||
@@ -875,7 +986,7 @@ final class SessionModel: ObservableObject {
|
||||
clipboardEnabled = false
|
||||
clipboardReason = 0
|
||||
Task.detached { sync.stop() }
|
||||
} else if conn.hostSupportsClipboard {
|
||||
} else if conn.hostSupportsClipboard, conn.canUseClipboard {
|
||||
startClipboardSync(conn)
|
||||
}
|
||||
#endif
|
||||
@@ -892,6 +1003,9 @@ final class SessionModel: ObservableObject {
|
||||
// success; this only fires after the timeout.
|
||||
self.resizeIndicator.tick(now: Date().timeIntervalSinceReferenceDate)
|
||||
self.resizing = self.resizeIndicator.active
|
||||
// Access chip + expiry warnings: the same tick that drives every other live
|
||||
// readout also walks the countdown and picks up mid-session grant edits.
|
||||
self.updateAccessState()
|
||||
let (frames, bytes, total) = self.meter.drain()
|
||||
self.fps = frames
|
||||
self.mbps = Double(bytes) * 8 / 1_000_000
|
||||
|
||||
@@ -96,6 +96,21 @@ struct StreamHUDView: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
// The session's access level (per-client access §7). tvOS carries it HERE, as a
|
||||
// stats-overlay line, instead of the floating chip the pointer platforms wear — a
|
||||
// couch surface where every extra overlay competes with the picture keeps the
|
||||
// fact with the other session facts. Absent for full-and-permanent sessions
|
||||
// (every old host): today's overlay must not change there.
|
||||
if model.accessLimited {
|
||||
Text(model.accessRemainingSecs == 0
|
||||
? "access \(model.accessLevel.label.lowercased())"
|
||||
: "access \(model.accessLevel.label.lowercased()) · ends in "
|
||||
+ SessionModel.accessCountdown(model.accessRemainingSecs))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
if model.endToEndValid {
|
||||
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
||||
// corrected) — "(same-host clock)" when the host didn't answer the skew
|
||||
@@ -210,15 +225,20 @@ struct StreamHUDView: View {
|
||||
// Capture hint, shown only until input is captured — how to grab it. The RELEASE
|
||||
// shortcut is intentionally not surfaced in the overlay (it lives on the Stream menu
|
||||
// and, on macOS, the start-of-stream banner), keeping the HUD uncluttered while playing.
|
||||
// Both hints are additionally gated on the session's grants ALLOWING a capture
|
||||
// (per-client access §7): inviting a Controller-only or View-only session to
|
||||
// "capture input" the host would only drop is the lie the grants advert exists
|
||||
// to prevent. Read live off the connection — a re-render lands with the model's
|
||||
// access churn.
|
||||
#if os(macOS)
|
||||
if !model.mouseCaptured {
|
||||
if !model.mouseCaptured, connection.canSendPointer || connection.canSendKeyboard {
|
||||
Text("Click the stream to capture input")
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#elseif os(iOS)
|
||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) captures kb/mouse.
|
||||
if !model.mouseCaptured {
|
||||
if !model.mouseCaptured, connection.canSendPointer || connection.canSendKeyboard {
|
||||
Text("⌘⎋ captures keyboard & mouse")
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -361,6 +381,68 @@ struct MotionUnreachableBadge: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The session's access chip (per-client access §7) — "Controller only · ends in 1 h 58 m".
|
||||
/// Rides over the stream for the life of a LIMITED session, at every stats tier and with the
|
||||
/// overlay off entirely, in the badges' glass language: what this session may do (and for how
|
||||
/// long) is not a statistic, and a guest whose keyboard does nothing deserves the why on
|
||||
/// screen. Never mounted for full-and-permanent sessions — today's look does not change.
|
||||
/// (tvOS states the same fact as a stats-overlay line instead — a chip would fight the couch
|
||||
/// UI's single-focus rule.)
|
||||
struct AccessChipBadge: View {
|
||||
let label: String
|
||||
/// Seconds until access expires; `0` = permanent (the chip then shows the level alone).
|
||||
let remainingSecs: UInt32
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
Text(remainingSecs == 0
|
||||
? label
|
||||
: "\(label) · ends in \(SessionModel.accessCountdown(remainingSecs))")
|
||||
.font(.geist(12, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(
|
||||
remainingSecs == 0
|
||||
? "Access level: \(label)"
|
||||
: "Access level: \(label), ends in \(SessionModel.accessCountdown(remainingSecs))")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The expiry-warning toast (per-client access §7): the host's T−5 m / T−1 m `AccessUpdate`
|
||||
/// warnings, surfaced briefly in the badge stack — every platform, tvOS included (unlike the
|
||||
/// chip, a warning is worth a moment of couch overlay; it is how "the pad just died" becomes
|
||||
/// "the evening's access ended, ask for more").
|
||||
struct AccessWarningBadge: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "clock.badge.exclamationmark")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.yellow)
|
||||
Text(text)
|
||||
.font(.geist(12, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(text)
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The muted-microphone badge — the mute STATE, as opposed to the buttons that flip it. It rides
|
||||
/// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -48,11 +48,8 @@ struct GamepadPairView: View {
|
||||
|
||||
@StateObject private var ceremony = PairCeremony()
|
||||
@State private var pin = ""
|
||||
#if os(macOS)
|
||||
@State private var clientName = Host.current().localizedName ?? "Mac"
|
||||
#else
|
||||
@State private var clientName = UIDevice.current.name
|
||||
#endif
|
||||
// Same source the connect path knocks with — see the note in `PairSheet`.
|
||||
@State private var clientName = DeviceName.current
|
||||
@State private var focusID: String?
|
||||
/// The field row the keyboard tray is editing; nil ⇒ the row list owns the controller.
|
||||
@State private var editing: String?
|
||||
|
||||
@@ -49,7 +49,7 @@ final class PairCeremony: ObservableObject {
|
||||
let identity = try ClientIdentityStore.shared.loadForPairing()
|
||||
return try PunktfunkKit.pair(
|
||||
host: address, port: port, identity: identity,
|
||||
pin: pin, name: name.isEmpty ? "Mac" : name)
|
||||
pin: pin, name: name.isEmpty ? DeviceName.current : name)
|
||||
}
|
||||
await MainActor.run {
|
||||
guard !token.cancelled else { return } // screen dismissed mid-ceremony
|
||||
|
||||
@@ -21,11 +21,9 @@ struct PairSheet: View {
|
||||
let onPaired: (Data) -> Void
|
||||
|
||||
@State private var pin = ""
|
||||
#if os(macOS)
|
||||
@State private var clientName = Host.current().localizedName ?? "Mac"
|
||||
#else
|
||||
@State private var clientName = UIDevice.current.name
|
||||
#endif
|
||||
// Same source the connect path knocks with (`DeviceName.current`), so a device the operator
|
||||
// approves from the console's pending list and one that pairs by PIN land under one name.
|
||||
@State private var clientName = DeviceName.current
|
||||
@StateObject private var ceremony = PairCeremony()
|
||||
|
||||
private var busy: Bool { ceremony.busy }
|
||||
|
||||
@@ -48,11 +48,17 @@ final class AudioRing: @unchecked Sendable {
|
||||
/// gaps per 10 minutes at a 5 ms quantum, against 3 at 8 ms and 1 at 16 ms on an identical
|
||||
/// link. Mirrors `JitterTuning::COREAUDIO.deprime_ms`.
|
||||
private static let deprimeMS = 60
|
||||
/// How long a packet DROUGHT may be concealed (`DroughtConceal`) before this ring is allowed
|
||||
/// to underrun and the hysteresis above is allowed to run: twice that window — long enough to
|
||||
/// ride out the delivery stalls that de-prime rings today, short enough that a genuinely dead
|
||||
/// stream is not papered over. DERIVED from the fuse rather than written out, so it cannot
|
||||
/// drift away from the thing it exists to protect. Mirrors `JitterTuning::plc_max_ms`.
|
||||
static let plcMaxMS = deprimeMS * 2
|
||||
/// Floor in callbacks under `deprimeMS`, so a large-quantum device keeps real hysteresis
|
||||
/// instead of de-priming on the first short read. Mirrors `MIN_DEPRIME_CALLBACKS`.
|
||||
private static let minDeprimeCallbacks = 2
|
||||
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
|
||||
private static let frameMS = 5
|
||||
static let frameMS = 5
|
||||
/// Depth average must exceed target by this before drift correction fires — the middle of the
|
||||
/// headroom band, so the smooth shed always gets its chance BEFORE the hard cap trims.
|
||||
private static let shedExcessMS = 15
|
||||
@@ -151,6 +157,10 @@ final class AudioRing: @unchecked Sendable {
|
||||
/// no timestamps, so the drain thread (which has both a packet's `pts_ns` and the video leg)
|
||||
/// hands the number back for reporting. Mirrors `NativeClient::audio_av_offset_ms`.
|
||||
private var avOffsetMS = 0
|
||||
/// Drought concealment the drain thread has synthesized this session, ms — STORED here for the
|
||||
/// same reason `avOffsetMS` is: the ring cannot compute it, but it is where the numbers a
|
||||
/// listener's complaint needs can be read under one lock.
|
||||
private var plcMS = 0
|
||||
private let channels: Int
|
||||
private let perMS: Int
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
@@ -224,6 +234,16 @@ final class AudioRing: @unchecked Sendable {
|
||||
avOffsetMS = ms
|
||||
}
|
||||
|
||||
/// Store the drain thread's running drought concealment (`DroughtConceal.totalMS`) for
|
||||
/// reporting. Concealment that nobody can see is concealment that hides the bug it is
|
||||
/// covering: a healthy `underruns` bought with a climbing `plc_ms` is a link in trouble, not
|
||||
/// a link that is fine.
|
||||
func notePlcMS(_ ms: Int) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
plcMS = ms
|
||||
}
|
||||
|
||||
/// Buffered depth in interleaved samples — what the sync loop measures against (`bufferedMS`
|
||||
/// is the same quantity rounded for humans). Everything queued here must play before the frame
|
||||
/// the drain thread is about to write, which is exactly what delays it.
|
||||
@@ -481,6 +501,10 @@ final class AudioRing: @unchecked Sendable {
|
||||
/// Reported next to the depth, never instead of it: a deep ring on a jittery link is
|
||||
/// CORRECT behaviour, and only the offset separates that from a ring holding audio late.
|
||||
let avOffsetMS: Int
|
||||
/// Audio synthesized for packet droughts this session (`DroughtConceal`), ms — read next
|
||||
/// to `underruns`, which it exists to prevent, because the two only mean something
|
||||
/// together.
|
||||
let plcMS: Int
|
||||
}
|
||||
|
||||
var stats: Stats {
|
||||
@@ -491,7 +515,8 @@ final class AudioRing: @unchecked Sendable {
|
||||
targetMS: target / max(perMS, 1),
|
||||
underruns: underrunCount,
|
||||
sheds: shedCount,
|
||||
avOffsetMS: avOffsetMS)
|
||||
avOffsetMS: avOffsetMS,
|
||||
plcMS: plcMS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,6 +666,70 @@ struct AvSync {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drought concealment
|
||||
|
||||
/// Bounded concealment of a packet DROUGHT — the Apple leg of the policy the three Rust clients
|
||||
/// share (`punktfunk_core::audio::DroughtConceal`; design/host-source-stutter-fixes.md, WP-C1).
|
||||
///
|
||||
/// The decode path already conceals a SEQ GAP: core's in-ABI decoder synthesizes the packets the
|
||||
/// sequence says went missing before the one that arrived (`nextAudioPcm`). But that only fires
|
||||
/// when a LATER packet arrives to reveal the gap. When the wire simply goes quiet — a delivery
|
||||
/// stall on a bunching Wi-Fi link, or a host whose capture stalled — nothing arrives to reveal
|
||||
/// anything: `AudioRing` drains to empty, the render callback runs short, and `noteRead` de-primes
|
||||
/// and then re-primes a whole target's worth of fresh silence. The artifact is far longer than the
|
||||
/// audio actually missing, and this is the shape the 2026-08-15 field session spent 3–16 % of its
|
||||
/// wall-clock in.
|
||||
///
|
||||
/// So a drought that is draining the ring gets concealed too, from the same decoder state
|
||||
/// (`PunktfunkConnection.audioPlc`), for a bounded time. Denominated in TIME, never in frames or
|
||||
/// callbacks: that is the recorded lesson from the very fuse this protects, where a count gave an
|
||||
/// iPad a third of a Mac's slack (`AudioRing.deprimeMS`, and
|
||||
/// `testDeprimeFuseIsADurationNotACallbackCount`).
|
||||
///
|
||||
/// Time is passed IN, so the policy stays as deterministic as the ring's own.
|
||||
struct DroughtConceal {
|
||||
/// A drought must outlast ordinary arrival jitter before anything is synthesized for it: two
|
||||
/// protocol frames, the same tolerance the host's capture-hole infill uses at the other end.
|
||||
private static let afterMS = 2 * AudioRing.frameMS
|
||||
/// …and the ring must actually be running out. A drought a deep ring can cover is not audible,
|
||||
/// and concealing it would synthesize audio the late packets are about to duplicate — pushing
|
||||
/// the whole stream later and handing the drift shed a mess to clean up audibly.
|
||||
private static let floorMS = 2 * AudioRing.frameMS
|
||||
|
||||
/// Concealed since the last real packet.
|
||||
private var concealedMS = 0
|
||||
private let maxMS: Int
|
||||
/// Concealed over the session — what the 10 s `plc_ms=` line reports. Concealment must be
|
||||
/// visible: a policy that quietly papers over a failing link is a policy that hides the bug.
|
||||
private(set) var totalMS = 0
|
||||
|
||||
init(maxMS: Int) {
|
||||
self.maxMS = maxMS
|
||||
}
|
||||
|
||||
/// A packet arrived, ending any drought — the next one starts from a full budget.
|
||||
///
|
||||
/// The Rust twin also hands back the frames it concealed, for its caller to subtract from the
|
||||
/// loss concealment the seq path is about to ask for. Here that subtraction is core's, on the
|
||||
/// far side of the ABI, because that is where the gap tracker lives (see
|
||||
/// `punktfunk_connection_audio_plc`) — a packet genuinely lost inside a covered drought must
|
||||
/// not be concealed twice either way.
|
||||
mutating func packet() {
|
||||
concealedMS = 0
|
||||
}
|
||||
|
||||
/// Should one more frame be concealed? `depthMS` is the playout ring as the render callback
|
||||
/// last left it.
|
||||
mutating func conceal(sinceLastPacketMS: Int, depthMS: Int) -> Bool {
|
||||
if sinceLastPacketMS < Self.afterMS || depthMS > Self.floorMS || concealedMS >= maxMS {
|
||||
return false
|
||||
}
|
||||
concealedMS += AudioRing.frameMS
|
||||
totalMS += AudioRing.frameMS
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
|
||||
/// stereo (the standard layout is correct). For 5.1/7.1 we list explicit channel labels via
|
||||
/// `kAudioChannelLayoutTag_UseChannelDescriptions` — preset tags (DTS_5_1 etc.) don't reliably
|
||||
|
||||
@@ -1030,6 +1030,16 @@ public final class SessionAudio {
|
||||
defer { drainDone.signal() }
|
||||
var drained = 0
|
||||
var av = AvSync(channels: channels)
|
||||
// WP-C1 — the drought half of concealment. Core heals a SEQ GAP, but only when a later
|
||||
// packet arrives to reveal it; when the wire simply goes quiet nothing arrives to
|
||||
// reveal anything, and the ring drains into an underrun and a de-prime whose re-prime
|
||||
// is a longer artifact than the audio that was missing.
|
||||
var drought = DroughtConceal(maxMS: AudioRing.plcMaxMS)
|
||||
var lastPacketNs = DispatchTime.now().uptimeNanoseconds
|
||||
// Something has decoded, so there is both state to conceal from and continuity to
|
||||
// hold. Until then a session whose host never sends audio keeps the long timeout below
|
||||
// rather than waking two hundred times a second to do nothing.
|
||||
var decoded = false
|
||||
// Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is
|
||||
// stereo-only — and is handed back as interleaved f32 PCM in wire channel order.
|
||||
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
|
||||
@@ -1038,11 +1048,48 @@ public final class SessionAudio {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
let pcm: PunktfunkConnection.AudioPCM?
|
||||
do {
|
||||
pcm = try connection.nextAudioPcm(timeoutMs: 100)
|
||||
// Wait at most one frame WHILE there is a stream to protect: the drought
|
||||
// decision has to be made on the wire's schedule, not whenever the next packet
|
||||
// happens to turn up.
|
||||
pcm = try connection.nextAudioPcm(
|
||||
timeoutMs: decoded ? UInt32(AudioRing.frameMS) : 100)
|
||||
} catch {
|
||||
return false // session closed
|
||||
}
|
||||
guard let pcm, pcm.frameCount > 0 else { return true }
|
||||
guard let pcm, pcm.frameCount > 0 else {
|
||||
// Nothing on the wire. If the ring is draining with it, conceal from the
|
||||
// decoder's own state — the same libopus interpolation the loss path uses,
|
||||
// bounded by this ring's de-prime fuse so a genuinely dead stream is not
|
||||
// papered over. ONE frame per tick, not a burst: this arm runs every frame,
|
||||
// which is the rate the callback drains at, so concealment keeps pace with
|
||||
// playout instead of racing ahead of a depth reading it has already
|
||||
// invalidated.
|
||||
guard decoded else { return true }
|
||||
let quietMS = Int(
|
||||
(DispatchTime.now().uptimeNanoseconds &- lastPacketNs) / 1_000_000)
|
||||
guard drought.conceal(sinceLastPacketMS: quietMS, depthMS: ring.bufferedMS)
|
||||
else {
|
||||
return true
|
||||
}
|
||||
let plc: PunktfunkConnection.AudioPCM?
|
||||
do {
|
||||
plc = try connection.audioPlc()
|
||||
} catch {
|
||||
return false // session closed
|
||||
}
|
||||
if let plc {
|
||||
plc.samples.withUnsafeBufferPointer { p in
|
||||
if let base = p.baseAddress {
|
||||
ring.write(base, count: plc.frameCount * plc.channels)
|
||||
}
|
||||
}
|
||||
}
|
||||
ring.notePlcMS(drought.totalMS)
|
||||
return true
|
||||
}
|
||||
decoded = true
|
||||
lastPacketNs = DispatchTime.now().uptimeNanoseconds
|
||||
drought.packet()
|
||||
// Place this frame against the picture it belongs with BEFORE queueing it: the
|
||||
// depth read here is everything that must still play first, which is exactly what
|
||||
// delays it. Skipped wholesale when no meter was wired, so an un-armed session
|
||||
@@ -1070,12 +1117,14 @@ public final class SessionAudio {
|
||||
// Periodic vitals (~10 s at the protocol's 5 ms frames). The other three clients
|
||||
// log buffer depth and underruns; without this an Apple audio report — latency or
|
||||
// dropout — arrives with no numbers at all, which is the position every platform
|
||||
// was in before the 2026-08 audio work.
|
||||
// was in before the 2026-08 audio work. `plc_ms` rides along because a healthy
|
||||
// `underruns` bought with a climbing `plc_ms` is a link in trouble, not a link
|
||||
// that is fine.
|
||||
drained += 1
|
||||
if drained % 2_000 == 0 {
|
||||
let s = ring.stats
|
||||
log.info(
|
||||
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds) av_offset_ms=\(s.avOffsetMS)"
|
||||
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds) av_offset_ms=\(s.avOffsetMS) plc_ms=\(s.plcMS)"
|
||||
)
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// The name this device tells a host it is — the label an operator approves in the web console.
|
||||
|
||||
import Foundation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// The name the USER knows this device by: "Enrico's iPad", "Wohnzimmer UG", "Enricos MacBook Pro".
|
||||
///
|
||||
/// The host shows it in its pending-approval list — the web console's outstanding-pairings view and
|
||||
/// the dialog that approves a knock — and files the device under it in the trust store. It is the
|
||||
/// ONLY thing distinguishing one waiting device from another there, so it must come from the OS
|
||||
/// name the user set, not from a placeholder.
|
||||
///
|
||||
/// The core's own default (`punktfunk_connect_ex9` and earlier) reads `COMPUTERNAME` / `HOSTNAME`
|
||||
/// — a Windows variable and a shell variable. Neither exists in a `launchd`-started GUI app, so
|
||||
/// every Apple client used to fall through to the literal "This device" and a console with an
|
||||
/// iPad, an Apple TV and a Mac pending showed three rows of it. Pass this to
|
||||
/// `punktfunk_connect_ex10` instead (`PunktfunkConnection.init` does, by default).
|
||||
public enum DeviceName {
|
||||
/// This device's user-facing name, never empty.
|
||||
public static var current: String {
|
||||
#if os(macOS)
|
||||
let name = (Host.current().localizedName ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? (hostName ?? kind) : name
|
||||
#else
|
||||
let name = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// iOS/tvOS 16+ answer `name` with the MODEL ("iPad") unless the app holds the
|
||||
// user-assigned-device-name entitlement — which turns a household's three iPads into
|
||||
// three identical rows in the host's approval list. The hostname is not behind that
|
||||
// gate on every OS version, and when the user has named the device it carries that
|
||||
// name ("Enricos-iPad"), so prefer it whenever `name` came back generic.
|
||||
if name.isEmpty || name == kind {
|
||||
if let host = hostName { return host }
|
||||
}
|
||||
return name.isEmpty ? kind : name
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The OS hostname without its mDNS `.local` suffix — nil when it is unset or the placeholder
|
||||
/// every unconfigured device reports, which would name nothing.
|
||||
private static var hostName: String? {
|
||||
let host = ProcessInfo.processInfo.hostName
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let bare = host.hasSuffix(".local") ? String(host.dropLast(6)) : host
|
||||
guard !bare.isEmpty, bare.caseInsensitiveCompare("localhost") != .orderedSame else {
|
||||
return nil
|
||||
}
|
||||
return bare
|
||||
}
|
||||
|
||||
/// What to call the device when the OS has no name for it — the product, which at least tells
|
||||
/// an operator which of the pending rows is the Apple TV. (iOS/tvOS 16+ answer
|
||||
/// `UIDevice.current.name` with exactly this unless the app holds the user-assigned-name
|
||||
/// entitlement, so the two agree more often than not.)
|
||||
public static var kind: String {
|
||||
#if os(macOS)
|
||||
return "Mac"
|
||||
#elseif os(tvOS)
|
||||
return "Apple TV"
|
||||
#else
|
||||
return UIDevice.current.model // "iPad" / "iPhone"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,12 @@ enum HTTPResponseParser {
|
||||
guard let length = Int(field.trimmingCharacters(in: .whitespaces)), length >= 0 else {
|
||||
throw HTTPParseError.malformedHeader
|
||||
}
|
||||
let end = head.bodyStart + length
|
||||
// A malicious host can send Content-Length = Int.max; `bodyStart + length` would then
|
||||
// overflow, and Swift integer overflow TRAPS (uncatchable crash), not throws. Add
|
||||
// reporting overflow and reject instead. security-review 2026-08-15 (low: HTTPResponse
|
||||
// Int overflow).
|
||||
let (end, overflow) = head.bodyStart.addingReportingOverflow(length)
|
||||
if overflow { throw HTTPParseError.malformedHeader }
|
||||
return b.count >= end ? end : nil
|
||||
}
|
||||
return nil // framed by connection close
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -99,6 +99,12 @@ public enum HostRejection: Sendable {
|
||||
case superseded
|
||||
case wireVersionMismatch
|
||||
case busy
|
||||
/// This device's access grant expired (per-client access §4) — at connect (an expired
|
||||
/// record races the knock path), or as the typed close ending a live session.
|
||||
case accessExpired
|
||||
/// The Hello asked to launch a title but this device's grants exclude `LAUNCH` — refused
|
||||
/// at the handshake so the user gets a sentence, not a bare desktop they didn't ask for.
|
||||
case launchNotPermitted
|
||||
|
||||
init?(status: Int32) {
|
||||
switch status {
|
||||
@@ -111,6 +117,8 @@ public enum HostRejection: Sendable {
|
||||
case PUNKTFUNK_STATUS_REJECTED_SUPERSEDED.rawValue: self = .superseded
|
||||
case PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION.rawValue: self = .wireVersionMismatch
|
||||
case PUNKTFUNK_STATUS_REJECTED_BUSY.rawValue: self = .busy
|
||||
case PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED.rawValue: self = .accessExpired
|
||||
case PUNKTFUNK_STATUS_REJECTED_LAUNCH_NOT_PERMITTED.rawValue: self = .launchNotPermitted
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
@@ -140,6 +148,12 @@ public enum HostRejection: Sendable {
|
||||
return "Client and host versions don't match — update both to the same release."
|
||||
case .busy:
|
||||
return "The host is busy with another session."
|
||||
case .accessExpired:
|
||||
return "Your access to this host has expired — ask its owner to grant "
|
||||
+ "access again."
|
||||
case .launchNotPermitted:
|
||||
return "This device isn't permitted to launch games on the host — connect "
|
||||
+ "to the desktop instead, or ask the owner to allow launching."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,6 +466,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.
|
||||
@@ -473,6 +495,135 @@ public final class PunktfunkConnection {
|
||||
hostCaps & UInt8(PUNKTFUNK_HOST_CAP_PEN) != 0
|
||||
}
|
||||
|
||||
// MARK: - Per-client access (design/per-client-access.md §7)
|
||||
|
||||
/// The `PUNKTFUNK_GRANT_*` access bits — what a paired device may DO on the host, per the
|
||||
/// session's live grants (``accessGrants``). Values are wire/ABI-frozen (the header's
|
||||
/// expression macros don't import into Swift, like `userFlagChunkAligned`'s).
|
||||
public static let grantGamepad: UInt32 = 1 << 0
|
||||
public static let grantPointer: UInt32 = 1 << 1
|
||||
public static let grantKeyboard: UInt32 = 1 << 2
|
||||
public static let grantClipboard: UInt32 = 1 << 3
|
||||
public static let grantMic: UInt32 = 1 << 4
|
||||
public static let grantLaunch: UInt32 = 1 << 5
|
||||
/// Every defined grant — full control, today's behavior and what an old host's Welcome
|
||||
/// decodes to.
|
||||
public static let grantAll: UInt32 = 0x3F
|
||||
|
||||
/// The three user-facing access presets plus "Custom", DERIVED from the mask (never
|
||||
/// stored — design §3.2, no drift). The label vocabulary is the cross-client one the web
|
||||
/// console's Access column uses.
|
||||
public enum AccessLevel: Sendable, Equatable {
|
||||
case fullControl
|
||||
case controllerOnly
|
||||
case viewOnly
|
||||
case custom
|
||||
|
||||
public init(grants: UInt32) {
|
||||
switch grants & PunktfunkConnection.grantAll {
|
||||
case PunktfunkConnection.grantAll: self = .fullControl
|
||||
case PunktfunkConnection.grantGamepad: self = .controllerOnly
|
||||
case 0: self = .viewOnly
|
||||
default: self = .custom
|
||||
}
|
||||
}
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .fullControl: return "Full control"
|
||||
case .controllerOnly: return "Controller only"
|
||||
case .viewOnly: return "View only"
|
||||
case .custom: return "Custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's LIVE effective access grants (`PUNKTFUNK_GRANT_*`): the Welcome advert
|
||||
/// first, then latest-wins over every mid-session `AccessUpdate` (a console edit) — so a
|
||||
/// 1 Hz poll of this is how the chip and the capture gates track changes. Full control
|
||||
/// against an old host, and after close (nothing to restrict on a dead session).
|
||||
///
|
||||
/// Courtesy truth only: the HOST enforces the mask regardless. The client uses it to not
|
||||
/// capture what can't land — a keyboard that silently does nothing is the failure mode
|
||||
/// this exists to prevent.
|
||||
public var accessGrants: UInt32 {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return Self.grantAll }
|
||||
var grants: UInt32 = Self.grantAll
|
||||
_ = punktfunk_connection_grants(h, &grants)
|
||||
return grants
|
||||
}
|
||||
|
||||
/// Seconds until this session's access expires, LIVE (the core counts it down from the
|
||||
/// Welcome / the latest `AccessUpdate`, anchored to this device's clock — skew never moves
|
||||
/// it). `0` = permanent — show no countdown then; while a deadline exists it clamps to
|
||||
/// ≥ 1, so `0` stays unambiguous. Poll ~1 Hz for the "ends in 1 h 58 m" chip.
|
||||
public var accessExpiresInSeconds: UInt32 {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return 0 }
|
||||
var secs: UInt32 = 0
|
||||
_ = punktfunk_connection_access_expires_in(h, &secs)
|
||||
return secs
|
||||
}
|
||||
|
||||
/// The session's grants allow controller input (pads, rich DualSense input).
|
||||
public var canSendGamepad: Bool { accessGrants & Self.grantGamepad != 0 }
|
||||
/// The session's grants allow pointing input (mouse, scroll, touch, pen) — the
|
||||
/// pointer-lock / touch-capture gate.
|
||||
public var canSendPointer: Bool { accessGrants & Self.grantPointer != 0 }
|
||||
/// The session's grants allow key input — the keyboard-grab gate.
|
||||
public var canSendKeyboard: Bool { accessGrants & Self.grantKeyboard != 0 }
|
||||
/// The session's grants allow the shared clipboard (AND this with
|
||||
/// ``hostSupportsClipboard`` before offering the toggle).
|
||||
public var canUseClipboard: Bool { accessGrants & Self.grantClipboard != 0 }
|
||||
/// The session's grants allow mic injection — hide the mic UI without it.
|
||||
public var canUseMic: Bool { accessGrants & Self.grantMic != 0 }
|
||||
/// Anything about this session's access differs from the everyday full-and-permanent —
|
||||
/// the chip's visibility gate: full + permanent must look exactly like today.
|
||||
public var accessIsLimited: Bool {
|
||||
accessGrants & Self.grantAll != Self.grantAll || accessExpiresInSeconds != 0
|
||||
}
|
||||
|
||||
/// The grant bit one wire input kind needs — the Swift mirror of core's exhaustive
|
||||
/// `classify` (keys → keyboard; mouse/scroll/touch → pointer; pads → gamepad), consulted
|
||||
/// by ``send(_:)``'s courtesy filter. An unknown/future kind maps to 0 — never granted —
|
||||
/// matching the host's default-deny.
|
||||
private static func grantBit(forInputKind kind: UInt8) -> UInt32 {
|
||||
switch UInt32(kind) {
|
||||
case PUNKTFUNK_INPUT_KIND_KEY_DOWN.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_KEY_UP.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_TEXT_INPUT.rawValue:
|
||||
return grantKeyboard
|
||||
case PUNKTFUNK_INPUT_KIND_MOUSE_MOVE.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_MOUSE_MOVE_ABS.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_DOWN.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_UP.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_MOUSE_SCROLL.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_TOUCH_DOWN.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_TOUCH_MOVE.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_TOUCH_UP.rawValue:
|
||||
return grantPointer
|
||||
case PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_STATE.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue:
|
||||
return grantGamepad
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the LIVE grants include `bit`. Call with `abiLock` held and a live handle —
|
||||
/// the send paths' shape, so the read and the send see the same session.
|
||||
private func granted(_ bit: UInt32, handle h: OpaquePointer) -> Bool {
|
||||
var grants: UInt32 = Self.grantAll
|
||||
_ = punktfunk_connection_grants(h, &grants)
|
||||
return grants & bit != 0
|
||||
}
|
||||
|
||||
/// One forwarded host-cursor shape (the cursor channel, ABI v11): straight-alpha RGBA,
|
||||
/// `rgba.count == width * height * 4`, hotspot within the bitmap. Cache by `serial` —
|
||||
/// states reference shapes by it and a re-shown serial never resends pixels.
|
||||
@@ -596,6 +747,7 @@ public final class PunktfunkConnection {
|
||||
preferredCodec: UInt8 = 0, // 0 = auto; else PUNKTFUNK_CODEC_* soft preference
|
||||
clientCaps: UInt8 = 0, // ABI v11: PUNKTFUNK_CLIENT_CAP_CURSOR = render the host cursor locally
|
||||
launchID: String? = nil,
|
||||
deviceName: String? = nil, // nil = this device's OS name (`DeviceName.current`)
|
||||
timeoutMs: UInt32 = 10_000
|
||||
) throws {
|
||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||
@@ -608,25 +760,33 @@ public final class PunktfunkConnection {
|
||||
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
|
||||
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
|
||||
// the session; the host resolves it against its own library — nil = the host's default.
|
||||
// `label` is what an unpaired knock shows up as in the host's approval list (and the web
|
||||
// console's outstanding-pairings view): this device's OS name unless the caller overrode
|
||||
// it. Without it the core falls back to environment variables no Apple app has, and every
|
||||
// device pending approval reads "This device".
|
||||
let override = deviceName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let label = override.isEmpty ? DeviceName.current : override
|
||||
handle = host.withCString { cs in
|
||||
withOptionalCString(identity?.certPEM) { cert in
|
||||
withOptionalCString(identity?.keyPEM) { key in
|
||||
withOptionalCString(launchID) { launch in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex9(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||
cert, key, timeoutMs, &connectStatus)
|
||||
label.withCString { name in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex10(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||
cert, key, name, timeoutMs, &connectStatus)
|
||||
}
|
||||
}
|
||||
return punktfunk_connect_ex10(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
nil, &observed, cert, key, name, timeoutMs, &connectStatus)
|
||||
}
|
||||
return punktfunk_connect_ex9(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
nil, &observed, cert, key, timeoutMs, &connectStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,6 +837,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`.
|
||||
@@ -973,6 +1139,46 @@ public final class PunktfunkConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthesize one frame of concealment from the in-core decoder's own state — no packet
|
||||
/// involved, nothing pulled off the wire. `nil` when there is nothing to extrapolate from
|
||||
/// (before the first decode, or when libopus declines), which the caller treats exactly like a
|
||||
/// timeout: write nothing this tick. Throws `.closed` once the session ended.
|
||||
///
|
||||
/// `nextAudioPcm` heals a gap the SEQUENCE reveals; that needs a later packet to arrive and
|
||||
/// reveal it. This is for the wire simply going quiet, where nothing arrives to reveal
|
||||
/// anything and the ring drains into an underrun and a de-prime whose re-prime is a longer
|
||||
/// artifact than the audio that was missing. `DroughtConceal` owns WHEN to ask — bounded in
|
||||
/// time, and only while the ring is genuinely running out.
|
||||
///
|
||||
/// Same audio thread as `nextAudioPcm`, whose borrowed buffer this call invalidates (they
|
||||
/// share the slot). The returned `samples` are copied out. `ptsNs`/`seq` read 0: this frame was
|
||||
/// never on the wire, so it has no capture instant and must not reach an `AvSync` observation.
|
||||
public func audioPlc() throws -> AudioPCM? {
|
||||
audioLock.lock()
|
||||
defer { audioLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var out = PunktfunkAudioPcm()
|
||||
let rc = punktfunk_connection_audio_plc(h, &out)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
let channels = Int(out.channels)
|
||||
let total = Int(out.frame_count) * channels
|
||||
guard let base = out.samples, total > 0 else { return nil }
|
||||
// Copy: the pointer borrows connection memory only until the next PCM call.
|
||||
let samples = Array(UnsafeBufferPointer(start: base, count: total))
|
||||
return AudioPCM(
|
||||
samples: samples, frameCount: Int(out.frame_count),
|
||||
channels: channels, ptsNs: out.pts_ns, seq: out.seq)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next force-feedback update for the GCController haptics engine:
|
||||
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop.
|
||||
/// Drain from the (single) feedback thread, alongside `nextHidOutput`. Drops the v2
|
||||
@@ -1257,12 +1463,17 @@ public final class PunktfunkConnection {
|
||||
}
|
||||
|
||||
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
|
||||
/// silently dropped after close.
|
||||
/// silently dropped after close — and dropped when the session's live grants exclude the
|
||||
/// event's class (the courtesy mirror of the host's classify-and-drop: the HOST enforces
|
||||
/// regardless, but not putting undeliverable events on the wire is what lets every input
|
||||
/// path honor a mid-session grant edit without each caller re-checking).
|
||||
public func send(_ event: PunktfunkInputEvent) {
|
||||
var ev = event
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
guard let h = handle, !closeRequested,
|
||||
granted(Self.grantBit(forInputKind: ev.kind), handle: h)
|
||||
else { return }
|
||||
_ = punktfunk_connection_send_input(h, &ev)
|
||||
}
|
||||
|
||||
@@ -1273,7 +1484,9 @@ public final class PunktfunkConnection {
|
||||
guard !samples.isEmpty else { return }
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
// The pen plane is pointing input — same courtesy grant gate as `send(_:)`.
|
||||
guard let h = handle, !closeRequested, granted(Self.grantPointer, handle: h)
|
||||
else { return }
|
||||
samples.withUnsafeBufferPointer { buf in
|
||||
_ = punktfunk_connection_send_pen(h, buf.baseAddress, UInt32(buf.count))
|
||||
}
|
||||
@@ -1325,7 +1538,10 @@ public final class PunktfunkConnection {
|
||||
public func sendMic(_ opus: Data, seq: UInt32, ptsNs: UInt64) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
// Mic injection needs its grant — same courtesy gate as `send(_:)` (the host drops
|
||||
// the plane regardless; the UI additionally hides the mic controls via `canUseMic`).
|
||||
guard let h = handle, !closeRequested, granted(Self.grantMic, handle: h)
|
||||
else { return }
|
||||
opus.withUnsafeBytes { p in
|
||||
_ = punktfunk_connection_send_mic(
|
||||
h, p.bindMemory(to: UInt8.self).baseAddress, UInt(opus.count), seq, ptsNs)
|
||||
@@ -1339,7 +1555,9 @@ public final class PunktfunkConnection {
|
||||
public func sendTouchpad(pad: UInt8 = 0, finger: UInt8, active: Bool, x: UInt16, y: UInt16) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
// Rich pad input rides the GAMEPAD grant (it IS controller input) — same gate as `send`.
|
||||
guard let h = handle, !closeRequested, granted(Self.grantGamepad, handle: h)
|
||||
else { return }
|
||||
var rich = PunktfunkRichInput()
|
||||
rich.kind = UInt8(PUNKTFUNK_RICH_TOUCHPAD)
|
||||
rich.pad = pad
|
||||
@@ -1360,7 +1578,9 @@ public final class PunktfunkConnection {
|
||||
) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
// Motion is controller input too — same GAMEPAD gate as `sendTouchpad`.
|
||||
guard let h = handle, !closeRequested, granted(Self.grantGamepad, handle: h)
|
||||
else { return }
|
||||
var rich = PunktfunkRichInput()
|
||||
rich.kind = UInt8(PUNKTFUNK_RICH_MOTION)
|
||||
rich.pad = pad
|
||||
@@ -1569,6 +1789,20 @@ public final class PunktfunkConnection {
|
||||
/// Shorthand for the single most actionable reason: the host's launched game exited.
|
||||
public var endedBecauseGameExited: Bool { sessionEndReason == .gameExited }
|
||||
|
||||
/// The typed rejection a MID-SESSION close carried, if any — an access expiry being the
|
||||
/// case this exists for: `sessionEndReason` can only file that deliberate close under
|
||||
/// `.hostError`, and "ended with an error" is the wrong sentence for "your access
|
||||
/// expired". Same read discipline as `sessionEndReason` (ask after the end, before
|
||||
/// teardown); nil for every ordinary end and for connect-time rejections (those surface
|
||||
/// from the connect itself as `.rejected`).
|
||||
public var endRejection: HostRejection? {
|
||||
guard let h = liveHandle() else { return nil }
|
||||
var status: Int32 = 0
|
||||
guard punktfunk_connection_end_reject(h, &status) == statusOK, status != 0
|
||||
else { return nil }
|
||||
return HostRejection(status: status)
|
||||
}
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Snapshot the handle unless close is pending (callers hold their plane lock).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -162,6 +162,28 @@ public final class FrameStore<Frame>: @unchecked Sendable {
|
||||
return f
|
||||
}
|
||||
|
||||
/// Cadence-driven take: hand back the oldest frame only once its DUE time has arrived, so the
|
||||
/// store's job becomes "hold what is not due yet" instead of "release one per present
|
||||
/// opportunity" (design/presenter-cadence-rework.md §4.3). `due` projects the frame's due
|
||||
/// instant on the same clock as `now`; nil (no cadence estimate for that frame) means due
|
||||
/// immediately.
|
||||
///
|
||||
/// The preroll gate does not apply here. It exists only to build headroom for a per-slot
|
||||
/// drain, and under cadence targeting the cushion IS the headroom — prerolling on top would
|
||||
/// stack `capacity − 1` frames of standing latency the user never asked for. `underflows` is
|
||||
/// not counted either: an empty store is the normal steady state once frames are held until
|
||||
/// due, so the honest starvation signal is `CadenceHealth.late` (the due time had already
|
||||
/// passed when the frame became presentable), not a run-dry count.
|
||||
func take(dueBy now: CFTimeInterval, due: (Frame) -> CFTimeInterval?) -> Frame? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let oldest = frames.first else { return nil }
|
||||
if let at = due(oldest), at > now { return nil } // held — not due yet
|
||||
if isFifo { return frames.removeFirst() }
|
||||
frames.removeAll(keepingCapacity: true)
|
||||
return oldest
|
||||
}
|
||||
|
||||
/// Return a frame the render thread took but could not present (no drawable yet, or a
|
||||
/// transient render failure). Newest-wins keeps it only while the slot is still empty — a
|
||||
/// newer decoded frame wins; FIFO reinserts it at the FRONT (it is the oldest; a transient
|
||||
@@ -205,6 +227,269 @@ private final class VsyncClock: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tuning for one cadence loop. Gains are SHIFT COUNTS — the loop is fixed-point Int64
|
||||
/// throughout, so it runs identically on every client and in the offline harness, and carries no
|
||||
/// float into a present path.
|
||||
///
|
||||
/// ⚠ **These values are provisional, and saying so is part of the design.** The plan asks for
|
||||
/// constants fitted to recorded `(src_pts, received, decoded)` traces (its spike S2), and S2 was
|
||||
/// never run. What is here is derived from first principles — a proportional time constant of tens
|
||||
/// of frames, an integral an order slower, a cushion of a few mean-absolute-deviations — and the
|
||||
/// first real trace should replace them.
|
||||
///
|
||||
/// ⚠ A hand-written port of `punktfunk_core::phase::CadenceTuning`, exactly as `AudioRing` is a
|
||||
/// port of `JitterPolicy`: this pipeline is Swift and does not link that Rust type. **Every
|
||||
/// constant and rule here must stay in lockstep with it** — `CadenceClockTests` and the Rust
|
||||
/// `phase::tests` assert the two against the same synthetic input, and that agreement IS the
|
||||
/// contract.
|
||||
struct CadenceTuning: Equatable {
|
||||
/// Proportional gain on the offset estimate: `1 >> offsetShift` of the residual per frame.
|
||||
var offsetShift: UInt8
|
||||
/// Integral gain on the per-frame rate (skew) term: `1 >> skewShift`.
|
||||
var skewShift: UInt8
|
||||
/// EMA weight for the residual mean-absolute-deviation.
|
||||
var jitterShift: UInt8
|
||||
/// Per-sample residual clamp — one outlier must not yank the estimate.
|
||||
var errorClampNs: Int64
|
||||
/// Cushion = `mad * cushionNum / cushionDen`, clamped to
|
||||
/// `[cushionFloorNs, frameIntervalNs]`.
|
||||
var cushionNum: UInt16
|
||||
var cushionDen: UInt16
|
||||
var cushionFloorNs: Int64
|
||||
/// Source-timestamp gap beyond which the loop re-anchors instead of tracking.
|
||||
var reanchorGapNs: Int64
|
||||
|
||||
/// For callers that snap the due time onto a display grid afterwards: the snap-up itself
|
||||
/// carries roughly half a refresh of implicit slack, so the cushion can be small. Every Apple
|
||||
/// present snaps — onto `VsyncClock.nextVsync` under arrival/glass pacing, onto the
|
||||
/// CAMetalDisplayLink's own vend under deadline pacing — so this is the one this client runs.
|
||||
static func snapping() -> CadenceTuning {
|
||||
CadenceTuning(
|
||||
offsetShift: 5, skewShift: 10, jitterShift: 5, errorClampNs: 20_000_000,
|
||||
cushionNum: 2, cushionDen: 1, cushionFloorNs: 500_000,
|
||||
reanchorGapNs: 500_000_000)
|
||||
}
|
||||
|
||||
/// For callers presenting at the due time directly (VRR, direct scanout): no implicit slack,
|
||||
/// so the cushion must cover more of the distribution on its own.
|
||||
static func freeRunning() -> CadenceTuning {
|
||||
var t = snapping()
|
||||
t.cushionNum = 3
|
||||
t.cushionFloorNs = 2_000_000
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
/// Loop health for the pf-present line — the numbers that say whether the cushion is doing its
|
||||
/// job. Mirrors `punktfunk_core::phase::CadenceHealth`.
|
||||
///
|
||||
/// Residual PERCENTILES are deliberately absent: this type holds no histogram, and the client
|
||||
/// stat paths (the latency meters) are where distributions belong.
|
||||
struct CadenceHealth: Equatable {
|
||||
/// Frames folded since the last `reset`.
|
||||
var frames: UInt64 = 0
|
||||
/// …of which the due time was already past when the frame became presentable. The direct
|
||||
/// signal that the cushion is too small.
|
||||
var late: UInt64 = 0
|
||||
/// Times the loop gave up tracking and re-anchored (gap, regression, or explicit reset).
|
||||
var reanchors: UInt64 = 0
|
||||
var offsetNs: Int64 = 0
|
||||
var skewNs: Int64 = 0
|
||||
var jitterNs: Int64 = 0
|
||||
var cushionNs: Int64 = 0
|
||||
}
|
||||
|
||||
/// Plays frames out on the SOURCE's cadence instead of on their arrival instant.
|
||||
///
|
||||
/// The defect it exists for: every client presents a frame as soon as it is decoded, so the
|
||||
/// transport's jitter — and, on a host whose compositor delivers raggedly, the compositor's —
|
||||
/// lands on the glass 1:1. The 2026-08-15 Skynet field log has KWin's screencast arriving
|
||||
/// 0.11–8.22 ms off its own grid (up to a full 120 Hz period) for 24 minutes on a session with the
|
||||
/// bitrate pinned and zero loss. The loop estimates the offset between the source clock and the
|
||||
/// present clock and hands back a due time on the source's own timeline plus a cushion sized to
|
||||
/// the measured jitter.
|
||||
///
|
||||
/// **Type-2 on purpose.** It tracks offset *and* per-frame rate, because two free-running crystals
|
||||
/// produce a ramp and a proportional-only loop lags a ramp forever.
|
||||
///
|
||||
/// **It smooths the offset, never the timestamps.** Due is `srcPts + offset + cushion`, so genuine
|
||||
/// variation in the source's own cadence — a variable-rate renderer, an irregular capture tick —
|
||||
/// passes straight through, and only the transport's contribution to `ready − pts` is filtered.
|
||||
/// Anything that made due times more evenly spaced than the source would be a bug.
|
||||
///
|
||||
/// **Domain-agnostic by construction.** A constant offset between clock domains is absorbed by the
|
||||
/// offset estimator, so a caller feeds `readyNs` and reads the due time in ONE domain with no
|
||||
/// conversion anywhere in this path. On Apple that domain is `CACurrentMediaTime` — the clock
|
||||
/// `presentAtMediaTime` consumes — so the decode-output instant is converted ONCE on the way in
|
||||
/// (`Stage2Pipeline.mediaTimeNs(forRealtimeNs:)`) and the due time comes back needing none.
|
||||
/// Suspend/resume breaks the constant; the gap re-anchor below is what covers it.
|
||||
///
|
||||
/// A late frame's due time is returned in the PAST, unclamped: clamping it to `readyNs` would
|
||||
/// quietly turn every late frame into a fresh anchor, which is precisely the arrival-driven
|
||||
/// presentation this exists to stop being.
|
||||
///
|
||||
/// Prior art is ordinary and old: MPEG-2 TS PCR recovery and RTP playout scheduling (RFC 3550
|
||||
/// §6.4.1 carries the jitter estimator this MAD mirrors).
|
||||
///
|
||||
/// ⚠ A hand-written port of `punktfunk_core::phase::CadenceClock` — see `CadenceTuning` for the
|
||||
/// lockstep contract. Sendable; lock-guarded — the decode-completion thread folds frames while the
|
||||
/// render thread reads health.
|
||||
final class CadenceClock: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let tuning: CadenceTuning
|
||||
/// `ready − srcPts`, smoothed. Absorbs the clock-domain constant.
|
||||
private var offsetNs: Int64 = 0
|
||||
/// Per-frame drift of that offset — the integral term.
|
||||
private var skewNs: Int64 = 0
|
||||
/// EMA of |residual|, the cushion's input.
|
||||
private var madNs: Int64 = 0
|
||||
/// nil until the first sample anchors the loop.
|
||||
private var lastPtsNs: UInt64?
|
||||
/// Last frame interval seen, so `cushionNs` can apply its ceiling.
|
||||
private var frameIntervalNs: Int64 = 0
|
||||
private var counters = CadenceHealth()
|
||||
|
||||
init(tuning: CadenceTuning) {
|
||||
self.tuning = tuning
|
||||
}
|
||||
|
||||
/// Force a re-anchor on the next sample. Call on every discontinuity the client already knows
|
||||
/// about: reanchor, codec rebuild, surface recreate, jump-to-live, resume.
|
||||
func reset() {
|
||||
lock.lock()
|
||||
lastPtsNs = nil
|
||||
skewNs = 0
|
||||
// `madNs` deliberately SURVIVES. It describes the link, not the stream, and a cushion that
|
||||
// collapsed to its floor at every rebuild would spend the next few hundred frames
|
||||
// presenting late — the exact failure the cushion exists to prevent.
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Fold one presentable frame and return when it is due, in the present clock domain.
|
||||
///
|
||||
/// `readyNs` is when the frame became presentable; `frameIntervalNs` is the nominal source
|
||||
/// interval and the cushion's ceiling.
|
||||
///
|
||||
/// The result **may be earlier than `readyNs`** — that is a late frame, and the caller's
|
||||
/// contract is "already due ⇒ present at the next opportunity", never "drag the grid back to
|
||||
/// now".
|
||||
func dueNs(srcPtsNs: UInt64, readyNs: Int64, frameIntervalNs: Int64) -> Int64 {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
self.frameIntervalNs = frameIntervalNs
|
||||
counters.frames += 1
|
||||
let pts = Int64(bitPattern: srcPtsNs)
|
||||
let raw = saturatingSub(readyNs, pts)
|
||||
|
||||
let anchored: Bool
|
||||
if let last = lastPtsNs {
|
||||
// Source time going BACKWARDS, or a gap so long the estimate cannot be trusted to
|
||||
// have tracked across it: re-anchor rather than slew for seconds.
|
||||
anchored =
|
||||
!(srcPtsNs < last
|
||||
|| srcPtsNs - last > UInt64(bitPattern: tuning.reanchorGapNs))
|
||||
} else {
|
||||
anchored = false
|
||||
}
|
||||
if anchored {
|
||||
// Advance the estimate one frame on the rate term, then correct it by a bounded
|
||||
// fraction of what the new sample says.
|
||||
offsetNs = saturatingAdd(offsetNs, skewNs)
|
||||
let err = min(
|
||||
max(saturatingSub(raw, offsetNs), -tuning.errorClampNs), tuning.errorClampNs)
|
||||
offsetNs = saturatingAdd(offsetNs, shrTowardZero(err, tuning.offsetShift))
|
||||
skewNs = saturatingAdd(skewNs, shrTowardZero(err, tuning.skewShift))
|
||||
let dev = abs(err) - madNs
|
||||
madNs = saturatingAdd(madNs, shrTowardZero(dev, tuning.jitterShift))
|
||||
} else {
|
||||
offsetNs = raw
|
||||
skewNs = 0
|
||||
counters.reanchors += 1
|
||||
}
|
||||
lastPtsNs = srcPtsNs
|
||||
|
||||
let due = saturatingAdd(saturatingAdd(pts, offsetNs), lockedCushionNs())
|
||||
if due < readyNs { counters.late += 1 }
|
||||
return due
|
||||
}
|
||||
|
||||
/// A frame whose timestamp is not on the source cadence — a repeat the host re-anchored at
|
||||
/// submit, a stamp its plausibility gate replaced with "now", or one that reached us with no
|
||||
/// usable pts at all. Those samples do not lie on the source's timeline, and folding them in
|
||||
/// would drag the offset estimate toward "now" exactly when the stream is idle and the
|
||||
/// estimate matters most.
|
||||
///
|
||||
/// Returns a due time from the CURRENT estimate, leaving offset, skew and jitter untouched:
|
||||
/// the frame is simply due once it is ready, cushioned like any other.
|
||||
func noteOffCadence(readyNs: Int64, frameIntervalNs: Int64) -> Int64 {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
self.frameIntervalNs = frameIntervalNs
|
||||
return saturatingAdd(readyNs, lockedCushionNs())
|
||||
}
|
||||
|
||||
func jitterNs() -> Int64 {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return madNs
|
||||
}
|
||||
|
||||
/// How far past the estimate a frame is held, to absorb the measured jitter.
|
||||
///
|
||||
/// The one-frame-interval ceiling is an INVARIANT, not a tunable: a cushion past a whole frame
|
||||
/// buys latency for smoothness the source cannot supply, and at that point the honest fix is a
|
||||
/// deeper buffer the user asked for, not a loop quietly holding frames.
|
||||
func cushionNs() -> Int64 {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return lockedCushionNs()
|
||||
}
|
||||
|
||||
func health() -> CadenceHealth {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
var h = counters
|
||||
h.offsetNs = offsetNs
|
||||
h.skewNs = skewNs
|
||||
h.jitterNs = madNs
|
||||
h.cushionNs = lockedCushionNs()
|
||||
return h
|
||||
}
|
||||
|
||||
private func lockedCushionNs() -> Int64 {
|
||||
let den = Int64(max(tuning.cushionDen, 1))
|
||||
let want = saturatingMul(madNs, Int64(tuning.cushionNum)) / den
|
||||
let ceiling = frameIntervalNs > 0 ? frameIntervalNs : Int64.max
|
||||
return min(max(want, min(tuning.cushionFloorNs, ceiling)), ceiling)
|
||||
}
|
||||
}
|
||||
|
||||
/// Arithmetic shift that rounds toward ZERO, so a negative residual is damped by exactly as much
|
||||
/// as its positive twin. A plain `>>` rounds toward −∞, which biases a loop that spends its whole
|
||||
/// life within a few nanoseconds of zero error.
|
||||
private func shrTowardZero(_ v: Int64, _ shift: UInt8) -> Int64 {
|
||||
v < 0 ? -((-v) >> shift) : v >> shift
|
||||
}
|
||||
|
||||
/// The Rust loop is `saturating_*` throughout — a garbage timestamp must clamp the estimate, never
|
||||
/// trap the render thread. Swift's operators trap instead of saturating, so the port carries its
|
||||
/// own; for every reachable input these are plain `+`, `−`, `×`.
|
||||
private func saturatingAdd(_ a: Int64, _ b: Int64) -> Int64 {
|
||||
let (v, overflow) = a.addingReportingOverflow(b)
|
||||
return overflow ? (b > 0 ? Int64.max : Int64.min) : v
|
||||
}
|
||||
|
||||
private func saturatingSub(_ a: Int64, _ b: Int64) -> Int64 {
|
||||
let (v, overflow) = a.subtractingReportingOverflow(b)
|
||||
return overflow ? (b > 0 ? Int64.min : Int64.max) : v
|
||||
}
|
||||
|
||||
private func saturatingMul(_ a: Int64, _ b: Int64) -> Int64 {
|
||||
let (v, overflow) = a.multipliedReportingOverflow(by: b)
|
||||
guard overflow else { return v }
|
||||
return (a > 0) == (b > 0) ? Int64.max : Int64.min
|
||||
}
|
||||
|
||||
/// When a ready frame is pushed to the layer — the stage-2 vs stage-3 presenter split. Same decode
|
||||
/// half, same newest-wins ring; only the present cadence differs.
|
||||
///
|
||||
@@ -329,7 +614,8 @@ public final class PresentLinkInfo: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deadline pacing's staged frame-rate hint. SessionPresenter pushes the stream rate from the
|
||||
/// Deadline pacing's staged frame-rate hint, and — on every pacing — the session's nominal source
|
||||
/// interval (`sourceIntervalNs`). SessionPresenter pushes the stream rate from the
|
||||
/// MAIN thread (session start + every layout/Reconfigure); the link's own thread drains and
|
||||
/// applies it, so the CAMetalDisplayLink is only ever touched from the thread that runs it. The
|
||||
/// floor is PINNED at the stream rate — no idle ramp-down: with a low floor the link idles toward
|
||||
@@ -369,6 +655,16 @@ private final class FrameRateHint: @unchecked Sendable {
|
||||
pending = nil
|
||||
return p
|
||||
}
|
||||
/// The nominal SOURCE interval in nanoseconds — the cadence clock's cushion ceiling. Read on
|
||||
/// every pacing, not just deadline: this box is where the negotiated stream rate already
|
||||
/// lives, staged from main on session start and every Reconfigure, and the decode-completion
|
||||
/// thread needs it under a lock. 0 = not known yet, which the clock handles by running its
|
||||
/// cushion uncapped (the shared core's own behaviour for a zero interval).
|
||||
func sourceIntervalNs() -> Int64 {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return streamHz > 0 ? Int64(1_000_000_000.0 / Double(streamHz)) : 0
|
||||
}
|
||||
private static func range(hz: Float, boosted: Bool) -> CAFrameRateRange {
|
||||
#if os(tvOS)
|
||||
// A TV is a FIXED-rate display: there is no ProMotion panel to lift and no Pencil to
|
||||
@@ -663,6 +959,11 @@ final class PresentGate: @unchecked Sendable {
|
||||
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
|
||||
/// multiples; immediate flips scatter). Lock-guarded — `presented` lands on a Metal callback thread.
|
||||
private final class PresentDebugStats: @unchecked Sendable {
|
||||
/// The session's cadence loop, for the line's `cadence` segment — `nil` under the latency
|
||||
/// intent, and then the line is emitted exactly as it was before source-timestamp playout
|
||||
/// existed. `late` is the number WP8 gates on: a due time already past when the frame became
|
||||
/// presentable is the direct signal that the cushion is too small.
|
||||
private let cadence: CadenceClock?
|
||||
private let lock = NSLock()
|
||||
private var last = CACurrentMediaTime()
|
||||
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0, noDrawable = 0
|
||||
@@ -685,6 +986,10 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
private var inFlight = 0
|
||||
private var maxInFlight = 0
|
||||
|
||||
init(cadence: CadenceClock?) {
|
||||
self.cadence = cadence
|
||||
}
|
||||
|
||||
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
|
||||
|
||||
/// A wake that found the stage-3 gate closed (a present still in flight) — the frame stays in
|
||||
@@ -743,6 +1048,17 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
let vendP50 = vends.isEmpty ? 0 : vends[vends.count / 2]
|
||||
let vendMax = vends.last ?? 0
|
||||
let inflightMax = maxInFlight
|
||||
// Loop health, appended only where a loop exists — `late`/`frames` is WP8's cushion
|
||||
// criterion and `reanchor` says whether the estimate is tracking at all.
|
||||
let loop = cadence?.health()
|
||||
let cadenceLine =
|
||||
loop.map {
|
||||
String(
|
||||
format: " cadence late=%llu/%llu reanchor=%llu jitterUs=%lld cushionUs=%lld "
|
||||
+ "skewNs=%lld",
|
||||
$0.late, $0.frames, $0.reanchors, $0.jitterNs / 1000, $0.cushionNs / 1000,
|
||||
$0.skewNs)
|
||||
} ?? ""
|
||||
let line = String(
|
||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d "
|
||||
+ "dropped=%d qDrop=%d qDry=%d maxRenderMs=%.1f inflightMax=%d forced=%d "
|
||||
@@ -751,7 +1067,7 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
decoded, ok, failed, empty, gated, noDrawable, dropped,
|
||||
smoothing.overflowDrops, smoothing.underflows, maxRenderMs, inflightMax,
|
||||
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
|
||||
vendP50, vendMax)
|
||||
vendP50, vendMax) + cadenceLine
|
||||
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
|
||||
maxRenderMs = 0
|
||||
maxInFlight = inFlight // the window peak restarts from the live depth
|
||||
@@ -806,6 +1122,11 @@ public final class Stage2Pipeline {
|
||||
/// at most one per vsync, so the FIFO store drains on the display's cadence rather than on
|
||||
/// arrival. Ignored under `.deadline` (the link IS the cadence there).
|
||||
private let vsyncPaced: Bool
|
||||
/// Source-timestamp playout for the SMOOTHNESS intent: every decoded frame is stamped with
|
||||
/// when it is due on the host's own cadence, and the present decision aims there instead of at
|
||||
/// the moment the frame happened to decode. `nil` under `latency`, whose path keeps no cadence
|
||||
/// arithmetic in it at all — see the intent gate in `init`.
|
||||
private let cadence: CadenceClock?
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
@@ -885,12 +1206,23 @@ public final class Stage2Pipeline {
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
self.presentFloorMeter = presentFloorMeter
|
||||
// The intent gate: source-timestamp playout is what `smooth` MEANS now, and `latency` is
|
||||
// defined as arrival-driven with no cushion — so the store policy, which is the intent's
|
||||
// only other expression (`PresentPriority.storePolicy`: smooth → fifo, latency →
|
||||
// newest-wins), is what decides whether a clock exists at all. A latency session runs the
|
||||
// same present path it ran before this existed.
|
||||
switch storePolicy {
|
||||
case .newestWins: self.cadence = nil
|
||||
case .fifo: self.cadence = CadenceClock(tuning: .snapping())
|
||||
}
|
||||
let ring = ring
|
||||
let recovery = recovery
|
||||
let renderSignal = renderSignal
|
||||
let gate = gate
|
||||
let decodeReport = decodeReport
|
||||
let phaseReporter = phaseReporter
|
||||
let cadence = cadence
|
||||
let rateHint = frameRateHint
|
||||
self.decoder = VideoDecoder(
|
||||
onDecoded: { frame in
|
||||
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
||||
@@ -911,7 +1243,11 @@ public final class Stage2Pipeline {
|
||||
// present) on a proven clean re-anchor (IDR / RFI anchor / 2nd recovery mark) or the
|
||||
// bounded backstop. decoderKeyframe=false: VT doesn't flag IDRs, the wire FLAG_SOF does.
|
||||
guard gate.onDecoded(flags: frame.flags) else { return }
|
||||
ring.submit(frame)
|
||||
// Decoder OUTPUT is where the cadence loop is sampled — the instant the frame
|
||||
// becomes presentable. Receipt would not model decode at all and could hand back a
|
||||
// due time already past by the moment the frame exists; dequeue would fold the
|
||||
// present path's own wait into the estimate and make the loop chase its output.
|
||||
ring.submit(Stage2Pipeline.dated(frame, by: cadence, hint: rateHint))
|
||||
// FRAME ARRIVAL is the render trigger (never the display link — see the header).
|
||||
renderSignal.signal()
|
||||
},
|
||||
@@ -936,6 +1272,11 @@ public final class Stage2Pipeline {
|
||||
decodeReport.bind(connection) // arm the Automatic-bitrate decode signal for this session
|
||||
phaseReporter.bind(connection) // arm phase reports (flushed only by the deadline link)
|
||||
gate.reseed(framesDropped: connection.framesDropped()) // baseline the freeze to this session
|
||||
// A fresh session is a fresh source clock: re-anchor on its first frame rather than slew
|
||||
// for seconds off the previous host's offset. (Mid-session discontinuities — background
|
||||
// resume, a stream idle under the infinite GOP — arrive as a source-timestamp gap the loop
|
||||
// re-anchors on by itself; this seam covers the one it cannot see.)
|
||||
cadence?.reset()
|
||||
token = StopFlag() // fresh token per start — a stop is permanent (like StreamPump)
|
||||
|
||||
// Configure the decoder's chroma + the layer's initial colorimetry before the first frame. The
|
||||
@@ -962,7 +1303,7 @@ public final class Stage2Pipeline {
|
||||
connection: connection, token: token, pumpStopped: pumpStopped,
|
||||
ring: ring, renderSignal: renderSignal,
|
||||
device: presenter.metalDevice, queue: presenter.metalQueue,
|
||||
decodeMeter: decodeMeter,
|
||||
decodeMeter: decodeMeter, cadence: cadence, rateHint: frameRateHint,
|
||||
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
|
||||
} else {
|
||||
thread = Thread {
|
||||
@@ -1088,7 +1429,8 @@ public final class Stage2Pipeline {
|
||||
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
|
||||
// times every present). Deadline sessions ALWAYS carry the stats (their pf-present line
|
||||
// streams to Console.app via presentLog — the on-device pacing decomposition).
|
||||
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
|
||||
let debugStats =
|
||||
(presentDebug || pacing == .deadline) ? PresentDebugStats(cadence: cadence) : nil
|
||||
if pacing == .deadline {
|
||||
startDeadlinePresenter(debugStats: debugStats)
|
||||
return
|
||||
@@ -1118,6 +1460,12 @@ public final class Stage2Pipeline {
|
||||
// Stage-3's bounded in-flight present gate; nil = stage-2's present-on-arrival. A local
|
||||
// (like the ring) so neither the render thread nor the presented handlers capture `self`.
|
||||
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
|
||||
// Cadence targeting turns the store into a holding buffer: a frame comes out once it is
|
||||
// DUE, not once a present opportunity exists (§4.3). The latency intent has no clock and
|
||||
// keeps the unconditional take, byte for byte.
|
||||
let takeReady: () -> ReadyFrame? = cadence == nil
|
||||
? { ring.take() }
|
||||
: { ring.take(dueBy: CACurrentMediaTime(), due: { $0.dueMediaTime }) }
|
||||
let renderThread = Thread {
|
||||
defer { renderStopped.signal() }
|
||||
// macOS smoothness: the vsync this thread last presented onto — at most ONE present
|
||||
@@ -1150,7 +1498,7 @@ public final class Stage2Pipeline {
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
return
|
||||
}
|
||||
guard !token.isStopped, let frame = ring.take() else {
|
||||
guard !token.isStopped, let frame = takeReady() else {
|
||||
gate?.release() // armed but nothing to render — don't hold the gate stale
|
||||
debugStats?.emptyWake()
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
@@ -1158,8 +1506,14 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link ⇒
|
||||
// immediate — see VsyncClock). OFF: flip as soon as the GPU finishes.
|
||||
//
|
||||
// Under cadence targeting the grid is entered at the frame's DUE time rather than
|
||||
// at this instant, so two frames the host emitted one period apart land one period
|
||||
// apart on glass however unevenly they arrived. Never before `now`: a due time in
|
||||
// the past means the frame is late, not that the grid moves back.
|
||||
let now = CACurrentMediaTime()
|
||||
let presentAt = vsyncEnabled
|
||||
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
||||
? vsyncClock.nextVsync(after: max(now, frame.dueMediaTime ?? now)) : nil
|
||||
let renderStarted = CACurrentMediaTime()
|
||||
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
|
||||
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||
@@ -1238,6 +1592,13 @@ public final class Stage2Pipeline {
|
||||
let hint = frameRateHint
|
||||
let layer = presenter.layer
|
||||
let stash = LatestBox<CAMetalDrawable>()
|
||||
// Cadence targeting under deadline pacing: the link's vend IS the grid snap, so the clock
|
||||
// only has to hold a frame back until it is due and the next update presents it — at most
|
||||
// one refresh later. Same holding-buffer rule as the arrival/glass loop (§4.3); latency
|
||||
// sessions have no clock and take unconditionally.
|
||||
let takeReady: () -> ReadyFrame? = cadence == nil
|
||||
? { ring.take() }
|
||||
: { ring.take(dueBy: CACurrentMediaTime(), due: { $0.dueMediaTime }) }
|
||||
|
||||
// ⭐ Shrink the drawable pool to 2 for THIS pacing — the measured fix for a present floor
|
||||
// stuck at two refreshes (field 2026-08-13, Apple TV 4K / tvOS 27: `os present +32.5` at
|
||||
@@ -1328,7 +1689,7 @@ public final class Stage2Pipeline {
|
||||
// layer's CURRENT config, so drawableSize/format have to be right before a vend
|
||||
// can succeed at all (see reconcileLayer — the session-start bootstrap, where
|
||||
// the layer still has its initial 0×0 size and every vend fails allocation).
|
||||
guard !token.isStopped, let frame = ring.take() else {
|
||||
guard !token.isStopped, let frame = takeReady() else {
|
||||
debugStats?.emptyWake()
|
||||
debugStats?.flushIfDue(ring: ring, gate: nil)
|
||||
return
|
||||
@@ -1409,8 +1770,9 @@ public final class Stage2Pipeline {
|
||||
|
||||
/// MAIN thread (SessionPresenter — session start + every layout/Reconfigure): hint the
|
||||
/// deadline link with the stream cadence. Staged; the link's own thread applies it (see
|
||||
/// `FrameRateHint`). No-op under arrival/glass pacing, where the hosting view's CADisplayLink
|
||||
/// is the hinted link.
|
||||
/// `FrameRateHint`). Under arrival/glass pacing no link reads it — the hosting view's
|
||||
/// CADisplayLink is the hinted one there — but the stored rate is still the cadence clock's
|
||||
/// nominal source interval, and hence its cushion ceiling, on every pacing.
|
||||
public func setFrameRateHint(hz: Float) {
|
||||
frameRateHint.stage(hz: hz)
|
||||
}
|
||||
@@ -1489,7 +1851,7 @@ public final class Stage2Pipeline {
|
||||
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
||||
ring: FrameStore<ReadyFrame>, renderSignal: DispatchSemaphore,
|
||||
device: MTLDevice, queue: MTLCommandQueue,
|
||||
decodeMeter: LatencyMeter?,
|
||||
decodeMeter: LatencyMeter?, cadence: CadenceClock?, rateHint: FrameRateHint,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||
@@ -1547,10 +1909,15 @@ public final class Stage2Pipeline {
|
||||
Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
decodeMeter?.record(
|
||||
ptsNs: UInt64(receivedNs), atNs: decodedNs, offsetNs: 0)
|
||||
// Same cadence sample as the VideoToolbox half: the wavelet decode's
|
||||
// completion IS this frame's presentable instant.
|
||||
ring.submit(
|
||||
ReadyFrame(
|
||||
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
||||
image: .planar(planes), flags: flags))
|
||||
Stage2Pipeline.dated(
|
||||
ReadyFrame(
|
||||
ptsNs: ptsNs, receivedNs: receivedNs,
|
||||
decodedNs: decodedNs, image: .planar(planes),
|
||||
flags: flags),
|
||||
by: cadence, hint: rateHint))
|
||||
renderSignal.signal()
|
||||
}
|
||||
if submitted {
|
||||
@@ -1585,5 +1952,50 @@ public final class Stage2Pipeline {
|
||||
let realtimeNow = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
return realtimeNow + Int64((t - caNow) * 1_000_000_000)
|
||||
}
|
||||
|
||||
/// The exact inverse: a client `CLOCK_REALTIME` nanosecond instant (`ReadyFrame.decodedNs`)
|
||||
/// expressed on the `CACurrentMediaTime` timeline the present path schedules against.
|
||||
///
|
||||
/// It reads the two clocks in the SAME ORDER as `realtimeNs(forDisplayLinkTimestamp:)` above
|
||||
/// and forms the same difference, so the sub-microsecond skew between the two reads is the
|
||||
/// same sign in both and cancels on a round trip.
|
||||
///
|
||||
/// The cadence loop needs this because its rule is one domain in, SAME domain out: it is fed
|
||||
/// the decode-output instant in media time and its due time comes back in media time, with no
|
||||
/// second conversion anywhere downstream. (A constant realtime↔media offset would be absorbed
|
||||
/// by the loop's own offset estimator and need no conversion at all — but the two clocks
|
||||
/// diverge across device sleep, which is exactly why the conversion is done per frame here
|
||||
/// rather than once per session.)
|
||||
static func mediaTimeNs(forRealtimeNs t: Int64) -> Int64 {
|
||||
let caNow = CACurrentMediaTime()
|
||||
var ts = timespec()
|
||||
clock_gettime(CLOCK_REALTIME, &ts)
|
||||
let realtimeNow = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
return Int64(caNow * 1_000_000_000) + (t - realtimeNow)
|
||||
}
|
||||
|
||||
/// Stamp a decoded frame with when it is DUE on the source's cadence, at the moment it enters
|
||||
/// the ready store. Returns the frame untouched when the session has no clock (the latency
|
||||
/// intent).
|
||||
///
|
||||
/// A frame whose wire pts did not survive (`ptsNs == 0` — the decoder's "unknown" value) is
|
||||
/// not on the source's timeline at all, so it is folded through `noteOffCadence`: due as soon
|
||||
/// as it is ready, and the estimate left alone. Folding "now" in would drag the offset toward
|
||||
/// this instant precisely when the loop has the least evidence.
|
||||
private static func dated(
|
||||
_ frame: ReadyFrame, by clock: CadenceClock?, hint: FrameRateHint
|
||||
) -> ReadyFrame {
|
||||
guard let clock else { return frame }
|
||||
let readyNs = mediaTimeNs(forRealtimeNs: frame.decodedNs)
|
||||
let interval = hint.sourceIntervalNs()
|
||||
let dueNs =
|
||||
frame.ptsNs > 0
|
||||
? clock.dueNs(
|
||||
srcPtsNs: frame.ptsNs, readyNs: readyNs, frameIntervalNs: interval)
|
||||
: clock.noteOffCadence(readyNs: readyNs, frameIntervalNs: interval)
|
||||
var dated = frame
|
||||
dated.dueMediaTime = Double(dueNs) / 1_000_000_000
|
||||
return dated
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -47,6 +47,12 @@ public struct ReadyFrame: @unchecked Sendable {
|
||||
/// context so the re-anchor gate can classify this decoded frame (IDR / RFI anchor / recovery
|
||||
/// mark) at present time — the async decode callback has no other access to it. 0 when unknown.
|
||||
public let flags: UInt32
|
||||
/// When this frame is DUE on the SOURCE's cadence, in `CACurrentMediaTime` seconds — the
|
||||
/// domain the present path schedules against (`presentAtMediaTime`, `VsyncClock`). Stamped
|
||||
/// where the frame enters the ready store, by the pipeline's `CadenceClock`; `nil` under the
|
||||
/// latency intent, which has no clock and presents on arrival. May be in the PAST: that is a
|
||||
/// late frame, and the contract is "already due ⇒ present at the next opportunity".
|
||||
public var dueMediaTime: CFTimeInterval?
|
||||
|
||||
/// The VideoToolbox path's buffer; nil for a PyroWave planar frame. (Kept as the accessor
|
||||
/// the decode round-trip tests assert against.)
|
||||
|
||||
@@ -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
|
||||
@@ -471,15 +472,25 @@ public final class StreamLayerView: NSView {
|
||||
// NSApp.isActive / isKeyWindow are still false for the click coming in from
|
||||
// another app) — only the auto-engage paths require already-held key status.
|
||||
// `connection != nil` is the session-active gate (presenter internals are opaque here).
|
||||
guard captureEnabled, !captured, connection != nil, window != nil,
|
||||
guard captureEnabled, !captured, let connection, window != nil,
|
||||
fromClick || (NSApp.isActive && window?.isKeyWindow == true)
|
||||
else { return }
|
||||
// Per-client access §7 — never capture what can't land: a Controller-only or
|
||||
// View-only session gets NO mouse/keyboard grab (its clicks stay local UI clicks),
|
||||
// instead of a frozen cursor over input the host silently drops. Live grants, so a
|
||||
// mid-session re-grant makes the next click work; the revoke direction is released
|
||||
// by the session model's access tick.
|
||||
guard connection.canSendPointer || connection.canSendKeyboard else { return }
|
||||
// If the cursor grab is refused (e.g. the reactivating click arrives before the app is
|
||||
// frontmost), stay released so the NEXT click retries — never latch captured=true over
|
||||
// a free cursor, which would make mouseDown's `!captured` guard reject every later click.
|
||||
// In the desktop mouse model there is no grab (the pointer stays free) — capture
|
||||
// always engages and the monitor forwards absolute positions instead.
|
||||
guard cursorCapture.capture(in: self, disassociate: !desktopMouse) else { return }
|
||||
// always engages and the monitor forwards absolute positions instead. A session
|
||||
// whose grants exclude POINTER also keeps its cursor free (keyboard-only capture):
|
||||
// freezing a pointer whose motion cannot land would just trap the user's mouse.
|
||||
guard cursorCapture.capture(
|
||||
in: self, disassociate: !desktopMouse && connection.canSendPointer)
|
||||
else { return }
|
||||
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
||||
// Install AFTER the warp + setForwarding: the engage warp generates no forwarded
|
||||
// delta (the monitor isn't up yet), and the engage click's suppression latch is
|
||||
@@ -570,6 +581,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 +931,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")
|
||||
}
|
||||
|
||||
@@ -316,7 +316,11 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// full-screen + frontmost and may drop the lock (Slide Over/Stage Manager/backgrounding) —
|
||||
/// syncPointerLock() handles the actual grant/drop and falls back to absolute when unlocked.
|
||||
private var wantsPointerLock: Bool {
|
||||
// The trailing grant test is per-client access §7 — no pointer lock without the
|
||||
// POINTER bit (a Controller-only guest's trackpad stays a normal local pointer);
|
||||
// read live, so a mid-session re-grant lets the next resolve pass lock.
|
||||
captured && pointerCaptureEnabled && UIDevice.current.userInterfaceIdiom == .pad
|
||||
&& connection?.canSendPointer == true
|
||||
}
|
||||
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock && !pointerLockForcedOff }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -719,5 +719,117 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
XCTAssertEqual(stats.bufferedMS, 30)
|
||||
XCTAssertEqual(stats.avOffsetMS, 37, "positive = audio behind the picture")
|
||||
}
|
||||
|
||||
// MARK: - Drought concealment (WP-C1)
|
||||
|
||||
/// Concealment is for a ring that is running OUT. A drought a deep ring can cover is
|
||||
/// inaudible, and synthesizing over it would insert audio the late packets are about to
|
||||
/// duplicate — the stream would then run permanently later and the drift shed would have to
|
||||
/// cut it back out, audibly.
|
||||
///
|
||||
/// Mirrors `a_drought_is_concealed_only_while_the_ring_is_running_out`.
|
||||
func testADroughtIsConcealedOnlyWhileTheRingIsRunningOut() {
|
||||
var c = DroughtConceal(maxMS: AudioRing.plcMaxMS)
|
||||
let stalledMS = 3 * AudioRing.frameMS
|
||||
XCTAssertFalse(
|
||||
c.conceal(sinceLastPacketMS: stalledMS, depthMS: 40),
|
||||
"a 40 ms ring covers this drought by itself")
|
||||
XCTAssertTrue(
|
||||
c.conceal(sinceLastPacketMS: stalledMS, depthMS: 0), "an empty ring does not")
|
||||
XCTAssertEqual(c.totalMS, AudioRing.frameMS)
|
||||
}
|
||||
|
||||
/// Ordinary arrival jitter is not a drought — this policy must be invisible until the wire has
|
||||
/// genuinely stopped.
|
||||
///
|
||||
/// Mirrors `ordinary_jitter_is_not_a_drought`.
|
||||
func testOrdinaryJitterIsNotADrought() {
|
||||
var c = DroughtConceal(maxMS: AudioRing.plcMaxMS)
|
||||
for _ in 0..<1_000 {
|
||||
XCTAssertFalse(c.conceal(sinceLastPacketMS: AudioRing.frameMS, depthMS: 0))
|
||||
}
|
||||
XCTAssertEqual(c.totalMS, 0)
|
||||
}
|
||||
|
||||
/// The window is bounded, and bounded in TIME — the whole reason `deprimeMS` stopped being a
|
||||
/// callback count (`testDeprimeFuseIsADurationNotACallbackCount`). Derived from the fuse, so
|
||||
/// it cannot drift away from the thing it protects: an edit to one is an edit to both.
|
||||
///
|
||||
/// Mirrors `drought_concealment_is_bounded_at_twice_the_deprime_fuse`.
|
||||
func testDroughtConcealmentIsBoundedAtTwiceTheDeprimeFuse() {
|
||||
let deprimeMS = 60 // AudioRing.deprimeMS / JitterTuning::COREAUDIO.deprime_ms
|
||||
XCTAssertEqual(AudioRing.plcMaxMS, 2 * deprimeMS)
|
||||
var c = DroughtConceal(maxMS: AudioRing.plcMaxMS)
|
||||
var ms = 0
|
||||
for _ in 0..<1_000 where c.conceal(sinceLastPacketMS: 2 * AudioRing.frameMS, depthMS: 0) {
|
||||
ms += AudioRing.frameMS
|
||||
}
|
||||
XCTAssertEqual(ms, AudioRing.plcMaxMS, "must use exactly the budget, and stop there")
|
||||
XCTAssertEqual(c.totalMS, AudioRing.plcMaxMS, "and report every millisecond of it")
|
||||
}
|
||||
|
||||
/// A packet ends the drought and hands back a full budget for the next one — a link that
|
||||
/// stalls once a minute must be covered every time, not only the first.
|
||||
///
|
||||
/// The other half of the Rust `concealment_already_paid_for_is_not_paid_for_twice` — that
|
||||
/// frames a drought already covered are subtracted from the loss concealment the seq path then
|
||||
/// asks for — cannot be tested from here: on this leg the gap tracker lives behind the C ABI,
|
||||
/// and so does the subtraction (`drought_concealment_is_not_charged_again_by_the_loss_path` in
|
||||
/// `punktfunk_core::abi`).
|
||||
func testAPacketEndsTheDroughtAndRefreshesTheBudget() {
|
||||
var c = DroughtConceal(maxMS: AudioRing.plcMaxMS)
|
||||
for _ in 0..<1_000 where c.conceal(sinceLastPacketMS: 2 * AudioRing.frameMS, depthMS: 0) {}
|
||||
XCTAssertEqual(c.totalMS, AudioRing.plcMaxMS, "budget spent")
|
||||
XCTAssertFalse(c.conceal(sinceLastPacketMS: 2 * AudioRing.frameMS, depthMS: 0))
|
||||
c.packet()
|
||||
XCTAssertTrue(
|
||||
c.conceal(sinceLastPacketMS: 2 * AudioRing.frameMS, depthMS: 0),
|
||||
"the next drought must start from a full budget")
|
||||
XCTAssertEqual(
|
||||
c.totalMS, AudioRing.plcMaxMS + AudioRing.frameMS,
|
||||
"the SESSION total keeps counting — it is what the log line reports")
|
||||
}
|
||||
|
||||
/// THE field scenario this exists for, played against the real ring: the wire goes quiet for
|
||||
/// longer than the de-prime fuse (a Wi-Fi delivery stall, or a host whose capture stalled).
|
||||
/// Without concealment the ring drains, starves, and re-primes a whole target's worth of fresh
|
||||
/// silence — an artifact far longer than the audio that was missing. Fed one synthesized frame
|
||||
/// per drain tick instead, playback continues through the whole budget and nobody hears the
|
||||
/// stall at all.
|
||||
func testConcealmentRidesOutAStallThatWouldOtherwiseDeprime() {
|
||||
/// Prime, then stall the wire for `ms`, ticking the drain thread's 5 ms loop and the
|
||||
/// device callback in step. Returns when the first silent callback lands (nil = none).
|
||||
func stall(ms: Int, concealing: Bool) -> Int? {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 25 * perMS)
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 25 * perMS) }
|
||||
var drought = DroughtConceal(maxMS: AudioRing.plcMaxMS)
|
||||
for tick in 0..<(ms / AudioRing.frameMS) {
|
||||
if concealing,
|
||||
drought.conceal(
|
||||
sinceLastPacketMS: tick * AudioRing.frameMS, depthMS: ring.bufferedMS) {
|
||||
feed.withUnsafeBufferPointer {
|
||||
ring.write($0.baseAddress!, count: AudioRing.frameMS * perMS)
|
||||
}
|
||||
}
|
||||
scratch.withUnsafeMutableBufferPointer {
|
||||
ring.read(into: $0.baseAddress!, count: want)
|
||||
}
|
||||
if scratch.allSatisfy({ $0 == 0 }) { return tick * AudioRing.frameMS }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// The defect: 25 ms of ring, a 60 ms fuse — the stall is silent well inside the budget.
|
||||
guard let deprimedAt = stall(ms: AudioRing.plcMaxMS, concealing: false) else {
|
||||
return XCTFail("the unconcealed stall must still de-prime — the ring changed under us")
|
||||
}
|
||||
XCTAssertLessThan(deprimedAt, AudioRing.plcMaxMS)
|
||||
XCTAssertNil(
|
||||
stall(ms: AudioRing.plcMaxMS, concealing: true),
|
||||
"a stall inside the budget must not reach the listener at all (unconcealed: silent "
|
||||
+ "after \(deprimedAt) ms)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import XCTest
|
||||
|
||||
#if canImport(Metal)
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Source-timestamp playout: the Swift `CadenceClock` against the SAME synthetic inputs its Rust
|
||||
/// original runs (`punktfunk_core::phase::tests`) — one test per Rust test, matching names, the
|
||||
/// same constants, the same deterministic LCG. The port is hand-written because this pipeline does
|
||||
/// not link the Rust type, so agreement on these vectors is the whole lockstep contract: a
|
||||
/// constant or a rounding rule that drifts on either side fails here, not on a user's screen.
|
||||
final class CadenceClockTests: XCTestCase {
|
||||
/// 120 Hz in ns.
|
||||
private static let p: Int64 = 8_333_333
|
||||
|
||||
/// A source stamping realtime, played out by a client whose present clock is monotonic and
|
||||
/// therefore a whole different era. The loop must never need to be told about this.
|
||||
private static let pts0: UInt64 = 1_786_000_000_000_000_000
|
||||
private static let domain: Int64 = -1_785_000_000_000_000_000
|
||||
/// Transport + decode: what `ready − pts` sits at once the domain is taken out.
|
||||
private static let delay: Int64 = 12_000_000
|
||||
|
||||
/// Deterministic LCG in ±spread around zero — no OS randomness in tests. The multiplier,
|
||||
/// increment and the `>> 33` fold are the Rust harness's, so both sides replay the identical
|
||||
/// jitter sequence for a given seed.
|
||||
private struct Lcg {
|
||||
private var state: UInt64
|
||||
init(_ seed: UInt64) { state = seed }
|
||||
mutating func noise(_ spreadNs: Int64) -> Int64 {
|
||||
state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
|
||||
guard spreadNs != 0 else { return 0 }
|
||||
return Int64(state >> 33) % (2 * spreadNs) - spreadNs
|
||||
}
|
||||
}
|
||||
|
||||
private static func ptsAt(_ k: Int64) -> UInt64 {
|
||||
UInt64(bitPattern: Int64(pts0) + k * p)
|
||||
}
|
||||
|
||||
/// Run `n` frames of a well-behaved 120 Hz source and hand back the clock.
|
||||
private static func settled(_ n: Int64, spread: Int64) -> CadenceClock {
|
||||
let c = CadenceClock(tuning: .snapping())
|
||||
var rng = Lcg(7)
|
||||
for k in 0..<n {
|
||||
let ready = Int64(bitPattern: ptsAt(k)) + domain + delay + rng.noise(spread)
|
||||
_ = c.dueNs(srcPtsNs: ptsAt(k), readyNs: ready, frameIntervalNs: p)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func testSettlesFromCold() {
|
||||
let c = Self.settled(400, spread: 1_000_000)
|
||||
let err = c.health().offsetNs - (Self.domain + Self.delay)
|
||||
XCTAssertLessThan(
|
||||
abs(err), 500_000,
|
||||
"offset must converge on the true transport delay, off by \(err) ns")
|
||||
XCTAssertEqual(c.health().reanchors, 1, "only the cold start anchors")
|
||||
}
|
||||
|
||||
/// The type-2 property, and the reason the loop carries a rate term at all: two free-running
|
||||
/// crystals produce a RAMP, and a proportional-only loop lags a ramp forever. Asserted against
|
||||
/// its own type-1 twin so the difference is the measurement, not a threshold anyone chose.
|
||||
func testTracksAClockRamp() {
|
||||
let ramp: Int64 = 400 // ns per frame ≈ 48 ppm, an ordinary crystal pair
|
||||
func run(_ tuning: CadenceTuning) -> Int64 {
|
||||
let c = CadenceClock(tuning: tuning)
|
||||
var lastErr: Int64 = 0
|
||||
for k in Int64(0)..<4_000 {
|
||||
let pts = Int64(bitPattern: Self.ptsAt(k))
|
||||
let ready = pts + Self.domain + Self.delay + k * ramp
|
||||
_ = c.dueNs(srcPtsNs: Self.ptsAt(k), readyNs: ready, frameIntervalNs: Self.p)
|
||||
lastErr = (ready - pts) - c.health().offsetNs
|
||||
}
|
||||
return abs(lastErr)
|
||||
}
|
||||
let type2 = run(.snapping())
|
||||
// The same loop with its integral gain switched off: a shift this large truncates every
|
||||
// residual to zero, which is exactly "proportional only".
|
||||
var type1Tuning = CadenceTuning.snapping()
|
||||
type1Tuning.skewShift = 63
|
||||
let type1 = run(type1Tuning)
|
||||
XCTAssertLessThan(
|
||||
type2 * 4, type1,
|
||||
"a rate term must beat proportional-only on a ramp: \(type2) ns vs \(type1) ns")
|
||||
XCTAssertLessThan(type2, 3_000, "steady-state ramp error \(type2) ns")
|
||||
}
|
||||
|
||||
func testRejectsASingleOutlier() {
|
||||
let c = Self.settled(400, spread: 200_000)
|
||||
let before = c.health().offsetNs
|
||||
// One frame arrives half a second late — a stall, not a new operating point.
|
||||
let k: Int64 = 400
|
||||
_ = c.dueNs(
|
||||
srcPtsNs: Self.ptsAt(k),
|
||||
readyNs: Int64(bitPattern: Self.ptsAt(k)) + Self.domain + Self.delay + 500_000_000,
|
||||
frameIntervalNs: Self.p)
|
||||
let moved = abs(c.health().offsetNs - before)
|
||||
// The clamped correction, plus the one frame of rate the loop advances by regardless —
|
||||
// that advance is the estimate doing its job, not the outlier moving it.
|
||||
let t = CadenceTuning.snapping()
|
||||
let bound = (t.errorClampNs >> t.offsetShift) + abs(c.health().skewNs)
|
||||
XCTAssertLessThanOrEqual(
|
||||
moved, bound, "one outlier moved the estimate \(moved) ns, past the clamp's \(bound)")
|
||||
}
|
||||
|
||||
func testReanchorsOnAGap() {
|
||||
let c = Self.settled(400, spread: 200_000)
|
||||
let anchors = c.health().reanchors
|
||||
// The stream was paused for two seconds; the estimate cannot have tracked across that.
|
||||
let far = Self.ptsAt(400) + 2_000_000_000
|
||||
let ready = Int64(bitPattern: far) + Self.domain + Self.delay + 4_000_000
|
||||
_ = c.dueNs(srcPtsNs: far, readyNs: ready, frameIntervalNs: Self.p)
|
||||
XCTAssertEqual(c.health().reanchors, anchors + 1)
|
||||
XCTAssertEqual(
|
||||
c.health().offsetNs, ready - Int64(bitPattern: far),
|
||||
"a re-anchor adopts the new sample outright rather than slewing to it")
|
||||
}
|
||||
|
||||
func testReanchorsOnRegression() {
|
||||
let c = Self.settled(400, spread: 200_000)
|
||||
let anchors = c.health().reanchors
|
||||
let back = Self.ptsAt(200) // source timestamps went backwards
|
||||
_ = c.dueNs(
|
||||
srcPtsNs: back, readyNs: Int64(bitPattern: back) + Self.domain + Self.delay,
|
||||
frameIntervalNs: Self.p)
|
||||
XCTAssertEqual(c.health().reanchors, anchors + 1)
|
||||
}
|
||||
|
||||
/// A due time in the past is returned AS IS. Clamping it to `readyNs` would quietly turn every
|
||||
/// late frame into a fresh anchor, which is how an arrival-driven presenter behaves — the
|
||||
/// thing this clock exists to stop being.
|
||||
func testLateFrameReturnsPastDue() {
|
||||
let c = Self.settled(400, spread: 200_000)
|
||||
let k: Int64 = 400
|
||||
let ready = Int64(bitPattern: Self.ptsAt(k)) + Self.domain + Self.delay + 30_000_000
|
||||
let due = c.dueNs(srcPtsNs: Self.ptsAt(k), readyNs: ready, frameIntervalNs: Self.p)
|
||||
XCTAssertLessThan(due, ready, "a frame that arrived 30 ms late must read as already due")
|
||||
XCTAssertEqual(c.health().late, 1)
|
||||
}
|
||||
|
||||
func testOffCadenceDoesNotMoveTheLoop() {
|
||||
let c = Self.settled(400, spread: 500_000)
|
||||
let before = c.health()
|
||||
let due = c.noteOffCadence(readyNs: 1_000_000, frameIntervalNs: Self.p)
|
||||
let after = c.health()
|
||||
XCTAssertEqual(before.offsetNs, after.offsetNs)
|
||||
XCTAssertEqual(before.skewNs, after.skewNs)
|
||||
XCTAssertEqual(before.jitterNs, after.jitterNs)
|
||||
XCTAssertEqual(before.frames, after.frames, "and it is not a cadence sample")
|
||||
XCTAssertEqual(due, 1_000_000 + c.cushionNs())
|
||||
}
|
||||
|
||||
/// One domain in, same domain out: shifting the whole present-side trace by an arbitrary
|
||||
/// constant must change every due time by exactly that constant and nothing else. This is what
|
||||
/// lets each client feed its own clock without a conversion in the path — and on Apple it is
|
||||
/// what makes `mediaTimeNs(forRealtimeNs:)` the ONE conversion in the whole loop.
|
||||
func testDomainOffsetIsAbsorbed() {
|
||||
let shift: Int64 = 987_654_321_000
|
||||
func run(_ extra: Int64) -> [Int64] {
|
||||
let c = CadenceClock(tuning: .snapping())
|
||||
var rng = Lcg(11)
|
||||
return (Int64(0)..<300).map { k in
|
||||
let ready =
|
||||
Int64(bitPattern: Self.ptsAt(k)) + Self.domain + Self.delay + extra
|
||||
+ rng.noise(2_000_000)
|
||||
return c.dueNs(srcPtsNs: Self.ptsAt(k), readyNs: ready, frameIntervalNs: Self.p)
|
||||
}
|
||||
}
|
||||
let a = run(0)
|
||||
let b = run(shift)
|
||||
for (i, pair) in zip(a, b).enumerated() {
|
||||
XCTAssertEqual(
|
||||
pair.1 - pair.0, shift, "frame \(i) shifted by \(pair.1 - pair.0) not \(shift)")
|
||||
}
|
||||
}
|
||||
|
||||
/// The invariant that separates this from a metronome: a source that genuinely runs at an
|
||||
/// irregular rate is REPRODUCED, not evened out. Anything that made these due spacings more
|
||||
/// uniform than the source's own would be a bug.
|
||||
func testPreservesSourceCadence() {
|
||||
let c = CadenceClock(tuning: .snapping())
|
||||
// A deliberately lumpy source: alternating short and long frames.
|
||||
let spacings = (0..<300).map { $0 % 2 == 0 ? Self.p / 2 : Self.p * 3 / 2 }
|
||||
var pts = Self.pts0
|
||||
var dues: [Int64] = []
|
||||
var ptss: [Int64] = []
|
||||
var rng = Lcg(13)
|
||||
for s in spacings {
|
||||
pts = UInt64(bitPattern: Int64(bitPattern: pts) + s)
|
||||
let ready = Int64(bitPattern: pts) + Self.domain + Self.delay + rng.noise(500_000)
|
||||
ptss.append(Int64(bitPattern: pts))
|
||||
dues.append(c.dueNs(srcPtsNs: pts, readyNs: ready, frameIntervalNs: Self.p))
|
||||
}
|
||||
// Compare the back half, once the loop has settled.
|
||||
for i in 200..<dues.count {
|
||||
let dDue = dues[i] - dues[i - 1]
|
||||
let dPts = ptss[i] - ptss[i - 1]
|
||||
XCTAssertLessThan(
|
||||
abs(dDue - dPts), 200_000, "due spacing \(dDue) must follow the source's \(dPts)")
|
||||
}
|
||||
}
|
||||
|
||||
func testCushionRespectsCeiling() {
|
||||
let c = CadenceClock(tuning: .freeRunning())
|
||||
var rng = Lcg(17)
|
||||
// Jitter far wider than a frame — the cushion must still never exceed one interval.
|
||||
for k in Int64(0)..<500 {
|
||||
let ready =
|
||||
Int64(bitPattern: Self.ptsAt(k)) + Self.domain + Self.delay + rng.noise(40_000_000)
|
||||
_ = c.dueNs(srcPtsNs: Self.ptsAt(k), readyNs: ready, frameIntervalNs: Self.p)
|
||||
XCTAssertLessThanOrEqual(
|
||||
c.cushionNs(), Self.p, "cushion \(c.cushionNs()) ns exceeded the frame interval")
|
||||
}
|
||||
XCTAssertGreaterThan(c.jitterNs(), Self.p, "the harness must actually have stressed it")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,116 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Pins the macOS ⌘-chord passthrough — the rule deciding which keyDowns `InputCapture`'s local
|
||||
/// monitor takes off AppKit and forwards to the host instead of letting a menu key equivalent
|
||||
/// claim them. Two things are worth a test rather than a comment:
|
||||
///
|
||||
/// * ⌘Q reaching the host at all. That is the whole point — it is the compositor chord on
|
||||
/// Hyprland/KDE/GNOME, and it used to quit the client.
|
||||
/// * ⌘⎋ and ⌃⌘F NOT reaching it, under every combination. They are the way out of a captured
|
||||
/// stream; forward either and the user is locked in.
|
||||
final class CommandChordTests: XCTestCase {
|
||||
// kVK_ANSI_* — physical positions, layout-independent (the same constants the monitor uses).
|
||||
private let q: UInt16 = 12, w: UInt16 = 13, h: UInt16 = 4, m: UInt16 = 46
|
||||
private let f: UInt16 = 3, esc: UInt16 = 53, leftArrow: UInt16 = 123
|
||||
|
||||
/// Captured, setting on, capture mouse model — the shipping default.
|
||||
private func forwards(
|
||||
_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags,
|
||||
forwarding: Bool = true, inhibit: Bool = true, desktop: Bool = false
|
||||
) -> Bool {
|
||||
InputCapture.forwardsCommandChord(
|
||||
keyCode: keyCode, flags: flags, forwarding: forwarding,
|
||||
inhibitShortcuts: inhibit, desktopMouse: desktop)
|
||||
}
|
||||
|
||||
func testCommandChordsGoToTheHostWhileCaptured() {
|
||||
XCTAssertTrue(forwards(q, .command)) // ⌘Q — the reported break
|
||||
XCTAssertTrue(forwards(w, .command))
|
||||
XCTAssertTrue(forwards(h, .command))
|
||||
XCTAssertTrue(forwards(m, .command))
|
||||
XCTAssertTrue(forwards(q, [.command, .shift])) // ⇧⌘Q
|
||||
XCTAssertTrue(forwards(m, [.command, .control, .option, .shift]))
|
||||
}
|
||||
|
||||
func testTheEscapeHatchesAreNeverForwarded() {
|
||||
// ⌘⎋ releases capture, ⌃⌘F leaves fullscreen. Neither may ever reach the host.
|
||||
XCTAssertFalse(forwards(esc, .command))
|
||||
XCTAssertFalse(forwards(f, [.control, .command]))
|
||||
XCTAssertTrue(InputCapture.isClientReservedChord(keyCode: esc, flags: .command))
|
||||
XCTAssertTrue(
|
||||
InputCapture.isClientReservedChord(keyCode: f, flags: [.control, .command]))
|
||||
}
|
||||
|
||||
/// The reservation is exact: it is ⌘⎋ and ⌃⌘F specifically, not "anything with Esc or F in
|
||||
/// it". ⇧⌘⎋ and ⌘F are the host's like any other chord.
|
||||
func testNeighbouringChordsAreNotReserved() {
|
||||
XCTAssertTrue(forwards(esc, [.command, .shift]))
|
||||
XCTAssertTrue(forwards(f, .command))
|
||||
XCTAssertFalse(InputCapture.isClientReservedChord(keyCode: f, flags: .command))
|
||||
}
|
||||
|
||||
func testNothingWithoutCommandIsClaimedHere() {
|
||||
// The ⌃⌥⇧ family and bare keys reach the monitor's earlier blocks / the responder chain.
|
||||
XCTAssertFalse(forwards(q, [.control, .option, .shift]))
|
||||
XCTAssertFalse(forwards(q, []))
|
||||
XCTAssertFalse(forwards(esc, []))
|
||||
}
|
||||
|
||||
func testReleasedCaptureLeavesTheMenuAlone() {
|
||||
// Not forwarding = the user is in the local UI: ⌘Q must quit the app, ⌘W close the window.
|
||||
XCTAssertFalse(forwards(q, .command, forwarding: false))
|
||||
XCTAssertFalse(forwards(w, .command, forwarding: false))
|
||||
}
|
||||
|
||||
func testTheCrossClientSettingTurnsItOff() {
|
||||
XCTAssertFalse(forwards(q, .command, inhibit: false))
|
||||
}
|
||||
|
||||
func testTheDesktopMouseModelKeepsChordsLocal() {
|
||||
// Matches the SDL clients' keyboard grab: a remote desktop is something you ⌘Tab away from.
|
||||
XCTAssertFalse(forwards(q, .command, desktop: true))
|
||||
XCTAssertFalse(forwards(q, .command, inhibit: true, desktop: true))
|
||||
}
|
||||
|
||||
/// `deviceIndependentFlagsMask` also carries Caps Lock and the `.function`/`.numericPad` bits
|
||||
/// every arrow key sets, so comparing it for equality made chords stop being recognized in
|
||||
/// exactly the states a user does not connect to their keyboard: Caps Lock on, or the chord
|
||||
/// spelled with an arrow. `chordFlags` isolates the four real modifiers.
|
||||
func testCapsLockAndArrowBitsDoNotChangeAChord() throws {
|
||||
let capsQ = try XCTUnwrap(keyEvent(q, [.command, .capsLock]))
|
||||
XCTAssertEqual(InputCapture.chordFlags(capsQ), .command)
|
||||
XCTAssertTrue(forwards(q, InputCapture.chordFlags(capsQ)))
|
||||
|
||||
// ⌘⎋ with Caps Lock on is still the escape hatch, not a chord for the host.
|
||||
let capsEsc = try XCTUnwrap(keyEvent(esc, [.command, .capsLock]))
|
||||
XCTAssertEqual(InputCapture.chordFlags(capsEsc), .command)
|
||||
XCTAssertFalse(forwards(esc, InputCapture.chordFlags(capsEsc)))
|
||||
|
||||
// ⌘← — arrows set .function|.numericPad, which say nothing about the chord.
|
||||
let cmdLeft = try XCTUnwrap(keyEvent(leftArrow, [.command, .function, .numericPad]))
|
||||
XCTAssertEqual(InputCapture.chordFlags(cmdLeft), .command)
|
||||
XCTAssertTrue(forwards(leftArrow, InputCapture.chordFlags(cmdLeft)))
|
||||
}
|
||||
|
||||
/// A forwarded chord is only useful if the key has a host VK — the monitor swallows either
|
||||
/// way, so an unmapped one would silently do nothing. Spot-check the common ⌘ letters.
|
||||
func testTheCommonChordKeysMapToHostVKs() {
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[q], 0x51) // VK 'Q'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[w], 0x57) // VK 'W'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[h], 0x48) // VK 'H'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[m], 0x4D) // VK 'M'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[leftArrow], 0x25) // VK_LEFT
|
||||
}
|
||||
|
||||
private func keyEvent(_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags) -> NSEvent? {
|
||||
NSEvent.keyEvent(
|
||||
with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
||||
windowNumber: 0, context: nil, characters: "", charactersIgnoringModifiers: "",
|
||||
isARepeat: false, keyCode: keyCode)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -45,7 +45,7 @@ BUNDLE_ID="io.unom.punktfunk"
|
||||
# The App Store set, in listing order — the first three are what most people ever see, so they are
|
||||
# the stream itself, the machines it found, and the couch/controller mode. Everything else in
|
||||
# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`.
|
||||
SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
|
||||
SCENES=(${SCENES:-01-stream 02-hosts 11-library 12-controllers 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
|
||||
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
|
||||
|
||||
mkdir -p "$OUT"
|
||||
@@ -63,9 +63,13 @@ require_xcode() {
|
||||
# ---------------------------------------------------------------------------- macOS
|
||||
|
||||
shoot_macos() {
|
||||
log "macOS — building (swift build -c release)…"
|
||||
swift build -c release >/dev/null
|
||||
local bin=".build/release/PunktfunkClient"
|
||||
# DEBUG build, deliberately: the whole shot harness lives behind `#if DEBUG`
|
||||
# (ScreenshotHost/ScreenshotScenes), so a release binary launches as the NORMAL app, never
|
||||
# prints PF_SHOT_WINDOW, and every scene "never reported a window". Debug renders the same
|
||||
# pixels — SwiftUI has no release-only visuals.
|
||||
log "macOS — building (swift build)…"
|
||||
swift build >/dev/null
|
||||
local bin=".build/debug/PunktfunkClient"
|
||||
[ -x "$bin" ] || die "build produced no $bin"
|
||||
|
||||
for scene in "${SCENES[@]}"; do
|
||||
@@ -142,6 +146,14 @@ shoot_sim() {
|
||||
# incremental build instead of cold-building into a throwaway tmpdir — CI pins this
|
||||
# (apple.yml); local runs keep the self-cleaning mktemp default.
|
||||
local dd; dd="${PF_SHOT_DERIVED_DATA:-$(mktemp -d)}"; mkdir -p "$dd"
|
||||
# tvOS-SIMULATOR trap (Xcode 26.6 and the 27 beta, local only so far): the build planner
|
||||
# schedules the SwiftPM MACRO plugin targets that swiftui-navigation-transitions pulls in
|
||||
# (OnceMacro/SwizzlingMacro/AssociationMacro) for the *tvOS* triple and never plans their
|
||||
# swift-syntax dependencies at all — "unable to resolve module dependency: 'SwiftSyntax'".
|
||||
# Device archives and iOS builds don't hit it (only the tvOS target links that package), and
|
||||
# prebuilt-vs-source swift-syntax makes no difference. Until Xcode fixes the planner, the
|
||||
# workaround is temporarily unlinking SwiftUINavigationTransitions from the tvOS target
|
||||
# (HomeView's use is canImport-guarded — the push transition degrades to the crossfade).
|
||||
xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \
|
||||
-sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \
|
||||
CODE_SIGNING_ALLOWED=NO build >/dev/null \
|
||||
|
||||
@@ -796,7 +796,9 @@ from the config directory for a true factory reset."
|
||||
);
|
||||
return NEEDS_INTERACTION;
|
||||
}
|
||||
match library::fetch_games(&host.addr, library::DEFAULT_MGMT_PORT, &identity, pin) {
|
||||
// The port this host actually serves its library on — learned from its advert and saved,
|
||||
// falling back to 47990. Reaching for the constant here is what broke a moved port.
|
||||
match library::fetch_games(&host.addr, host.effective_mgmt_port(), &identity, pin) {
|
||||
Ok(games) => {
|
||||
if has(args, "--json") {
|
||||
let rows: Vec<serde_json::Value> = games
|
||||
|
||||
@@ -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),
|
||||
@@ -450,6 +457,45 @@ impl ServiceState {
|
||||
trust::parse_hex32(&fp_hex),
|
||||
);
|
||||
}
|
||||
ConsoleCmd::SendLogs {
|
||||
addr,
|
||||
mgmt,
|
||||
fp_hex,
|
||||
host_name,
|
||||
} => {
|
||||
// Blocking network (5 s connect / 10 s global, the library agent's budgets) —
|
||||
// a worker thread keeps the service loop's host refresh alive meanwhile. The
|
||||
// result lands as a shared-model notice; the shell toasts it on its next sync.
|
||||
let identity = self.identity.clone();
|
||||
let pin = trust::parse_hex32(&fp_hex);
|
||||
let console = self.console.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-sendlogs".into())
|
||||
.spawn(move || {
|
||||
let header = format!(
|
||||
"punktfunk-session {} ({} {}) — client log bundle",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
);
|
||||
match pf_client_core::logring::send_to_host(
|
||||
&addr, mgmt, &identity, pin, &header,
|
||||
) {
|
||||
Ok(id) => {
|
||||
tracing::info!(host = %host_name, id, "client logs uploaded");
|
||||
console.set_notice(format!(
|
||||
"Logs sent to {host_name} — download them from its web \
|
||||
console's Logs page"
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(host = %host_name, error = %e, "client log upload failed");
|
||||
console.set_notice(format!("Couldn't send logs — {e}"));
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
ConsoleCmd::Pair {
|
||||
addr,
|
||||
port,
|
||||
@@ -682,6 +728,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 +743,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,
|
||||
|
||||
+33
-10
@@ -23,6 +23,7 @@
|
||||
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
mod ring_layer;
|
||||
|
||||
/// The session control socket: a line-per-connection unix socket other same-user
|
||||
/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which
|
||||
@@ -618,14 +619,29 @@ mod session_main {
|
||||
}
|
||||
|
||||
pub fn run() -> u8 {
|
||||
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines).
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines) — plus
|
||||
// the in-process ring (`pf_client_core::logring`, DEBUG+ regardless of RUST_LOG) that
|
||||
// "Send logs to host" uploads. The env filter scopes the STDERR layer only: the ring
|
||||
// exists precisely for the diagnostics nobody enabled before the bug happened.
|
||||
{
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::Layer;
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
),
|
||||
)
|
||||
.with(
|
||||
crate::ring_layer::RingLayer
|
||||
.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
|
||||
// Before ANY Vulkan call — and that includes the two probe flags below, which is the
|
||||
// whole reason this sits at the top of `run` instead of beside the session setup it
|
||||
@@ -986,9 +1002,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).
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Thin `tracing` layer feeding `pf_client_core::logring` — the source for the console's
|
||||
//! "Send logs to host" action. Captures at DEBUG+ regardless of `RUST_LOG` (its own filter is
|
||||
//! applied at install), mirroring the host's `log_capture::RingLayer`: the whole point is that
|
||||
//! a field report carries the diagnostics nobody thought to enable beforehand.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing_subscriber::layer::Context;
|
||||
|
||||
pub(crate) struct RingLayer;
|
||||
|
||||
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
|
||||
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
|
||||
struct V(String);
|
||||
impl Visit for V {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
if field.name() == "message" {
|
||||
// The message leads; fields follow. Events put it first anyway, so
|
||||
// this is belt-and-braces against odd macro orderings.
|
||||
let rest = std::mem::take(&mut self.0);
|
||||
let _ = write!(self.0, "{value:?}");
|
||||
self.0.push_str(&rest);
|
||||
} else {
|
||||
let _ = write!(self.0, " {}={:?}", field.name(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v = V(String::new());
|
||||
event.record(&mut v);
|
||||
let meta = event.metadata();
|
||||
pf_client_core::logring::note(format!(
|
||||
"{} {:5} {} {}",
|
||||
wallclock(),
|
||||
meta.level().as_str(),
|
||||
meta.target(),
|
||||
v.0
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-08-15T12:03:47.123Z` from the system clock — wall time, so a bundle correlates with
|
||||
/// the host log it lands next to. No chrono dep; same civil-date derivation the host uses.
|
||||
fn wallclock() -> String {
|
||||
let ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let secs = (ms / 1000) as i64;
|
||||
let days = secs.div_euclid(86_400);
|
||||
let tod = secs.rem_euclid(86_400);
|
||||
// Howard Hinnant's civil_from_days.
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if mo <= 2 { y + 1 } else { y };
|
||||
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
|
||||
format!(
|
||||
"{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
|
||||
ms % 1000
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user