Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6eb5edaff4 | ||
|
|
9dde564835 | ||
|
|
b66bcef528 | ||
|
|
42848c56b7 | ||
|
|
39b9e9e276 | ||
|
|
79114891df | ||
|
|
f033d3f5df | ||
|
|
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
|
||||
|
||||
@@ -288,10 +288,13 @@ jobs:
|
||||
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
|
||||
$aliasNames = @{
|
||||
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
|
||||
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
|
||||
}
|
||||
# Under Azure signing there is no .cer, so MSIX_CER_PATH is unset. The quotes below are
|
||||
# load-bearing: "$($env:UNSET)" interpolates to an empty string (a legal key), whereas a
|
||||
# BARE $env:UNSET is $null and a null key is a hard error in a hash literal — which is
|
||||
# exactly how windows-host.yml's publish step broke. Added explicitly rather than relying
|
||||
# on that accident, so removing the quotes can't silently reintroduce it.
|
||||
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
|
||||
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
|
||||
@@ -472,7 +472,13 @@ jobs:
|
||||
# Refresh the channel alias (delete-then-reupload, like flatpak.yml/decky.yml) for a
|
||||
# predictable download URL: stable release -> `latest/`, canary main build -> `canary/`.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
|
||||
# Build this incrementally, NOT as one literal: under Azure signing there is no .cer, so
|
||||
# HOST_CER_PATH is unset — and an unset $env: var is $null, which is a HARD ERROR as a hash
|
||||
# literal key ("A null key is not allowed in a hash literal"), not the empty-string key it
|
||||
# looks like it should be. The $files guard above filters the missing .cer out just fine;
|
||||
# this line ran before anything could use it and failed the whole publish step.
|
||||
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe' }
|
||||
if ($env:HOST_CER_PATH) { $aliasNames[$env:HOST_CER_PATH] = 'punktfunk-host-windows.cer' }
|
||||
foreach ($f in $files) {
|
||||
$an = $aliasNames[$f]; if (-not $an) { continue }
|
||||
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
|
||||
|
||||
@@ -642,6 +642,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
+1
@@ -3115,6 +3115,7 @@ dependencies = [
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+5
-1
@@ -5,13 +5,17 @@ machine, so we take security reports seriously and appreciate responsible disclo
|
||||
|
||||
## Supported versions
|
||||
|
||||
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag; the current line is **0.22.x**) and
|
||||
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag) and
|
||||
**canary** (built from `main`). Fixes ship as a new release on those tracks; in practice
|
||||
we don't backport to older minor versions, so the supported versions are the latest stable release
|
||||
and the current canary build. If you're on an older build, please check that the issue still
|
||||
reproduces on the latest stable before reporting it. See
|
||||
[Release Channels](https://docs.punktfunk.unom.io/docs/channels).
|
||||
|
||||
Security fixes are **free of charge**, ship **without undue delay**, and are **separated from
|
||||
feature updates where feasible**: on the stable track they arrive as patch releases (`vX.Y.Z+1`)
|
||||
that carry the fix rather than waiting on the next feature release.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please report security issues privately by email to security@punktfunk.com.**
|
||||
|
||||
@@ -22,7 +22,8 @@ Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "n
|
||||
|
||||
## Get it
|
||||
|
||||
Published to **Google Play (Internal Testing)** — join the beta via the
|
||||
Published to **Google Play (Open Testing)** — join via the
|
||||
[public opt-in link](https://play.google.com/apps/testing/io.unom.punktfunk) or the
|
||||
[Discord](https://discord.gg/kaPNvzMuGU). Per-device setup and pairing:
|
||||
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
|
||||
|
||||
|
||||
@@ -142,6 +142,10 @@ dependencies {
|
||||
// job runs `:app:testDebugUnitTest -PskipRustBuild` (see kit/build.gradle.kts). ---
|
||||
testImplementation(composeBom)
|
||||
testImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
// Deterministic cover art for the library scene: FakeImageLoaderEngine answers the coverflow's
|
||||
// AsyncImage synchronously with generated posters — no network, no async race under the frozen
|
||||
// animation clock.
|
||||
testImplementation("io.coil-kt:coil-test:2.7.0")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub
|
||||
|
||||
@@ -243,6 +243,15 @@ fun ConnectScreen(
|
||||
knownHostStore.learnOs(dh.host, dh.port, dh.os)
|
||||
any = true
|
||||
}
|
||||
// And the mgmt port, so a host that moved off 47990 keeps its library once this
|
||||
// device can no longer see the advert (VPN, routed subnet, multicast-dead Wi-Fi).
|
||||
val mgmt = dh.mgmtPort
|
||||
if (mgmt != null &&
|
||||
knownHostStore.get(dh.host, dh.port)?.let { it.mgmtPort != mgmt } == true
|
||||
) {
|
||||
knownHostStore.learnMgmtPort(dh.host, dh.port, mgmt)
|
||||
any = true
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
@@ -313,13 +322,24 @@ fun ConnectScreen(
|
||||
// What the stream screen is handed: the settings this connect actually used, plus the HOST's
|
||||
// clipboard decision (a property of the record, not a global). A host we never saved — a
|
||||
// connect that failed to pin — falls back to the on default the setting always had.
|
||||
fun session(handle: Long, record: KnownHost?, profile: StreamProfile?) = ActiveSession(
|
||||
handle,
|
||||
settings.effectiveFor(profile),
|
||||
clipboardSync = record?.clipboardSync ?: true,
|
||||
profileName = profile?.name,
|
||||
hostId = record?.id,
|
||||
)
|
||||
fun session(handle: Long, record: KnownHost?, profile: StreamProfile?): ActiveSession {
|
||||
// The session's own Welcome carries where this host serves its library. Save it now: this
|
||||
// is the only source that does not need an mDNS advert, so it is what makes a host that
|
||||
// moved off 47990 browsable over a VPN or when it was added by address. 0 = not
|
||||
// advertised, and learnMgmtPort ignores it.
|
||||
if (record != null) {
|
||||
NativeBridge.nativeHostMgmtPort(handle).takeIf { it > 0 }?.let {
|
||||
knownHostStore.learnMgmtPort(record.address, record.port, it)
|
||||
}
|
||||
}
|
||||
return ActiveSession(
|
||||
handle,
|
||||
settings.effectiveFor(profile),
|
||||
clipboardSync = record?.clipboardSync ?: true,
|
||||
profileName = profile?.name,
|
||||
hostId = record?.id,
|
||||
)
|
||||
}
|
||||
|
||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||
|
||||
@@ -69,7 +69,7 @@ import kotlinx.coroutines.delay
|
||||
* to be the same one whichever interface asked.
|
||||
*/
|
||||
@Composable
|
||||
fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List<PadInfo>? = null) {
|
||||
BackHandler(onBack = onBack)
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
ControllersBody(
|
||||
@@ -77,6 +77,7 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
scroll = rememberScrollState(),
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// The touch screen holds the probes for its whole life: events are OBSERVED (not consumed)
|
||||
// while the test is off, which is what keeps the "Last input" line live while browsing.
|
||||
// Nothing else here wants the pad, so there is no one to hand them to.
|
||||
@@ -99,7 +100,12 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
* drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive: Boolean = true) {
|
||||
internal fun ConsoleControllersScreen(
|
||||
gamepadSetting: Int,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
@@ -139,6 +145,7 @@ fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive:
|
||||
scroll = scroll,
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// Only while testing: the rest of the time the screen's own nav holds the
|
||||
// probes, so the "Last input" line is a test-time readout here rather than
|
||||
// an always-on one. A pad that reaches this screen at all has already
|
||||
@@ -200,14 +207,17 @@ private fun ControllersBody(
|
||||
onTestingChange: (Boolean) -> Unit,
|
||||
observeInput: Boolean,
|
||||
contentPadding: PaddingValues,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
heading: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
|
||||
// Device list, re-read on every hot-plug event.
|
||||
// Device list, re-read on every hot-plug event. [padsOverride] replaces it wholesale: the
|
||||
// screenshot harness runs where no InputDevice can exist, and the connected-pad card is the
|
||||
// point of that shot.
|
||||
var generation by remember { mutableIntStateOf(0) }
|
||||
val pads = remember(generation) { Gamepad.pads() }
|
||||
val pads = padsOverride ?: remember(generation) { Gamepad.pads() }.map(::padInfoOf)
|
||||
val others = remember(generation) {
|
||||
InputDevice.getDeviceIds()
|
||||
.toList()
|
||||
@@ -392,8 +402,8 @@ private fun ControllersBody(
|
||||
// Every real controller is forwarded now (Automatic forwards them all, each on its own
|
||||
// wire pad index) — not just the first. A joystick-only device Android doesn't classify as
|
||||
// a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it.
|
||||
pads.forEach { dev ->
|
||||
PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting)
|
||||
pads.forEach { info ->
|
||||
PadRow(info, gamepadSetting = gamepadSetting)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,19 +685,19 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
|
||||
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
||||
@Composable
|
||||
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
private fun PadRow(info: PadInfo, gamepadSetting: Int) {
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
if (forwarded) {
|
||||
Text(info.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
if (info.forwarded) {
|
||||
// Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad
|
||||
// user can tell which physical pad is which. The stream's wire pad index is
|
||||
// assigned separately (lowest-free per device) once streaming starts.
|
||||
val number = dev.controllerNumber
|
||||
val number = info.controllerNumber
|
||||
Text(
|
||||
if (number > 0) "forwarded · player $number" else "forwarded to host",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -696,11 +706,11 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
}
|
||||
}
|
||||
Text(
|
||||
deviceDetail(dev),
|
||||
info.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val resolved = Gamepad.prefFor(dev)
|
||||
val resolved = info.resolvedPref
|
||||
Text(
|
||||
if (gamepadSetting == Gamepad.PREF_AUTO) {
|
||||
"Streams as: ${prefLabel(resolved)} (automatic)"
|
||||
@@ -711,9 +721,8 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val canRumble = deviceHasVibrator(dev)
|
||||
if (canRumble) {
|
||||
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
|
||||
if (info.canRumble) {
|
||||
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
|
||||
} else {
|
||||
Text(
|
||||
"No rumble motors reported — host rumble will be silent",
|
||||
@@ -794,6 +803,32 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
private fun isForwarded(dev: InputDevice): Boolean =
|
||||
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||
|
||||
/**
|
||||
* Everything [PadRow] renders, decoupled from [InputDevice] so the screenshot harness can compose
|
||||
* the connected-pad card at all — Robolectric enumerates no input devices, and a marketing shot of
|
||||
* "no controller detected" sells nothing. Production always maps a real device via [padInfoOf];
|
||||
* [dev] powers the rumble test and is absent only in the harness (the button then no-ops).
|
||||
*/
|
||||
internal data class PadInfo(
|
||||
val name: String,
|
||||
val detail: String,
|
||||
val forwarded: Boolean,
|
||||
val controllerNumber: Int,
|
||||
val resolvedPref: Int,
|
||||
val canRumble: Boolean,
|
||||
val dev: InputDevice? = null,
|
||||
)
|
||||
|
||||
internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
|
||||
name = dev.name,
|
||||
detail = deviceDetail(dev),
|
||||
forwarded = isForwarded(dev),
|
||||
controllerNumber = dev.controllerNumber,
|
||||
resolvedPref = Gamepad.prefFor(dev),
|
||||
canRumble = deviceHasVibrator(dev),
|
||||
dev = dev,
|
||||
)
|
||||
|
||||
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
||||
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
|
||||
@@ -59,7 +59,6 @@ import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import io.unom.punktfunk.components.launcherIcon
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.library.LibraryClient
|
||||
import io.unom.punktfunk.kit.library.LibraryResult
|
||||
@@ -120,14 +119,16 @@ fun LibraryScreen(
|
||||
}
|
||||
val streamSettings = remember(settings, profile) { settings.effectiveFor(profile) }
|
||||
|
||||
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||
// Keyed on the mgmt port too: a discovery tick can learn it after this screen is composed, and
|
||||
// the fetch must redo itself against the real port rather than stay on a stale 47990 failure.
|
||||
LaunchedEffect(host.address, host.port, host.fpHex, host.effectiveMgmtPort) {
|
||||
state = LibState.Loading
|
||||
state = withContext(Dispatchers.IO) {
|
||||
val id = runCatching { obtainIdentity(IdentityStore(context)) }.getOrNull()
|
||||
?: return@withContext LibState.Message("Identity unavailable — re-pair may be required.")
|
||||
when (val res = LibraryClient.fetch(
|
||||
address = host.address,
|
||||
mgmtPort = DEFAULT_MGMT_PORT,
|
||||
mgmtPort = host.effectiveMgmtPort,
|
||||
certPem = id.certPem,
|
||||
keyPem = id.privateKeyPem,
|
||||
fpHex = host.fpHex,
|
||||
@@ -254,8 +255,10 @@ private fun MessageState(text: String) {
|
||||
)
|
||||
}
|
||||
|
||||
// Internal (not private): the screenshot harness composes the real coverflow with mock games —
|
||||
// the library screen itself can't be shot, its state comes off the network.
|
||||
@Composable
|
||||
private fun Coverflow(
|
||||
internal fun Coverflow(
|
||||
games: List<GameEntry>,
|
||||
loader: ImageLoader,
|
||||
navActive: Boolean,
|
||||
|
||||
@@ -526,10 +526,25 @@ class MainActivity : ComponentActivity() {
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val handle = streamHandle
|
||||
if (handle != 0L) {
|
||||
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
|
||||
// Resolved before the gamepad and remote-pointer hooks so neither can claim them as
|
||||
// its own BACK. See [mouseSideButton] for how a mouse's BACK is told from a pad's or
|
||||
// a remote's; it answers null for every device that cannot be a mouse, so asking it
|
||||
// first re-routes nothing else.
|
||||
mouseSideButton(event)?.let { back ->
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN ->
|
||||
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
|
||||
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Gamepad buttons (incl. DPAD only when truly from a gamepad — else KEYCODE_DPAD_* are
|
||||
// keyboard arrows and belong to the VK path below).
|
||||
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
|
||||
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
|
||||
// why this asks it rather than `buttonBit`).
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
val bit = Gamepad.buttonBit(event.keyCode)
|
||||
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
|
||||
if (bit != 0) {
|
||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||
@@ -540,17 +555,6 @@ class MainActivity : ComponentActivity() {
|
||||
return true // consumed
|
||||
}
|
||||
}
|
||||
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
|
||||
// Resolved before the remote-pointer hook so pointer mode can't eat them as its own
|
||||
// BACK. See [mouseSideButton] for how a mouse's BACK is told from a remote's.
|
||||
mouseSideButton(event)?.let { back ->
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN ->
|
||||
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
|
||||
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
|
||||
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
|
||||
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
@@ -567,12 +571,13 @@ class MainActivity : ComponentActivity() {
|
||||
return true
|
||||
}
|
||||
when (event.keyCode) {
|
||||
// Whatever [mouseSideButton] didn't claim. A view-level FALLBACK BACK appears when
|
||||
// a BUTTON_* press goes unconsumed, and an air-mouse remote stamps its own BACK
|
||||
// SOURCE_MOUSE; both are duplicates of something already handled, and letting
|
||||
// either through doubles as Android navigation and yanks the user out of the
|
||||
// stream. A remote/keyboard BACK is never mouse-sourced, so it still falls through
|
||||
// to the BackHandler and exits.
|
||||
// Whatever [mouseSideButton] and the pad branch didn't claim. A view-level FALLBACK
|
||||
// BACK appears when a BUTTON_* press goes unconsumed, and an air-mouse remote stamps
|
||||
// its own BACK SOURCE_MOUSE; both are duplicates of something already handled, and
|
||||
// letting either through doubles as Android navigation and yanks the user out of the
|
||||
// stream. A remote/keyboard BACK is never mouse-sourced and never gamepad-sourced,
|
||||
// so it still falls through to the BackHandler and exits — which for a device with
|
||||
// no pad on it is the documented way out.
|
||||
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD ->
|
||||
if (event.isFromSource(InputDevice.SOURCE_MOUSE) ||
|
||||
event.flags and KeyEvent.FLAG_FALLBACK != 0
|
||||
|
||||
+69
-23
@@ -34,19 +34,34 @@ class ScreenshotTest {
|
||||
// cursor via an infinite animation that otherwise keeps Compose perpetually "busy", so
|
||||
// setContent's wait-for-idle never returns. Frozen, the capture is also deterministic.
|
||||
|
||||
/** Full-screen content scenes: the compose root fills the device, so a root capture is the shot. */
|
||||
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
/**
|
||||
* Full-screen content scenes: the compose root fills the device, so a root capture is the
|
||||
* shot. [statusBar] draws the fake system bar and pushes content below it (see
|
||||
* [ShotStatusFrame]) — off for the immersive surfaces (stream, console shell), which hide
|
||||
* the real bar too.
|
||||
*/
|
||||
private fun shootRoot(
|
||||
name: String,
|
||||
statusBar: Boolean = true,
|
||||
content: @androidx.compose.runtime.Composable () -> Unit,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
compose.onRoot().captureRoboImage("$out/phone-$name.png")
|
||||
}
|
||||
|
||||
/** Dialog scenes: the AlertDialog is a separate window, so capture the whole screen (all windows). */
|
||||
private fun shootScreen(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
private fun shootScreen(
|
||||
name: String,
|
||||
statusBar: Boolean = true,
|
||||
content: @androidx.compose.runtime.Composable () -> Unit,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } }
|
||||
// 1.6 s, not 0.8: a ModalBottomSheet's entrance spring is still mid-rise at 0.8 s and the
|
||||
// add-host sheet's Connect button was captured half below the frame.
|
||||
compose.mainClock.advanceTimeBy(1600)
|
||||
captureScreenRoboImage("$out/phone-$name.png")
|
||||
}
|
||||
|
||||
@@ -73,25 +88,25 @@ class ScreenshotTest {
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
||||
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
fun stream() = shootRoot("stream", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
fun streamCompact() = shootRoot("stream-compact", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
fun streamNormal() = shootRoot("stream-normal", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
|
||||
// Both banner texts, in the stream's own landscape geometry — it is bottom-centre, so the
|
||||
// aspect is load-bearing.
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamBannerPad() = shootRoot("stream-banner-pad") { StreamBannerScene(pad = true) }
|
||||
fun streamBannerPad() = shootRoot("stream-banner-pad", statusBar = false) { StreamBannerScene(pad = true) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamBannerTouch() = shootRoot("stream-banner-touch") { StreamBannerScene(pad = false) }
|
||||
fun streamBannerTouch() = shootRoot("stream-banner-touch", statusBar = false) { StreamBannerScene(pad = false) }
|
||||
|
||||
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
|
||||
@Test
|
||||
@@ -114,15 +129,15 @@ class ScreenshotTest {
|
||||
|
||||
// The console flow is the full-screen aurora takeover (a root capture).
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
fun connectingConsole() = shootRoot("connecting-console", statusBar = false) { ConnectConsoleScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
fun consoleSettings() = shootRoot("console-settings", statusBar = false) { ConsoleSettingsScene() }
|
||||
|
||||
/** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */
|
||||
@Test
|
||||
fun consoleSettingsLight() =
|
||||
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
|
||||
shootRoot("console-settings-light", statusBar = false) { ConsoleSettingsScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console actually runs in, and a DIFFERENT layout since the
|
||||
@@ -132,16 +147,16 @@ class ScreenshotTest {
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleSettingsLandscape() =
|
||||
shootRoot("console-settings-landscape") { ConsoleSettingsScene() }
|
||||
shootRoot("console-settings-landscape", statusBar = false) { ConsoleSettingsScene() }
|
||||
|
||||
// The console home, the screen the living backdrop is most of. The default sdk (36) draws the
|
||||
// real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two
|
||||
// renderings of the same palette can be compared rather than assumed equivalent.
|
||||
@Test
|
||||
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
|
||||
fun consoleHome() = shootRoot("console-home", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
@Test
|
||||
fun consoleHomeLight() = shootRoot("console-home-light") { ConsoleHomeScene(paletteId = "holo") }
|
||||
fun consoleHomeLight() = shootRoot("console-home-light", statusBar = false) { ConsoleHomeScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console UI actually runs in, and the only one wide enough to
|
||||
@@ -149,7 +164,7 @@ class ScreenshotTest {
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleHomeLandscape() = shootRoot("console-home-landscape") { ConsoleHomeScene() }
|
||||
fun consoleHomeLandscape() = shootRoot("console-home-landscape", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
/**
|
||||
* The API 31/32 field. `RuntimeShader` is API 33+, so everything below it keeps the four
|
||||
@@ -158,24 +173,46 @@ class ScreenshotTest {
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs") { ConsoleHomeScene() }
|
||||
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
// The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a
|
||||
// pale palette, because the console draws them through a ColorScheme derived from the palette's
|
||||
// ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
@Test
|
||||
fun consoleLicenses() = shootRoot("console-licenses") { ConsoleLicensesScene() }
|
||||
fun consoleLicenses() = shootRoot("console-licenses", statusBar = false) { ConsoleLicensesScene() }
|
||||
|
||||
@Test
|
||||
fun consoleLicensesLight() =
|
||||
shootRoot("console-licenses-light") { ConsoleLicensesScene(paletteId = "holo") }
|
||||
shootRoot("console-licenses-light", statusBar = false) { ConsoleLicensesScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
fun consoleControllers() = shootRoot("console-controllers", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The touch presentation, pads connected — landscape, like every store frame: the app is
|
||||
* built for horizontal use, and a portrait capture shows a layout nobody streams in.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun controllers() = shootRoot("controllers") { ControllersScene() }
|
||||
|
||||
/** The console presentation at the same landscape geometry — the store's FEEL THE GAME frame. */
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleControllersLandscape() =
|
||||
shootRoot("console-controllers-landscape", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The library coverflow with a mock shelf — the store's PICK & PLAY frame. Landscape: the
|
||||
* orientation the coverflow actually runs in, and the only one wide enough for neighbours.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun library() = shootRoot("library", statusBar = false) { LibraryScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllersLight() =
|
||||
shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") }
|
||||
shootRoot("console-controllers-light", statusBar = false) { ConsoleControllersScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
@@ -197,4 +234,13 @@ class ScreenshotTest {
|
||||
HostsScene()
|
||||
PairDialog()
|
||||
}
|
||||
|
||||
/**
|
||||
* The add-host sheet (separate window → whole-screen capture). Pixel-like geometry, not the
|
||||
* default 360×800dp: same 1080×2400 px, but at 420 dpi the extra dp headroom is what lets the
|
||||
* sheet's Connect button — the row that carries the resolution promise — fit in frame.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w411dp-h915dp-420dpi")
|
||||
fun addHost() = shootScreen("add-host") { AddHostScene() }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
package io.unom.punktfunk.screenshots
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Shader
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BatteryFull
|
||||
import androidx.compose.material.icons.filled.SignalCellular4Bar
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
@@ -35,8 +53,27 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import io.unom.punktfunk.GamepadHome
|
||||
import io.unom.punktfunk.GamepadInk
|
||||
import io.unom.punktfunk.GamepadPalette
|
||||
import coil.ImageLoader
|
||||
import coil.test.FakeImageLoaderEngine
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.AddHostSheet
|
||||
import io.unom.punktfunk.ConsoleControllersScreen
|
||||
import io.unom.punktfunk.ConsoleHeader
|
||||
import io.unom.punktfunk.ConsoleLegendInset
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
import io.unom.punktfunk.ControllersScreen
|
||||
import io.unom.punktfunk.Coverflow
|
||||
import io.unom.punktfunk.GamepadAuroraBackground
|
||||
import io.unom.punktfunk.GamepadHintBar
|
||||
import io.unom.punktfunk.PadGlyph
|
||||
import io.unom.punktfunk.PadInfo
|
||||
import io.unom.punktfunk.consoleLegendInsets
|
||||
import io.unom.punktfunk.consoleSafeArea
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.library.Artwork
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import io.unom.punktfunk.GamepadSettingsScreen
|
||||
import io.unom.punktfunk.HomeTile
|
||||
import io.unom.punktfunk.LocalGamepadInk
|
||||
@@ -70,6 +107,51 @@ internal fun ShotTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = BrandDark, content = content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Robolectric has no system UI, so every capture was missing the status bar and the content sat
|
||||
* where the bar belongs — on the Pixel render the app title collided with the camera punch-hole.
|
||||
* This frame draws a plausible bar (time left, radios right, the CENTRE left empty for the hole)
|
||||
* and pushes the scene below it, the same geometry real insets produce. The height mirrors a
|
||||
* Pixel's tall bar as measured off a real 1344×2992 capture (~145 px ≈ 40 dp).
|
||||
*/
|
||||
@Composable
|
||||
internal fun ShotStatusFrame(content: @Composable () -> Unit) {
|
||||
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(40.dp).padding(horizontal = 28.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"21:47",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Wifi, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(15.dp),
|
||||
)
|
||||
Icon(
|
||||
Icons.Filled.SignalCellular4Bar, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Icon(
|
||||
Icons.Filled.BatteryFull, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
private data class MockHost(
|
||||
val name: String,
|
||||
val address: String,
|
||||
@@ -510,8 +592,8 @@ internal fun ConsoleHomeScene(paletteId: String = "violet") {
|
||||
* whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* before this could catch light-grey body text stranded on a pastel field.
|
||||
*
|
||||
* Robolectric enumerates no input devices, so the controllers scene renders its deterministic
|
||||
* "nothing connected" state.
|
||||
* Robolectric enumerates no input devices, so the controllers scenes inject [shotPads] — the
|
||||
* deterministic connected-pads state the store listing needs.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
@@ -520,14 +602,147 @@ internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
@Composable
|
||||
internal fun ConsoleControllersScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) {
|
||||
ConsoleControllersScreen(gamepadSetting = 0, onBack = {}, navActive = false)
|
||||
// Robolectric enumerates no input devices, so the shot injects the two pads the store
|
||||
// listing talks about — the empty "no controller detected" state proves the palette but
|
||||
// sells nothing.
|
||||
ConsoleControllersScreen(
|
||||
gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The touch presentation of the same screen, with the same injected pads. Wrapped in a background
|
||||
* [Surface]: the activity provides the dark ground in the app, and without one here the content
|
||||
* color falls back to black-on-white while the cards stay dark.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ControllersScene() =
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
ControllersScreen(gamepadSetting = 0, onBack = {}, padsOverride = shotPads())
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Add a host" bottom sheet over the host grid — the store's onboarding frame. State is
|
||||
* hoisted in production (ConnectScreen), so the scene passes a filled-in form directly; the
|
||||
* mode label mirrors what a paired 120 Hz phone shows on the connect button.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddHostScene() {
|
||||
HostsScene()
|
||||
AddHostSheet(
|
||||
hostName = "Living Room PC", onHostNameChange = {},
|
||||
host = "192.168.1.42", onHostChange = {},
|
||||
port = "9777", onPortChange = {},
|
||||
connecting = false, modeLabel = "2992×1344@120",
|
||||
onDismiss = {}, onConnect = { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
|
||||
/** The two pads the store listing names: DualSense (adaptive triggers, LEDs, rumble) and Xbox. */
|
||||
internal fun shotPads() = listOf(
|
||||
PadInfo(
|
||||
name = "DualSense Wireless Controller",
|
||||
detail = "054C:0CE6 · gamepad · joystick",
|
||||
forwarded = true, controllerNumber = 1,
|
||||
resolvedPref = Gamepad.PREF_DUALSENSE, canRumble = true,
|
||||
),
|
||||
PadInfo(
|
||||
name = "Xbox Wireless Controller",
|
||||
detail = "045E:0B13 · gamepad · joystick",
|
||||
forwarded = true, controllerNumber = 2,
|
||||
resolvedPref = Gamepad.PREF_XBOXONE, canRumble = true,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Publish the palette locals `App` would normally provide. A scene that calls a console screen
|
||||
* directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently
|
||||
* prove nothing at all.
|
||||
*/
|
||||
/**
|
||||
* The game-library coverflow (the real [Coverflow] over the real console chrome) with a mock shelf.
|
||||
* The library screen itself can't be shot — its state comes off the network — so the scene rebuilds
|
||||
* the same shell [io.unom.punktfunk.LibraryScreen] draws around it: aurora, header, floating hint
|
||||
* bar. Cover art is answered synchronously by coil-test's [FakeImageLoaderEngine] with generated
|
||||
* posters, so the frozen animation clock never races an async load.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LibraryScene(paletteId: String = "violet") = ConsolePalette(paletteId) {
|
||||
val context = LocalContext.current
|
||||
val loader = remember { shotLibraryLoader(context) }
|
||||
val games = remember { shotGames() }
|
||||
val hazeState = remember { HazeState() }
|
||||
val landscape =
|
||||
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
ConsoleHeader("Living Room PC — Library")
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Coverflow(games, loader, navActive = false, onLaunch = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
listOf(PadGlyph.hint('A', "Launch"), PadGlyph.hint('B', "Close")),
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A believable shelf: four titles with art plus the Steam launcher entry (brand-mark tile). */
|
||||
private fun shotGames() = listOf(
|
||||
GameEntry("custom:aurora", "custom", "Aurora Drift", Artwork("shot://art/aurora", null, null)),
|
||||
GameEntry("steam:starfall", "steam", "Starfall Vale", Artwork("shot://art/starfall", null, null)),
|
||||
GameEntry("heroic:neon", "heroic", "Neon Circuit", Artwork("shot://art/neon", null, null)),
|
||||
GameEntry("gog:ember", "gog", "Ember Peaks", Artwork("shot://art/ember", null, null)),
|
||||
GameEntry("steam:launcher", "steam", "Steam", Artwork(null, null, null), role = "launcher", icon = "steam"),
|
||||
)
|
||||
|
||||
private fun shotLibraryLoader(context: Context): ImageLoader {
|
||||
val engine = FakeImageLoaderEngine.Builder()
|
||||
.intercept("shot://art/aurora", cover(context, 0xFF6656F2, 0xFF141040, "A"))
|
||||
.intercept("shot://art/starfall", cover(context, 0xFFE86FA8, 0xFF3A1030, "S"))
|
||||
.intercept("shot://art/neon", cover(context, 0xFF35D0C5, 0xFF0A2A33, "N"))
|
||||
.intercept("shot://art/ember", cover(context, 0xFFEF8F4B, 0xFF3A1608, "E"))
|
||||
.default(ColorDrawable(0xFF221E44.toInt()))
|
||||
.build()
|
||||
return ImageLoader.Builder(context).components { add(engine) }.build()
|
||||
}
|
||||
|
||||
/** A generated 2:3 poster: vertical brand-adjacent gradient + a big monogram. */
|
||||
private fun cover(context: Context, top: Long, bottom: Long, mark: String): Drawable {
|
||||
val w = 600
|
||||
val h = 900
|
||||
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bmp)
|
||||
canvas.drawRect(
|
||||
0f, 0f, w.toFloat(), h.toFloat(),
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = LinearGradient(
|
||||
0f, 0f, 0f, h.toFloat(), top.toInt(), bottom.toInt(), Shader.TileMode.CLAMP,
|
||||
)
|
||||
},
|
||||
)
|
||||
canvas.drawText(
|
||||
mark, w / 2f, h / 2f + 110f,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xD9FFFFFF.toInt()
|
||||
textSize = 320f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
textAlign = Paint.Align.CENTER
|
||||
},
|
||||
)
|
||||
return BitmapDrawable(context.resources, bmp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
|
||||
@@ -50,6 +50,10 @@ class TvScreenshotTest {
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
|
||||
/** The library coverflow at TV geometry — the store's PICK & PLAY frame for the TV listing. */
|
||||
@Test
|
||||
fun library() = shootRoot("library") { LibraryScene() }
|
||||
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ tolerates it being raw JSON *or* base64-encoded JSON.
|
||||
Usage (upload a new build):
|
||||
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--aab path/to/app-release.aab --track internal --status completed [--no-commit]
|
||||
--aab path/to/app-release.aab --track beta --also-track alpha \
|
||||
--status completed [--no-commit]
|
||||
|
||||
Usage (promote a build that is already on Play, no rebuild):
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
@@ -164,6 +165,9 @@ def main():
|
||||
ap.add_argument("--promote-from", metavar="TRACK",
|
||||
help="with --promote: assert the code is on TRACK, then clear TRACK")
|
||||
ap.add_argument("--track", default="internal")
|
||||
ap.add_argument("--also-track", action="append", default=[], metavar="TRACK",
|
||||
help="assign the same versionCode to this track too, in the same edit "
|
||||
"(repeatable). Canary uses it to feed open + closed testing at once.")
|
||||
ap.add_argument("--status", default="completed")
|
||||
ap.add_argument("--user-fraction", type=float,
|
||||
help="staged rollout fraction, 0<f<1; required by --status inProgress")
|
||||
@@ -183,6 +187,11 @@ def main():
|
||||
sys.exit(f"ERROR: --user-fraction must be strictly between 0 and 1 (got {a.user_fraction})")
|
||||
if a.aab and not os.path.isfile(a.aab):
|
||||
sys.exit(f"ERROR: AAB not found: {a.aab}")
|
||||
for t in a.also_track:
|
||||
# `--also-track <promote-from>` would assign and clear the same track in one edit;
|
||||
# whichever PUT lands second silently wins. Refuse the ambiguity instead.
|
||||
if t in (a.track, a.promote_from):
|
||||
sys.exit(f"ERROR: --also-track {t} duplicates --track/--promote-from")
|
||||
|
||||
notes = load_release_notes(a.release_notes_file, a.release_notes_language) \
|
||||
if a.release_notes_file else None
|
||||
@@ -209,6 +218,11 @@ def main():
|
||||
put_track(app, edit, tok, a.track, [vc], a.status, a.user_fraction, notes)
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}"
|
||||
+ (f" userFraction={a.user_fraction}" if a.user_fraction is not None else ""))
|
||||
# Same edit, so one commit (and one Play review) covers every track the code lands on —
|
||||
# the tracks can never disagree about which canary is current.
|
||||
for t in a.also_track:
|
||||
put_track(app, edit, tok, t, [vc], a.status, a.user_fraction, notes)
|
||||
print(f"assigned versionCode={vc} -> track={t} status={a.status}")
|
||||
# Same edit as the assignment above, so the code is never active on both tracks at once.
|
||||
if a.promote_from:
|
||||
put_track(app, edit, tok, a.promote_from, [], a.status)
|
||||
|
||||
@@ -230,6 +230,46 @@ object Gamepad {
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The BTN_* bit for one key event from a SOURCE_GAMEPAD device — [buttonBit] plus the
|
||||
* Select-family button of every pad that carries no `BUTTON_SELECT` scancode at all.
|
||||
*
|
||||
* Plenty of controllers deliver that button as the plain `KEYCODE_BACK` a remote's Back uses,
|
||||
* with no `BUTTON_SELECT` behind it: it is the Android-TV shape, where every input device is
|
||||
* expected to offer Back, and a pad reaches it whether the vendor prints "Back" on the button
|
||||
* (NVIDIA's SHIELD controller) or "Select"/"View" (most pads in an Android mode). Which one is
|
||||
* on the couch cannot be told from here, and does not need to be — the keycode is what routes.
|
||||
*
|
||||
* Read through [buttonBit] alone that button mapped to nothing, so it fell out of the
|
||||
* streaming branch unconsumed and reached the activity's back stack, which is the
|
||||
* deliberate-quit exit: ONE press of Select dropped the session and the host logged a client
|
||||
* quit. `KEYCODE_BACK` is in fact the ONLY keycode that can get there from a pad — a mapped
|
||||
* button is consumed here, anything with a VK is consumed on the keycode path, volume/power go
|
||||
* to the system, and a FLAG_FALLBACK BACK is swallowed — which is what identifies this as the
|
||||
* cause of such a report without knowing the hardware.
|
||||
*
|
||||
* It also meant such a pad could not produce [BTN_BACK] at all, so every shortcut built on
|
||||
* Select — the emergency exit chord this client's own start banner advertises, the mic mute,
|
||||
* the stats tier — was unreachable on exactly the devices whose users have no keyboard.
|
||||
*
|
||||
* A pad that DOES carry `BUTTON_SELECT` is unaffected in both directions: it never had the
|
||||
* bug, and this changes nothing for it.
|
||||
*
|
||||
* FLAG_FALLBACK events are excluded: those are the synthetic BACK the framework raises after
|
||||
* an unconsumed `BUTTON_*` press (a pad reporting L2/R2 as keys, say), not a button anyone
|
||||
* touched, and forwarding one would put a phantom Select on the wire. `MainActivity` drops
|
||||
* them on the keycode path for the same reason.
|
||||
*
|
||||
* Callers must gate on `SOURCE_GAMEPAD` before asking, exactly as [buttonBit]'s `KEYCODE_DPAD_*`
|
||||
* rows require: a remote's or keyboard's BACK shares this keycode and has to keep leaving the
|
||||
* stream — for a device with no pad on it, Back IS the documented way out.
|
||||
*/
|
||||
fun padButtonBit(keyCode: Int, flags: Int): Int = when {
|
||||
keyCode != KeyEvent.KEYCODE_BACK -> buttonBit(keyCode)
|
||||
flags and KeyEvent.FLAG_FALLBACK != 0 -> 0
|
||||
else -> BTN_BACK
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
|
||||
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
|
||||
|
||||
@@ -477,6 +477,16 @@ object NativeBridge {
|
||||
// cross only when the host pastes (a "fetch:" event answered by nativeClipServeText). Host
|
||||
// copies arrive as "offer:" events, fetched eagerly into the system clipboard.
|
||||
|
||||
/**
|
||||
* The management-API port the host reported in this session's `Welcome` — where its game
|
||||
* library is served — or 0 if it advertised none (older host, or no management API).
|
||||
*
|
||||
* Persist it on the host record: unlike the mDNS `mgmt` TXT, this arrives over the connection
|
||||
* we have already authenticated, so it is what makes a host that moved off 47990 browsable
|
||||
* over a VPN, a routed subnet, or when it was added by address.
|
||||
*/
|
||||
external fun nativeHostMgmtPort(handle: Long): Int
|
||||
|
||||
/** Whether the host advertised a working shared-clipboard service (HOST_CAP_CLIPBOARD). */
|
||||
external fun nativeClipSupported(handle: Long): Boolean
|
||||
|
||||
|
||||
+7
-1
@@ -19,13 +19,16 @@ data class DiscoveredHost(
|
||||
val pairingRequired: Boolean = false,
|
||||
val mac: List<String> = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN)
|
||||
val os: String = "", // TXT "os" (OS-identity chain, e.g. "linux/fedora/bazzite"); "" on older hosts
|
||||
// TXT "mgmt" — the management-API port the library is served on, distinct from `port` (the
|
||||
// native QUIC plane). null on an older host / older native lib, meaning "assume 47990".
|
||||
val mgmtPort: Int? = null,
|
||||
)
|
||||
|
||||
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||
private const val FIELD_SEP = '\u001F'
|
||||
|
||||
/**
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac␟os`),
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt`),
|
||||
* or null if it's malformed. Fields past the 6th are optional — an older native lib omits them
|
||||
* (`mac` 7th, `os` 8th). Pure — unit-tested without Android (see ParseRecordTest). The native side
|
||||
* already applied the protocol gate and address selection, so this is just field marshaling.
|
||||
@@ -46,6 +49,9 @@ fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
else emptyList(),
|
||||
os = if (f.size > 7) sanitizeOsChain(f[7]) else "",
|
||||
// 9th field, absent on an older native lib. `0` (and anything out of range) means "not
|
||||
// advertised" → null, and the caller falls back to 47990.
|
||||
mgmtPort = if (f.size > 8) f[8].toIntOrNull()?.takeIf { it in 1..65535 } else null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+36
-1
@@ -32,6 +32,16 @@ data class KnownHost(
|
||||
* first learned (or forever, against an older host).
|
||||
*/
|
||||
val os: String = "",
|
||||
/**
|
||||
* The host's management-API port (mDNS `mgmt` TXT), where the game library is served — NOT
|
||||
* [port], which is the native QUIC plane. Learned while online and kept for the same reason as
|
||||
* [mac] and [os], except this one is load-bearing: a host that moved its mgmt port off 47990
|
||||
* (the supported way to share a machine with a Sunshine fork, whose web UI owns that port)
|
||||
* served its library only while mDNS was reachable, because the advert was the sole place the
|
||||
* real port ever existed. `null` until learned — resolve with [effectiveMgmtPort].
|
||||
* Mirrors the Apple client's `StoredHost.mgmtPort` and the Rust `KnownHost.mgmt_port`.
|
||||
*/
|
||||
val mgmtPort: Int? = null,
|
||||
/** Stable record identity — see the class doc. Minted here for a genuinely new record. */
|
||||
val id: String = newRecordId(),
|
||||
/**
|
||||
@@ -54,7 +64,16 @@ data class KnownHost(
|
||||
* that no longer exist are dropped when the cards are rendered.
|
||||
*/
|
||||
val pinnedProfileIds: List<String> = emptyList(),
|
||||
)
|
||||
) {
|
||||
/**
|
||||
* Where this host's management API actually is: the port learned from its advert, else 47990.
|
||||
* The twin of the Apple client's `StoredHost.effectiveMgmtPort` and the Rust
|
||||
* `KnownHost::effective_mgmt_port`. Resolve through this — the constant is the FALLBACK, not
|
||||
* the answer.
|
||||
*/
|
||||
val effectiveMgmtPort: Int
|
||||
get() = mgmtPort ?: io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists trusted hosts — the pinned-fingerprint store *and* the saved-hosts list — keyed by
|
||||
@@ -130,6 +149,17 @@ class KnownHostStore(context: Context) {
|
||||
save(h.copy(os = os))
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn/refresh a saved host's management-API port from its live advert — same contract as
|
||||
* [learnMac]. This is the one that keeps a moved mgmt port working once mDNS isn't reachable.
|
||||
*/
|
||||
fun learnMgmtPort(address: String, port: Int, mgmtPort: Int) {
|
||||
if (mgmtPort <= 0) return
|
||||
val h = get(address, port) ?: return
|
||||
if (h.mgmtPort == mgmtPort) return
|
||||
save(h.copy(mgmtPort = mgmtPort))
|
||||
}
|
||||
|
||||
/** Forget [host] (the next connect re-pairs / re-TOFUs). */
|
||||
fun remove(host: KnownHost) {
|
||||
prefs.edit().remove(host.id).apply()
|
||||
@@ -180,6 +210,10 @@ class KnownHostStore(context: Context) {
|
||||
paired = j.optBoolean("paired", false),
|
||||
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||
os = j.optString("os", ""),
|
||||
// 0 (or absent) = never learned. `optInt` cannot express "missing", hence the sentinel
|
||||
// rather than a bare default — a record written before this field existed must decode
|
||||
// to null and fall back to 47990, not to port 0.
|
||||
mgmtPort = j.optInt("mgmt", 0).takeIf { it > 0 },
|
||||
// A record without an id can only be one this build wrote before the migration ran, or
|
||||
// a hand-edited file; minting here keeps the parse total rather than dropping a host.
|
||||
id = j.optString("id", "").ifEmpty { newRecordId() },
|
||||
@@ -266,6 +300,7 @@ class KnownHostStore(context: Context) {
|
||||
.put("paired", host.paired)
|
||||
.put("mac", host.mac.joinToString(","))
|
||||
.put("os", host.os)
|
||||
.put("mgmt", host.mgmtPort ?: 0)
|
||||
.put("clip", host.clipboardSync)
|
||||
.put("profile", host.profileId ?: "")
|
||||
.put("pins", JSONArray(host.pinnedProfileIds))
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.KeyEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of [Gamepad.padButtonBit] — the streaming branch's gamepad keycode resolution
|
||||
* (`KeyEvent`'s keycode/flag constants are compile-time-inlined ints, so no Android runtime is
|
||||
* involved). Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*
|
||||
* The regression it pins is a field report: one press of Select disconnected the session. Plenty
|
||||
* of pads deliver that button as the plain `KEYCODE_BACK` a remote uses, with no `BUTTON_SELECT`
|
||||
* scancode behind it — so it mapped to nothing, fell out of the gamepad branch unconsumed, and
|
||||
* reached the activity back stack, which is the deliberate-quit exit. The same gap made
|
||||
* [Gamepad.BTN_BACK] unreachable on those pads, and with it every shortcut built on Select: the
|
||||
* exit chord `StreamScreen`'s own start banner advertises, the mic mute, the stats tier.
|
||||
*
|
||||
* Which controller the report came from is not knowable from the logs and does not matter:
|
||||
* `KEYCODE_BACK` is the only keycode that reaches the back stack from a SOURCE_GAMEPAD device, so
|
||||
* a one-press quit identifies the button's keycode on its own.
|
||||
*/
|
||||
class PadButtonBitTest {
|
||||
|
||||
/** The report: Select on an Android-TV pad arrives as BACK and must be the Select bit. */
|
||||
@Test
|
||||
fun `a pad's BACK is its Select button`() {
|
||||
assertEquals(Gamepad.BTN_BACK, Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0))
|
||||
// Same bit either spelling reaches us by — a pad that DOES carry BUTTON_SELECT is unchanged.
|
||||
assertEquals(
|
||||
Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_SELECT, 0),
|
||||
Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* With Select mapped, the three Select chords are reachable on a pad that has only a BACK
|
||||
* keycode — which is the whole point of the mapping, not a side effect of it. Held-state
|
||||
* assembly is [GamepadRouter]'s (see `GamepadChordTest`); what is pinned here is that the
|
||||
* bits a SHIELD can actually produce cover each chord.
|
||||
*/
|
||||
@Test
|
||||
fun `the Select chords are reachable from a BACK-only pad`() {
|
||||
val select = Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0)
|
||||
val start = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_START, 0)
|
||||
val l1 = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_L1, 0)
|
||||
val r1 = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_R1, 0)
|
||||
val x = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_X, 0)
|
||||
val y = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_Y, 0)
|
||||
assertEquals(GamepadRouter.EXIT_CHORD, select or start or l1 or r1)
|
||||
assertEquals(GamepadRouter.STATS_CHORD, select or x)
|
||||
assertEquals(GamepadRouter.MIC_CHORD, select or y)
|
||||
}
|
||||
|
||||
/**
|
||||
* The synthetic BACK the framework raises after an unconsumed `BUTTON_*` press is not a button
|
||||
* anyone touched — forwarding it would put a phantom Select on the wire, and one of those
|
||||
* landing while Start + L1 + R1 were held would complete the exit chord out of nowhere.
|
||||
*/
|
||||
@Test
|
||||
fun `a fallback BACK is not a button press`() {
|
||||
assertEquals(0, Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, KeyEvent.FLAG_FALLBACK))
|
||||
// Only BACK is filtered on the flag; a real button keeps its bit whatever rides alongside.
|
||||
assertEquals(
|
||||
Gamepad.BTN_A,
|
||||
Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.FLAG_FALLBACK),
|
||||
)
|
||||
}
|
||||
|
||||
/** Everything else is [Gamepad.buttonBit] verbatim — BACK is the only row this adds. */
|
||||
@Test
|
||||
fun `every other keycode is unchanged`() {
|
||||
for (code in 0..0x400) {
|
||||
if (code == KeyEvent.KEYCODE_BACK) continue
|
||||
assertEquals(Gamepad.buttonBit(code), Gamepad.padButtonBit(code, 0))
|
||||
}
|
||||
// And BACK is genuinely a new row, not one buttonBit already had.
|
||||
assertEquals(0, Gamepad.buttonBit(KeyEvent.KEYCODE_BACK))
|
||||
}
|
||||
}
|
||||
+25
@@ -47,6 +47,31 @@ class ParseRecordTest {
|
||||
rec("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/fedora/bazzite"),
|
||||
)!!
|
||||
assertEquals("linux/fedora/bazzite", h.os)
|
||||
// A record from a native lib predating the 9th field: no mgmt port, so the caller falls
|
||||
// back to 47990. Absent must read as "unknown", never as port 0.
|
||||
assertNull(h.mgmtPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ninthFieldCarriesTheMgmtPort() {
|
||||
// 47991, not the 47990 default — a host that MOVED its mgmt port is the whole reason this
|
||||
// field is on the wire, and a test pinned to the default would pass against a hardcode.
|
||||
val h = parseHostRecord(
|
||||
rec("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/arch", "47991"),
|
||||
)!!
|
||||
assertEquals(47991, h.mgmtPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mgmtPortOutOfRangeOrUnparsableReadsAsUnknown() {
|
||||
// Unauthenticated advert data: 0 (the "not advertised" sentinel the Rust side emits),
|
||||
// a non-number, and an out-of-range value must all mean "assume the default" rather than
|
||||
// produce a port the client would then fail to connect to.
|
||||
val base = arrayOf("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/arch")
|
||||
assertNull(parseHostRecord(rec(*base, "0"))!!.mgmtPort)
|
||||
assertNull(parseHostRecord(rec(*base, "not-a-port"))!!.mgmtPort)
|
||||
assertNull(parseHostRecord(rec(*base, "70000"))!!.mgmtPort)
|
||||
assertNull(parseHostRecord(rec(*base, ""))!!.mgmtPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -32,7 +32,7 @@ const PROTO: &str = "punktfunk/1";
|
||||
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||
const FIELD_SEP: char = '\u{1f}';
|
||||
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac␟os`
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt`
|
||||
/// (`␟` = [`FIELD_SEP`]). Records are newline-joined in a poll snapshot; [`Host::encode`] strips
|
||||
/// the framing bytes from every field so no value can break it. New fields append (the Kotlin
|
||||
/// parser tolerates both arities), never reorder.
|
||||
@@ -49,6 +49,10 @@ struct Host {
|
||||
/// OS-identity chain from the mDNS `os` TXT (`linux/fedora/bazzite`, ...), for the host
|
||||
/// card's OS icon. Empty if absent (older host).
|
||||
os: String,
|
||||
/// Management-API port from the mDNS `mgmt` TXT — where the game library is served, distinct
|
||||
/// from `port` (the native QUIC plane). `0` if absent. Kotlin persists it on the host record so
|
||||
/// a host that moved off 47990 keeps its library once mDNS is no longer reachable.
|
||||
mgmt: u16,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
@@ -61,7 +65,7 @@ impl Host {
|
||||
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||
}
|
||||
format!(
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
clean(&self.key),
|
||||
clean(&self.name),
|
||||
clean(&self.addr),
|
||||
@@ -70,6 +74,7 @@ impl Host {
|
||||
clean(&self.pair),
|
||||
clean(&self.mac),
|
||||
clean(&self.os),
|
||||
self.mgmt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -193,6 +198,8 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
pair: val("pair"),
|
||||
mac: val("mac"),
|
||||
os: val("os"),
|
||||
// 0 = the host didn't advertise one (older host); Kotlin then falls back to 47990.
|
||||
mgmt: val("mgmt").parse().unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -213,7 +220,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||
@@ -277,10 +284,11 @@ mod tests {
|
||||
pair: "required".into(),
|
||||
mac: "aa:bb:cc:dd:ee:ff".into(),
|
||||
os: "linux/fedora/bazzite".into(),
|
||||
mgmt: 47991,
|
||||
};
|
||||
let encoded = h.encode();
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields.len(), 8);
|
||||
assert_eq!(fields.len(), 9);
|
||||
assert_eq!(fields[0], "host-123");
|
||||
assert_eq!(fields[1], "home-worker-2");
|
||||
assert_eq!(fields[2], "192.168.1.70");
|
||||
@@ -289,6 +297,9 @@ mod tests {
|
||||
assert_eq!(fields[5], "required");
|
||||
assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff");
|
||||
assert_eq!(fields[7], "linux/fedora/bazzite");
|
||||
// A NON-default port on purpose: the whole point of carrying this field is the host that
|
||||
// moved off 47990, so a test pinned to the default would pass against a hardcoded value.
|
||||
assert_eq!(fields[8], "47991");
|
||||
assert!(
|
||||
!encoded.contains('\n'),
|
||||
"a record must never contain the record separator"
|
||||
@@ -308,13 +319,11 @@ mod tests {
|
||||
pair: "required\n".into(),
|
||||
mac: "aa:bb\u{1f}cc".into(),
|
||||
os: "linux\u{1f}evil/arch".into(),
|
||||
// A numeric field cannot smuggle a separator — it is formatted from a u16, not cleaned.
|
||||
mgmt: 47991,
|
||||
};
|
||||
let encoded = h.encode();
|
||||
assert_eq!(
|
||||
encoded.matches(FIELD_SEP).count(),
|
||||
7,
|
||||
"exactly eight fields"
|
||||
);
|
||||
assert_eq!(encoded.matches(FIELD_SEP).count(), 8, "exactly nine fields");
|
||||
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields[0], "kinjected");
|
||||
|
||||
@@ -50,6 +50,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupport
|
||||
client(handle).is_some_and(|h| h.client.host_caps() & HOST_CAP_CLIPBOARD != 0)
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeHostMgmtPort(handle)` — the management-API port the host reported in this
|
||||
/// session's `Welcome`, or `0` if it advertised none (older host / no management API).
|
||||
///
|
||||
/// Kotlin persists this on the host record, which is what lets the library screen reach a host that
|
||||
/// moved its mgmt port off 47990 WITHOUT ever having seen an mDNS advert — the VPN / routed-subnet
|
||||
/// / added-by-address cases, where the `mgmt` TXT the discovery path relies on never arrives.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostMgmtPort(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jint {
|
||||
client(handle).map_or(0, |h| jint::from(h.client.mgmt_port()))
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
|
||||
/// clipboard-related happens on either side until an `enabled: true` crosses.
|
||||
#[unsafe(no_mangle)]
|
||||
|
||||
@@ -354,9 +354,14 @@ struct ContentView: View {
|
||||
// Persist on the next runloop tick: HostStore is an ObservableObject, and mutating
|
||||
// its @Published from inside .onChange (a view-update callback) trips SwiftUI's
|
||||
// "Publishing changes from within view updates". A one-tick delay is imperceptible.
|
||||
// The session's own Welcome told us where this host's library lives — the one
|
||||
// source that does not need an mDNS advert, so it also covers a host reached by
|
||||
// address over a VPN. 0 = not advertised; updateMgmtPort ignores it.
|
||||
let liveMgmtPort = model.connection?.hostMgmtPort
|
||||
let store = store
|
||||
DispatchQueue.main.async {
|
||||
store.markConnected(host.id)
|
||||
store.updateMgmtPort(host.id, port: liveMgmtPort)
|
||||
if let approvedFingerprint { store.pin(host.id, fingerprint: approvedFingerprint) }
|
||||
}
|
||||
case .idle:
|
||||
@@ -1262,6 +1267,9 @@ struct ContentView: View {
|
||||
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
||||
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
||||
store.updateOsChain(host.id, chain: live.osChain) // ditto for the card's OS mark
|
||||
// ...and the mgmt port, so the library keeps working against a host that moved it once
|
||||
// this device can no longer see the advert (VPN, routed subnet, multicast-dead Wi-Fi).
|
||||
store.updateMgmtPort(host.id, port: live.mgmtPort)
|
||||
} else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
||||
// Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the
|
||||
// dial times out. With auto-wake off, connects go straight through (no packet).
|
||||
@@ -1320,6 +1328,7 @@ struct ContentView: View {
|
||||
guard !model.isBusy else { return }
|
||||
let host = StoredHost(
|
||||
name: d.name, address: d.host, port: d.port,
|
||||
mgmtPort: d.mgmtPort,
|
||||
macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses,
|
||||
osChain: d.osChain.isEmpty ? nil : d.osChain)
|
||||
store.add(host)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// can wait for layout instead of guessing with a fixed sleep.
|
||||
|
||||
#if DEBUG
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -43,6 +44,17 @@ enum ScreenshotMode {
|
||||
/// readiness ping for the capture script.
|
||||
struct ScreenshotHostView: View {
|
||||
let scene: ShotScene
|
||||
|
||||
init(scene: ShotScene) {
|
||||
self.scene = scene
|
||||
// Pin the palette for the capture. The aurora screens read the LIVE `uiPalette` default,
|
||||
// and a reused Simulator (or a dev Mac) carries whatever was last picked there — the
|
||||
// Apple TV set once shipped out on a sunset palette that a test device had persisted.
|
||||
// Idempotent, and only ever runs in shot mode (this view exists behind that gate).
|
||||
UserDefaults.standard.set(
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_PALETTE"] ?? "violet",
|
||||
forKey: DefaultsKey.uiPalette)
|
||||
}
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
@@ -35,6 +35,11 @@ enum ShotScenes {
|
||||
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotSettings())
|
||||
},
|
||||
// 06–10 are the iOS/macOS console-shell block below; the library is cross-platform
|
||||
// (tvOS renders the same coverflow), hence the number above that range.
|
||||
ShotScene(name: "11-library", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotLibrary())
|
||||
},
|
||||
]
|
||||
#if os(iOS) || os(macOS)
|
||||
// The gamepad-mode console screens (no tvOS — native focus engine there). Dev-only shots
|
||||
@@ -68,6 +73,13 @@ enum ShotScenes {
|
||||
ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .timedOut, gamepadUI: false))
|
||||
},
|
||||
// FEEL THE GAME — the controller test panel with injected pads. Gated with the
|
||||
// console block because ControllerTestView doesn't build on tvOS, not because it
|
||||
// is a console screen. Landscape like the rest of the store set: the app is built
|
||||
// for horizontal use, so the two pads sit as side-by-side columns (see the scene).
|
||||
ShotScene(name: "12-controllers", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotControllers())
|
||||
},
|
||||
]
|
||||
#endif
|
||||
scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) {
|
||||
@@ -193,6 +205,24 @@ enum ShotMock {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// A believable shelf for the library coverflow. Decoded rather than constructed:
|
||||
/// `GameEntry`'s memberwise init is internal to PunktfunkKit, and Codable is its public
|
||||
/// construction surface. No art URLs — the posters render their deterministic fallback
|
||||
/// (title tiles, the Steam entry its brand mark), which is also what keeps the shot offline.
|
||||
static let games: [GameEntry] = {
|
||||
let json = """
|
||||
[
|
||||
{"id": "custom:aurora", "store": "custom", "title": "Aurora Drift", "art": {}},
|
||||
{"id": "steam:starfall", "store": "steam", "title": "Starfall Vale", "art": {}},
|
||||
{"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit", "art": {}},
|
||||
{"id": "gog:ember", "store": "gog", "title": "Ember Peaks", "art": {}},
|
||||
{"id": "steam:launcher", "store": "steam", "title": "Steam", "art": {},
|
||||
"role": "launcher", "icon": "steam"}
|
||||
]
|
||||
"""
|
||||
return (try? JSONDecoder().decode([GameEntry].self, from: Data(json.utf8))) ?? []
|
||||
}()
|
||||
|
||||
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
|
||||
static let fingerprint = hostFingerprint(0)
|
||||
|
||||
@@ -230,6 +260,19 @@ private struct ShotHome: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Library
|
||||
|
||||
/// The library coverflow with the mock shelf — the store listing's PICK & PLAY frame. The real
|
||||
/// `LibraryCoverflowView`, no network: artless entries settle to their deterministic fallback
|
||||
/// posters, and the entrance's 700 ms backstop has long fired by the time the driver captures.
|
||||
private struct ShotLibrary: View {
|
||||
var body: some View {
|
||||
LibraryCoverflowView(
|
||||
games: ShotMock.games, artLoader: nil,
|
||||
onLaunch: { _ in }, onDismiss: {}, controllerActive: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gamepad-mode console screens (dev-only glass preview)
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
@@ -311,6 +354,61 @@ private struct ShotConnect: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Controllers (the pads the store listing names)
|
||||
|
||||
/// The FEEL THE GAME frame: the controller test panel rendering the two pads the listing talks
|
||||
/// about. A GCController cannot be constructed, so the panel draws injected `ShotPad`s — the
|
||||
/// DualSense leads with the feedback surface (adaptive-trigger effects, rumble backend, lightbar
|
||||
/// + player LEDs), the Xbox pad carries the input readout, frozen mid-game.
|
||||
private struct ShotControllers: View {
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
// The panel is a window-modal sheet in the app — float it at sheet width over the
|
||||
// dimmed host grid, the way the other mac sheet shots read.
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
|
||||
ControllerTestView(shotPads: Self.pads)
|
||||
.frame(width: 500, height: 840)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(radius: 40, y: 16)
|
||||
}
|
||||
#else
|
||||
// Landscape canvas: one column per pad, so neither story is cut by the short height —
|
||||
// the DualSense feedback surface left, the Xbox live-input readout right.
|
||||
HStack(spacing: 0) {
|
||||
ControllerTestView(shotPads: [Self.pads[0]])
|
||||
ControllerTestView(shotPads: [Self.pads[1]])
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Transport/battery/player ride in `detail` — the panel has no dedicated battery row.
|
||||
/// Each pad shows a different half of the panel: the DualSense skips the input card (the
|
||||
/// effect grid is the marketing point), the Xbox pad skips rumble and shows the readout.
|
||||
static let pads: [ControllerTestView.ShotPad] = [
|
||||
.init(
|
||||
name: "DualSense Wireless Controller",
|
||||
detail: "Bluetooth · 85% · Player 1",
|
||||
isDualSense: true, hasAdaptiveTriggers: true, hasLight: true,
|
||||
rumbleBackend: "DualSense HID · Bluetooth"),
|
||||
.init(
|
||||
name: "Xbox Wireless Controller",
|
||||
detail: "Bluetooth · 60% · Player 2",
|
||||
isDualSense: false, hasAdaptiveTriggers: false, hasLight: false,
|
||||
input: .init(
|
||||
leftStick: .init(x: -0.31, y: 0.54),
|
||||
rightStick: .init(x: 0.72, y: -0.16),
|
||||
leftTrigger: 0.08, rightTrigger: 0.62,
|
||||
buttons: [
|
||||
("A", true), ("B", false), ("X", false), ("Y", false),
|
||||
("LB", false), ("RB", true), ("L3", false), ("R3", false),
|
||||
("Menu", false), ("Opts", false),
|
||||
("↑", false), ("↓", false), ("←", false), ("→", false),
|
||||
])),
|
||||
]
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Edit host (add/edit sheet with the Wake-on-LAN MAC field)
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
|
||||
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
|
||||
//
|
||||
// Every card renders a plain value model (`ShotPad` / `InputSnapshot`) that the live path samples
|
||||
// out of the real pad each timeline tick. A GCController cannot be constructed, and the App Store
|
||||
// screenshot harness needs this panel with pads the capture machine doesn't have — ShotScenes
|
||||
// injects them via `shotPads` (the same seam Android's ControllersScreen grew for its capture).
|
||||
//
|
||||
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
|
||||
// layout); macOS + iOS/iPadOS cover the validation need.
|
||||
|
||||
@@ -14,10 +19,63 @@ import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ControllerTestView: View {
|
||||
/// What one panel section says about a pad, as plain values. The live path flattens the
|
||||
/// active `DiscoveredController` into one; the screenshot harness hands the panel pads that
|
||||
/// were never connected. `input`/`rumbleBackend` are the harness's section knobs (nil hides
|
||||
/// that card) — the live path always shows both, fed from the live pad and tester.
|
||||
struct ShotPad: Identifiable {
|
||||
let name: String
|
||||
/// The header's second line. Production shows the GC product category; a shot packs
|
||||
/// transport/battery/player facts into it (the panel has no dedicated battery row).
|
||||
let detail: String
|
||||
let isDualSense: Bool
|
||||
let hasAdaptiveTriggers: Bool
|
||||
let hasLight: Bool
|
||||
var input: InputSnapshot? = nil
|
||||
var rumbleBackend: String? = nil
|
||||
var id: String { name }
|
||||
}
|
||||
|
||||
/// One frame of the input readout. The live path samples the real `GCExtendedGamepad` into
|
||||
/// one of these on every 30 Hz tick; the harness writes a mid-game frame by hand.
|
||||
struct InputSnapshot {
|
||||
struct Stick {
|
||||
var x: Float
|
||||
var y: Float
|
||||
var pressed = false
|
||||
}
|
||||
struct Touch {
|
||||
/// Finger position in GC's -1...1 axes; nil = lifted. (GC snaps a lifted finger to
|
||||
/// exactly (0, 0), so a real (0, 0) contact is indistinguishable anyway.)
|
||||
var primary: CGPoint?
|
||||
var secondary: CGPoint?
|
||||
var clicked = false
|
||||
}
|
||||
struct Motion {
|
||||
var gyro: SIMD3<Double>
|
||||
var accel: SIMD3<Double>
|
||||
}
|
||||
var leftStick: Stick
|
||||
var rightStick: Stick
|
||||
var leftTrigger: Float = 0
|
||||
var rightTrigger: Float = 0
|
||||
/// Grid order; label → pressed.
|
||||
var buttons: [(String, Bool)]
|
||||
var touchpad: Touch?
|
||||
var motion: Motion?
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
@StateObject private var tester = ControllerTester()
|
||||
|
||||
/// Screenshot-harness injection — nil (the app) renders the live active pad.
|
||||
private let shotPads: [ShotPad]?
|
||||
|
||||
init(shotPads: [ShotPad]? = nil) {
|
||||
self.shotPads = shotPads
|
||||
}
|
||||
|
||||
@State private var heavyOn = false
|
||||
@State private var lightOn = false
|
||||
@State private var intensity = 0.75
|
||||
@@ -62,12 +120,12 @@ struct ControllerTestView: View {
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let active = gamepads.active {
|
||||
header(active)
|
||||
inputCard
|
||||
rumbleCard()
|
||||
triggerCard(active)
|
||||
extrasCard(active)
|
||||
if let shotPads {
|
||||
ForEach(shotPads) { pad in
|
||||
shotPanel(pad)
|
||||
}
|
||||
} else if let active = gamepads.active {
|
||||
livePanel(active)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No controller",
|
||||
@@ -81,9 +139,10 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 420, minHeight: 540)
|
||||
.onAppear { tester.target(gamepads.active?.controller) }
|
||||
.onDisappear { tester.stop() }
|
||||
.onAppear { if shotPads == nil { tester.target(gamepads.active?.controller) } }
|
||||
.onDisappear { if shotPads == nil { tester.stop() } }
|
||||
.onChange(of: gamepads.active?.id) { _, _ in
|
||||
guard shotPads == nil else { return }
|
||||
heavyOn = false
|
||||
lightOn = false
|
||||
playerLED = -1
|
||||
@@ -91,16 +150,53 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Panels
|
||||
|
||||
@ViewBuilder
|
||||
private func livePanel(_ active: GamepadManager.DiscoveredController) -> some View {
|
||||
let pad = Self.describe(active)
|
||||
header(pad)
|
||||
liveInputCard
|
||||
rumbleCard(backend: tester.rumbleBackend, health: tester.rumbleHealth)
|
||||
triggerCard(pad)
|
||||
extrasCard(pad)
|
||||
}
|
||||
|
||||
/// An injected pad's cards, in the live panel's order. The adaptive-trigger card is skipped
|
||||
/// outright for a pad without them — the live path's "needs a DualSense" hint is a diagnosis,
|
||||
/// and a capture has nothing to diagnose.
|
||||
@ViewBuilder
|
||||
private func shotPanel(_ pad: ShotPad) -> some View {
|
||||
header(pad)
|
||||
if let input = pad.input {
|
||||
card("Input") { inputReadout(input) }
|
||||
}
|
||||
if let backend = pad.rumbleBackend {
|
||||
rumbleCard(backend: backend, health: nil)
|
||||
}
|
||||
if pad.hasAdaptiveTriggers {
|
||||
triggerCard(pad)
|
||||
}
|
||||
extrasCard(pad)
|
||||
}
|
||||
|
||||
/// The live pad, flattened to what the panel renders about it.
|
||||
private static func describe(_ c: GamepadManager.DiscoveredController) -> ShotPad {
|
||||
ShotPad(
|
||||
name: c.name, detail: c.productCategory, isDualSense: c.isDualSense,
|
||||
hasAdaptiveTriggers: c.hasAdaptiveTriggers, hasLight: c.hasLight)
|
||||
}
|
||||
|
||||
// MARK: Header
|
||||
|
||||
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
private func header(_ pad: ShotPad) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||
Image(systemName: pad.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
Text(pad.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(pad.detail).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -108,13 +204,13 @@ struct ControllerTestView: View {
|
||||
|
||||
// MARK: Input
|
||||
|
||||
private var inputCard: some View {
|
||||
private var liveInputCard: some View {
|
||||
card("Input") {
|
||||
// Poll the live controller at 30 Hz — no handlers installed, so nothing else's
|
||||
// capture is disturbed.
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
|
||||
if let gp = gamepads.active?.controller.extendedGamepad {
|
||||
inputReadout(gp, controller: gamepads.active?.controller)
|
||||
inputReadout(Self.snapshot(gp, controller: gamepads.active?.controller))
|
||||
} else {
|
||||
Text("Not an extended gamepad").foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -122,40 +218,82 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// One readout frame off the live pad.
|
||||
private static func snapshot(
|
||||
_ g: GCExtendedGamepad, controller: GCController?
|
||||
) -> InputSnapshot {
|
||||
var buttons: [(String, Bool)] = [
|
||||
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
||||
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
||||
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
||||
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
||||
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
||||
("Menu", g.buttonMenu.isPressed),
|
||||
("Opts", g.buttonOptions?.isPressed ?? false),
|
||||
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
||||
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
||||
]
|
||||
let tp = touchpad(g)
|
||||
if let tp { buttons.append(("Pad", tp.button.isPressed)) }
|
||||
return InputSnapshot(
|
||||
leftStick: .init(
|
||||
x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
||||
pressed: g.leftThumbstickButton?.isPressed ?? false),
|
||||
rightStick: .init(
|
||||
x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
||||
pressed: g.rightThumbstickButton?.isPressed ?? false),
|
||||
leftTrigger: g.leftTrigger.value, rightTrigger: g.rightTrigger.value,
|
||||
buttons: buttons,
|
||||
touchpad: tp.map {
|
||||
.init(primary: finger($0.primary), secondary: finger($0.secondary),
|
||||
clicked: $0.button.isPressed)
|
||||
},
|
||||
motion: controller?.motion.map { m -> InputSnapshot.Motion in
|
||||
let a = totalAccel(m)
|
||||
return .init(
|
||||
gyro: .init(m.rotationRate.x, m.rotationRate.y, m.rotationRate.z),
|
||||
accel: .init(a.0, a.1, a.2))
|
||||
})
|
||||
}
|
||||
|
||||
private static func finger(_ pad: GCControllerDirectionPad) -> CGPoint? {
|
||||
let x = pad.xAxis.value, y = pad.yAxis.value
|
||||
// GC snaps a lifted finger to exactly (0, 0).
|
||||
return (x == 0 && y == 0) ? nil : CGPoint(x: CGFloat(x), y: CGFloat(y))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
|
||||
private func inputReadout(_ s: InputSnapshot) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
||||
pressed: g.leftThumbstickButton?.isPressed ?? false)
|
||||
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
||||
pressed: g.rightThumbstickButton?.isPressed ?? false)
|
||||
stick("L", s.leftStick)
|
||||
stick("R", s.rightStick)
|
||||
VStack(spacing: 8) {
|
||||
triggerBar("L2", value: g.leftTrigger.value)
|
||||
triggerBar("R2", value: g.rightTrigger.value)
|
||||
triggerBar("L2", value: s.leftTrigger)
|
||||
triggerBar("R2", value: s.rightTrigger)
|
||||
}
|
||||
}
|
||||
buttonGrid(g)
|
||||
if let tp = Self.touchpad(g) {
|
||||
buttonGrid(s.buttons)
|
||||
if let tp = s.touchpad {
|
||||
touchpadView(tp)
|
||||
}
|
||||
if let m = controller?.motion {
|
||||
if let m = s.motion {
|
||||
motionReadout(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
|
||||
private func stick(_ label: String, _ s: InputSnapshot.Stick) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.secondary.opacity(0.3))
|
||||
Circle()
|
||||
.fill(pressed ? Color.accentColor : Color.secondary)
|
||||
.fill(s.pressed ? Color.accentColor : Color.secondary)
|
||||
.frame(width: 12, height: 12)
|
||||
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
|
||||
.offset(x: CGFloat(s.x) * 22, y: CGFloat(-s.y) * 22) // GC y is +up
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||||
Text("\(label) \(sgn(s.x)),\(sgn(s.y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,20 +313,8 @@ struct ControllerTestView: View {
|
||||
.frame(width: 150)
|
||||
}
|
||||
|
||||
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
|
||||
var items: [(String, Bool)] = [
|
||||
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
||||
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
||||
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
||||
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
||||
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
||||
("Menu", g.buttonMenu.isPressed),
|
||||
("Opts", g.buttonOptions?.isPressed ?? false),
|
||||
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
||||
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
||||
]
|
||||
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
|
||||
return LazyVGrid(
|
||||
private func buttonGrid(_ items: [(String, Bool)]) -> some View {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
|
||||
) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
@@ -203,12 +329,9 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func touchpadView(
|
||||
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||
button: GCControllerButtonInput)
|
||||
) -> some View {
|
||||
private func touchpadView(_ tp: InputSnapshot.Touch) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||
Text("Touchpad\(tp.clicked ? " — click" : "")")
|
||||
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||
@@ -219,29 +342,25 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
|
||||
let x = pad.xAxis.value, y = pad.yAxis.value
|
||||
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
|
||||
return Circle().fill(color).frame(width: 10, height: 10)
|
||||
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
|
||||
.opacity(active ? 1 : 0)
|
||||
private func fingerDot(_ p: CGPoint?, color: Color) -> some View {
|
||||
Circle().fill(color).frame(width: 10, height: 10)
|
||||
.offset(x: (p?.x ?? 0) * 71, y: -(p?.y ?? 0) * 33)
|
||||
.opacity(p == nil ? 0 : 1)
|
||||
}
|
||||
|
||||
private func motionReadout(_ m: GCMotion) -> some View {
|
||||
let a = Self.totalAccel(m)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
private func motionReadout(_ m: InputSnapshot.Motion) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f", m.gyro.x, m.gyro.y, m.gyro.z))
|
||||
.font(.caption2.monospaced())
|
||||
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
|
||||
Text(String(format: "accel %+.2f %+.2f %+.2f", m.accel.x, m.accel.y, m.accel.z))
|
||||
.font(.caption2.monospaced())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Rumble
|
||||
|
||||
private func rumbleCard() -> some View {
|
||||
private func rumbleCard(backend: String, health: String?) -> some View {
|
||||
card("Rumble") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Strength", selection: $intensity) {
|
||||
@@ -253,9 +372,9 @@ struct ControllerTestView: View {
|
||||
.pickerStyle(.segmented)
|
||||
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||
Toggle("Light motor (right)", isOn: $lightOn)
|
||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||
Label("Backend: \(backend)", systemImage: "waveform")
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
if let problem = tester.rumbleHealth {
|
||||
if let problem = health {
|
||||
Label(problem, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.orange)
|
||||
}
|
||||
@@ -276,9 +395,9 @@ struct ControllerTestView: View {
|
||||
|
||||
// MARK: Adaptive triggers
|
||||
|
||||
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
private func triggerCard(_ pad: ShotPad) -> some View {
|
||||
card("Adaptive triggers") {
|
||||
if c.hasAdaptiveTriggers {
|
||||
if pad.hasAdaptiveTriggers {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Apply to", selection: $triggerTarget) {
|
||||
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
|
||||
@@ -315,8 +434,8 @@ struct ControllerTestView: View {
|
||||
// MARK: Lightbar + player LED
|
||||
|
||||
@ViewBuilder
|
||||
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
if c.hasLight {
|
||||
private func extrasCard(_ pad: ShotPad) -> some View {
|
||||
if pad.hasLight {
|
||||
card("Lightbar & player LED") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
|
||||
@@ -114,6 +114,10 @@ enum SettingsFields {
|
||||
.init(name: "invert_scroll", key: DefaultsKey.invertScroll,
|
||||
overlay: \.invertScroll, effective: \.invertScroll)
|
||||
}
|
||||
static var inhibitShortcuts: SettingsField<Bool> {
|
||||
.init(name: "inhibit_shortcuts", key: DefaultsKey.inhibitShortcuts,
|
||||
overlay: \.inhibitShortcuts, effective: \.inhibitShortcuts)
|
||||
}
|
||||
static var modifierLayout: SettingsField<String> {
|
||||
.init(name: "modifier_layout", key: DefaultsKey.modifierLayout,
|
||||
overlay: \.modifierLayout, effective: \.modifierLayout)
|
||||
@@ -205,6 +209,7 @@ extension SettingsView {
|
||||
#endif
|
||||
#if os(macOS)
|
||||
base.mouseMode = mouseMode
|
||||
base.inhibitShortcuts = inhibitShortcuts
|
||||
base.vsync = vsync
|
||||
base.windowedSafePresent = windowedSafePresent
|
||||
#endif
|
||||
|
||||
@@ -515,6 +515,9 @@ extension SettingsView {
|
||||
Text("Desktop (absolute)").tag(MouseInputMode.desktop.rawValue)
|
||||
}
|
||||
}
|
||||
described(inhibitShortcutsDescription, field: "inhibit_shortcuts") {
|
||||
Toggle("Capture system shortcuts", isOn: scoped(SettingsFields.inhibitShortcuts))
|
||||
}
|
||||
#endif
|
||||
described(
|
||||
(ModifierLayout(rawValue: effective.modifierLayout) ?? .mac).detail,
|
||||
@@ -534,6 +537,19 @@ extension SettingsView {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Dynamic like the captions above, because the setting genuinely has no effect under the
|
||||
/// desktop mouse model (system chords stay local there on every client) — and a toggle that
|
||||
/// silently does nothing should say so instead of leaving the user to find out.
|
||||
private var inhibitShortcutsDescription: String {
|
||||
if (MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop {
|
||||
return "⌘ shortcuts stay on this Mac under the desktop mouse model. Switch Mouse "
|
||||
+ "input to Capture to send them to the host."
|
||||
}
|
||||
return "Sends ⌘ shortcuts to the host while input is captured, so ⌘Q and friends reach "
|
||||
+ "the remote desktop instead of this app. ⌘⎋ always stays local — it is what "
|
||||
+ "releases capture."
|
||||
}
|
||||
|
||||
/// The SELECTED mouse model explained — dynamic, like the touch-mode caption.
|
||||
private var mouseModeDescription: String {
|
||||
switch MouseInputMode(rawValue: effective.mouseMode) ?? .capture {
|
||||
|
||||
@@ -115,6 +115,10 @@ struct SettingsView: View {
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.mouseMode) var mouseMode = MouseInputMode.capture.rawValue
|
||||
/// Cross-client `inhibit_shortcuts` — here, the ⌘-chord passthrough (⌘Q & co. reach the host
|
||||
/// instead of the app menu while captured). macOS-only: it is the one platform whose window
|
||||
/// system hands a plain app no keyboard grab, so the client has to claim the chords itself.
|
||||
@AppStorage(DefaultsKey.inhibitShortcuts) var inhibitShortcuts = true
|
||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||||
|
||||
@@ -162,6 +162,17 @@ final class HostStore: ObservableObject {
|
||||
hosts[i].osChain = chain
|
||||
}
|
||||
|
||||
/// Learn/refresh this host's management-API port from its live advert — same contract as
|
||||
/// `updateMacs`. Until this existed, `StoredHost.mgmtPort` was declared and read but never
|
||||
/// written, so `effectiveMgmtPort` always answered 47990 and a host that had moved its mgmt
|
||||
/// port simply had no working library here.
|
||||
func updateMgmtPort(_ hostID: UUID, port: UInt16?) {
|
||||
guard let port, port > 0,
|
||||
let i = hosts.firstIndex(where: { $0.id == hostID }),
|
||||
hosts[i].mgmtPort != port else { return }
|
||||
hosts[i].mgmtPort = port
|
||||
}
|
||||
|
||||
/// Bind this host to a settings profile, or to "Default settings" (nil) — the ONLY way the
|
||||
/// default changes. A one-off "Connect with ▸" deliberately never lands here (§5.2:
|
||||
/// predictable, not sticky).
|
||||
|
||||
@@ -61,6 +61,16 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable {
|
||||
/// (`sanitizeOsChain`) — drives the host card's OS mark and is persisted like the MACs.
|
||||
/// Empty when not advertised (older host). Advisory/unauthenticated like the rest.
|
||||
public let osChain: String
|
||||
/// The host's management-API port (mDNS `mgmt` TXT) — where the game library is served, NOT
|
||||
/// `port`, which is the native QUIC plane. nil when not advertised (older host), and the
|
||||
/// client then assumes `punktfunkDefaultMgmtPort`.
|
||||
///
|
||||
/// Persisted onto the saved host like the MACs and the OS chain, and for a sharper reason:
|
||||
/// `StoredHost.mgmtPort` has existed all along but nothing ever wrote it, so
|
||||
/// `effectiveMgmtPort` always resolved to 47990. A host that moved its mgmt port off 47990 —
|
||||
/// the supported way to share a machine with a Sunshine fork, whose web UI owns that port —
|
||||
/// therefore had no working library on any Apple client at all.
|
||||
public let mgmtPort: UInt16?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -211,12 +221,12 @@ public final class HostDiscovery: ObservableObject {
|
||||
public static func debugAdvert(
|
||||
id: String, name: String, host: String, port: UInt16 = 9777,
|
||||
fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true,
|
||||
macAddresses: [String] = [], osChain: String = ""
|
||||
macAddresses: [String] = [], osChain: String = "", mgmtPort: UInt16? = nil
|
||||
) -> DiscoveredHost {
|
||||
DiscoveredHost(
|
||||
id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex,
|
||||
requiresPairing: requiresPairing, allowsTofu: allowsTofu,
|
||||
macAddresses: macAddresses, osChain: osChain)
|
||||
macAddresses: macAddresses, osChain: osChain, mgmtPort: mgmtPort)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -429,6 +439,7 @@ public final class HostDiscovery: ObservableObject {
|
||||
var id: String?
|
||||
var macs: [String] = []
|
||||
var osChain = ""
|
||||
var mgmtPort: UInt16?
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
fp = entry(txt, "fp")
|
||||
pair = entry(txt, "pair")
|
||||
@@ -438,13 +449,16 @@ public final class HostDiscovery: ObservableObject {
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
osChain = sanitizeOsChain(entry(txt, "os") ?? "")
|
||||
// Unauthenticated input, so range-check rather than trust: a non-numeric or 0 value
|
||||
// means "not advertised" and the client falls back to the default.
|
||||
mgmtPort = entry(txt, "mgmt").flatMap(UInt16.init).flatMap { $0 > 0 ? $0 : nil }
|
||||
}
|
||||
return DiscoveredHost(
|
||||
id: (id?.isEmpty == false) ? id! : name,
|
||||
name: name, host: address, port: port,
|
||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||
allowsTofu: pair == "optional", macAddresses: macs,
|
||||
osChain: osChain)
|
||||
osChain: osChain, mgmtPort: mgmtPort)
|
||||
}
|
||||
|
||||
private static func key(_ result: NWBrowser.Result) -> String {
|
||||
|
||||
@@ -452,6 +452,14 @@ public final class PunktfunkConnection {
|
||||
/// The host capability bitfield (`Welcome.host_caps`): `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` /
|
||||
/// `PUNKTFUNK_HOST_CAP_CLIPBOARD`. `0` for an older host that didn't say.
|
||||
public private(set) var hostCaps: UInt8 = 0
|
||||
/// The host's management-API port, from this session's `Welcome` — where its game library is
|
||||
/// served. `0` when the host advertised none (an older host, or one with no management API);
|
||||
/// resolve through `StoredHost.effectiveMgmtPort` rather than dialing a `0`.
|
||||
///
|
||||
/// Read this after a connect and persist it: it is the only source that does not depend on
|
||||
/// mDNS, so it is what makes a moved mgmt port work for a host reached over a VPN or added by
|
||||
/// address on a network where discovery never functions.
|
||||
public private(set) var hostMgmtPort: UInt16 = 0
|
||||
/// Whether this host advertises the shared clipboard (`HOST_CAP_CLIPBOARD`) — the gate for
|
||||
/// offering the clipboard toggle. Absent on an older host, or one whose operator policy
|
||||
/// (`PUNKTFUNK_CLIPBOARD=off`) keeps the feature dark.
|
||||
@@ -677,6 +685,12 @@ public final class PunktfunkConnection {
|
||||
var caps: UInt8 = 0
|
||||
_ = punktfunk_connection_host_caps(handle, &caps)
|
||||
hostCaps = caps
|
||||
// Where this host serves its game library, straight from the session's Welcome. 0 = the
|
||||
// host advertised none (older host / no management API), and the caller keeps whatever it
|
||||
// already had. This is the answer that does NOT require an mDNS advert to have been seen.
|
||||
var mgmt: UInt16 = 0
|
||||
_ = punktfunk_connection_mgmt_port(handle, &mgmt)
|
||||
hostMgmtPort = mgmt
|
||||
}
|
||||
|
||||
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||
|
||||
@@ -86,6 +86,21 @@ public final class InputCapture {
|
||||
/// its Esc suppression need it in both states).
|
||||
private var cmdKeysDown: Set<UInt32> = []
|
||||
|
||||
#if os(macOS)
|
||||
/// Windows VKs the ⌘-chord passthrough sent DOWN (see the keyDown monitor). macOS stops
|
||||
/// delivering keyUp for ordinary keys while Command is held, so the release half of ⌘Q/⌘W/…
|
||||
/// cannot be relied on to arrive through the responder chain at all: these are flushed when
|
||||
/// the last ⌘ comes up (`flushCommandChord`), which is what stands between the host and a
|
||||
/// key held down for the rest of the session.
|
||||
private var commandChordVKs: Set<UInt32> = []
|
||||
|
||||
/// Mirrors StreamLayerView's live mouse model — ⌃⌥⇧M flips it mid-session, so it can't be
|
||||
/// read from the settings. The ⌘-chord passthrough stays off under the desktop model, matching
|
||||
/// what the SDL clients' keyboard grab does: a remote desktop is something you ⌘Tab away from,
|
||||
/// not into.
|
||||
public var desktopMouse = false
|
||||
#endif
|
||||
|
||||
#if !os(macOS)
|
||||
/// The key currently auto-repeating, and the timer driving it. iOS/tvOS only — see
|
||||
/// `startAutoRepeat`. Main-queue only, like every other field here.
|
||||
@@ -244,19 +259,27 @@ public final class InputCapture {
|
||||
) { [weak self] _ in
|
||||
self?.releaseAll()
|
||||
})
|
||||
// ⌘⎋ — the capture toggle — is detected here so it works in both states. ONLY
|
||||
// that one combo is intercepted: swallowing keys wholesale at the monitor level
|
||||
// risks starving GC's own delivery, so the no-beep behavior lives in
|
||||
// StreamLayerView (first responder consumes keyDown/keyUp while captured).
|
||||
// (On iOS there is no NSEvent monitor — the GC key handler detects the combo.)
|
||||
// This monitor is the FIRST thing in the app to see a key: AppKit calls it before
|
||||
// `sendEvent:`, so before any menu key equivalent and before StreamLayerView's keyDown.
|
||||
// Returning nil discards the event outright — which cuts BOTH of those off, and on macOS
|
||||
// the second one is the host's only key path (the GCKeyboard send is iOS-only; see
|
||||
// `attach(keyboard:)`). So the rule here is: anything swallowed must either be handled
|
||||
// client-side or forwarded to the host from inside this block, because nothing downstream
|
||||
// will get a second chance at it.
|
||||
//
|
||||
// ⌘⎋ (capture toggle) and ⌃⌥⇧M (mouse model) are client-side in BOTH states; ⌃⌥⇧Q/D/S/A
|
||||
// and ⌃⌘F are client-side only while forwarding (released, the events pass through and the
|
||||
// menu's identical key equivalents handle them). Every OTHER ⌘ chord is the HOST's while
|
||||
// captured — see `forwardsCommandChord`. (On iOS there is no NSEvent monitor — the GC key
|
||||
// handler detects the combos.)
|
||||
#if os(macOS)
|
||||
keyEventMonitor = NSEvent.addLocalMonitorForEvents(
|
||||
matching: [.keyDown]
|
||||
) { [weak self] event in
|
||||
guard let self else { return event }
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
let flags = Self.chordFlags(event)
|
||||
if event.keyCode == 53 /* Esc */, flags == .command {
|
||||
self.suppressedVK = 0x1B // the same physical Esc is en route via GC
|
||||
self.suppressedVK = 0x1B // VK_ESC — its keyUp still reaches the responder chain
|
||||
self.onToggleCapture?()
|
||||
return nil
|
||||
}
|
||||
@@ -266,7 +289,7 @@ public final class InputCapture {
|
||||
// (latched like ⌘⎋'s Esc) so it doesn't type into the host, and swallow the
|
||||
// event so it doesn't beep.
|
||||
if event.keyCode == 46 /* M */, flags == [.control, .option, .shift] {
|
||||
self.suppressedVK = 0x4D // VK_M — the same physical M is en route via GC
|
||||
self.suppressedVK = 0x4D // VK_M — its keyUp still reaches the responder chain
|
||||
self.onToggleMouseMode?()
|
||||
return nil
|
||||
}
|
||||
@@ -304,10 +327,34 @@ public final class InputCapture {
|
||||
// captured stream view swallows the menu's identical equivalent); the F is latched so its
|
||||
// keyUp can't type into the host. keyCode 3 = kVK_ANSI_F (layout-independent).
|
||||
if self.forwarding, flags == [.control, .command], event.keyCode == 3 /* F */ {
|
||||
self.suppressedVK = 0x46 // VK_F — the same physical F is en route via GC
|
||||
self.suppressedVK = 0x46 // VK_F — its keyUp still reaches the responder chain
|
||||
self.onToggleFullscreen?()
|
||||
return nil
|
||||
}
|
||||
// Every OTHER ⌘ chord belongs to the HOST while captured — the cross-client "capture
|
||||
// system shortcuts" setting, which the Apple client had no answer to because SDL's
|
||||
// keyboard grab is what implements it everywhere else. Without this the app menu's key
|
||||
// equivalents fire first, so ⌘Q quits the client instead of reaching the compositor as
|
||||
// Super+Q — one of the most-bound chords on a Linux desktop, and the reported break.
|
||||
//
|
||||
// It has to SEND from here: returning nil is what keeps the menu out, and it takes
|
||||
// StreamLayerView's keyDown — the host's only key path on macOS — out with it.
|
||||
// Chords with no host VK are swallowed but not sent: doing nothing beats a menu
|
||||
// opening under a captured stream. The ⌘ itself needs no handling — modifiers arrive
|
||||
// as flagsChanged, which this monitor never sees, so it was already forwarded as
|
||||
// VK_LWIN/VK_RWIN (or Alt, under the Windows modifier layout) when it went down.
|
||||
//
|
||||
// The two cheap conditions are repeated in front of the call on purpose: off-session,
|
||||
// `SessionSettings.current` re-reads the whole defaults suite, and this monitor sees
|
||||
// every keystroke the app receives — including the ones typed into the host list.
|
||||
if self.forwarding, flags.contains(.command), Self.forwardsCommandChord(
|
||||
keyCode: event.keyCode, flags: flags, forwarding: self.forwarding,
|
||||
inhibitShortcuts: SessionSettings.current.inhibitShortcuts,
|
||||
desktopMouse: self.desktopMouse
|
||||
) {
|
||||
if let vk = Self.keyCodeToVK[event.keyCode] { self.sendCommandChordKey(vk) }
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
#endif
|
||||
@@ -358,6 +405,9 @@ public final class InputCapture {
|
||||
cmdKeysDown.removeAll()
|
||||
chordModifiersDown.removeAll()
|
||||
suppressedVK = nil
|
||||
#if os(macOS)
|
||||
commandChordVKs.removeAll() // their releases are in `pressedVKs`, flushed just below
|
||||
#endif
|
||||
for vk in pressedVKs {
|
||||
emitKey(vk, down: false)
|
||||
}
|
||||
@@ -522,7 +572,15 @@ public final class InputCapture {
|
||||
// Keep cmdKeysDown in step (the ⌘⎋ toggle + Esc suppression read it); sendKey
|
||||
// adds the VK to pressedVKs so releaseAll/blur flushes a held modifier cleanly.
|
||||
if vk == 0x5B || vk == 0x5C {
|
||||
if down { cmdKeysDown.insert(vk) } else { cmdKeysDown.remove(vk) }
|
||||
if down {
|
||||
cmdKeysDown.insert(vk)
|
||||
} else {
|
||||
cmdKeysDown.remove(vk)
|
||||
// Last ⌘ up: release the chord keys whose own keyUp macOS never delivered. BEFORE
|
||||
// the ⌘'s own release goes out, so the host never sees the letter outlive the
|
||||
// modifier it was pressed with.
|
||||
if cmdKeysDown.isEmpty { flushCommandChord() }
|
||||
}
|
||||
}
|
||||
sendKey(vk, down: down)
|
||||
}
|
||||
@@ -552,6 +610,68 @@ public final class InputCapture {
|
||||
}
|
||||
return (mod.vk, down)
|
||||
}
|
||||
|
||||
// MARK: - ⌘ chord passthrough
|
||||
|
||||
/// The four modifiers a client chord is ever spelled with, isolated from the incidental bits
|
||||
/// `deviceIndependentFlagsMask` also carries: Caps Lock, and the `.function`/`.numericPad`
|
||||
/// pair every arrow and F-key sets. Equality against the raw masked flags meant a chord
|
||||
/// stopped being recognized the moment Caps Lock was on — ⌘⎋ and ⌃⌥⇧Q, both escape hatches,
|
||||
/// included. That was survivable while the monitor claimed six chords; it is not, now that it
|
||||
/// swallows every ⌘ chord there is.
|
||||
static let chordFlagMask: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
|
||||
|
||||
/// One event's chord modifiers (see `chordFlagMask`).
|
||||
static func chordFlags(_ event: NSEvent) -> NSEvent.ModifierFlags {
|
||||
event.modifierFlags.intersection(chordFlagMask)
|
||||
}
|
||||
|
||||
/// The ⌘ chords the CLIENT keeps while captured, which is to say: the way out. ⌘⎋ releases
|
||||
/// the mouse/keyboard and ⌃⌘F leaves fullscreen — hand either of those to the host and a
|
||||
/// captured stream becomes a room with no door. (⌃⌥⇧Q/D/S/A carry no ⌘ and never reach here.)
|
||||
static func isClientReservedChord(keyCode: UInt16, flags: NSEvent.ModifierFlags) -> Bool {
|
||||
if keyCode == 53, flags == .command { return true } // ⌘⎋ — capture toggle
|
||||
if keyCode == 3, flags == [.control, .command] { return true } // ⌃⌘F — fullscreen
|
||||
return false
|
||||
}
|
||||
|
||||
/// Does this keyDown get taken off AppKit and forwarded to the host instead? Only while input
|
||||
/// is actually captured, only with the cross-client `inhibit_shortcuts` on, and never under the
|
||||
/// desktop mouse model (where the chords stay local by design) — and never for the client's own
|
||||
/// reserved chords, whatever the setting says.
|
||||
static func forwardsCommandChord(
|
||||
keyCode: UInt16, flags: NSEvent.ModifierFlags,
|
||||
forwarding: Bool, inhibitShortcuts: Bool, desktopMouse: Bool
|
||||
) -> Bool {
|
||||
guard forwarding, inhibitShortcuts, !desktopMouse else { return false }
|
||||
guard flags.contains(.command) else { return false }
|
||||
return !isClientReservedChord(keyCode: keyCode, flags: flags)
|
||||
}
|
||||
|
||||
/// Forward one key of a ⌘ chord the monitor just took off AppKit, remembering it so its
|
||||
/// release can be synthesized (see `commandChordVKs`).
|
||||
private func sendCommandChordKey(_ vk: UInt32) {
|
||||
commandChordVKs.insert(vk)
|
||||
sendKey(vk, down: true)
|
||||
}
|
||||
|
||||
/// Release whatever the ⌘-chord passthrough sent down and is still held — called when the last
|
||||
/// physical ⌘ comes up. A keyUp that DID arrive has already taken its VK out of `pressedVKs`,
|
||||
/// so this only fires for the ones macOS swallowed.
|
||||
private func flushCommandChord() {
|
||||
// Same cause, different victim: a one-shot latch whose key-up never arrived goes on to eat
|
||||
// the NEXT press of that key (⌃⌘F's F, ⌘⎋'s Esc). Once ⌘ is up, a pending latch is stale.
|
||||
suppressedVK = nil
|
||||
guard !commandChordVKs.isEmpty else { return }
|
||||
for vk in commandChordVKs where pressedVKs.contains(vk) {
|
||||
pressedVKs.remove(vk)
|
||||
emitKey(vk, down: false)
|
||||
if inputDebug {
|
||||
inputLog.debug("key \(vk, privacy: .public) up SYNTHESIZED (⌘ chord release)")
|
||||
}
|
||||
}
|
||||
commandChordVKs.removeAll()
|
||||
}
|
||||
#endif
|
||||
|
||||
private func attach(mouse: GCMouse) {
|
||||
|
||||
@@ -410,8 +410,9 @@ public final class StreamLayerView: NSView {
|
||||
// keycode) → Windows VK and forward via InputCapture.sendKey, then CONSUME (return without
|
||||
// super) to stop the responder chain's "unhandled keyDown" beep. Keys with no VK mapping
|
||||
// are still consumed while captured so they don't beep either. The ⌘⎋ toggle's Esc is
|
||||
// swallowed upstream by InputCapture's keyDown monitor (suppressedVK), so it never gets
|
||||
// here as a send; ⌘-combos still arrive via performKeyEquivalent and stay functional (⌘D).
|
||||
// swallowed upstream by InputCapture's keyDown monitor (suppressedVK), so it never gets here
|
||||
// as a send — and so are ⌘ combos generally while captured, which that monitor forwards to the
|
||||
// host itself (`forwardsCommandChord`) rather than letting a menu key equivalent claim them.
|
||||
// Modifier keys never fire keyDown/keyUp — they come through flagsChanged below.
|
||||
public override var acceptsFirstResponder: Bool { true }
|
||||
// A click after the app was inactive (Cmd-Tab away and back) must reach mouseDown so the
|
||||
@@ -570,6 +571,9 @@ public final class StreamLayerView: NSView {
|
||||
let wasCaptured = captured
|
||||
if wasCaptured { releaseCapture() }
|
||||
desktopMouse = on
|
||||
// The ⌘-chord passthrough is off under the desktop model (system chords stay local there,
|
||||
// as on every other client) — and the model moves live, so the capture is told, not asked.
|
||||
inputCapture?.desktopMouse = on
|
||||
if wasCaptured { engageCapture(fromClick: false) }
|
||||
window?.invalidateCursorRects(for: self)
|
||||
if on, let p = reappearAt, let sp = cgScreenPoint(forHostX: p.x, p.y) {
|
||||
@@ -917,6 +921,7 @@ public final class StreamLayerView: NSView {
|
||||
) ?? .capture
|
||||
let absOK = connection.resolvedCompositor != .gamescope
|
||||
desktopMouse = mode == .desktop && absOK
|
||||
capture.desktopMouse = desktopMouse
|
||||
if mode == .desktop && !absOK {
|
||||
streamInputLog.info("desktop mouse mode unavailable on a gamescope host (relative-only) — using capture")
|
||||
}
|
||||
|
||||
@@ -157,6 +157,16 @@ public enum DefaultsKey {
|
||||
/// Read live at the wire boundary by `InputCapture`. Control/Shift never move (same position on
|
||||
/// both keyboards).
|
||||
public static let modifierLayout = "punktfunk.modifierLayout"
|
||||
/// Send system chords to the host while input is captured — the cross-client
|
||||
/// `inhibit_shortcuts`, ON by default. On the SDL clients it is SDL's keyboard grab (Alt+Tab,
|
||||
/// the Windows key); macOS has no such grab from a plain app, so `InputCapture`'s keyDown
|
||||
/// monitor implements it by taking every ⌘ chord off AppKit before a menu key equivalent can
|
||||
/// fire and forwarding it instead — which is what makes ⌘Q reach the host's compositor rather
|
||||
/// than quitting the client. Off keeps the chords local (the second-screen/work profile).
|
||||
/// The client's own reserved chords (⌘⎋, ⌃⌘F, ⌃⌥⇧…) are never forwarded either way, and — as
|
||||
/// on the SDL clients — the setting has no effect under the `desktop` mouse model, which is
|
||||
/// something you ⌘Tab *away* from. macOS-only today; nothing reads it on iOS/tvOS.
|
||||
public static let inhibitShortcuts = "punktfunk.inhibitShortcuts"
|
||||
/// iPad: capture the mouse/trackpad pointer (pointer lock → relative movement) for games,
|
||||
/// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad
|
||||
/// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost
|
||||
|
||||
@@ -33,6 +33,9 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
public var touchMode = "trackpad"
|
||||
public var mouseMode = "capture"
|
||||
public var invertScroll = false
|
||||
/// Cross-client `inhibit_shortcuts` (default on): system chords reach the host while input is
|
||||
/// captured. See `DefaultsKey.inhibitShortcuts` — on macOS this is the ⌘-chord passthrough.
|
||||
public var inhibitShortcuts = true
|
||||
public var gamepadType = 0
|
||||
public var gamepadForwarding = true
|
||||
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
|
||||
@@ -97,6 +100,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
touchMode = str(DefaultsKey.touchMode, touchMode)
|
||||
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
|
||||
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
|
||||
inhibitShortcuts = bool(DefaultsKey.inhibitShortcuts, inhibitShortcuts)
|
||||
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
|
||||
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
|
||||
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
|
||||
@@ -177,6 +181,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
if let v = overlay.touchMode { s.touchMode = v }
|
||||
if let v = overlay.mouseMode { s.mouseMode = v }
|
||||
if let v = overlay.invertScroll { s.invertScroll = v }
|
||||
if let v = overlay.inhibitShortcuts { s.inhibitShortcuts = v }
|
||||
if let v = overlay.gamepadType { s.gamepadType = v }
|
||||
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
|
||||
if let v = overlay.systemButtons { s.systemButtons = v }
|
||||
|
||||
@@ -109,6 +109,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
public var touchMode: String?
|
||||
public var mouseMode: String?
|
||||
public var invertScroll: Bool?
|
||||
public var inhibitShortcuts: Bool?
|
||||
public var gamepadType: Int?
|
||||
public var gamepadForwarding: Bool?
|
||||
public var systemButtons: String?
|
||||
@@ -153,6 +154,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
case touchMode = "touch_mode"
|
||||
case mouseMode = "mouse_mode"
|
||||
case invertScroll = "invert_scroll"
|
||||
case inhibitShortcuts = "inhibit_shortcuts"
|
||||
case gamepadType = "gamepad"
|
||||
case gamepadForwarding = "gamepad_forwarding"
|
||||
case systemButtons = "system_buttons"
|
||||
@@ -189,6 +191,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
touchMode = str(.touchMode)
|
||||
mouseMode = str(.mouseMode)
|
||||
invertScroll = bool(.invertScroll)
|
||||
inhibitShortcuts = bool(.inhibitShortcuts)
|
||||
gamepadType = int(.gamepadType)
|
||||
gamepadForwarding = bool(.gamepadForwarding)
|
||||
systemButtons = str(.systemButtons)
|
||||
@@ -227,6 +230,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
try c.encodeIfPresent(touchMode, forKey: AnyKey(Key.touchMode.rawValue))
|
||||
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
|
||||
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
|
||||
try c.encodeIfPresent(inhibitShortcuts, forKey: AnyKey(Key.inhibitShortcuts.rawValue))
|
||||
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
|
||||
@@ -283,6 +287,7 @@ public enum OverlayField {
|
||||
case "touch_mode": overlay.touchMode = nil
|
||||
case "mouse_mode": overlay.mouseMode = nil
|
||||
case "invert_scroll": overlay.invertScroll = nil
|
||||
case "inhibit_shortcuts": overlay.inhibitShortcuts = nil
|
||||
case "gamepad": overlay.gamepadType = nil
|
||||
case "gamepad_forwarding": overlay.gamepadForwarding = nil
|
||||
case "system_buttons": overlay.systemButtons = nil
|
||||
@@ -321,6 +326,7 @@ public enum OverlayField {
|
||||
case "touch_mode": return o.touchMode != nil
|
||||
case "mouse_mode": return o.mouseMode != nil
|
||||
case "invert_scroll": return o.invertScroll != nil
|
||||
case "inhibit_shortcuts": return o.inhibitShortcuts != nil
|
||||
case "gamepad": return o.gamepadType != nil
|
||||
case "gamepad_forwarding": return o.gamepadForwarding != nil
|
||||
case "system_buttons": return o.systemButtons != nil
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Pins the macOS ⌘-chord passthrough — the rule deciding which keyDowns `InputCapture`'s local
|
||||
/// monitor takes off AppKit and forwards to the host instead of letting a menu key equivalent
|
||||
/// claim them. Two things are worth a test rather than a comment:
|
||||
///
|
||||
/// * ⌘Q reaching the host at all. That is the whole point — it is the compositor chord on
|
||||
/// Hyprland/KDE/GNOME, and it used to quit the client.
|
||||
/// * ⌘⎋ and ⌃⌘F NOT reaching it, under every combination. They are the way out of a captured
|
||||
/// stream; forward either and the user is locked in.
|
||||
final class CommandChordTests: XCTestCase {
|
||||
// kVK_ANSI_* — physical positions, layout-independent (the same constants the monitor uses).
|
||||
private let q: UInt16 = 12, w: UInt16 = 13, h: UInt16 = 4, m: UInt16 = 46
|
||||
private let f: UInt16 = 3, esc: UInt16 = 53, leftArrow: UInt16 = 123
|
||||
|
||||
/// Captured, setting on, capture mouse model — the shipping default.
|
||||
private func forwards(
|
||||
_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags,
|
||||
forwarding: Bool = true, inhibit: Bool = true, desktop: Bool = false
|
||||
) -> Bool {
|
||||
InputCapture.forwardsCommandChord(
|
||||
keyCode: keyCode, flags: flags, forwarding: forwarding,
|
||||
inhibitShortcuts: inhibit, desktopMouse: desktop)
|
||||
}
|
||||
|
||||
func testCommandChordsGoToTheHostWhileCaptured() {
|
||||
XCTAssertTrue(forwards(q, .command)) // ⌘Q — the reported break
|
||||
XCTAssertTrue(forwards(w, .command))
|
||||
XCTAssertTrue(forwards(h, .command))
|
||||
XCTAssertTrue(forwards(m, .command))
|
||||
XCTAssertTrue(forwards(q, [.command, .shift])) // ⇧⌘Q
|
||||
XCTAssertTrue(forwards(m, [.command, .control, .option, .shift]))
|
||||
}
|
||||
|
||||
func testTheEscapeHatchesAreNeverForwarded() {
|
||||
// ⌘⎋ releases capture, ⌃⌘F leaves fullscreen. Neither may ever reach the host.
|
||||
XCTAssertFalse(forwards(esc, .command))
|
||||
XCTAssertFalse(forwards(f, [.control, .command]))
|
||||
XCTAssertTrue(InputCapture.isClientReservedChord(keyCode: esc, flags: .command))
|
||||
XCTAssertTrue(
|
||||
InputCapture.isClientReservedChord(keyCode: f, flags: [.control, .command]))
|
||||
}
|
||||
|
||||
/// The reservation is exact: it is ⌘⎋ and ⌃⌘F specifically, not "anything with Esc or F in
|
||||
/// it". ⇧⌘⎋ and ⌘F are the host's like any other chord.
|
||||
func testNeighbouringChordsAreNotReserved() {
|
||||
XCTAssertTrue(forwards(esc, [.command, .shift]))
|
||||
XCTAssertTrue(forwards(f, .command))
|
||||
XCTAssertFalse(InputCapture.isClientReservedChord(keyCode: f, flags: .command))
|
||||
}
|
||||
|
||||
func testNothingWithoutCommandIsClaimedHere() {
|
||||
// The ⌃⌥⇧ family and bare keys reach the monitor's earlier blocks / the responder chain.
|
||||
XCTAssertFalse(forwards(q, [.control, .option, .shift]))
|
||||
XCTAssertFalse(forwards(q, []))
|
||||
XCTAssertFalse(forwards(esc, []))
|
||||
}
|
||||
|
||||
func testReleasedCaptureLeavesTheMenuAlone() {
|
||||
// Not forwarding = the user is in the local UI: ⌘Q must quit the app, ⌘W close the window.
|
||||
XCTAssertFalse(forwards(q, .command, forwarding: false))
|
||||
XCTAssertFalse(forwards(w, .command, forwarding: false))
|
||||
}
|
||||
|
||||
func testTheCrossClientSettingTurnsItOff() {
|
||||
XCTAssertFalse(forwards(q, .command, inhibit: false))
|
||||
}
|
||||
|
||||
func testTheDesktopMouseModelKeepsChordsLocal() {
|
||||
// Matches the SDL clients' keyboard grab: a remote desktop is something you ⌘Tab away from.
|
||||
XCTAssertFalse(forwards(q, .command, desktop: true))
|
||||
XCTAssertFalse(forwards(q, .command, inhibit: true, desktop: true))
|
||||
}
|
||||
|
||||
/// `deviceIndependentFlagsMask` also carries Caps Lock and the `.function`/`.numericPad` bits
|
||||
/// every arrow key sets, so comparing it for equality made chords stop being recognized in
|
||||
/// exactly the states a user does not connect to their keyboard: Caps Lock on, or the chord
|
||||
/// spelled with an arrow. `chordFlags` isolates the four real modifiers.
|
||||
func testCapsLockAndArrowBitsDoNotChangeAChord() throws {
|
||||
let capsQ = try XCTUnwrap(keyEvent(q, [.command, .capsLock]))
|
||||
XCTAssertEqual(InputCapture.chordFlags(capsQ), .command)
|
||||
XCTAssertTrue(forwards(q, InputCapture.chordFlags(capsQ)))
|
||||
|
||||
// ⌘⎋ with Caps Lock on is still the escape hatch, not a chord for the host.
|
||||
let capsEsc = try XCTUnwrap(keyEvent(esc, [.command, .capsLock]))
|
||||
XCTAssertEqual(InputCapture.chordFlags(capsEsc), .command)
|
||||
XCTAssertFalse(forwards(esc, InputCapture.chordFlags(capsEsc)))
|
||||
|
||||
// ⌘← — arrows set .function|.numericPad, which say nothing about the chord.
|
||||
let cmdLeft = try XCTUnwrap(keyEvent(leftArrow, [.command, .function, .numericPad]))
|
||||
XCTAssertEqual(InputCapture.chordFlags(cmdLeft), .command)
|
||||
XCTAssertTrue(forwards(leftArrow, InputCapture.chordFlags(cmdLeft)))
|
||||
}
|
||||
|
||||
/// A forwarded chord is only useful if the key has a host VK — the monitor swallows either
|
||||
/// way, so an unmapped one would silently do nothing. Spot-check the common ⌘ letters.
|
||||
func testTheCommonChordKeysMapToHostVKs() {
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[q], 0x51) // VK 'Q'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[w], 0x57) // VK 'W'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[h], 0x48) // VK 'H'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[m], 0x4D) // VK 'M'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[leftArrow], 0x25) // VK_LEFT
|
||||
}
|
||||
|
||||
private func keyEvent(_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags) -> NSEvent? {
|
||||
NSEvent.keyEvent(
|
||||
with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
||||
windowNumber: 0, context: nil, characters: "", charactersIgnoringModifiers: "",
|
||||
isARepeat: false, keyCode: keyCode)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -45,7 +45,7 @@ BUNDLE_ID="io.unom.punktfunk"
|
||||
# The App Store set, in listing order — the first three are what most people ever see, so they are
|
||||
# the stream itself, the machines it found, and the couch/controller mode. Everything else in
|
||||
# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`.
|
||||
SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
|
||||
SCENES=(${SCENES:-01-stream 02-hosts 11-library 12-controllers 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
|
||||
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
|
||||
|
||||
mkdir -p "$OUT"
|
||||
@@ -63,9 +63,13 @@ require_xcode() {
|
||||
# ---------------------------------------------------------------------------- macOS
|
||||
|
||||
shoot_macos() {
|
||||
log "macOS — building (swift build -c release)…"
|
||||
swift build -c release >/dev/null
|
||||
local bin=".build/release/PunktfunkClient"
|
||||
# DEBUG build, deliberately: the whole shot harness lives behind `#if DEBUG`
|
||||
# (ScreenshotHost/ScreenshotScenes), so a release binary launches as the NORMAL app, never
|
||||
# prints PF_SHOT_WINDOW, and every scene "never reported a window". Debug renders the same
|
||||
# pixels — SwiftUI has no release-only visuals.
|
||||
log "macOS — building (swift build)…"
|
||||
swift build >/dev/null
|
||||
local bin=".build/debug/PunktfunkClient"
|
||||
[ -x "$bin" ] || die "build produced no $bin"
|
||||
|
||||
for scene in "${SCENES[@]}"; do
|
||||
@@ -142,6 +146,14 @@ shoot_sim() {
|
||||
# incremental build instead of cold-building into a throwaway tmpdir — CI pins this
|
||||
# (apple.yml); local runs keep the self-cleaning mktemp default.
|
||||
local dd; dd="${PF_SHOT_DERIVED_DATA:-$(mktemp -d)}"; mkdir -p "$dd"
|
||||
# tvOS-SIMULATOR trap (Xcode 26.6 and the 27 beta, local only so far): the build planner
|
||||
# schedules the SwiftPM MACRO plugin targets that swiftui-navigation-transitions pulls in
|
||||
# (OnceMacro/SwizzlingMacro/AssociationMacro) for the *tvOS* triple and never plans their
|
||||
# swift-syntax dependencies at all — "unable to resolve module dependency: 'SwiftSyntax'".
|
||||
# Device archives and iOS builds don't hit it (only the tvOS target links that package), and
|
||||
# prebuilt-vs-source swift-syntax makes no difference. Until Xcode fixes the planner, the
|
||||
# workaround is temporarily unlinking SwiftUINavigationTransitions from the tvOS target
|
||||
# (HomeView's use is canImport-guarded — the push transition degrades to the crossfade).
|
||||
xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \
|
||||
-sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \
|
||||
CODE_SIGNING_ALLOWED=NO build >/dev/null \
|
||||
|
||||
@@ -796,7 +796,9 @@ from the config directory for a true factory reset."
|
||||
);
|
||||
return NEEDS_INTERACTION;
|
||||
}
|
||||
match library::fetch_games(&host.addr, library::DEFAULT_MGMT_PORT, &identity, pin) {
|
||||
// The port this host actually serves its library on — learned from its advert and saved,
|
||||
// falling back to 47990. Reaching for the constant here is what broke a moved port.
|
||||
match library::fetch_games(&host.addr, host.effective_mgmt_port(), &identity, pin) {
|
||||
Ok(games) => {
|
||||
if has(args, "--json") {
|
||||
let rows: Vec<serde_json::Value> = games
|
||||
|
||||
@@ -1108,6 +1108,18 @@ impl HostsPage {
|
||||
{
|
||||
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
|
||||
}
|
||||
// Same for its management port — and this one is not cosmetic: without it a host
|
||||
// that moved off 47990 loses its library the moment mDNS is unavailable, because
|
||||
// the advert was the only place the real port ever lived.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && a.mgmt_port.is_some())
|
||||
{
|
||||
if let Some(p) = a.mgmt_port {
|
||||
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
|
||||
}
|
||||
}
|
||||
saved.push_back(HostCard {
|
||||
connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()),
|
||||
kind: CardKind::Saved {
|
||||
@@ -1183,18 +1195,33 @@ impl HostsPage {
|
||||
});
|
||||
}
|
||||
|
||||
/// The advertised mgmt port for the host `req` points at, when a matching live
|
||||
/// advert carries the `mgmt` TXT.
|
||||
/// The mgmt port for the host `req` points at: a matching live advert's `mgmt` TXT first,
|
||||
/// else the port a previous advert taught us and we saved on the host record.
|
||||
///
|
||||
/// The saved rung is not redundant. Reading the advert alone meant a host that had moved its
|
||||
/// mgmt port off 47990 served its library on the LAN and nowhere else — over a VPN, a routed
|
||||
/// subnet, or any multicast-dead network there is no advert to read, and the fallback silently
|
||||
/// went back to a port nothing was listening on. `None` here still means "assume the default".
|
||||
fn mgmt_port_for(&self, req: &ConnectRequest) -> Option<u16> {
|
||||
self.adverts
|
||||
let matches_req = |fp: &str, addr: &str, port: u16| {
|
||||
req.fp_hex
|
||||
.as_deref()
|
||||
.is_some_and(|want| !fp.is_empty() && fp == want)
|
||||
|| (addr == req.addr && port == req.port)
|
||||
};
|
||||
if let Some(p) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| {
|
||||
req.fp_hex
|
||||
.as_deref()
|
||||
.is_some_and(|fp| !a.fp_hex.is_empty() && a.fp_hex == fp)
|
||||
|| (a.addr == req.addr && a.port == req.port)
|
||||
})
|
||||
.find(|a| matches_req(&a.fp_hex, &a.addr, a.port))
|
||||
.and_then(|a| a.mgmt_port)
|
||||
{
|
||||
return Some(p);
|
||||
}
|
||||
crate::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| matches_req(&h.fp_hex, &h.addr, h.port))
|
||||
.and_then(|h| h.mgmt_port)
|
||||
}
|
||||
|
||||
/// Rename a saved host — an entry in an alert, then upsert + refresh.
|
||||
|
||||
@@ -73,8 +73,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
paired: k.is_some_and(|h| h.paired) || fake,
|
||||
saved: k.is_some(),
|
||||
online: false,
|
||||
// Explicit --mgmt wins; else the port this host's advert taught us and we saved;
|
||||
// else 47990. The middle rung is what survives mDNS being unavailable later.
|
||||
mgmt_port: arg_value("--mgmt")
|
||||
.and_then(|p| p.parse().ok())
|
||||
.or_else(|| k.and_then(|h| h.mgmt_port))
|
||||
.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
can_wake: false,
|
||||
last_used: k.and_then(|h| h.last_used),
|
||||
@@ -181,7 +184,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
vsync: settings_at_start.vsync,
|
||||
allow_vrr: settings_at_start.allow_vrr,
|
||||
json_status,
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32], mgmt_port: u16| {
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
trust::touch_last_used(&fp_hex);
|
||||
// A request-access connect just succeeded → the operator approved us. Save the
|
||||
@@ -191,6 +194,10 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
trust::persist_host(&p.name, &p.addr, p.port, &fp_hex, true);
|
||||
}
|
||||
}
|
||||
// Where this host serves its library, from the session's own Welcome — recorded
|
||||
// AFTER the persist above so a host saved by this very connect gets it too. `0` =
|
||||
// the host advertised none, and the call is a no-op.
|
||||
trust::learn_mgmt_port_by_fp(&fp_hex, mgmt_port);
|
||||
})),
|
||||
overlay: Some(Box::new(overlay)),
|
||||
window_size: crate::session_main::window_size(&settings_at_start),
|
||||
@@ -682,6 +689,12 @@ impl ServiceState {
|
||||
|| (d.addr == h.addr && d.port == h.port)
|
||||
});
|
||||
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
|
||||
// Write the advertised mgmt port down while the host is visible, so this console
|
||||
// keeps working against a moved port once it is not. No-op (and no disk write)
|
||||
// when unchanged, so this is safe on every refresh tick.
|
||||
if let Some(p) = advert.and_then(|d| d.mgmt_port) {
|
||||
pf_client_core::trust::learn_mgmt_port(&h.fp_hex, &h.addr, h.port, p);
|
||||
}
|
||||
let row = HostRow {
|
||||
key: key.clone(),
|
||||
name: host_display_name(&h.name, &h.addr),
|
||||
@@ -691,8 +704,12 @@ impl ServiceState {
|
||||
paired: h.paired,
|
||||
saved: true,
|
||||
online,
|
||||
// Live advert first, then what we saved from an earlier one, then 47990 —
|
||||
// the same three rungs `os` uses just below. Reading the advert ALONE is why
|
||||
// a host on a moved mgmt port lost its library the moment mDNS went quiet.
|
||||
mgmt_port: advert
|
||||
.and_then(|d| d.mgmt_port)
|
||||
.or(h.mgmt_port)
|
||||
.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
can_wake: !online && !h.mac.is_empty(),
|
||||
last_used: h.last_used,
|
||||
|
||||
@@ -986,9 +986,16 @@ mod session_main {
|
||||
vsync: settings.vsync,
|
||||
allow_vrr: settings.allow_vrr,
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32], mgmt_port: u16| {
|
||||
let fp = trust::hex(&fingerprint);
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
trust::touch_last_used(&fp);
|
||||
// Save where this host serves its library, learned from the session's own
|
||||
// Welcome rather than an mDNS advert — so it keeps working on a network where
|
||||
// discovery never does. `0` = the host advertised none; leave what we have.
|
||||
if mgmt_port != 0 {
|
||||
trust::learn_mgmt_port_by_fp(&fp, mgmt_port);
|
||||
}
|
||||
})),
|
||||
// The Skia console UI (stats OSD, capture HUD) — compiled out of the
|
||||
// power-user build (`--no-default-features` drops the `ui` feature).
|
||||
|
||||
@@ -691,6 +691,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
fp_hex: Some(k.fp_hex.clone()),
|
||||
pair_optional: false,
|
||||
mac: k.mac.clone(),
|
||||
mgmt_port: k.mgmt_port,
|
||||
profile: None,
|
||||
launch: None,
|
||||
};
|
||||
@@ -715,6 +716,18 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
}) {
|
||||
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
|
||||
}
|
||||
// Same for its management port — load-bearing, unlike the two above: a host moved off
|
||||
// 47990 loses its library entirely once mDNS is gone unless we write the port down.
|
||||
if let Some(p) = hosts
|
||||
.iter()
|
||||
.find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
&& h.mgmt_port.is_some()
|
||||
})
|
||||
.and_then(|h| h.mgmt_port)
|
||||
{
|
||||
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
|
||||
}
|
||||
let can_wake = !online && !k.mac.is_empty();
|
||||
let menu = {
|
||||
let (svc, target) = (props.svc.clone(), target.clone());
|
||||
@@ -1046,6 +1059,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||
pair_optional: h.pair == "optional",
|
||||
mac: h.mac.clone(),
|
||||
mgmt_port: h.mgmt_port,
|
||||
profile: None,
|
||||
launch: None,
|
||||
};
|
||||
@@ -1140,6 +1154,11 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
mac: Vec::new(),
|
||||
// Added by hand, so nothing has told us where its mgmt API is: fall back to
|
||||
// 47990 (exactly today's behaviour) until an advert teaches us otherwise.
|
||||
// A host that moved its mgmt port AND is never visible on mDNS still needs the
|
||||
// host to announce the port in-band — see the note in `Target::mgmt_port`.
|
||||
mgmt_port: None,
|
||||
profile: None,
|
||||
launch: None,
|
||||
},
|
||||
|
||||
@@ -104,7 +104,7 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
|
||||
let mut state = LibraryState::default();
|
||||
let games = match library::fetch_games(
|
||||
&target.addr,
|
||||
library::DEFAULT_MGMT_PORT,
|
||||
target.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
&identity,
|
||||
pin,
|
||||
) {
|
||||
@@ -120,7 +120,10 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
|
||||
}
|
||||
|
||||
// Seed cached posters; queue the art pipeline for the rest.
|
||||
let base = library::base_url(&target.addr, library::DEFAULT_MGMT_PORT);
|
||||
let base = library::base_url(
|
||||
&target.addr,
|
||||
target.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
);
|
||||
let cache = art_cache_dir();
|
||||
let mut jobs: VecDeque<(String, Vec<String>)> = VecDeque::new();
|
||||
for g in &games {
|
||||
|
||||
@@ -103,6 +103,11 @@ pub(crate) struct Target {
|
||||
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a
|
||||
/// magic packet before connecting to an offline host. Empty when none is known.
|
||||
pub(crate) mac: Vec<String>,
|
||||
/// This host's management-API port (saved store or live advert), where the library screen
|
||||
/// fetches from. `None` = unknown, use [`pf_client_core::library::DEFAULT_MGMT_PORT`]. Carried
|
||||
/// on the target for the same reason as `mac`: the library screen has no `KnownHost` in hand,
|
||||
/// and assuming 47990 there is what made a moved mgmt port work on the LAN but not over a VPN.
|
||||
pub(crate) mgmt_port: Option<u16>,
|
||||
/// A ONE-OFF settings profile for this connect ("Connect with"): `Some(id)` overrides the
|
||||
/// host's binding for this launch, `Some("")` forces the global defaults on a bound host,
|
||||
/// `None` honors the binding. It never rebinds anything — the default changes only through
|
||||
@@ -406,6 +411,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
fp_hex: p.host.fp_hex.clone(),
|
||||
pair_optional: false,
|
||||
mac: p.host.mac.clone(),
|
||||
mgmt_port: p.host.mgmt_port,
|
||||
profile: p.profile_override.clone(),
|
||||
launch: None, // routed explicitly below (initiate_launch*)
|
||||
};
|
||||
@@ -447,6 +453,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
fp_hex: u.fp.clone(),
|
||||
pair_optional: false,
|
||||
mac: Vec::new(),
|
||||
// A link carries no mgmt port (nor a MAC), so this stays unknown until
|
||||
// an advert teaches it — same fallback as the hand-added case.
|
||||
mgmt_port: None,
|
||||
profile: u.profile.clone(),
|
||||
launch: u.launch.clone(),
|
||||
};
|
||||
|
||||
@@ -62,10 +62,18 @@
|
||||
{
|
||||
"type": "application",
|
||||
"name": "punktfunk-gamescope",
|
||||
"version": "upstream gamescope pinned by packaging/nix/gamescope.nix (nixpkgs) or built by packaging/gamescope/build-punktfunk-gamescope.sh, plus 3 local patches from packaging/gamescope/patches/",
|
||||
"version": "upstream gamescope pinned by packaging/nix/gamescope.nix (nixpkgs) or built by packaging/gamescope/build-punktfunk-gamescope.sh, plus the local patch series from packaging/gamescope/patches/",
|
||||
"description": "Patched gamescope compositor distributed via sysext/Arch/nix channels alongside the host",
|
||||
"licenses": [{ "license": { "id": "BSD-2-Clause" } }],
|
||||
"externalReferences": [{ "type": "vcs", "url": "https://github.com/ValveSoftware/gamescope" }]
|
||||
},
|
||||
{
|
||||
"type": "application",
|
||||
"name": "Bun",
|
||||
"version": "1.3.14 (pinned in .gitea/workflows/windows-host.yml)",
|
||||
"description": "Portable JavaScript runtime bundled in the Windows host installer to run the web console (.output) and the plugin/script runner. Embeds JavaScriptCore (LGPL-2.1).",
|
||||
"licenses": [{ "license": { "id": "MIT" } }],
|
||||
"externalReferences": [{ "type": "vcs", "url": "https://github.com/oven-sh/bun" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Vendored & bundled components — CVE watch and update cadence
|
||||
|
||||
Due-diligence record for every third-party component that ships with Punktfunk but is
|
||||
**not** tracked by a package manager's advisory feed (CRA Art. 13(5); Annex I Part II §1).
|
||||
Everything resolved through Cargo/bun/pnpm lockfiles is already scanned weekly by
|
||||
`.gitea/workflows/audit.yml` (cargo-audit against RustSec, bun/pnpm audit) — this file
|
||||
covers what those scanners cannot see: vendored source trees, git-rev pins, and binaries
|
||||
staged into installers. The component inventory itself lives in
|
||||
`compliance/sbom/manual-components.cdx.json` and is merged into every release SBOM;
|
||||
keep the two files in sync when a component is added, removed, or re-pinned.
|
||||
|
||||
Owner for all of it: Enrico (sole maintainer). Standing cadence: **walk this table once
|
||||
per quarter and before every stable release**; act immediately on any advisory from the
|
||||
watch feeds below.
|
||||
|
||||
| Component | Where / pin | How to update | Watch |
|
||||
|---|---|---|---|
|
||||
| **pyrowave** (+ Granite, volk, Vulkan-Headers subtree) | `crates/pyrowave-sys/vendor/pyrowave`, pin = `PYROWAVE_COMMIT` in `scripts/vendor-pyrowave.sh`; exact commits recorded in `vendor/pyrowave/PUNKTFUNK-VENDOR.txt` | Bump the commit in the script, re-run it (network required; never from CI), re-apply `crates/pyrowave-sys/patches/`. ⚠️ **Bitstream changes are protocol-affecting** — the wire bit means "PyroWave as of this pin"; a bitstream-changing bump must bump the protocol version and re-diff the Apple Metal hand-port (see the script header). | GitHub releases/commits of Themaister/pyrowave + Themaister/Granite (niche projects, no CVE feed — repo watch is the feed) |
|
||||
| **libvpl** 2.17.0 | `crates/libvpl-sys/vendor/libvpl` (dispatcher statically linked; needs cmake + libclang) | Manual re-vendor from intel/libvpl at the new tag; rebuild `libvpl-sys` | Intel Security Center (INTEL-SA advisories for oneVPL/media) + intel/libvpl releases |
|
||||
| **windows-rs** git pin | `rev = acb5a1a7…` on microsoft/windows-rs (workspace `[patch]`/git deps: `windows`, `windows-reactor`, …) | Move the rev / return to crates.io once the needed fixes are released. Note: cargo-audit matches these by name+version from Cargo.lock, but a pre-release rev may not map cleanly onto RustSec advisories — treat the pin itself as the thing to retire. | RustSec (already weekly) + microsoft/windows-rs releases |
|
||||
| **usbfs-iso / uac-host** git pin | `rev = f3de1fd…` on unom-io/usbfs-iso | First-party fork — we are upstream; fix in the fork, move the rev | Own repo (issues land in our tracker) |
|
||||
| **FFmpeg** (host encode only) | Linux: system `libav*` (distro-updated, not ours to patch — but Arch soname majors can break us, see ffmpeg9 note). Windows: AMF/QSV shared DLLs staged from `FFMPEG_DIR` by `pack-host-installer.ps1`; LGPL notice bundled | Windows: rebuild/refresh the staged DLL set, ship in the next installer. Linux: nothing to ship; verify against new distro majors | ffmpeg-security announcements (ffmpeg.org security page) — a libav* CVE in decode/parse paths we use ⇒ refresh the Windows DLLs without undue delay |
|
||||
| **SDL3** | Desktop clients, dynamically linked; system-provided or bundled per platform package | Bump the bundled copy in the affected package; system copies are distro-updated | libsdl-org/SDL GitHub security advisories + releases |
|
||||
| **gamescope** + patch series | Pin in `packaging/nix/gamescope.nix` / built by `packaging/gamescope/build-punktfunk-gamescope.sh`; local patches in `packaging/gamescope/patches/` | Bump the pin, re-rebase the patch series, rebuild sysext/Arch/nix + .deb channels. ⚠️ the gamescope CI legs are best-effort: a broken patch shows up as a *missing package*, not a red build | ValveSoftware/gamescope releases + security advisories |
|
||||
| **Bun runtime** 1.3.14 | Pinned in `.gitea/workflows/windows-host.yml` (`bun-v1.3.14`); bundled portable in the Windows host installer to run the web console + plugin runner. Embeds JavaScriptCore | Bump the version string in the workflow; next installer build picks it up | oven-sh/bun releases (security notes ride in release notes) |
|
||||
|
||||
Not on this list on purpose:
|
||||
|
||||
- **VB-CABLE** — no longer bundled (audio-substrate program, 2026-08; the host mints its
|
||||
own virtual audio devices). If it ever returns, it returns to this table first.
|
||||
- **openh264 / rav1d CPU decode floor** — crates.io dependencies with vendored C/asm
|
||||
inside the `-sys` crates; cargo-audit tracks the crate advisories, and the upstream
|
||||
(Cisco openh264, memorysafety/rav1d) security feeds surface through RustSec. No
|
||||
separate manual watch needed unless we pin them to git.
|
||||
|
||||
## Security-update availability (CRA: ≥10 years)
|
||||
|
||||
Where users fetch fixes, and why old artifacts don't vanish (verified 2026-08-14):
|
||||
|
||||
- **Gitea releases + package registries** (git.unom.io): no cleanup rules configured,
|
||||
and Gitea does not expire releases or packages on its own — the full release history
|
||||
(v0.17.x through current) is still served with assets. Blobs live in the `unom-git`
|
||||
S3 bucket with an R2 mirror, and the box is restic-backed every 6 h. Old release
|
||||
assets (and their `.sha256` sidecars) therefore stay downloadable.
|
||||
- **Bazzite sysext feeds**: stable channels publish with `KEEP=0` (keep everything);
|
||||
only canary channels prune (`KEEP=6`) — see `rpm.yml` + `publish-sysext-feed.sh`.
|
||||
- **Flatpak repo** (flatpak.unom.io): published by rsync *without* `--delete`; old
|
||||
OSTree commits accumulate, both channels stay in the signed summary.
|
||||
- **Policy**: never add cleanup that deletes *security* releases; if storage pressure
|
||||
ever forces pruning, prune canary builds, never tagged stable releases. SBOMs are
|
||||
release assets, so the ≥10-year SBOM retention rides on the same guarantee.
|
||||
@@ -313,6 +313,20 @@ pub struct KnownHost {
|
||||
/// sleep. `default` (and elided when empty) so pre-existing stores load unchanged.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub os: String,
|
||||
/// The host's management-API port (mDNS `mgmt` TXT), where the game library is served —
|
||||
/// distinct from `port`, which is the native QUIC plane. Learned from the advert while the
|
||||
/// host is online and persisted here for the same reason as `mac` and `os`: so it survives the
|
||||
/// advert going away.
|
||||
///
|
||||
/// That is not a cosmetic loss like a missing OS icon. A host that moved its mgmt port off
|
||||
/// 47990 — the supported fix for sharing a machine with a Sunshine fork, whose web UI owns
|
||||
/// that port — was reachable only for as long as mDNS was: on a VPN, a routed subnet, or a
|
||||
/// multicast-dead network the library silently went blank, because the port the client had
|
||||
/// already been told was never written down. `None` = never learned, resolve via
|
||||
/// [`KnownHost::effective_mgmt_port`]. Optional + `default` so pre-existing stores load
|
||||
/// (the Apple client's `StoredHost.mgmtPort` is the same field for the same reason).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mgmt_port: Option<u16>,
|
||||
/// Share this machine's clipboard with THIS host (design/clipboard-and-file-transfer.md
|
||||
/// §5.3 — the Apple client's `StoredHost.clipboardSync`). Per-host, not global: handing a
|
||||
/// host your clipboard is a trust decision about that host. Default off; the host must
|
||||
@@ -353,6 +367,7 @@ impl Default for KnownHost {
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
os: String::new(),
|
||||
mgmt_port: None,
|
||||
clipboard_sync: false,
|
||||
profile_id: None,
|
||||
pinned_profiles: Vec::new(),
|
||||
@@ -362,6 +377,17 @@ impl Default for KnownHost {
|
||||
}
|
||||
|
||||
impl KnownHost {
|
||||
/// Where this host's management API actually is: the port learned from its advert, else the
|
||||
/// compiled-in 47990. The twin of the Apple client's `StoredHost.effectiveMgmtPort`.
|
||||
///
|
||||
/// Every library/art call resolves through this rather than reaching for
|
||||
/// [`crate::library::DEFAULT_MGMT_PORT`] directly — that constant is the FALLBACK, not the
|
||||
/// answer, and call sites that treated it as the answer are why a moved port only worked while
|
||||
/// mDNS was up.
|
||||
pub fn effective_mgmt_port(&self) -> u16 {
|
||||
self.mgmt_port.unwrap_or(crate::library::DEFAULT_MGMT_PORT)
|
||||
}
|
||||
|
||||
/// This host's pinned profiles that still exist, in card order, without duplicates — what
|
||||
/// a grid renders. Dangling pins (the profile was deleted) simply disappear, per design
|
||||
/// §5.2a: a pin is presentation state, never a reason to show an error.
|
||||
@@ -506,6 +532,13 @@ impl KnownHosts {
|
||||
if !entry.os.is_empty() {
|
||||
h.os = entry.os;
|
||||
}
|
||||
// And for the learned mgmt port. Stated explicitly rather than left to the
|
||||
// does-not-mention-it rule below: this one is load-bearing (a host that moved off
|
||||
// 47990 is unreachable for the library without it), so a reconnect upsert that
|
||||
// carries `None` must visibly not clear what a discovery taught us.
|
||||
if entry.mgmt_port.is_some() {
|
||||
h.mgmt_port = entry.mgmt_port;
|
||||
}
|
||||
// Everything below is state the user set ON this record, which a refresh (a
|
||||
// reconnect, a re-pair, a rediscovery) never carries and therefore must never
|
||||
// clear: the per-host clipboard decision — which survives today only because this
|
||||
@@ -581,6 +614,9 @@ impl KnownHosts {
|
||||
if h.os.is_empty() {
|
||||
h.os = old.os;
|
||||
}
|
||||
if h.mgmt_port.is_none() {
|
||||
h.mgmt_port = old.mgmt_port;
|
||||
}
|
||||
if h.profile_id.is_none() {
|
||||
h.profile_id = old.profile_id;
|
||||
}
|
||||
@@ -692,6 +728,27 @@ pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) {
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's management-API port from its live advert (mDNS `mgmt` TXT),
|
||||
/// matched like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when
|
||||
/// unchanged, so the hosts page can call it on every discovery tick without churning the store.
|
||||
///
|
||||
/// This is what makes a moved mgmt port outlive mDNS. Until it existed the port was read straight
|
||||
/// off the live advert and thrown away, so the library worked on the LAN and went blank over a VPN.
|
||||
pub fn learn_mgmt_port(fp_hex: &str, addr: &str, port: u16, mgmt_port: u16) {
|
||||
if mgmt_port == 0 {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.mgmt_port == Some(mgmt_port) {
|
||||
return;
|
||||
}
|
||||
h.mgmt_port = Some(mgmt_port);
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
|
||||
/// fingerprint). No-op — and no disk write — when unchanged. Called from the wake-and-wait flow when
|
||||
/// a woken host reappears on a different IP than the stored one, so this and future connects dial the
|
||||
@@ -725,6 +782,28 @@ pub fn touch_last_used(fp_hex: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a host's management-API port learned from the **session's own `Welcome`**, keyed by
|
||||
/// fingerprint alone — the identity a just-connected client is certain of.
|
||||
///
|
||||
/// This is the mDNS-free path, and the one that matters most: [`learn_mgmt_port`] can only fire
|
||||
/// where an advert is visible, whereas this fires on any successful connect, including a host
|
||||
/// added by IP on a network where discovery has never worked. No-op — and no disk write — when
|
||||
/// the fingerprint isn't stored or the value is unchanged, so it is safe on every connect.
|
||||
pub fn learn_mgmt_port_by_fp(fp_hex: &str, mgmt_port: u16) {
|
||||
if fp_hex.is_empty() || mgmt_port == 0 {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp_hex) else {
|
||||
return;
|
||||
};
|
||||
if h.mgmt_port == Some(mgmt_port) {
|
||||
return;
|
||||
}
|
||||
h.mgmt_port = Some(mgmt_port);
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Run the SPAKE2 PIN ceremony against a host. `device_name` is the label the HOST
|
||||
/// stores this client under (its paired-devices list); the 90 s budget covers a
|
||||
/// human-typed PIN. Returns the host's now-verified certificate fingerprint to pin.
|
||||
@@ -1781,6 +1860,9 @@ mod tests {
|
||||
last_used: Some(1000),
|
||||
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
|
||||
os: "linux/fedora/bazzite".into(),
|
||||
// Deliberately NOT 47990: a host that moved its mgmt port is the case this field
|
||||
// exists for, so the default would make the assertions below pass vacuously.
|
||||
mgmt_port: Some(47991),
|
||||
clipboard_sync: true,
|
||||
profile_id: Some("aaaaaaaaaaaa".into()),
|
||||
pinned_profiles: vec!["bbbbbbbbbbbb".into()],
|
||||
@@ -1804,6 +1886,9 @@ mod tests {
|
||||
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
|
||||
// The learned OS chain rides the same rule as `mac`: a carrier-less upsert keeps it.
|
||||
assert_eq!(h.os, "linux/fedora/bazzite");
|
||||
// And the learned mgmt port. If a reconnect could reset this to None the host would fall
|
||||
// back to 47990 and its library would 404 — the exact regression this rule prevents.
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
assert!(h.clipboard_sync);
|
||||
assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa"));
|
||||
assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]);
|
||||
@@ -1823,6 +1908,51 @@ mod tests {
|
||||
assert_eq!(k.hosts[0].pinned_profiles, vec!["dddddddddddd".to_string()]);
|
||||
}
|
||||
|
||||
/// The mgmt port a host advertises has to OUTLIVE the advert: a store written before the field
|
||||
/// existed must load, resolve to 47990, and then take and keep a learned value. Without the
|
||||
/// middle rung a host moved off 47990 (to share a box with a Sunshine fork, whose web UI owns
|
||||
/// that port) served its library on the LAN and nowhere else — over a VPN or a routed subnet
|
||||
/// there is no advert to read and the client silently went back to a dead port.
|
||||
#[test]
|
||||
fn mgmt_port_survives_a_store_that_predates_it_and_then_persists() {
|
||||
// A store written before the field existed: no `mgmt_port` key at all.
|
||||
let old = r#"{"hosts":[{
|
||||
"name": "Gaming PC", "addr": "192.168.1.50", "port": 9777,
|
||||
"fp_hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"paired": true
|
||||
}]}"#;
|
||||
let mut k: KnownHosts = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(k.hosts[0].mgmt_port, None, "absent key decodes to None");
|
||||
assert_eq!(
|
||||
k.hosts[0].effective_mgmt_port(),
|
||||
crate::library::DEFAULT_MGMT_PORT,
|
||||
"unknown resolves to the compiled-in default, i.e. today's behaviour"
|
||||
);
|
||||
// Unset stays out of the serialized form, so an untouched store is byte-stable.
|
||||
assert!(!serde_json::to_string(&k).unwrap().contains("mgmt_port"));
|
||||
|
||||
// Learning one (what a discovery tick does) takes effect and round-trips.
|
||||
k.hosts[0].mgmt_port = Some(47991);
|
||||
assert_eq!(k.hosts[0].effective_mgmt_port(), 47991);
|
||||
let round: KnownHosts = serde_json::from_str(&serde_json::to_string(&k).unwrap()).unwrap();
|
||||
assert_eq!(round.hosts[0].mgmt_port, Some(47991));
|
||||
|
||||
// A re-key carries it onto the surviving record — otherwise a host that regenerated its
|
||||
// identity would silently drop back to 47990.
|
||||
let fresh = fp('a');
|
||||
let mut k2 = k;
|
||||
k2.upsert_trusted(KnownHost {
|
||||
name: "Gaming PC".into(),
|
||||
addr: "192.168.1.50".into(),
|
||||
port: 9777,
|
||||
fp_hex: fresh.clone(),
|
||||
paired: true,
|
||||
..Default::default()
|
||||
});
|
||||
let kept = k2.hosts.iter().find(|h| h.fp_hex == fresh).unwrap();
|
||||
assert_eq!(kept.mgmt_port, Some(47991), "re-key must not lose the port");
|
||||
}
|
||||
|
||||
/// A host that regenerated its identity (reinstall, wiped ProgramData, re-key) ends up with
|
||||
/// ONE record for its address — the live one. This is the `.173` lockout: `upsert` keys on
|
||||
/// the fingerprint, so the re-paired host used to be appended beside the dead record, and
|
||||
@@ -1840,6 +1970,7 @@ mod tests {
|
||||
last_used: Some(1000),
|
||||
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
|
||||
os: "windows".into(),
|
||||
mgmt_port: Some(47991),
|
||||
clipboard_sync: true,
|
||||
profile_id: Some("aaaaaaaaaaaa".into()),
|
||||
pinned_profiles: vec!["bbbbbbbbbbbb".into()],
|
||||
@@ -1864,6 +1995,9 @@ mod tests {
|
||||
// What describes the BOX rides along, so a reinstall doesn't cost the user their setup.
|
||||
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
|
||||
assert_eq!(h.os, "windows");
|
||||
// The mgmt port describes the BOX, not the retired certificate: a reinstall must not send
|
||||
// the library back to 47990 on a host that serves it somewhere else.
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa"));
|
||||
assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]);
|
||||
assert_eq!(h.last_used, Some(1000));
|
||||
|
||||
@@ -21,6 +21,10 @@ tracing = "0.1"
|
||||
# `FramePayload::Cuda` owns a zero-copy `DeviceBuffer`; `libc` for the per-thread `setpriority`.
|
||||
pf-zerocopy = { path = "../pf-zerocopy" }
|
||||
libc = "0.2"
|
||||
# The rtkit fallback in `thread_qos` (one blocking system-bus call per boosted thread). Same zbus
|
||||
# the host already pulls via ashpd; `tokio` mirrors ashpd's backend choice so this adds the
|
||||
# `blocking-api` surface without changing the resolved I/O backend, and no default `async-io`.
|
||||
zbus = { version = "5", default-features = false, features = ["tokio", "blocking-api"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The DXGI capture identity (`WinCaptureTarget`/`D3d11Frame`/`pack_luid`/`make_device`) + the GPU
|
||||
|
||||
@@ -44,10 +44,9 @@ pub fn boost_thread_priority(critical: bool) {
|
||||
// Best-effort nice of the CALLING thread. On Linux `setpriority(PRIO_PROCESS, 0, …)` acts on
|
||||
// the calling thread (the kernel resolves who==0 to the current task/tid), and both call
|
||||
// sites run inside their worker thread — so this nices exactly the capture/encode (critical)
|
||||
// and send (non-critical) threads, nothing else. Silently no-ops without CAP_SYS_NICE / a
|
||||
// raised RLIMIT_NICE, which is fine. We deliberately do NOT use SCHED_RR/FIFO by default: a
|
||||
// realtime CPU class can preempt the compositor AND the game's own render thread, adding the
|
||||
// very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR).
|
||||
// and send (non-critical) threads, nothing else. We deliberately do NOT use SCHED_RR/FIFO by
|
||||
// default: a realtime CPU class can preempt the compositor AND the game's own render thread,
|
||||
// adding the very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR).
|
||||
let nice = if critical { -10 } else { -5 };
|
||||
// SAFETY: `setpriority` takes three by-value integers and no pointers, so there is nothing to
|
||||
// alias or outlive. `PRIO_PROCESS` with `who == 0` targets the calling task on Linux and
|
||||
@@ -57,10 +56,24 @@ pub fn boost_thread_priority(critical: bool) {
|
||||
if rc == 0 {
|
||||
tracing::debug!(critical, nice, "thread nice raised");
|
||||
} else {
|
||||
tracing::debug!(
|
||||
critical,
|
||||
"setpriority(nice) no-op (needs CAP_SYS_NICE / RLIMIT_NICE)"
|
||||
);
|
||||
// The direct call needs CAP_SYS_NICE or a raised RLIMIT_NICE, and the host binary can
|
||||
// NEVER carry a file capability (a capped process's /proc/<pid>/exe is unreadable to
|
||||
// KWin, which kills desktop streaming — the 0.26.0-1 field incident). RealtimeKit is
|
||||
// the sanctioned unprivileged path: the same broker PipeWire's clients use, present on
|
||||
// effectively every desktop install. Packaging also ships a `user@.service.d`
|
||||
// LimitNICE drop-in so the direct call works on rtkit-less boxes — but only from the
|
||||
// next login, and existing installs upgrade the binary alone; rtkit is what fixes the
|
||||
// installed base. A 2026-08-14 field log showed exactly this rung missing: every
|
||||
// fresh-launch shader storm descheduled the unprioritized audio/send threads.
|
||||
match linux_rtkit::make_high_priority(nice) {
|
||||
Ok(()) => tracing::debug!(critical, nice, "thread nice raised via rtkit"),
|
||||
Err(e) => tracing::debug!(
|
||||
critical,
|
||||
reason = %e,
|
||||
"setpriority(nice) no-op (needs CAP_SYS_NICE / RLIMIT_NICE, and rtkit \
|
||||
was unavailable)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
@@ -68,3 +81,43 @@ pub fn boost_thread_priority(critical: bool) {
|
||||
let _ = critical;
|
||||
}
|
||||
}
|
||||
|
||||
/// RealtimeKit fallback for [`boost_thread_priority`]: ask the system-bus broker
|
||||
/// (`org.freedesktop.RealtimeKit1`) to renice the calling thread when the direct
|
||||
/// `setpriority` was refused. This is how PulseAudio/PipeWire clients get their boosts on a
|
||||
/// stock desktop — no capability anywhere, which matters here because a file capability on the
|
||||
/// host binary breaks KWin's client identification outright.
|
||||
///
|
||||
/// Only the high-priority (nice) verb is used, never `MakeThreadRealtime` — the SCHED_RR
|
||||
/// reservations in [`boost_thread_priority`]'s comment apply to rtkit-granted RR too (and the
|
||||
/// RT verb additionally demands an RLIMIT_RTTIME we don't set).
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_rtkit {
|
||||
/// One-shot blocking D-Bus call. Must be made from a plain worker thread, never from async
|
||||
/// context — which already holds for every caller: `boost_thread_priority` acts on the
|
||||
/// calling thread, so it only ever runs inside the dedicated capture/encode/send threads.
|
||||
/// The connection is per-call rather than cached: this runs at most a handful of times per
|
||||
/// session (thread starts), and holding a system-bus connection for the session's lifetime
|
||||
/// to save microseconds at session start is a bad trade against a wedged bus daemon pinning
|
||||
/// a socket in every session forever.
|
||||
pub(super) fn make_high_priority(nice: i32) -> Result<(), zbus::Error> {
|
||||
// SAFETY: `gettid` takes no arguments, touches no memory, and returns the calling
|
||||
// thread's kernel tid — always valid on Linux.
|
||||
let tid = unsafe { libc::syscall(libc::SYS_gettid) } as u64;
|
||||
let pid = u64::from(std::process::id());
|
||||
let conn = zbus::blocking::Connection::system()?;
|
||||
// `MakeThreadHighPriorityWithPID(u64 process, u64 thread, i32 priority)` — priority is a
|
||||
// nice level, floored by rtkit's MinNiceLevel (defaults well below our -10). The WithPID
|
||||
// variant with our own pid is the explicit spelling of "this thread of this process";
|
||||
// rtkit still authenticates the caller via the bus, so it grants nothing a plain
|
||||
// `setpriority` caller couldn't be granted.
|
||||
conn.call_method(
|
||||
Some("org.freedesktop.RealtimeKit1"),
|
||||
"/org/freedesktop/RealtimeKit1",
|
||||
Some("org.freedesktop.RealtimeKit1"),
|
||||
"MakeThreadHighPriorityWithPID",
|
||||
&(pid, tid, nice),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,30 @@ pub struct HostConfig {
|
||||
/// text ("Living Room PC"); the DNS-level `<label>.local.` target keeps using a sanitized
|
||||
/// machine-safe label, so a spacey display name can't produce an invalid mDNS record.
|
||||
pub host_name: Option<String>,
|
||||
/// `PUNKTFUNK_MGMT_BIND` — the management API's listen address (`IP:PORT`), equivalent to the
|
||||
/// `--mgmt-bind` CLI flag, which still wins when both are given. Unset = `0.0.0.0:47990`.
|
||||
///
|
||||
/// This exists so moving the port SURVIVES: `--mgmt-bind` lives in a unit file / service
|
||||
/// registration that a package upgrade rewrites, whereas `host.env` is operator-owned and is
|
||||
/// the documented place every other knob lives. The motivating case is coexistence with a
|
||||
/// Sunshine fork — 47990 is *their* web UI port as well as our management API, and it is the
|
||||
/// only port the two share once the GameStream planes are off, so moving it is the whole fix.
|
||||
///
|
||||
/// Kept as the raw string rather than a parsed `SocketAddr`: this crate is the
|
||||
/// parse-once-from-env layer, and `main.rs` owns turning a bad value into the same
|
||||
/// `bad --mgmt-bind (want IP:PORT)` error the flag produces, from one place.
|
||||
pub mgmt_bind: Option<String>,
|
||||
/// `PUNKTFUNK_NATIVE_PORT` — the native punktfunk/1 (QUIC) control port, equivalent to the
|
||||
/// `--native-port` CLI flag, which still wins. Unset = 9777.
|
||||
///
|
||||
/// Same survives-an-upgrade argument as [`Self::mgmt_bind`]: `--native-port` lives in an
|
||||
/// ExecStart a package rewrites. Unlike the mgmt port, the CLIENT side of moving this already
|
||||
/// worked — `KnownHost.port` is persisted per host and `--connect HOST:PORT` names it — so this
|
||||
/// key is the last piece of making the native port genuinely movable.
|
||||
///
|
||||
/// Raw string, parsed in `main.rs`, for the same reason as `mgmt_bind`: a typo'd port must be a
|
||||
/// startup ERROR, not a silent fall back to 9777 while the operator believes they moved it.
|
||||
pub native_port: Option<String>,
|
||||
/// `PUNKTFUNK_GAMESTREAM` — enable the GameStream/Moonlight-compat planes (nvhttp pairing,
|
||||
/// RTSP, ENet control, `_nvstream` mDNS) from `host.env`, equivalent to the `--gamestream`
|
||||
/// CLI flag (either source turns it on). **Default OFF** — the secure native-only host: the
|
||||
@@ -374,6 +398,14 @@ impl HostConfig {
|
||||
host_name: val("PUNKTFUNK_HOST_NAME")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
// Blank-is-unset, like `host_name` above: an operator who comments a value out by
|
||||
// emptying it (`PUNKTFUNK_MGMT_BIND=`) means "default", not "parse the empty string".
|
||||
mgmt_bind: val("PUNKTFUNK_MGMT_BIND")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
native_port: val("PUNKTFUNK_NATIVE_PORT")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
// Default OFF, explicit-on grammar: the Moonlight-compat planes are opt-in
|
||||
// everywhere (see the field doc); `--gamestream` on the CLI also turns them on.
|
||||
gamestream: env_on("PUNKTFUNK_GAMESTREAM").unwrap_or(false),
|
||||
|
||||
@@ -39,6 +39,14 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// [`SessionOpts::on_connected`]'s callback: the host's certificate fingerprint, then the
|
||||
/// management-API port from its `Welcome` (`0` = it advertised none).
|
||||
///
|
||||
/// A named type rather than the inline `Box<dyn FnMut(...)>` because adding the second parameter
|
||||
/// tipped it over `clippy::type_complexity` — factoring it out is what that lint asks for, and it
|
||||
/// gives the two positional arguments somewhere to be documented.
|
||||
pub type ConnectedFn = Box<dyn FnMut([u8; 32], u16)>;
|
||||
|
||||
pub struct SessionOpts {
|
||||
pub window_title: String,
|
||||
/// Start fullscreen (gamescope / `--fullscreen`).
|
||||
@@ -84,9 +92,14 @@ pub struct SessionOpts {
|
||||
pub allow_vrr: bool,
|
||||
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
||||
pub json_status: bool,
|
||||
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
||||
/// binary's business — this loop stays store-agnostic).
|
||||
pub on_connected: Option<Box<dyn FnMut([u8; 32])>>,
|
||||
/// Called once on `Connected` with the host's fingerprint and the management-API port the
|
||||
/// host reported in its `Welcome` (`0` = it advertised none). Trust persistence is the
|
||||
/// binary's business — this loop stays store-agnostic.
|
||||
///
|
||||
/// The port rides along because this is the one moment a client is guaranteed to have it
|
||||
/// WITHOUT mDNS: the session it just authenticated carries it. A client that saves it here
|
||||
/// can browse the library of a host it has only ever reached by address.
|
||||
pub on_connected: Option<ConnectedFn>,
|
||||
/// The console-UI overlay (§6.1) — `None` is the Skia-free power-user build (stats
|
||||
/// stay stdout-only). An overlay whose `init` fails degrades to `None` with a
|
||||
/// warning rather than killing the session. Browse mode requires one.
|
||||
@@ -1377,9 +1390,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
apply_capture(&mut window, &mouse, true, cap.desktop(), inhibit_shortcuts);
|
||||
st.capture = Some(cap);
|
||||
st.cursor_chan = Some(crate::cursor::CursorChannel::new(&c));
|
||||
// Read the mgmt port BEFORE `c` is moved into `st` — the Welcome's answer to
|
||||
// "where is this host's library", which the binary persists so it survives
|
||||
// without ever needing an mDNS advert.
|
||||
let mgmt_port = c.mgmt_port();
|
||||
st.connector = Some(c);
|
||||
if let Some(f) = opts.on_connected.as_mut() {
|
||||
f(fingerprint);
|
||||
f(fingerprint, mgmt_port);
|
||||
}
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Streaming);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="dpms">
|
||||
<copyright><![CDATA[
|
||||
SPDX-FileCopyrightText: 2015 Martin Gräßlin
|
||||
|
||||
SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
]]></copyright>
|
||||
<interface name="org_kde_kwin_dpms_manager" version="1">
|
||||
<description summary="Output dpms manager">
|
||||
The Dpms manager allows to get a org_kde_kwin_dpms for a given wl_output.
|
||||
The org_kde_kwin_dpms provides the currently used VESA Display Power Management
|
||||
Signaling state (see https://en.wikipedia.org/wiki/VESA_Display_Power_Management_Signaling ).
|
||||
In addition it allows to request a state change. A compositor is not obliged to honor it
|
||||
and will normally automatically switch back to on state.
|
||||
|
||||
Warning! The protocol described in this file is a desktop environment
|
||||
implementation detail. Regular clients must not use this protocol.
|
||||
Backward incompatible changes may be added without bumping the major
|
||||
version of the extension.
|
||||
</description>
|
||||
<request name="get">
|
||||
<description summary="Get org_kde_kwin_dpms for wl_output">
|
||||
Factory request to get the org_kde_kwin_dpms for a given wl_output.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="org_kde_kwin_dpms"/>
|
||||
<arg name="output" type="object" interface="wl_output"/>
|
||||
</request>
|
||||
</interface>
|
||||
<interface name="org_kde_kwin_dpms" version="1">
|
||||
<description summary="Dpms for a wl_output">
|
||||
This interface provides information about the VESA DPMS state for a wl_output.
|
||||
It gets created through the request get on the org_kde_kwin_dpms_manager interface.
|
||||
|
||||
On creating the resource the server will push whether DPSM is supported for the output,
|
||||
the currently used DPMS state and notifies the client through the done event once all
|
||||
states are pushed. Whenever a state changes the set of changes is committed with the
|
||||
done event.
|
||||
</description>
|
||||
<event name="supported">
|
||||
<description summary="Event indicating whether DPMS is supported on the wl_output">
|
||||
This event gets pushed on binding the resource and indicates whether the wl_output
|
||||
supports DPMS. There are operation modes of a Wayland server where DPMS might not
|
||||
make sense (e.g. nested compositors).
|
||||
</description>
|
||||
<arg name="supported" type="uint" summary="Boolean value whether DPMS is supported (1) for the wl_output or not (0)"/>
|
||||
</event>
|
||||
<enum name="mode">
|
||||
<entry name="On" value="0"/>
|
||||
<entry name="Standby" value="1"/>
|
||||
<entry name="Suspend" value="2"/>
|
||||
<entry name="Off" value="3"/>
|
||||
</enum>
|
||||
<event name="mode">
|
||||
<description summary="Event indicating used DPMS mode">
|
||||
This mode gets pushed on binding the resource and provides the currently used
|
||||
DPMS mode. It also gets pushed if DPMS is not supported for the wl_output, in that
|
||||
case the value will be On.
|
||||
|
||||
The event is also pushed whenever the state changes.
|
||||
</description>
|
||||
<arg name="mode" type="uint" summary="The new currently used mode"/>
|
||||
</event>
|
||||
<event name="done">
|
||||
<description summary="All changes are pushed">
|
||||
This event gets pushed on binding the resource once all other states are pushed.
|
||||
|
||||
In addition it gets pushed whenever a state changes to tell the client that all
|
||||
state changes have been pushed.
|
||||
</description>
|
||||
</event>
|
||||
<request name="set">
|
||||
<description summary="Request DPMS state change for the wl_output">
|
||||
Requests that the compositor puts the wl_output into the passed mode. The compositor
|
||||
is not obliged to change the state. In addition the compositor might leave the mode
|
||||
whenever it seems suitable. E.g. the compositor might return to On state on user input.
|
||||
|
||||
The client should not assume that the mode changed after requesting a new mode.
|
||||
Instead the client should listen for the mode event.
|
||||
</description>
|
||||
<arg name="mode" type="uint" summary="Requested mode"/>
|
||||
</request>
|
||||
<request name="release" type="destructor">
|
||||
<description summary="release the dpms object"/>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
|
||||
@@ -848,6 +848,15 @@ mod kwin;
|
||||
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
|
||||
mod kwin_output_mgmt;
|
||||
|
||||
// DPMS control of the box's live KDE desktop (org_kde_kwin_dpms) — how a bare-spawn gamescope
|
||||
// session honors `Topology::Exclusive`: the spawn is its own headless compositor, so the desktop's
|
||||
// physical outputs can't be *disabled* (KWin refuses zero enabled outputs and no output there is
|
||||
// ours) — they are put to DPMS-off for the stream instead, refcounted across concurrent spawns.
|
||||
// Consumed by `gamescope` (best-effort, with kscreen fallback).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/kwin_dpms.rs"]
|
||||
mod kwin_dpms;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/manager.rs"]
|
||||
pub mod manager;
|
||||
|
||||
@@ -69,6 +69,11 @@ pub struct GamescopeDisplay {
|
||||
/// the decision and this session's `create`. `None` = nothing resolved it (a caller that never
|
||||
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
|
||||
route: Option<crate::GamescopeRoute>,
|
||||
/// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
|
||||
/// the release of this display's [`crate::kwin_dpms`] darken hold — pending pickup by the
|
||||
/// registry via [`VirtualDisplay::take_topology_restore`], so it runs at the display's
|
||||
/// teardown (§6.1) and never before.
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at.
|
||||
@@ -441,6 +446,14 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
self.route = route;
|
||||
}
|
||||
|
||||
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
|
||||
// The DPMS darken-hold release the bare-spawn `create` registered (Exclusive topology
|
||||
// only). The registry stores it on this display's entry and runs it at teardown — which,
|
||||
// for gamescope, is the display's OWN teardown: every spawn is its own group, and the
|
||||
// cross-session ordering lives in `kwin_dpms`'s refcount, not in the group float.
|
||||
self.pending_restore.take()
|
||||
}
|
||||
|
||||
fn poolable_now(&self) -> bool {
|
||||
// Only a bare SPAWN is registry-poolable (its `create` reports `Owned`); Managed and
|
||||
// Attach report `SessionManaged`/`External`, so the registry must not reuse a kept spawn
|
||||
@@ -576,6 +589,23 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
hz = mode.refresh_hz,
|
||||
"gamescope virtual output ready"
|
||||
);
|
||||
// `Topology::Exclusive`, bare-spawn edition: this spawn is its OWN headless compositor —
|
||||
// nothing above touched the box's live desktop (KWin), which would otherwise keep driving
|
||||
// the physical panel with the idle desktop for the whole stream. The KWin route disables
|
||||
// the physicals outright, but that door is closed here (KWin refuses zero enabled outputs,
|
||||
// and no output on that desktop is ours to leave enabled) — so the desktop's panels go to
|
||||
// DPMS-off instead, best-effort and self-gating (a box with no KDE desktop declines
|
||||
// quietly inside `kwin_dpms`). Placed AFTER the spawn succeeded, so a failed create never
|
||||
// blanks the user's screen. The hold is refcounted in `kwin_dpms` rather than floated
|
||||
// through the registry's group restore, because every gamescope spawn is its own group
|
||||
// (`registry::group_key`) — the float alone would re-light the panel when the FIRST of two
|
||||
// concurrent spawns ends, under the second's still-live stream. Skipped for Managed (its
|
||||
// takeover already stopped the desktop) and Attach (it mirrors a gamescope that may itself
|
||||
// be driving the physical panel) — both returned earlier in this function.
|
||||
if crate::effective_topology() == crate::policy::Topology::Exclusive {
|
||||
crate::kwin_dpms::acquire_stream_darken();
|
||||
self.pending_restore = Some(Box::new(crate::kwin_dpms::release_stream_darken));
|
||||
}
|
||||
// Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
|
||||
Ok(VirtualOutput::owned(
|
||||
node_id,
|
||||
|
||||
@@ -704,7 +704,7 @@ fn kscreen_ok(args: &[String]) -> bool {
|
||||
/// before exiting, so a slow-but-working KWin gives us a kill on a request that already landed;
|
||||
/// any caller that treats `None` as "it failed" is asserting something it does not know, and for
|
||||
/// the restore path that assertion costs a monitor its refresh rate.
|
||||
fn kscreen_verdict(args: &[String]) -> Option<bool> {
|
||||
pub(crate) fn kscreen_verdict(args: &[String]) -> Option<bool> {
|
||||
match crate::proc::status_within(
|
||||
std::process::Command::new("kscreen-doctor").args(args),
|
||||
KSCREEN_BUDGET,
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
//! DPMS control of the box's live KDE desktop (`org_kde_kwin_dpms`) — how a bare-spawn gamescope
|
||||
//! session honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
|
||||
//!
|
||||
//! A bare spawn is its OWN headless compositor: nothing on that route touches the desktop the box
|
||||
//! is showing, so on a KDE machine the physical panel keeps displaying the (idle) desktop for the
|
||||
//! whole stream — while the same `exclusive` policy on the KWin route turns the physicals off
|
||||
//! outright. The KWin route's mechanism is closed to us here: KWin refuses an output configuration
|
||||
//! with ZERO enabled outputs, and a gamescope session has no KWin output of its own to leave
|
||||
//! enabled. DPMS is the honest translation of `exclusive` for this route — the desktop stays
|
||||
//! exactly where it is (no topology churn, no window re-homing), the panels go dark, and any
|
||||
//! LOCAL input wakes them, which is the right answer for a desktop someone can walk up to.
|
||||
//! Stream input never wakes them: it is injected into the nested gamescope's own EIS socket and
|
||||
//! does not pass through KWin.
|
||||
//!
|
||||
//! Driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the same
|
||||
//! stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a separate
|
||||
//! libkscreen/KDED layer that can be wedged while KWin itself answers fine), with a
|
||||
//! `kscreen-doctor --dpms` shell-out fallback. Best-effort everywhere — a box with no Wayland
|
||||
//! session, or a non-KDE desktop, declines quietly and the stream proceeds with the panel lit,
|
||||
//! exactly as before this module existed.
|
||||
//!
|
||||
//! **The hold is refcounted here, NOT floated through the registry's per-group restore.** Every
|
||||
//! gamescope spawn is its own display group (`registry::group_key` — deliberately, they are
|
||||
//! independent nested sessions), so the §6.1 group machinery alone would run the FIRST session's
|
||||
//! restore at that session's teardown and re-light the panel under a second, still-streaming
|
||||
//! session. Instead each exclusive spawn takes one [`acquire_stream_darken`] hold (the 0→1 edge
|
||||
//! darkens) and registers [`release_stream_darken`] as its per-display topology restore (the 1→0
|
||||
//! edge re-lights) — the same shape as `sleep_inhibit`'s refcount, riding the registry only for
|
||||
//! the *timing* of each release.
|
||||
//!
|
||||
//! Crash safety comes free: DPMS is non-persistent, so a host that dies holding the panel dark
|
||||
//! leaves nothing to journal — the screen re-lights on the next local input or compositor
|
||||
//! restart. (Contrast the Windows `pnp_disable_monitors` path, which needs a recovery journal
|
||||
//! precisely because its disable survives everything.)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use wayland_client::protocol::wl_callback::{self, WlCallback};
|
||||
use wayland_client::protocol::wl_output::{self, WlOutput};
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||
|
||||
// Client bindings for the vendored KDE dpms protocol (`protocols/dpms.xml`), generated inline like
|
||||
// the two in `kwin_output_mgmt`. Self-contained: its only foreign object type is the core
|
||||
// `wl_output`, which `wayland_client::protocol` already provides.
|
||||
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
pub mod protocol {
|
||||
use wayland_client;
|
||||
use wayland_client::protocol::*;
|
||||
|
||||
pub mod __interfaces {
|
||||
use wayland_client::protocol::__interfaces::*;
|
||||
wayland_scanner::generate_interfaces!("protocols/dpms.xml");
|
||||
}
|
||||
use self::__interfaces::*;
|
||||
|
||||
wayland_scanner::generate_client_code!("protocols/dpms.xml");
|
||||
}
|
||||
|
||||
use protocol::org_kde_kwin_dpms::{Event as DpmsEvent, OrgKdeKwinDpms as Dpms};
|
||||
use protocol::org_kde_kwin_dpms_manager::OrgKdeKwinDpmsManager as DpmsManager;
|
||||
|
||||
// The wire enum `org_kde_kwin_dpms.mode`. The XML types the `mode` request/event args as plain
|
||||
// `uint` (no `enum=` attribute), so the generated signatures take/deliver `u32` — these constants
|
||||
// are the protocol's values, kept in sync with the vendored `dpms.xml`.
|
||||
const DPMS_MODE_ON: u32 = 0;
|
||||
const DPMS_MODE_OFF: u32 = 3;
|
||||
|
||||
/// `org_kde_kwin_dpms_manager` is a frozen v1 protocol (its own header warns it may change
|
||||
/// without a version bump, but no v2 has appeared since 2015); bind `min(advertised, 1)`.
|
||||
const MANAGER_MAX: u32 = 1;
|
||||
/// `wl_output.name` — the connector name used for logging — arrived in v4. Everything else we do
|
||||
/// works at v1, so a lower advert just costs the log its names.
|
||||
const WL_OUTPUT_MAX: u32 = 4;
|
||||
|
||||
/// Overall budget for one darken/re-light operation (mirrors `kwin_output_mgmt::OP_BUDGET`):
|
||||
/// generous next to a healthy roundtrip, and only there so a wedged compositor can't pin the
|
||||
/// session-create (or group-teardown) thread.
|
||||
const OP_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Poll slice while waiting on the Wayland fd (matches `kwin_output_mgmt`).
|
||||
const POLL_MS: i32 = 100;
|
||||
|
||||
/// One output's accumulated state on this connection, keyed by its `wl_output` global name.
|
||||
#[derive(Default)]
|
||||
struct OutputState {
|
||||
proxy: Option<WlOutput>,
|
||||
/// Connector name (`DP-1`) from `wl_output.name` (v4) — logging only; the global number is
|
||||
/// the address everything operates on.
|
||||
connector: Option<String>,
|
||||
dpms: Option<Dpms>,
|
||||
/// `org_kde_kwin_dpms.supported` — `None` until the bind burst arrives.
|
||||
supported: Option<bool>,
|
||||
/// The last `org_kde_kwin_dpms.mode` seen — kept current, so the post-`set` wait can watch it
|
||||
/// flip.
|
||||
mode: Option<u32>,
|
||||
}
|
||||
|
||||
/// Everything one connection's queue accumulates.
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
manager: Option<DpmsManager>,
|
||||
/// Keyed by the `wl_output` GLOBAL NAME — a stable address for the compositor's lifetime, and
|
||||
/// the identity the darken records so the re-light (a separate, later connection) can find the
|
||||
/// same outputs again.
|
||||
outputs: HashMap<u32, OutputState>,
|
||||
/// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
|
||||
sync_done: u32,
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} => {
|
||||
if interface == DpmsManager::interface().name {
|
||||
let v = version.min(MANAGER_MAX);
|
||||
state.manager = Some(registry.bind::<DpmsManager, _, _>(name, v, qh, ()));
|
||||
} else if interface == WlOutput::interface().name {
|
||||
let v = version.min(WL_OUTPUT_MAX);
|
||||
// The global name rides in the UserData so the output's own events (and the
|
||||
// dpms object's, which gets the same stamp) can find this entry.
|
||||
let out = registry.bind::<WlOutput, _, _>(name, v, qh, name);
|
||||
state.outputs.entry(name).or_default().proxy = Some(out);
|
||||
}
|
||||
}
|
||||
// An output unplugged mid-operation: drop the entry so we never `set` on its corpse.
|
||||
wl_registry::Event::GlobalRemove { name } => {
|
||||
state.outputs.remove(&name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlOutput, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlOutput,
|
||||
event: wl_output::Event,
|
||||
global: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_output::Event::Name { name } = event {
|
||||
if let Some(o) = state.outputs.get_mut(global) {
|
||||
o.connector = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<Dpms, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &Dpms,
|
||||
event: DpmsEvent,
|
||||
global: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let Some(o) = state.outputs.get_mut(global) else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
DpmsEvent::Supported { supported } => o.supported = Some(supported != 0),
|
||||
DpmsEvent::Mode { mode } => o.mode = Some(mode),
|
||||
DpmsEvent::Done => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The manager has no events; the impl exists because `WlRegistry::bind` demands one.
|
||||
impl Dispatch<DpmsManager, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &DpmsManager,
|
||||
_: protocol::org_kde_kwin_dpms_manager::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlCallback, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlCallback,
|
||||
event: wl_callback::Event,
|
||||
serial: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_callback::Event::Done { .. } = event {
|
||||
state.sync_done = state.sync_done.max(*serial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why [`Session::open`] declined — the same honest-decline discipline as
|
||||
/// `kwin_output_mgmt::OpenFailure`: which rung said no decides both the log level and whether the
|
||||
/// `kscreen-doctor` fallback is worth attempting.
|
||||
enum OpenFailure {
|
||||
/// No Wayland connection at all (`WAYLAND_DISPLAY` unset/stale). The common case for the bare
|
||||
/// spawn's natural habitat — a headless plain-distro box with no desktop to darken.
|
||||
Connect(String),
|
||||
/// The compositor accepted the connection but did not answer the registry barrier in budget:
|
||||
/// a live but wedged session — the case the shell-out fallback exists for.
|
||||
RegistryBarrier,
|
||||
/// Connected and answering, but `org_kde_kwin_dpms_manager` is not advertised — not KWin. A
|
||||
/// definitive answer: no fallback can succeed here either (`kscreen-doctor` drives the same
|
||||
/// KDE-only machinery), so this rung declines without one.
|
||||
NoDpmsGlobal,
|
||||
/// The manager is there but the per-output DPMS state bursts never completed in budget.
|
||||
StateBarrier,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OpenFailure {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
OpenFailure::Connect(e) => write!(f, "no Wayland connection ({e})"),
|
||||
OpenFailure::RegistryBarrier => {
|
||||
write!(
|
||||
f,
|
||||
"the compositor did not answer the registry roundtrip in budget"
|
||||
)
|
||||
}
|
||||
OpenFailure::NoDpmsGlobal => {
|
||||
write!(f, "org_kde_kwin_dpms_manager is not advertised (not KWin)")
|
||||
}
|
||||
OpenFailure::StateBarrier => {
|
||||
write!(
|
||||
f,
|
||||
"the outputs' DPMS state never finished announcing in budget"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connected session with the manager bound and every output's DPMS state read.
|
||||
struct Session {
|
||||
conn: Connection,
|
||||
queue: wayland_client::EventQueue<State>,
|
||||
state: State,
|
||||
next_sync: u32,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// [`Session::connect`] for the operation named by `op`, logging the decline at a level that
|
||||
/// matches what it means: `Connect`/`NoDpmsGlobal` are the everyday non-KDE answers (most
|
||||
/// bare-spawn boxes have no desktop at all) and log at debug; the two barrier failures mean a
|
||||
/// LIVE session stopped answering — on a KDE box that is a panel left lit, so they warn.
|
||||
fn open(op: &'static str) -> Result<Session, OpenFailure> {
|
||||
let opened = Session::connect();
|
||||
if let Err(reason) = &opened {
|
||||
match reason {
|
||||
OpenFailure::Connect(_) | OpenFailure::NoDpmsGlobal => {
|
||||
tracing::debug!(op, %reason, "KWin DPMS unavailable");
|
||||
}
|
||||
OpenFailure::RegistryBarrier | OpenFailure::StateBarrier => {
|
||||
tracing::warn!(
|
||||
op,
|
||||
%reason,
|
||||
"KWin DPMS: in-process path unavailable — falling back to kscreen-doctor"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
opened
|
||||
}
|
||||
|
||||
/// Connect to the desktop's Wayland socket, bind the dpms manager + every `wl_output`, create
|
||||
/// a dpms status object per output and drain their state bursts — all bounded by [`OP_BUDGET`].
|
||||
fn connect() -> Result<Session, OpenFailure> {
|
||||
let conn = Connection::connect_to_env().map_err(|e| OpenFailure::Connect(e.to_string()))?;
|
||||
let queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
let mut s = Session {
|
||||
conn,
|
||||
queue,
|
||||
state: State::default(),
|
||||
next_sync: 0,
|
||||
};
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
// Phase 1: process the registry globals (binds the manager + every wl_output).
|
||||
if !s.sync_barrier(deadline) {
|
||||
return Err(OpenFailure::RegistryBarrier);
|
||||
}
|
||||
let Some(mgr) = s.state.manager.clone() else {
|
||||
return Err(OpenFailure::NoDpmsGlobal);
|
||||
};
|
||||
// Phase 2: one dpms status object per output (stamped with the output's global name so its
|
||||
// events land on the right entry), then a barrier that drains both the outputs' `name`
|
||||
// events and the dpms objects' supported/mode/done bursts.
|
||||
let qh = s.queue.handle();
|
||||
let bound: Vec<(u32, WlOutput)> = s
|
||||
.state
|
||||
.outputs
|
||||
.iter()
|
||||
.filter_map(|(g, o)| o.proxy.clone().map(|p| (*g, p)))
|
||||
.collect();
|
||||
for (global, out) in bound {
|
||||
let d = mgr.get(&out, &qh, global);
|
||||
if let Some(o) = s.state.outputs.get_mut(&global) {
|
||||
o.dpms = Some(d);
|
||||
}
|
||||
}
|
||||
if !s.sync_barrier(deadline) {
|
||||
return Err(OpenFailure::StateBarrier);
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
|
||||
fn sync_barrier(&mut self, deadline: Instant) -> bool {
|
||||
self.next_sync += 1;
|
||||
let serial = self.next_sync;
|
||||
let qh = self.queue.handle();
|
||||
let _cb = self.conn.display().sync(&qh, serial);
|
||||
self.pump_until(deadline, |st| st.sync_done >= serial)
|
||||
}
|
||||
|
||||
/// Bounded manual event loop — flush, dispatch, poll the fd. Mirrors
|
||||
/// `kwin_output_mgmt::Session::pump_until` (same rationale: `blocking_dispatch` can't be
|
||||
/// interrupted, so the fd is polled in [`POLL_MS`] slices against `deadline`).
|
||||
fn pump_until(&mut self, deadline: Instant, done: impl Fn(&State) -> bool) -> bool {
|
||||
loop {
|
||||
if done(&self.state) {
|
||||
return true;
|
||||
}
|
||||
if self.queue.dispatch_pending(&mut self.state).is_err() {
|
||||
return false;
|
||||
}
|
||||
if done(&self.state) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
if self.conn.flush().is_err() {
|
||||
return false;
|
||||
}
|
||||
let Some(guard) = self.conn.prepare_read() else {
|
||||
continue; // events already queued — loop dispatches them
|
||||
};
|
||||
let mut pfd = libc::pollfd {
|
||||
fd: self.conn.as_fd().as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
let timeout = (remaining.as_millis() as i32).clamp(0, POLL_MS);
|
||||
// SAFETY: `&mut pfd` points at one live, fully-initialized `libc::pollfd` on the stack
|
||||
// and the count `1` matches that single element, so `poll` reads `fd`/`events` and
|
||||
// writes `revents` strictly within `pfd`. `pfd.fd` is the Wayland connection's fd,
|
||||
// valid because `self.conn` (and the `prepare_read` guard) outlive the call. `poll`
|
||||
// blocks up to `timeout` ms and writes only `revents`; `pfd` is a fresh local that
|
||||
// aliases nothing.
|
||||
let r = unsafe { libc::poll(&mut pfd, 1, timeout) };
|
||||
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
|
||||
let _ = guard.read();
|
||||
} // else: timeout/signal — drop the guard, re-check the deadline
|
||||
}
|
||||
}
|
||||
|
||||
/// Request `target` on every DPMS-supporting output not already there — restricted to the
|
||||
/// globals in `only` when given (the re-light path, which must touch ONLY what the darken
|
||||
/// touched: a panel the USER had put to sleep before the stream is theirs to keep dark).
|
||||
/// Returns the outputs actually asked to change, `(global, connector)`, then waits (within
|
||||
/// budget) for each one's `mode` event to confirm — the protocol is explicit that `set` is a
|
||||
/// request the compositor may decline, so the confirmation is watched and its absence logged
|
||||
/// rather than assumed.
|
||||
fn set_mode(&mut self, target: u32, only: Option<&[u32]>) -> Vec<(u32, Option<String>)> {
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
let mut touched: Vec<(u32, Option<String>)> = Vec::new();
|
||||
for (global, o) in &self.state.outputs {
|
||||
if only.is_some_and(|list| !list.contains(global)) {
|
||||
continue;
|
||||
}
|
||||
if o.supported != Some(true) || o.mode == Some(target) {
|
||||
continue;
|
||||
}
|
||||
if let Some(dpms) = &o.dpms {
|
||||
dpms.set(target);
|
||||
touched.push((*global, o.connector.clone()));
|
||||
}
|
||||
}
|
||||
if touched.is_empty() {
|
||||
return touched;
|
||||
}
|
||||
let want: Vec<u32> = touched.iter().map(|(g, _)| *g).collect();
|
||||
// An output that vanished mid-wait (GlobalRemove pruned it) counts as settled — there is
|
||||
// nothing left to flip.
|
||||
let confirmed = self.pump_until(deadline, |st| {
|
||||
want.iter()
|
||||
.all(|g| st.outputs.get(g).is_none_or(|o| o.mode == Some(target)))
|
||||
});
|
||||
if !confirmed {
|
||||
tracing::warn!(
|
||||
outputs = ?touched,
|
||||
target,
|
||||
"KWin DPMS: the compositor did not confirm the mode change in budget (the \
|
||||
requests are flushed; it may still land, or KWin may have declined)"
|
||||
);
|
||||
}
|
||||
touched
|
||||
}
|
||||
}
|
||||
|
||||
/// What the 0→1 darken actually achieved — the record the 1→0 re-light undoes. Which arm did the
|
||||
/// work matters: the two are undone through different doors.
|
||||
enum Darkened {
|
||||
/// The in-process path turned these outputs off — `(wl_output global, connector)`. Global
|
||||
/// names are stable for the compositor's lifetime, so a later connection re-lights exactly
|
||||
/// these. If KWin restarted in between the names match nothing — and that is the CORRECT
|
||||
/// no-op, because a fresh KWin brings its outputs up lit anyway.
|
||||
Wayland(Vec<(u32, Option<String>)>),
|
||||
/// The `kscreen-doctor --dpms off` fallback ran (it takes no per-output address, so the
|
||||
/// re-light is the symmetric `--dpms on`).
|
||||
Kscreen,
|
||||
}
|
||||
|
||||
/// The host-wide darken hold — refcounted like `sleep_inhibit`: the 0→1 edge darkens, the 1→0
|
||||
/// edge re-lights, and everything between is bookkeeping. See the module docs for why the
|
||||
/// registry's per-group restore float can't provide this (every gamescope spawn is its own group).
|
||||
struct Holds {
|
||||
count: u32,
|
||||
/// What the 0→1 darken achieved, held until the 1→0 release undoes it. `None` while count > 0
|
||||
/// means the darken found nothing to do (no KDE, panels already dark) — the release then has
|
||||
/// nothing to undo, which is exactly right.
|
||||
darkened: Option<Darkened>,
|
||||
}
|
||||
|
||||
impl Holds {
|
||||
/// Take a hold; `true` on the 0→1 edge — the caller darkens and [`record`](Self::record)s.
|
||||
fn acquire_edge(&mut self) -> bool {
|
||||
self.count += 1;
|
||||
self.count == 1
|
||||
}
|
||||
|
||||
/// Store the 0→1 darken's outcome.
|
||||
fn record(&mut self, d: Option<Darkened>) {
|
||||
self.darkened = d;
|
||||
}
|
||||
|
||||
/// Drop a hold; `Some` on the 1→0 edge hands the caller the record to undo. A release with no
|
||||
/// hold outstanding is a caller bug (an unbalanced restore) — logged, never underflowed.
|
||||
fn release_edge(&mut self) -> Option<Darkened> {
|
||||
if self.count == 0 {
|
||||
tracing::warn!("KWin DPMS: release without a matching acquire (unbalanced restore)");
|
||||
return None;
|
||||
}
|
||||
self.count -= 1;
|
||||
if self.count == 0 {
|
||||
self.darkened.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static HOLDS: Mutex<Holds> = Mutex::new(Holds {
|
||||
count: 0,
|
||||
darkened: None,
|
||||
});
|
||||
|
||||
/// Take one darken hold for an exclusive-topology stream. The first hold turns the live KDE
|
||||
/// desktop's panels off (best-effort, bounded); later holds just count. Callers MUST balance each
|
||||
/// call with [`release_stream_darken`] — the gamescope backend does it by registering the release
|
||||
/// as the display's topology restore, so the registry runs it exactly once per display at
|
||||
/// teardown (§6.1).
|
||||
///
|
||||
/// The lock is deliberately held across the darken itself: a racing second acquire must queue
|
||||
/// behind it (and then see the recorded outcome), not observe a count of 2 with nothing darkened.
|
||||
/// Same discipline on the release side, which keeps a teardown-overlapping-connect sequence
|
||||
/// strictly ordered: re-light completes, then the new stream's darken runs.
|
||||
pub fn acquire_stream_darken() {
|
||||
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if h.acquire_edge() {
|
||||
let d = darken();
|
||||
h.record(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one darken hold; the last one out re-lights whatever the first hold's darken achieved.
|
||||
pub fn release_stream_darken() {
|
||||
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(d) = h.release_edge() {
|
||||
relight(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 0→1 darken: in-process over `org_kde_kwin_dpms` first, `kscreen-doctor --dpms off` as the
|
||||
/// wedged-compositor fallback. `None` = nothing was darkened (no desktop, not KDE, panels already
|
||||
/// off, or every arm declined) — and therefore nothing to restore.
|
||||
fn darken() -> Option<Darkened> {
|
||||
match Session::open("darken") {
|
||||
Ok(mut s) => {
|
||||
let touched = s.set_mode(DPMS_MODE_OFF, None);
|
||||
if touched.is_empty() {
|
||||
tracing::debug!(
|
||||
"KWin DPMS: no output to darken (none supported, or all already off)"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
tracing::info!(
|
||||
outputs = ?touched,
|
||||
"KWin DPMS: desktop outputs off for the exclusive gamescope stream"
|
||||
);
|
||||
Some(Darkened::Wayland(touched))
|
||||
}
|
||||
}
|
||||
// Definitive "not KDE" / "no desktop": no fallback can do better (kscreen-doctor drives
|
||||
// the same KDE-only machinery), so decline quietly — already logged by `open`.
|
||||
Err(OpenFailure::NoDpmsGlobal) | Err(OpenFailure::Connect(_)) => None,
|
||||
// A live session that stopped answering: the standalone tool rides a different stack
|
||||
// (libkscreen/KDED) and may still get through — the same rationale as `kwin.rs`'s
|
||||
// kscreen fallbacks, honest-verdict discipline included.
|
||||
Err(_) => match kscreen_dpms("off") {
|
||||
Some(true) => {
|
||||
tracing::info!(
|
||||
"KWin DPMS: desktop outputs off for the exclusive gamescope stream \
|
||||
(kscreen-doctor fallback)"
|
||||
);
|
||||
Some(Darkened::Kscreen)
|
||||
}
|
||||
// Killed at its budget — NOT a refusal: kscreen-doctor applies first and then waits
|
||||
// on the compositor, so a loaded KWin routinely lands the change and still gets
|
||||
// killed. Record the darken so the teardown re-light runs either way; a `--dpms on`
|
||||
// against a lit panel is a no-op.
|
||||
None => Some(Darkened::Kscreen),
|
||||
Some(false) => {
|
||||
tracing::warn!(
|
||||
"KWin DPMS: could not darken the desktop outputs for the exclusive topology \
|
||||
(in-process path and kscreen-doctor both declined) — the panel stays lit"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1→0 re-light. **This is the last line of defence for a dark monitor**, so every arm that
|
||||
/// gives up says so loudly (the same discipline as `kwin.rs::reenable_outputs_kscreen`) — a dark
|
||||
/// panel with no line in the log is the failure mode this chain exists to prevent. The worst case
|
||||
/// stays self-healing regardless: DPMS is non-persistent, and any local input wakes the panel.
|
||||
fn relight(d: Darkened) {
|
||||
match d {
|
||||
Darkened::Wayland(outputs) => {
|
||||
let globals: Vec<u32> = outputs.iter().map(|(g, _)| *g).collect();
|
||||
match Session::open("re-light") {
|
||||
Ok(mut s) => {
|
||||
s.set_mode(DPMS_MODE_ON, Some(&globals));
|
||||
tracing::info!(outputs = ?outputs, "KWin DPMS: desktop outputs back on");
|
||||
}
|
||||
Err(_) => match kscreen_dpms("on") {
|
||||
Some(true) | None => {
|
||||
tracing::info!(
|
||||
"KWin DPMS: desktop outputs back on (kscreen-doctor fallback)"
|
||||
);
|
||||
}
|
||||
Some(false) => {
|
||||
tracing::error!(
|
||||
outputs = ?outputs,
|
||||
"KWin DPMS: could NOT re-light the desktop outputs (in-process \
|
||||
restore and kscreen-doctor both declined) — the panel stays dark \
|
||||
until local input wakes it"
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Darkened::Kscreen => {
|
||||
if kscreen_dpms("on") == Some(false) {
|
||||
tracing::error!(
|
||||
"KWin DPMS: could NOT re-light the desktop outputs (kscreen-doctor refused \
|
||||
the --dpms on it earlier accepted the off for) — the panel stays dark until \
|
||||
local input wakes it"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `kscreen-doctor --dpms <on|off>` for its verdict, on `kwin.rs`'s shared budget and three-state
|
||||
/// convention (`Some(true)` ran and succeeded, `Some(false)` refused or unrunnable, `None` killed
|
||||
/// at the budget — which, for a tool that applies first and waits after, usually means it landed).
|
||||
fn kscreen_dpms(mode: &'static str) -> Option<bool> {
|
||||
crate::kwin::kscreen_verdict(&["--dpms".to_string(), mode.to_string()])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Darkened, Holds};
|
||||
|
||||
fn fresh() -> Holds {
|
||||
Holds {
|
||||
count: 0,
|
||||
darkened: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_acquire_darkens_later_ones_count() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge(), "0→1 must darken");
|
||||
h.record(Some(Darkened::Kscreen));
|
||||
assert!(
|
||||
!h.acquire_edge(),
|
||||
"a second concurrent stream must not re-darken"
|
||||
);
|
||||
assert!(!h.acquire_edge());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_last_release_relights() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge());
|
||||
h.record(Some(Darkened::Wayland(vec![(7, Some("DP-1".into()))])));
|
||||
assert!(!h.acquire_edge());
|
||||
// First release: a sibling still streams — the panel must stay dark.
|
||||
assert!(h.release_edge().is_none());
|
||||
// Last release hands back the record to undo.
|
||||
let d = h.release_edge();
|
||||
assert!(matches!(d, Some(Darkened::Wayland(v)) if v == vec![(7, Some("DP-1".into()))]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_darken_that_did_nothing_restores_nothing() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge());
|
||||
h.record(None); // no KDE / already dark: nothing was changed
|
||||
assert!(h.release_edge().is_none(), "nothing to undo");
|
||||
assert_eq!(h.count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbalanced_release_never_underflows() {
|
||||
let mut h = fresh();
|
||||
assert!(h.release_edge().is_none());
|
||||
assert_eq!(h.count, 0, "count must not wrap");
|
||||
// And the state machine still works afterwards.
|
||||
assert!(h.acquire_edge());
|
||||
h.record(Some(Darkened::Kscreen));
|
||||
assert!(matches!(h.release_edge(), Some(Darkened::Kscreen)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_cycle_rearms_the_darken() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge());
|
||||
h.record(Some(Darkened::Kscreen));
|
||||
assert!(h.release_edge().is_some());
|
||||
// A later stream on the same host lifetime darkens again.
|
||||
assert!(
|
||||
h.acquire_edge(),
|
||||
"the 0→1 edge must re-arm after a full cycle"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,19 @@ parse_deps = false
|
||||
# imports and their #[repr(C)] structs into the header, where socklen_t/ssize_t/iovec/msghdr are
|
||||
# undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs
|
||||
# `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module).
|
||||
exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
|
||||
#
|
||||
# `SOFT_LIMIT_KNEE` is host-side CAPTURE processing (the operator gain's soft knee, applied before
|
||||
# the encoder). No C embedder can act on it — they receive already-gained audio — so exporting it
|
||||
# would add a bare `#define` to the ABI surface, against R21 below, for a constant with no meaning
|
||||
# on that side of the boundary. Excluded rather than renamed: the header stays byte-identical.
|
||||
exclude = [
|
||||
"MsghdrX",
|
||||
"recvmsg_x",
|
||||
"mmsghdr",
|
||||
"sendmmsg",
|
||||
"recvmmsg",
|
||||
"SOFT_LIMIT_KNEE",
|
||||
]
|
||||
# Reached by no exported SIGNATURE, so cbindgen's sweep misses it — but a C embedder needs the
|
||||
# vocabulary: `punktfunk_connection_end_reason` writes one of these as a bare byte (deliberately,
|
||||
# so the JNI/Swift sides can marshal a `u8` rather than an enum), which without this would leave
|
||||
|
||||
@@ -3826,6 +3826,42 @@ fn build_clip_event(
|
||||
out
|
||||
}
|
||||
|
||||
/// The host's management-API port, from this session's `Welcome` — where its game library is
|
||||
/// served (distinct from the streaming ports). `0` means the host did not advertise one: an older
|
||||
/// host, or the standalone `punktfunk1-host` binary, which has no management API. Treat `0` as
|
||||
/// "unknown" and fall back to your own default (47990), never as a port to dial.
|
||||
///
|
||||
/// This exists so a client does NOT need mDNS to find the library. The port used to live only in
|
||||
/// the host's mDNS TXT, so a host that had moved it off 47990 — the supported way to coexist with
|
||||
/// a Sunshine fork, whose web UI owns that port — was reachable only where multicast worked. Read
|
||||
/// this after connect and prefer it over any cached or default value. Safe any time after connect.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `port` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_mgmt_port(
|
||||
c: *const PunktfunkConnection,
|
||||
port: *mut u16,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
// SAFETY: per the ABI contract - the out-param is OPTIONAL, so it is null-checked before
|
||||
// it is written; a non-null one is a caller-owned writable slot.
|
||||
unsafe {
|
||||
if !port.is_null() {
|
||||
*port = c.inner.mgmt_port();
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// The host capability bitfield the session's `Welcome` carried — a bitfield of
|
||||
/// `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / `PUNKTFUNK_HOST_CAP_CLIPBOARD` /
|
||||
/// `PUNKTFUNK_HOST_CAP_PEN`. A client tests `caps & PUNKTFUNK_HOST_CAP_CLIPBOARD` to decide
|
||||
|
||||
@@ -955,6 +955,68 @@ pub fn crossfade_drop(ring: &mut std::collections::VecDeque<f32>, drop: usize, f
|
||||
ring.drain(..drop);
|
||||
}
|
||||
|
||||
/// Where [`apply_gain`]'s soft knee begins, in linear amplitude (≈ −3.1 dBFS). Below this the
|
||||
/// gained signal is passed through EXACTLY — a boost whose peaks never reach the knee is plain
|
||||
/// multiplication, sample for sample, so the limiter costs nothing on material that does not need
|
||||
/// it.
|
||||
pub const SOFT_LIMIT_KNEE: f32 = 0.7;
|
||||
|
||||
/// Multiply `samples` by `gain`, bending anything that would overshoot full scale into a soft knee
|
||||
/// instead of slicing it flat.
|
||||
///
|
||||
/// **Why this is not a `clamp`.** The GameStream plane's gain was `(s * gain).clamp(-1.0, 1.0)`,
|
||||
/// which is a hard clip: the waveform's peaks are replaced by literal flat tops, and a flat top is
|
||||
/// a discontinuity in the first derivative. That radiates high-order harmonics — the harsher and
|
||||
/// more aliasing-prone the higher they go — which is why a field report of "+18 dB and everything
|
||||
/// warbles" is the expected outcome of that code and not a bug in anything downstream. Any operator
|
||||
/// who set `PUNKTFUNK_AUDIO_GAIN` much above ~1.5 was hearing this.
|
||||
///
|
||||
/// The curve here is `tanh`-based and chosen for three properties, in this order:
|
||||
///
|
||||
/// 1. **C¹-continuous at the knee.** The shaped branch's slope at `m == KNEE` is
|
||||
/// `(1-K) · sech²(0) · 1/(1-K) == 1`, exactly the slope of the linear branch it meets. There is
|
||||
/// no corner in the transfer curve, so the onset of limiting is not itself an audible event —
|
||||
/// the failure mode of a naïve piecewise limiter, which trades one discontinuity for another.
|
||||
/// 2. **Bounded by construction.** `tanh` is asymptotic to 1, so the output approaches but never
|
||||
/// exceeds full scale for any finite input, and `±inf` maps to `±1.0`. No sample can leave here
|
||||
/// out of range, which is what the encoder downstream assumes.
|
||||
/// 3. **Odd-symmetric.** `f(-x) == -f(x)`, so the distortion it does introduce is odd-harmonic and
|
||||
/// adds no DC offset — the benign, "saturating" flavour rather than the rectifying one.
|
||||
///
|
||||
/// Callers gate on `gain != 1.0`, so the default path is untouched and the wire stays byte-for-byte
|
||||
/// identical to a build without this. Note this is a WAVESHAPER, not a lookahead limiter: it is
|
||||
/// memoryless and therefore costs zero latency, which is the trade that makes it acceptable in the
|
||||
/// realtime encode path. It raises headroom; it does not raise *loudness* the way a compressor
|
||||
/// with a real time constant would, and it should not be sold as one.
|
||||
pub fn apply_gain(samples: &mut [f32], gain: f32) {
|
||||
// Unity is a no-op, not "multiply by one and shape": the shaper is only correct to apply to a
|
||||
// signal somebody asked to boost. Without this, calling at unity would bend every peak above
|
||||
// the knee — a silent quality change for anyone who forgot to gate the call, and the reason
|
||||
// the callers' `gain != 1.0` guards are a convenience rather than a load-bearing contract.
|
||||
if gain == 1.0 {
|
||||
return;
|
||||
}
|
||||
for s in samples {
|
||||
*s = soft_limit(*s * gain);
|
||||
}
|
||||
}
|
||||
|
||||
/// The waveshaper behind [`apply_gain`]: identity below [`SOFT_LIMIT_KNEE`], asymptotic to ±1.0
|
||||
/// above it. Exposed so the clients can mirror the curve if they ever grow a gain of their own.
|
||||
pub fn soft_limit(x: f32) -> f32 {
|
||||
let m = x.abs();
|
||||
if m <= SOFT_LIMIT_KNEE {
|
||||
return x;
|
||||
}
|
||||
let head = 1.0 - SOFT_LIMIT_KNEE;
|
||||
let shaped = SOFT_LIMIT_KNEE + head * ((m - SOFT_LIMIT_KNEE) / head).tanh();
|
||||
if x < 0.0 {
|
||||
-shaped
|
||||
} else {
|
||||
shaped
|
||||
}
|
||||
}
|
||||
|
||||
// ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
|
||||
|
||||
/// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout.
|
||||
@@ -2432,4 +2494,77 @@ mod tests {
|
||||
assert!(s.audible_tail <= 4, "{s:?}");
|
||||
assert!(s.audible <= 12, "{s:?}");
|
||||
}
|
||||
|
||||
/// Unity must be bit-exact. The callers gate on `gain != 1.0` anyway, but if this ever stopped
|
||||
/// holding, every default session's wire would shift and the "byte-for-byte identical" claim
|
||||
/// the tier machinery rests on would quietly become false.
|
||||
#[test]
|
||||
fn unity_gain_is_bit_exact() {
|
||||
let src: Vec<f32> = (0..512).map(|i| (i as f32 / 512.0) * 2.0 - 1.0).collect();
|
||||
let mut got = src.clone();
|
||||
apply_gain(&mut got, 1.0);
|
||||
assert_eq!(got, src, "unity gain must not touch a single sample");
|
||||
}
|
||||
|
||||
/// Below the knee the limiter is not in circuit at all: a boost whose peaks stay under
|
||||
/// `SOFT_LIMIT_KNEE` must be plain multiplication, or quiet material pays for a limiter it
|
||||
/// never needed.
|
||||
#[test]
|
||||
fn below_the_knee_is_plain_multiplication() {
|
||||
let mut got = vec![0.0, 0.1, -0.2, 0.34, -0.05];
|
||||
apply_gain(&mut got, 2.0);
|
||||
for (i, (g, s)) in got.iter().zip([0.0f32, 0.1, -0.2, 0.34, -0.05]).enumerate() {
|
||||
assert_eq!(*g, s * 2.0, "sample {i} must be untouched below the knee");
|
||||
}
|
||||
}
|
||||
|
||||
/// The property the hard `clamp` violated and this exists to restore: no input, however
|
||||
/// absurdly gained, may leave the shaper out of range — and non-finite input must not escape
|
||||
/// as something the encoder would choke on.
|
||||
#[test]
|
||||
fn nothing_escapes_full_scale() {
|
||||
for gain in [1.5f32, 4.0, 8.0, 64.0, 1000.0] {
|
||||
let mut got: Vec<f32> = (0..401).map(|i| (i as f32 - 200.0) / 200.0).collect();
|
||||
apply_gain(&mut got, gain);
|
||||
for s in &got {
|
||||
assert!(s.abs() <= 1.0, "gain {gain} produced {s}");
|
||||
}
|
||||
}
|
||||
assert_eq!(soft_limit(f32::INFINITY), 1.0);
|
||||
assert_eq!(soft_limit(f32::NEG_INFINITY), -1.0);
|
||||
}
|
||||
|
||||
/// Monotonic and odd-symmetric. Monotonicity is what keeps the shaper a limiter rather than a
|
||||
/// fold-back distortion; odd symmetry is what keeps its harmonics benign and its DC at zero.
|
||||
#[test]
|
||||
fn the_curve_is_monotonic_and_odd() {
|
||||
let mut prev = f32::NEG_INFINITY;
|
||||
for i in 0..=4000 {
|
||||
let x = (i as f32 - 2000.0) / 500.0; // -4.0 ..= 4.0
|
||||
let y = soft_limit(x);
|
||||
assert!(y >= prev, "not monotonic at {x}: {y} < {prev}");
|
||||
prev = y;
|
||||
assert!(
|
||||
(soft_limit(-x) + y).abs() < 1e-6,
|
||||
"not odd-symmetric at {x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The knee must not itself be an audible event. Both branches meet at the same value AND the
|
||||
/// same slope, so the transfer curve has no corner — a piecewise limiter that gets this wrong
|
||||
/// just swaps the clip's discontinuity for a softer one.
|
||||
#[test]
|
||||
fn the_knee_has_no_corner() {
|
||||
let k = SOFT_LIMIT_KNEE;
|
||||
assert!((soft_limit(k) - k).abs() < 1e-6, "value jumps at the knee");
|
||||
let h = 1e-4;
|
||||
let below = (soft_limit(k) - soft_limit(k - h)) / h;
|
||||
let above = (soft_limit(k + h) - soft_limit(k)) / h;
|
||||
assert!((below - 1.0).abs() < 1e-2, "linear side slope {below}");
|
||||
assert!(
|
||||
(above - below).abs() < 1e-2,
|
||||
"slope jumps at the knee: {below} -> {above}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,4 +73,8 @@ pub(crate) struct Negotiated {
|
||||
/// [`crate::quic::HOST_CAP_GAMEPAD_STATE`], [`crate::quic::HOST_CAP_CLIPBOARD`]. Exposed to the
|
||||
/// embedder via [`NativeClient::host_caps`] so a native client greys out unsupported toggles.
|
||||
pub(crate) host_caps: u8,
|
||||
/// The host's management-API port ([`crate::quic::Welcome::mgmt_port`]), `0` when it did not
|
||||
/// advertise one. Surfaced to the embedder via [`crate::NativeClient::mgmt_port`] so a client
|
||||
/// can reach the game library without ever having seen an mDNS advert.
|
||||
pub(crate) mgmt_port: u16,
|
||||
}
|
||||
|
||||
@@ -268,6 +268,9 @@ pub struct NativeClient {
|
||||
/// The host capability bitfield ([`crate::quic::Welcome::host_caps`]) — see
|
||||
/// [`NativeClient::host_caps`].
|
||||
pub host_caps: u8,
|
||||
/// The host's management-API port ([`crate::quic::Welcome::mgmt_port`]), or `0` when the host
|
||||
/// did not advertise one — see [`NativeClient::mgmt_port`].
|
||||
pub mgmt_port: u16,
|
||||
/// Speed-test accumulator, shared with the data-plane pump + control task.
|
||||
probe: Arc<Mutex<ProbeState>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
@@ -723,6 +726,7 @@ impl NativeClient {
|
||||
next_xfer_id: AtomicU32::new(1),
|
||||
pen_seq: AtomicU16::new(0),
|
||||
host_caps: negotiated.host_caps,
|
||||
mgmt_port: negotiated.mgmt_port,
|
||||
probe,
|
||||
shutdown,
|
||||
end_reason,
|
||||
@@ -1378,6 +1382,18 @@ impl NativeClient {
|
||||
self.host_caps
|
||||
}
|
||||
|
||||
/// The host's management-API port, from this session's [`crate::quic::Welcome`] — where its
|
||||
/// game library is served. `0` when the host did not advertise one (an older host, or the
|
||||
/// standalone `punktfunk1-host` binary, which has no management API); the caller then keeps
|
||||
/// its own default.
|
||||
///
|
||||
/// This is the mDNS-free answer to "where is the library": it arrives over the connection the
|
||||
/// client has already authenticated, so a host reached by IP over a VPN — or on any network
|
||||
/// where multicast never worked — no longer has to be assumed to be on 47990.
|
||||
pub fn mgmt_port(&self) -> u16 {
|
||||
self.mgmt_port
|
||||
}
|
||||
|
||||
/// Enable or disable the shared clipboard for this session (`design/clipboard-and-file-transfer.md`
|
||||
/// §3.1). Opt-in: nothing is announced or served until this crosses with `enabled = true`.
|
||||
/// `flags` carries [`crate::quic::CLIP_FLAG_FILES`]. Non-blocking; the host replies with a
|
||||
|
||||
@@ -255,6 +255,7 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
codec: welcome.codec,
|
||||
shard_payload: welcome.shard_payload,
|
||||
host_caps: welcome.host_caps,
|
||||
mgmt_port: welcome.mgmt_port,
|
||||
},
|
||||
welcome.host_caps,
|
||||
))
|
||||
|
||||
@@ -176,7 +176,16 @@ pub use stats::Stats;
|
||||
/// is unchanged (it simply keeps the double-arm race the pair exists to close). Additive and
|
||||
/// client-local: nothing new goes on the wire — the width is computed from frame indices the client
|
||||
/// already receives — so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 19;
|
||||
/// v20: `punktfunk_connection_mgmt_port` — reads the host's management-API port out of the
|
||||
/// session's `Welcome`, so a client can find the game library WITHOUT mDNS. The port previously
|
||||
/// existed only in the host's mDNS TXT, which made a host that had moved it off 47990 (the
|
||||
/// supported way to share a machine with a Sunshine fork, whose web UI owns that port) reachable
|
||||
/// only where multicast worked — over a VPN, a routed subnet, or for a host added by IP, the
|
||||
/// library silently fell back to a port nothing was listening on. A NEW symbol, not a widened one:
|
||||
/// every existing function keeps its signature and behaviour, and an embedder that never calls it
|
||||
/// is unchanged. The `Welcome` grew a trailing field, which older peers skip in both directions
|
||||
/// (see `Welcome::encode`), so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 20;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -340,6 +340,7 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
|
||||
mgmt_port: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
};
|
||||
|
||||
@@ -211,6 +211,22 @@ pub struct Welcome {
|
||||
/// advertised, so an unknown id reaching us is a bug, and falling back would yield an
|
||||
/// undecryptable session with a confusing failure signature.
|
||||
pub cipher: u8,
|
||||
/// The host's management-API port — where its game library is served, distinct from every
|
||||
/// other port here (`udp_port` is the data plane; the control plane is the QUIC port the
|
||||
/// client already dialed). `0` = not advertised (an older host), and the client falls back to
|
||||
/// the compiled-in 47990.
|
||||
///
|
||||
/// **Why this is on the wire at all:** the port was previously discoverable ONLY from the
|
||||
/// mDNS `mgmt` TXT. A host that moved it off 47990 — the supported way to share a machine with
|
||||
/// a Sunshine fork, whose web UI owns that port — therefore had a working library only where
|
||||
/// multicast worked. Carrying it in the `Welcome` means the client learns it over the
|
||||
/// connection it has already authenticated, so a VPN-only, routed-subnet or manually-added
|
||||
/// host needs no discovery at all.
|
||||
///
|
||||
/// Appended AFTER the cipher block (offset 69, or 101 when a ChaCha key precedes it) rather
|
||||
/// than at the next free fixed offset, and emitting it forces the `cipher` placeholder — see
|
||||
/// the note in [`Welcome::encode`]. `0` when an older host omitted it.
|
||||
pub mgmt_port: u16,
|
||||
/// The 256-bit ChaCha20-Poly1305 session key (RFC 8439 requires the full 32 bytes; wire
|
||||
/// cost is once per handshake) — present iff `cipher == 1`, at offsets 69..101. The legacy
|
||||
/// 16-byte `key` keeps its offset and stays independently random, so nothing downstream
|
||||
@@ -473,11 +489,24 @@ impl Welcome {
|
||||
self.key_chacha.is_some(),
|
||||
"key_chacha present iff cipher == 1"
|
||||
);
|
||||
if self.cipher != CIPHER_AES_128_GCM {
|
||||
//
|
||||
// ⚠ `mgmt_port` follows the cipher block, so emitting it FORCES the cipher byte even for
|
||||
// an AES session — the placeholder discipline `Hello::encode` already uses for
|
||||
// `audio_channels`/`preferred_codec`. Without that, an AES Welcome carrying a mgmt port
|
||||
// would put the port's low byte at offset 68, exactly where every 0.28.x client reads
|
||||
// `cipher` — and that decode is deliberately fail-closed on an unknown id, so the whole
|
||||
// handshake would break against currently-shipped clients. An explicit `cipher = 0` is
|
||||
// harmless by comparison: a current client reads AES (correct), and a pre-cipher client
|
||||
// stops before 68 regardless.
|
||||
let mgmt_present = self.mgmt_port != 0;
|
||||
if self.cipher != CIPHER_AES_128_GCM || mgmt_present {
|
||||
b.push(self.cipher);
|
||||
if let Some(k) = &self.key_chacha {
|
||||
b.extend_from_slice(k);
|
||||
}
|
||||
if mgmt_present {
|
||||
b.extend_from_slice(&self.mgmt_port.to_le_bytes());
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -488,9 +517,12 @@ impl Welcome {
|
||||
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
|
||||
// bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63]
|
||||
// chroma_format[64] audio_channels[65] codec[66] host_caps[67] cipher[68]
|
||||
// key_chacha[69..101] (everything from compositor on is an optional trailing byte; an
|
||||
// older host stops earlier; cipher/key_chacha are present only when ChaCha was
|
||||
// negotiated).
|
||||
// key_chacha[69..101] mgmt_port[69..71 | 101..103] (everything from compositor on is an
|
||||
// optional trailing byte; an older host stops earlier; cipher/key_chacha are present only
|
||||
// when ChaCha was negotiated). `mgmt_port` is the one field whose offset is NOT fixed: it
|
||||
// follows the cipher block, so it starts at 69 for an AES session and 101 when a 32-byte
|
||||
// ChaCha key precedes it. Emitting it forces the cipher byte (see `encode`), so "cipher
|
||||
// absent" and "mgmt_port present" can never both hold.
|
||||
if b.len() < 53 || &b[0..4] != MAGIC {
|
||||
return Err(PunktfunkError::InvalidArg("bad Welcome"));
|
||||
}
|
||||
@@ -518,6 +550,18 @@ impl Welcome {
|
||||
}
|
||||
_ => return Err(PunktfunkError::InvalidArg("bad Welcome")),
|
||||
};
|
||||
// The mgmt port sits after the cipher block, so its offset depends on whether a ChaCha key
|
||||
// preceded it. Absent (an older host, or one that did not advertise) → `0` = unknown, and
|
||||
// the client falls back to the compiled-in default.
|
||||
let mgmt_off = if cipher == CIPHER_CHACHA20_POLY1305 {
|
||||
101
|
||||
} else {
|
||||
69
|
||||
};
|
||||
let mgmt_port = b
|
||||
.get(mgmt_off..mgmt_off + 2)
|
||||
.map(|s| u16::from_le_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0);
|
||||
Ok(Welcome {
|
||||
abi_version: u32at(4),
|
||||
udp_port: u16at(8),
|
||||
@@ -585,6 +629,7 @@ impl Welcome {
|
||||
// Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state
|
||||
// snapshots; the client keeps sending legacy per-transition events).
|
||||
host_caps: b.get(67).copied().unwrap_or(0),
|
||||
mgmt_port,
|
||||
cipher,
|
||||
key_chacha,
|
||||
})
|
||||
@@ -671,6 +716,7 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||
mgmt_port: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
};
|
||||
@@ -736,6 +782,7 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: 0,
|
||||
mgmt_port: 0,
|
||||
cipher: CIPHER_AES_128_GCM,
|
||||
key_chacha: None,
|
||||
};
|
||||
@@ -779,6 +826,48 @@ mod tests {
|
||||
let cha_cfg = cha.session_config(Role::Client);
|
||||
assert_eq!(cha_cfg.key, SessionKey::ChaCha20Poly1305(k32));
|
||||
cha_cfg.validate().expect("ChaCha config validates");
|
||||
|
||||
// ── mgmt_port, the trailing field after the cipher block ──────────────────────────────
|
||||
//
|
||||
// ⚠ THE HAZARD THIS PINS: `mgmt_port` follows `cipher`, and `cipher` is emitted only when
|
||||
// non-default. Appending the port to an AES Welcome without forcing the cipher byte would
|
||||
// land the port's LOW BYTE at offset 68 — exactly where every shipped client reads
|
||||
// `cipher`, whose decode is fail-closed on an unknown id. 47991 is 0xBB57, so byte 68
|
||||
// would read 0x57 = 87, an unknown id, and EVERY 0.28.x client would fail the handshake
|
||||
// against a host that had merely moved its mgmt port. Assert the placeholder is there.
|
||||
let mgmt = Welcome {
|
||||
mgmt_port: 47991,
|
||||
..base
|
||||
};
|
||||
let menc = mgmt.encode();
|
||||
assert_eq!(menc.len(), 68 + 1 + 2, "cipher placeholder + LE u16 port");
|
||||
assert_eq!(
|
||||
menc[68], CIPHER_AES_128_GCM,
|
||||
"the cipher byte MUST be present (as 0) so a current client still reads AES here"
|
||||
);
|
||||
assert_eq!(&menc[69..71], &47991u16.to_le_bytes());
|
||||
assert_eq!(Welcome::decode(&menc).unwrap(), mgmt);
|
||||
|
||||
// With ChaCha the port sits after the 32-byte key instead, at 101..103.
|
||||
let both = Welcome {
|
||||
mgmt_port: 47991,
|
||||
cipher: CIPHER_CHACHA20_POLY1305,
|
||||
key_chacha: Some(k32),
|
||||
..base
|
||||
};
|
||||
let benc = both.encode();
|
||||
assert_eq!(benc.len(), 68 + 1 + 32 + 2);
|
||||
assert_eq!(&benc[101..103], &47991u16.to_le_bytes());
|
||||
assert_eq!(Welcome::decode(&benc).unwrap(), both);
|
||||
|
||||
// A host that advertises no mgmt port emits nothing extra — an AES Welcome stays exactly
|
||||
// 68 bytes, so this field costs the common case zero and cannot perturb an old client.
|
||||
assert_eq!(base.encode().len(), 68);
|
||||
// ...and an old host's Welcome decodes to 0 = unknown, never to a port we might dial.
|
||||
assert_eq!(Welcome::decode(&enc).unwrap().mgmt_port, 0);
|
||||
assert_eq!(Welcome::decode(&cenc).unwrap().mgmt_port, 0);
|
||||
// A truncated tail (one byte of the port) is not half a port: it reads as unknown.
|
||||
assert_eq!(Welcome::decode(&menc[..70]).unwrap().mgmt_port, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -873,6 +962,7 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_PYROWAVE,
|
||||
host_caps: 0,
|
||||
mgmt_port: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
}
|
||||
@@ -947,6 +1037,7 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_H264,
|
||||
host_caps: 0,
|
||||
mgmt_port: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
}
|
||||
@@ -1058,6 +1149,7 @@ mod tests {
|
||||
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||
mgmt_port: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,54 @@ pub const SAMPLE_RATE: u32 = 48_000;
|
||||
/// Stereo channel count — the default and the punktfunk/1 audio plane's fixed layout.
|
||||
pub const CHANNELS: usize = 2;
|
||||
|
||||
/// Highest boost `PUNKTFUNK_AUDIO_GAIN` will honour (+18 dB). Past this the soft knee is doing
|
||||
/// essentially all the work and the result is a squashed signal, not a louder one — so a runaway
|
||||
/// value (a stray `180` for `1.8`) is capped and said out loud rather than silently shipped.
|
||||
const MAX_CAPTURE_GAIN: f32 = 8.0;
|
||||
|
||||
/// The operator's capture gain, shared by BOTH audio planes (`PUNKTFUNK_AUDIO_GAIN`, default
|
||||
/// `1.0` = untouched).
|
||||
///
|
||||
/// **Why the host needs one at all.** WASAPI loopback is tapped UPSTREAM of the endpoint's master
|
||||
/// volume, so turning the host's speaker slider up does nothing whatsoever to the level a client
|
||||
/// receives. Before this, the native `punktfunk/1` plane had no gain of any kind, which left no
|
||||
/// host-side way to raise a quiet desktop mix — the GameStream plane's knob was the only one, and
|
||||
/// it applied to the wrong protocol.
|
||||
///
|
||||
/// Applied through [`punktfunk_core::audio::apply_gain`], whose soft knee replaces the hard
|
||||
/// `clamp(-1.0, 1.0)` this used to be. That clamp is why boosting was a trap: it flat-tops peaks,
|
||||
/// and flat tops are audible as harsh distortion long before the operator reaches the level they
|
||||
/// were chasing.
|
||||
///
|
||||
/// ⚠ This is headroom, not loudness. It cannot close a peak-to-loudness gap against
|
||||
/// already-limited broadcast content — that needs a real compressor with a time constant, which is
|
||||
/// deliberately NOT what this is.
|
||||
pub fn capture_gain() -> f32 {
|
||||
let raw: f32 = std::env::var("PUNKTFUNK_AUDIO_GAIN")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1.0);
|
||||
// A negative or non-finite gain is a typo, never an intent: it would invert or poison every
|
||||
// sample. Fall back to unity rather than shipping it.
|
||||
if !raw.is_finite() || raw <= 0.0 {
|
||||
if std::env::var("PUNKTFUNK_AUDIO_GAIN").is_ok() {
|
||||
tracing::warn!(
|
||||
"PUNKTFUNK_AUDIO_GAIN must be a positive number (1.0 = unchanged) — ignoring"
|
||||
);
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
if raw > MAX_CAPTURE_GAIN {
|
||||
tracing::warn!(
|
||||
requested = raw,
|
||||
capped = MAX_CAPTURE_GAIN,
|
||||
"PUNKTFUNK_AUDIO_GAIN is above the +18 dB ceiling — capping"
|
||||
);
|
||||
return MAX_CAPTURE_GAIN;
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
/// Produces interleaved `f32` PCM at [`SAMPLE_RATE`] in the channel count it was opened
|
||||
/// with. Lives on its own thread; never blocks the capture loop (drops if the consumer
|
||||
/// falls behind).
|
||||
|
||||
@@ -682,6 +682,10 @@ fn pw_thread(
|
||||
use pw::{properties::properties, spa};
|
||||
use spa::param::audio::{AudioFormat, AudioInfoRaw};
|
||||
use spa::pod::Pod;
|
||||
// The stream's `process` callbacks run ON this mainloop thread (we never hand PipeWire a
|
||||
// separate data loop), so PipeWire's own client `module-rt` boost of its data loops does not
|
||||
// cover it — the ~2.7 ms capture quantum lives or dies by this thread's scheduling.
|
||||
pf_frame::thread_qos::boost_thread_priority(true);
|
||||
|
||||
// Setup errors funnel through the ready handshake (mirrors mic_pw_thread's IIFE).
|
||||
let result = (|| -> Result<()> {
|
||||
|
||||
@@ -397,11 +397,9 @@ fn audio_body(
|
||||
// stays small.
|
||||
let start = Instant::now();
|
||||
let mut frame_no: u64 = 0;
|
||||
// Optional linear gain for quiet capture sources (PUNKTFUNK_AUDIO_GAIN, default 1.0).
|
||||
let gain: f32 = std::env::var("PUNKTFUNK_AUDIO_GAIN")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1.0);
|
||||
// Optional gain for quiet capture sources (PUNKTFUNK_AUDIO_GAIN, default 1.0). Soft-limited
|
||||
// rather than clamped — see `crate::audio::capture_gain`.
|
||||
let gain = crate::audio::capture_gain();
|
||||
tracing::info!(
|
||||
channels = layout.channels,
|
||||
streams = layout.streams,
|
||||
@@ -418,9 +416,7 @@ fn audio_body(
|
||||
while acc.len() >= frame_len {
|
||||
let mut frame: Vec<f32> = acc.drain(..frame_len).collect();
|
||||
if gain != 1.0 {
|
||||
for s in &mut frame {
|
||||
*s = (*s * gain).clamp(-1.0, 1.0);
|
||||
}
|
||||
punktfunk_core::audio::apply_gain(&mut frame, gain);
|
||||
}
|
||||
let n = enc.encode_float(&frame, &mut out)?;
|
||||
// AES-128-CBC the Opus payload (RTP header stays plaintext). Per-packet IV =
|
||||
|
||||
@@ -761,6 +761,9 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
|
||||
// paired clients can browse the game library out of the box (the bearer admin surface stays
|
||||
// loopback-gated in `mgmt::require_auth` regardless of the bind).
|
||||
let mut mgmt_bind_explicit = false;
|
||||
// Same question for the native port: an explicit `--native-port` out-ranks
|
||||
// `PUNKTFUNK_NATIVE_PORT` from host.env, resolved after the loop.
|
||||
let mut native_port_explicit = false;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let arg = args[i].as_str();
|
||||
@@ -793,7 +796,8 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
|
||||
"--native-port" => {
|
||||
native_port = next()?
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?
|
||||
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?;
|
||||
native_port_explicit = true;
|
||||
}
|
||||
"--data-port" => {
|
||||
data_port = Some(
|
||||
@@ -844,9 +848,34 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
|
||||
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
|
||||
// is confined to loopback peers in `mgmt::require_auth`, so binding wide adds no admin exposure.
|
||||
// An operator who pinned `--mgmt-bind` (e.g. `127.0.0.1:47990` to restore loopback-only) keeps it.
|
||||
//
|
||||
// Same two-source shape as `--gamestream` / `PUNKTFUNK_GAMESTREAM` below, and for the same
|
||||
// reason: the packaged units ship a fixed ExecStart, so `host.env` is the only route a package
|
||||
// user has to move this that an upgrade won't overwrite. CLI wins — it is the more explicit of
|
||||
// the two and the one a support instruction reaches for.
|
||||
if !mgmt_bind_explicit {
|
||||
opts.bind = std::net::SocketAddr::from(([0, 0, 0, 0], mgmt::DEFAULT_PORT));
|
||||
opts.bind = match pf_host_config::config().mgmt_bind.as_deref() {
|
||||
Some(s) => s
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad PUNKTFUNK_MGMT_BIND '{s}' (want IP:PORT)"))?,
|
||||
None => std::net::SocketAddr::from(([0, 0, 0, 0], mgmt::DEFAULT_PORT)),
|
||||
};
|
||||
}
|
||||
// Same two-source resolution as the mgmt bind above. A bad value is FATAL rather than ignored:
|
||||
// silently serving on 9777 while host.env says otherwise is the failure that reads as "I moved
|
||||
// the port and the client still can't reach me".
|
||||
if !native_port_explicit {
|
||||
if let Some(s) = pf_host_config::config().native_port.as_deref() {
|
||||
native_port = s
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad PUNKTFUNK_NATIVE_PORT '{s}' (want a port)"))?;
|
||||
}
|
||||
}
|
||||
// Publish the resolved port for the console, right here rather than inside `serve`: the
|
||||
// console's unit gates on `mgmt-token` (persisted a few lines above), so writing the endpoint
|
||||
// in the same function keeps the two files effectively simultaneous. A console that still wins
|
||||
// that race falls back to 47990 and its `Restart=always` retry picks the file up.
|
||||
mgmt::publish_endpoint(opts.bind);
|
||||
let native = native::NativeServe {
|
||||
port: native_port,
|
||||
require_pairing: !open,
|
||||
@@ -999,10 +1028,13 @@ USAGE:
|
||||
punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool)
|
||||
|
||||
SERVE OPTIONS:
|
||||
--mgmt-bind <IP:PORT> management API address (default: 0.0.0.0:47990 — paired clients
|
||||
--mgmt-bind <IP:PORT> management API address (or PUNKTFUNK_MGMT_BIND in host.env, which
|
||||
this flag overrides). Default: 0.0.0.0:47990 — paired clients
|
||||
reach the read-only surface, incl. the game library, over mTLS;
|
||||
the bearer admin API stays loopback-only. Pin 127.0.0.1:47990 to
|
||||
bind loopback only)
|
||||
bind loopback only. Move the PORT (e.g. 0.0.0.0:47991) to share a
|
||||
machine with Sunshine/Apollo/Vibeshine, whose web UI owns 47990 —
|
||||
clients follow via mDNS and the console via mgmt-endpoint
|
||||
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN); the
|
||||
admin endpoints it guards are honored only from a loopback peer
|
||||
(the co-located web console), never over the LAN
|
||||
@@ -1013,7 +1045,9 @@ SERVE OPTIONS:
|
||||
Also PUNKTFUNK_GAMESTREAM=1 in host.env (how a packaged install
|
||||
opts in — the shipped units run native-only)
|
||||
--native no-op (the native punktfunk/1 plane always runs in `serve` now)
|
||||
--native-port <PORT> native QUIC port (default 9777)
|
||||
--native-port <PORT> native QUIC port (or PUNKTFUNK_NATIVE_PORT in host.env, which
|
||||
this flag overrides). Default 9777. Clients follow via mDNS, and
|
||||
a manually-added host keeps whatever port it was added with
|
||||
--data-port <PORT> pin the per-session video data plane to this fixed UDP port and
|
||||
stream direct (no hole-punch) — open exactly this port in a host
|
||||
firewall to avoid the ~2.5 s punch-timeout. Default (unset) or
|
||||
|
||||
@@ -57,8 +57,98 @@ pub(crate) use plugins::ui_credential;
|
||||
|
||||
/// Default management port — adjacent to the GameStream block (47984…48010), and the same
|
||||
/// number Sunshine users already associate with "the config UI".
|
||||
///
|
||||
/// ⚠ That last part is also why it is the ONE port a Sunshine fork and a GameStream-off Punktfunk
|
||||
/// still collide on (47990 is their web UI). Moving it is supported — see [`publish_endpoint`] and
|
||||
/// `PUNKTFUNK_MGMT_BIND` — and every consumer derives the real port rather than assuming this one.
|
||||
pub const DEFAULT_PORT: u16 = 47990;
|
||||
|
||||
/// The file [`publish_endpoint`] writes the effective mgmt URL to, next to `mgmt-token`.
|
||||
const ENDPOINT_FILE: &str = "mgmt-endpoint";
|
||||
|
||||
/// The port the management API actually bound, recorded once by [`publish_endpoint`].
|
||||
static EFFECTIVE_PORT: std::sync::OnceLock<u16> = std::sync::OnceLock::new();
|
||||
|
||||
/// The mgmt port this process is serving on, or `0` when there is no management API at all — the
|
||||
/// standalone `punktfunk1-host` binary, which never calls [`publish_endpoint`].
|
||||
///
|
||||
/// The native handshake reads this to put the port in every session's `Welcome`, so a client learns
|
||||
/// it over the connection it has already authenticated instead of needing the mDNS advert. Resolved
|
||||
/// ONCE, from the same value the endpoint file carries, so the wire, the file and the advert cannot
|
||||
/// disagree — the whole point of this being a lookup rather than a fourth place to compute a port.
|
||||
///
|
||||
/// ⚠ `0` matters: advertising 47990 from a host with no mgmt API would point clients at a port
|
||||
/// nothing is listening on, which is strictly worse than saying nothing and letting them fall back.
|
||||
pub fn effective_port() -> u16 {
|
||||
EFFECTIVE_PORT.get().copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Publish the mgmt API's *effective* loopback URL to `<config-dir>/mgmt-endpoint`, in the same
|
||||
/// `KEY=VALUE` form as `mgmt-token` so the bundled console can source it directly as a systemd
|
||||
/// `EnvironmentFile` (and `windows::service::spawn_web` can read it with `read_env_file_value`).
|
||||
///
|
||||
/// **Why this exists:** the port used to be a literal `47990` in five places — this constant, the
|
||||
/// Windows service's console launch, `scripts/punktfunk-web.service`, the NixOS module, and the
|
||||
/// console's own default. Moving the listener therefore silently broke the console, because nothing
|
||||
/// downstream had any way to learn the new port. Now the host is the single source of truth and
|
||||
/// publishes what it actually bound; consumers keep a 47990 fallback purely so an OLD host with a
|
||||
/// NEW console still works.
|
||||
///
|
||||
/// Always loopback, never `bind`'s own address: the console proxies over loopback by design (see
|
||||
/// the module docs — the bearer-token admin surface is confined to loopback peers), so a wide
|
||||
/// `0.0.0.0` bind must not be echoed here as a LAN URL.
|
||||
///
|
||||
/// Best-effort: a console that cannot read this simply falls back to 47990, which is strictly what
|
||||
/// it did before, so a write failure must not stop the host from serving.
|
||||
pub fn publish_endpoint(bind: SocketAddr) {
|
||||
// Record it for [`effective_port`] BEFORE the write: the native handshake reads that to put the
|
||||
// port in every Welcome, and a failed file write must not also cost us the in-band answer.
|
||||
let _ = EFFECTIVE_PORT.set(bind.port());
|
||||
let dir = pf_paths::config_dir();
|
||||
if let Err(e) = pf_paths::create_private_dir(&dir) {
|
||||
tracing::warn!(error = %e, "could not create the config dir to publish the mgmt endpoint");
|
||||
return;
|
||||
}
|
||||
match write_endpoint(&dir, bind.port()) {
|
||||
Ok(path) => {
|
||||
tracing::debug!(path = %path.display(), port = bind.port(), "published mgmt endpoint")
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
dir = %dir.display(),
|
||||
error = %e,
|
||||
"could not publish the mgmt endpoint — a console on another port will fall back to 47990"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The IO half of [`publish_endpoint`], taking the directory so it is testable without touching
|
||||
/// `PUNKTFUNK_CONFIG_DIR` (which every other test in this process shares).
|
||||
///
|
||||
/// Deliberately NOT `pf_paths::write_secret_file`: this is not a secret — the same port is already
|
||||
/// in the mDNS TXT record — and locking it to SYSTEM/Administrators on Windows would keep a
|
||||
/// user-session console from reading the very thing it is published for. The 0700 config dir is the
|
||||
/// access control that matters.
|
||||
fn write_endpoint(dir: &std::path::Path, port: u16) -> std::io::Result<std::path::PathBuf> {
|
||||
let path = dir.join(ENDPOINT_FILE);
|
||||
// Write-then-rename rather than a plain truncating write: the console's systemd unit may source
|
||||
// this file at any moment, including while the host is restarting and rewriting it. A torn read
|
||||
// would hand systemd an EMPTY `PUNKTFUNK_MGMT_URL`, which is worse than a missing file — the
|
||||
// built-in default only applies to an UNSET variable, not a set-but-blank one. `rename` over an
|
||||
// existing path is atomic on Unix and replaces on Windows, so a reader sees old or new, never
|
||||
// half. (The consumers treat blank as unset too — this is the belt to that pair of braces.)
|
||||
let tmp = dir.join(format!("{ENDPOINT_FILE}.tmp"));
|
||||
std::fs::write(&tmp, endpoint_line(port))?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The published line. Must stay valid as BOTH a systemd `EnvironmentFile` entry and input to
|
||||
/// `windows::service::read_env_file_value` — i.e. exactly one `KEY=VALUE` line, no quoting, and no
|
||||
/// `=` inside the value (a URL has none).
|
||||
fn endpoint_line(port: u16) -> String {
|
||||
format!("PUNKTFUNK_MGMT_URL=https://127.0.0.1:{port}\n")
|
||||
}
|
||||
|
||||
/// Management server options (CLI: `serve --mgmt-bind ADDR --mgmt-token TOKEN`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Options {
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
//! Handler + auth tests for the management API, exercised through `app()`. Split out of the
|
||||
//! `mgmt` facade (plan §W5).
|
||||
|
||||
/// The published endpoint line has to satisfy TWO parsers written independently: systemd
|
||||
/// (`EnvironmentFile=`) and `windows::service::read_env_file_value`. This pins the shape both need
|
||||
/// — one `KEY=VALUE` line — and re-implements the Windows reader's split, so a change to the format
|
||||
/// fails here rather than silently pointing the console at the wrong port on the one platform CI
|
||||
/// cannot exercise.
|
||||
#[test]
|
||||
fn published_endpoint_line_parses_the_way_both_consumers_read_it() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-mgmt-endpoint-{}-{:p}",
|
||||
std::process::id(),
|
||||
&0u8 as *const u8
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = super::write_endpoint(&dir, 47991).unwrap();
|
||||
assert_eq!(path.file_name().unwrap(), super::ENDPOINT_FILE);
|
||||
|
||||
let contents = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(contents, "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47991\n");
|
||||
|
||||
// `read_env_file_value`'s exact logic: first non-blank line, split once on '=', take the value.
|
||||
let line = contents
|
||||
.lines()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.unwrap()
|
||||
.trim();
|
||||
let value = line.split_once('=').map_or(line, |(_, v)| v).trim();
|
||||
assert_eq!(value, "https://127.0.0.1:47991");
|
||||
// The value must survive that split intact — i.e. carry no '=' of its own.
|
||||
assert!(!value.contains('='));
|
||||
// Loopback whatever the listener binds: the console proxies over loopback by design, so a wide
|
||||
// 0.0.0.0 bind must never be echoed here as a LAN URL.
|
||||
assert!(value.starts_with("https://127.0.0.1:"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use crate::encode::Codec;
|
||||
#[cfg(feature = "gamestream")]
|
||||
|
||||
@@ -100,6 +100,11 @@ pub(super) fn audio_thread(
|
||||
/// pacing exists to prevent — so past this point the debt is forgiven, not repaid.
|
||||
const PACE_REANCHOR: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
let want = punktfunk_core::audio::normalize_channels(channels);
|
||||
// Same boost the video capture/encode loop takes, and this thread needs it MORE: it paces
|
||||
// 5 ms datagrams, so a scheduling stall here is directly audible where a late video frame
|
||||
// is one presentation slip. The 2026-08-14 field log's stutter was exactly this thread
|
||||
// descheduled by fresh-game-launch shader storms — it carried no priority at all.
|
||||
pf_frame::thread_qos::boost_thread_priority(true);
|
||||
// Tier and redundancy are ONE decision, budgeted against the session's video bitrate — see
|
||||
// `handshake::audio_budget`. An unparseable `audio.quality` was already warned about there
|
||||
// and fell back to the default, so nothing here can silently downgrade someone's audio.
|
||||
@@ -142,6 +147,20 @@ pub(super) fn audio_thread(
|
||||
};
|
||||
|
||||
let frame_len = SAMPLES_PER_FRAME * want as usize;
|
||||
// Operator capture gain, soft-limited (`PUNKTFUNK_AUDIO_GAIN`, default 1.0 = untouched). This
|
||||
// plane had NO gain at all until now, so `PUNKTFUNK_AUDIO_GAIN` silently did nothing on
|
||||
// punktfunk/1 while working on GameStream — and since WASAPI loopback taps upstream of the
|
||||
// endpoint's master volume, there was no other host-side way to lift a quiet desktop mix.
|
||||
// Read once per session rather than per frame: this is an operator setting, not a live control.
|
||||
let gain = crate::audio::capture_gain();
|
||||
if gain != 1.0 {
|
||||
tracing::info!(
|
||||
gain,
|
||||
"audio: applying operator capture gain (soft-limited above \
|
||||
{}; headroom, not loudness)",
|
||||
punktfunk_core::audio::SOFT_LIMIT_KNEE
|
||||
);
|
||||
}
|
||||
let mut acc: Vec<f32> = Vec::with_capacity(frame_len * 4);
|
||||
// Sized for the largest surround frame (7.1 HQ ≈ 1.3 KB at 5 ms); ample for normal quality.
|
||||
let mut opus_buf = vec![0u8; 4096];
|
||||
@@ -253,7 +272,10 @@ pub(super) fn audio_thread(
|
||||
}
|
||||
pace_due = Some(pace_due.unwrap_or_else(std::time::Instant::now) + FRAME_INTERVAL);
|
||||
|
||||
let frame: Vec<f32> = acc.drain(..frame_len).collect();
|
||||
let mut frame: Vec<f32> = acc.drain(..frame_len).collect();
|
||||
if gain != 1.0 {
|
||||
punktfunk_core::audio::apply_gain(&mut frame, gain);
|
||||
}
|
||||
let pts_ns = next_pts_ns;
|
||||
next_pts_ns += FRAME_MS as u64 * 1_000_000;
|
||||
match enc.encode_float(&frame, &mut opus_buf) {
|
||||
|
||||
@@ -658,9 +658,14 @@ pub(super) async fn negotiate(
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Where this host serves its game library, so the client never has to have seen an mDNS
|
||||
// advert to find it. `0` on the standalone punktfunk1-host binary (no management API),
|
||||
// and the client then keeps its compiled-in default.
|
||||
mgmt_port: crate::mgmt::effective_port(),
|
||||
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
|
||||
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
|
||||
// pre-cipher wire form. The host's own data plane picks the cipher up via
|
||||
// pre-cipher wire form — unless a mgmt port rides along, which forces the cipher
|
||||
// placeholder (see `Welcome::encode`). The host's own data plane picks the cipher up via
|
||||
// `welcome.session_config` — no other host change.
|
||||
cipher: if chacha {
|
||||
punktfunk_core::quic::CIPHER_CHACHA20_POLY1305
|
||||
|
||||
@@ -472,6 +472,9 @@ fn pad_audio_thread<C: crate::audio::AudioCapturer>(
|
||||
open: impl Fn() -> anyhow::Result<C>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
// Above-normal like the session send thread — this plane is silence-gated and tiny, but when
|
||||
// a pad speaker/haptics stream IS live it runs the same ≤10 ms cadence as session audio.
|
||||
crate::native::boost_thread_priority(false);
|
||||
let mut lanes = match build_lanes(kinds) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
|
||||
@@ -1063,13 +1063,22 @@ fn spawn_web(cfg: &WebConfig, data: &Path, job: HANDLE) -> Result<Child> {
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| read_env_file_value(&data.join("web-password")));
|
||||
// The mgmt URL resolves env-over-file too, for the same reason the token does — except the file
|
||||
// here is written by `mgmt::publish_endpoint` on every `serve`, so a host moved off 47990 (a
|
||||
// Sunshine fork owns that port as its web UI) carries the console with it instead of leaving it
|
||||
// proxying to a port nothing is listening on. The literal stays only as the last-resort default.
|
||||
let mgmt_url = std::env::var("PUNKTFUNK_MGMT_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| read_env_file_value(&data.join("mgmt-endpoint")))
|
||||
.unwrap_or_else(|| "https://127.0.0.1:47990".into());
|
||||
|
||||
let mut overrides: Vec<(&str, String)> = vec never takes them at all. Leaving this on does
|
||||
mean **Ctrl+Alt+Shift+Q is your way out** of a captured stream, since Alt+Tab no longer is.
|
||||
|
||||
On macOS the chords in question are the **⌘** ones — ⌘Q above all, which reaches the host as Super+Q,
|
||||
one of the most-bound chords on a Linux desktop. On, ⌘Q, ⌘W, ⌘H and the rest go to the host instead
|
||||
of this app's menu bar while input is captured. Off, they act on the Mac as usual, which means ⌘Q
|
||||
quits Punktfunk mid-stream. **⌘⎋ always stays local whichever way the toggle is set** — it is what
|
||||
releases capture, as is ⌃⌥⇧Q, and ⌃⌘F keeps working on the window. A few chords never reach the host
|
||||
either way, because macOS claims them before any app can see them: ⌘Tab, ⌘Space, and the Mission
|
||||
Control keys.
|
||||
|
||||
On Linux this needs a compositor that supports keyboard-shortcuts-inhibit — KDE Plasma, GNOME and
|
||||
the wlroots compositors all do, and X11 sessions grab the keyboard directly. Under
|
||||
[gamescope](/docs/gamescope) there is nothing to inhibit: it hands the session everything already.
|
||||
|
||||
@@ -156,7 +156,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_AUDIO_QUALITY` | `low` · `standard` · `high` *(default `high`)* | Desktop-audio encode quality. `high` (stereo 256 kbps Opus, effectively transparent) costs about 1 % of a normal video bitrate, so there's rarely a reason to go lower. `standard` is exactly the pre-0.25 encoder (stereo 128 kbps) — handy for an A/B comparison; `low` is for genuinely constrained links (noticeably lossy on music, still fine for game audio and voice). A typo warns in the log and keeps `high` rather than silently downgrading. Host-side only — clients play whatever arrives, no client setting involved. |
|
||||
| `PUNKTFUNK_AUDIO_REDUNDANCY` | `1` · `0` *(default: automatic)* | Send audio packets redundantly so a lossy link doesn't crackle. Leave it unset: the host turns redundancy on by itself, only toward clients that support it and only while the link is actually losing packets. `1` forces it on for the whole session, `0` never sends it. |
|
||||
| `PUNKTFUNK_AUDIO_GAIN` | float (default `1.0`) | **(Moonlight/GameStream sessions only)** Linear gain applied to captured desktop audio — bump it for a quiet source. The native `punktfunk/1` path ignores it; adjust the source's own volume there instead. |
|
||||
| `PUNKTFUNK_AUDIO_GAIN` | float (default `1.0`) | Gain applied to captured desktop audio — bump it for a quiet source. Applies to **both** the native `punktfunk/1` and Moonlight/GameStream paths. Peaks are rounded off by a soft limiter rather than clipped, so a boost distorts gracefully instead of abruptly; values above `8.0` (+18 dB) are capped, and a non-positive value is ignored. Note this buys **headroom, not loudness** — it cannot make a desktop mix as loud as already-limited streaming-app audio, and pushing it hard to try will audibly squash the signal. On Windows this is the only host-side control that works at all: loopback capture is tapped upstream of the endpoint's master volume, so the speaker slider does not affect what a client receives. |
|
||||
| `PUNKTFUNK_MIC_DEVICE` | name substring | **(Windows)** Target mic-uplink device by friendly-name substring (first match wins). |
|
||||
| `PUNKTFUNK_MIC_LEGACY_BUFFER` | `1` | Restore the fixed pre-adaptive mic buffering (a ~48 ms prime and ~120 ms cap on Windows; a buffer scaled to the recording app's audio quantum on Linux) instead of the adaptive per-client jitter target. One-release escape hatch: if the microphone coming out of the host only sounds right *with* this set, that's a bug — please report it. |
|
||||
| `PUNKTFUNK_NO_MIC_INSTALL` | set | **(Windows)** Skip installing the virtual-mic driver (e.g. when the host runs as SYSTEM). |
|
||||
@@ -195,6 +195,7 @@ it — leave it or delete it, it makes no difference.
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_HOST_NAME` | free text, e.g. `Living Room` | The name this host shows up under in Moonlight and in the Punktfunk clients. Default: the machine's own hostname — so a box called `bazzite-htpc` can present itself as `Living Room` without renaming the machine. Takes effect on host restart. Spaces and accents are fine; `.` becomes `-` (a dot would split the name in client lists) and it's capped at 63 characters. The machine's real hostname is still what the host answers to on the network. |
|
||||
| `PUNKTFUNK_MDNS` | `1` · `0` *(default on)* | mDNS adverts (native + GameStream). `0` skips them (same as `--no-mdns`) — for networks/containers where multicast doesn't work; add the host by address in the client instead. |
|
||||
| `PUNKTFUNK_NATIVE_PORT` | port *(default: `9777`)* | The native punktfunk/1 (QUIC) control port clients connect on — same as `serve --native-port`, which overrides it. Clients discover the port over mDNS, and a host you added by hand keeps whatever port you added it with, so moving this needs no change on the client. A value that isn't a port is a startup error rather than a silent fall back to 9777. |
|
||||
| `PUNKTFUNK_DATA_PORT` | port | Pin the per-session video data plane to a fixed UDP port and stream direct (no hole-punch) — open exactly that port in the host firewall. Same as `serve --data-port`; see [Troubleshooting](/docs/troubleshooting). Default: random port + hole-punch. |
|
||||
| `PUNKTFUNK_IDLE_TIMEOUT_MS` | ms (default `8000`) | How long the host waits before declaring a client that vanished (cable pulled, Wi-Fi dropped) gone — which is when a kept virtual display starts its linger. Lower it (e.g. `3000`) to reclaim displays sooner; it's clamped to ≥1 s and the keep-alive scales with it, so a live session never false-disconnects. A deliberate quit is instant regardless. Same as `--idle-timeout-ms` on `punktfunk1-host`. |
|
||||
| `PUNKTFUNK_JUMBO` | `1` | Stream in **jumbo frames** — ~9000-byte packets instead of the standard ~1500-byte ones, so a high-bitrate session spends less CPU and per-packet overhead on a wired LAN. Off by default, and safe to turn on: see the note below the table. |
|
||||
@@ -217,6 +218,7 @@ it — leave it or delete it, it makes no difference.
|
||||
| `PUNKTFUNK_MGMT_TOKEN` | token | Bearer token for the management API. If unset it's auto-generated and persisted to `~/.config/punktfunk/mgmt-token` (the bundled web console sources it). Set only to pin a specific token. |
|
||||
| `PUNKTFUNK_UI_PASSWORD` | password | Web-console login password. Normally generated on first start and stored in `~/.config/punktfunk/web-password` — see [Forgot your Password?](/docs/forgot-password). |
|
||||
| `PUNKTFUNK_PLUGIN_TOKEN` | token | The scoped token the [plugin/scripting runner](/docs/plugins) uses — a narrower credential than `PUNKTFUNK_MGMT_TOKEN`, never full admin. Same precedence: if unset it's generated and persisted to `~/.config/punktfunk/plugin-token`. Set only to pin a specific token. |
|
||||
| `PUNKTFUNK_MGMT_BIND` | `IP:PORT` *(default: `0.0.0.0:47990`)* | Where the management API listens. The `--mgmt-bind` flag overrides it. Two reasons to set it: pin `127.0.0.1:47990` to keep the API off the LAN entirely (paired clients then can't browse your library), or **move the port to share the machine with Sunshine, Apollo or Vibeshine** — 47990 is their web UI as well as our management API, and it's the only port the two still share once GameStream compat is off. Everything downstream follows the port you pick: native clients learn it from discovery, and the web console reads it from `~/.config/punktfunk/mgmt-endpoint`, which the host writes on every start. See [another streaming host is installed](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed). |
|
||||
| `PUNKTFUNK_CONFIG_DIR` | path | Override the config directory (default `~/.config/punktfunk`) — pairing state, certs, apps.json, captures. |
|
||||
| `PUNKTFUNK_UI_PLUGIN_PORT` | port *(default: console port + 1)* | The separate port [plugin](/docs/plugins) UIs are served from. They get their own origin on purpose — a plugin page can never act as *you* on the console. If the console log says this port couldn't be opened (plugin UIs then stay disabled rather than sharing the console's origin), point it at a free port and restart. |
|
||||
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, separated like `PATH` (`;` on Windows, `:` on Linux/macOS) | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots: your home directory on Linux/macOS, and on Windows the users base (`C:\Users`) plus your Steam install, wherever it is. Set it when box art lives somewhere else again — a second drive, a network mount, or a launcher installed outside all of those. Setting it **replaces** the defaults, so list every root you need. The host log's "dropped local art the proxy may not serve" line is this knob's cue: those entries still appear in your library, but their covers stay blank until the root is allowed. |
|
||||
|
||||
@@ -68,6 +68,10 @@ to one readable line.
|
||||
**⌃⌥⇧Q / M / D / S** — but not the microphone mute. **⌘⎋** also toggles capture,
|
||||
**⌃⌘F** toggles fullscreen, and **⌃⌥⇧C** starts or stops [clipboard sharing](/docs/clipboard). The
|
||||
**Stream** menu lists them all except the mouse-mode combo, which works but has no menu item.
|
||||
Every *other* ⌘ chord goes to the host while input is captured — ⌘Q reaches the host's compositor
|
||||
rather than quitting the app — unless you turn **Capture system shortcuts** off in
|
||||
[client settings](/docs/client-settings#input). ⌘⎋ and ⌃⌘F are held back either way, so there is
|
||||
always a way out.
|
||||
- **iPhone and iPad** with a hardware keyboard: **⌃⌥⇧Q** releases input while it is captured, and
|
||||
**⌘⎋** toggles capture in either direction. **⌃⌥⇧D** (disconnect) and **⌃⌥⇧S** (stats) come from
|
||||
the app's Stream shortcuts rather than from the stream itself; if they don't respond while you're
|
||||
@@ -154,7 +158,8 @@ There are two, and they are a per-client setting called **Mouse input**:
|
||||
- **Capture (games)** — the pointer locks to the stream and only relative movement is sent. The only
|
||||
cursor you see is the host's. This is what mouse-look in a game needs. The session window also
|
||||
grabs the keyboard here, so Alt+Tab and the Windows key (Super on Linux) reach the host rather than
|
||||
your own desktop — turn **Capture system shortcuts** off in
|
||||
your own desktop — on macOS that is the ⌘ chords, ⌘Q included, with ⌘⎋ kept back as the way out.
|
||||
Turn **Capture system shortcuts** off in
|
||||
[client settings](/docs/client-settings#input) to keep them local.
|
||||
- **Desktop (absolute)** — the pointer is not locked. It moves in and out of the stream freely and
|
||||
its position is sent as an absolute point — what you want for remote desktop work. Your local
|
||||
|
||||
@@ -29,6 +29,38 @@ and capture/display glitches.
|
||||
If you only want to try Punktfunk without removing the other host, at least make sure the other
|
||||
host is fully **stopped** first (they cannot both run at once).
|
||||
|
||||
### If you must run both anyway
|
||||
|
||||
Still unsupported, and you are on your own for the parts below — but if you keep Punktfunk's
|
||||
GameStream compat **off** (the default), the overlap narrows to two things you can move.
|
||||
|
||||
1. **The port.** With compat off, the Punktfunk *host* binds only UDP 9777, UDP 5353 and TCP
|
||||
**47990** — the web console is a separate service on 47992/47993, which nothing else wants — and
|
||||
47990 is the only one the other host wants, as its web UI. Whoever starts first takes it; the
|
||||
loser is not symmetric, because Punktfunk treats the failure as fatal and exits (the streaming
|
||||
plane goes with the console), while Sunshine merely loses its config UI. That is why it can look
|
||||
like it "worked until one day it didn't" — it is a boot race, not a setting. Move ours:
|
||||
|
||||
```sh
|
||||
# ~/.config/punktfunk/host.env
|
||||
PUNKTFUNK_MGMT_BIND=0.0.0.0:47991
|
||||
```
|
||||
|
||||
Nothing else needs changing: clients learn the port from discovery, and the web console reads it
|
||||
from `~/.config/punktfunk/mgmt-endpoint`, which the host rewrites on every start. A host added
|
||||
manually **by IP address** is the exception — it assumes 47990 and its library will stop loading,
|
||||
so re-add it from discovery. (You can move the other host instead: Sunshine and its forks derive
|
||||
every port from one base setting.)
|
||||
|
||||
2. **The display, on Windows.** Punktfunk defaults to an *exclusive* topology — while streaming it
|
||||
disables the other displays so its virtual one is the whole desktop, and re-asserts that every
|
||||
two seconds. Apollo-family forks are virtual-display-driven, so their monitor is what gets
|
||||
switched off, repeatedly. Set `PUNKTFUNK_NO_ISOLATE=1`, or pick a different topology in the web
|
||||
console, before blaming the other host.
|
||||
|
||||
To see who currently holds the port: `ss -lptn 'sport = :47990'` on Linux,
|
||||
`netstat -ano | findstr :47990` on Windows.
|
||||
|
||||
## The host isn't found on the network
|
||||
|
||||
- Make sure the host is actually running — on Linux `systemctl --user status punktfunk-host` (or you
|
||||
|
||||
+20
-20
@@ -10,30 +10,30 @@
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@scalar/api-reference-react": "^0.9.47",
|
||||
"@tanstack/react-router": "^1.121.0",
|
||||
"@tanstack/react-start": "^1.121.0",
|
||||
"@unom/app-ui": "^0.1.0",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@scalar/api-reference-react": "^0.9.63",
|
||||
"@tanstack/react-router": "^1.170.28",
|
||||
"@tanstack/react-start": "^1.168.45",
|
||||
"@unom/app-ui": "^0.2.1",
|
||||
"@unom/style": "^0.4.4",
|
||||
"@unom/ui": "^0.8.16",
|
||||
"fumadocs-core": "^16.10.5",
|
||||
"fumadocs-ui": "^16.10.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"@unom/ui": "^0.9.2",
|
||||
"fumadocs-core": "^16.14.4",
|
||||
"fumadocs-ui": "^16.14.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
|
||||
"@types/mdx": "^2.0.14",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5",
|
||||
"fumadocs-mdx": "^15.0.12",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^7.3.5",
|
||||
"vite-tsconfig-paths": "^5.1.0"
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"fumadocs-mdx": "^15.2.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -24,6 +24,16 @@ release is born complete and the announcement always has something to say.
|
||||
Discord `#releases`. Pressing "go" is the quality gate — a half-built release is never
|
||||
announced. Stable-only; a `-rc` tag is refused unless `allow_prerelease=true`.
|
||||
|
||||
**If a platform's run never appears, do not re-run the PR run — it cannot publish.** A re-run
|
||||
replays the original event (`pull_request`), and android's publish steps are gated on a `push`, so
|
||||
they stay skipped no matter how often you press it. Merging two PRs seconds apart can leave the
|
||||
older merge sha with **no run at all** — Gitea attributes the window's runs to the newer head
|
||||
(2026-08-14: `1e5dca4c` lost its run to `b5cace3a`, 12 s later), which is how an android change
|
||||
reaches main having never been built. Recover it by dispatching `android.yml` on that ref with
|
||||
**`publish=true`**; that is the only manual path reaching the registry and Play, and a plain
|
||||
dispatch stays build-only so a stray click can't ship to testers. Check for the gap by matching
|
||||
your own merge sha in the run list — "CI ran" is not the same as "your commit ran".
|
||||
|
||||
Editing the notes after the tag is fine: update this file, then re-run step 4 (or PATCH the body
|
||||
via the API) — the announce step always re-syncs from the file, so the file stays authoritative
|
||||
even across a tag re-point.
|
||||
@@ -54,8 +64,8 @@ no build, no assets on the Gitea release, nothing on Play. Two more checks sit d
|
||||
`play-upload.py` refuses text over the 500-char cap (printing the real count) before it uploads,
|
||||
because the API only rejects oversized notes at commit, by which point the AAB is already on Play.
|
||||
|
||||
Canary is exempt: it has no curated notes, and Play reusing text for internal testers costs
|
||||
nothing.
|
||||
Canary is exempt: it has no curated notes; open-testing users see the previous release's text on
|
||||
a canary, which is cosmetic and cheaper than gating every main push on a notes file.
|
||||
|
||||
Same freeze rule as the notes: once the tag exists, this file is the record of what that
|
||||
versionCode shipped.
|
||||
|
||||
@@ -105,7 +105,16 @@
|
||||
// is unchanged (it simply keeps the double-arm race the pair exists to close). Additive and
|
||||
// client-local: nothing new goes on the wire — the width is computed from frame indices the client
|
||||
// already receives — so [`WIRE_VERSION`] is unchanged.
|
||||
#define PUNKTFUNK_ABI_VERSION 19
|
||||
// v20: `punktfunk_connection_mgmt_port` — reads the host's management-API port out of the
|
||||
// session's `Welcome`, so a client can find the game library WITHOUT mDNS. The port previously
|
||||
// existed only in the host's mDNS TXT, which made a host that had moved it off 47990 (the
|
||||
// supported way to share a machine with a Sunshine fork, whose web UI owns that port) reachable
|
||||
// only where multicast worked — over a VPN, a routed subnet, or for a host added by IP, the
|
||||
// library silently fell back to a port nothing was listening on. A NEW symbol, not a widened one:
|
||||
// every existing function keeps its signature and behaviour, and an embedder that never calls it
|
||||
// is unchanged. The `Welcome` grew a trailing field, which older peers skip in both directions
|
||||
// (see `Welcome::encode`), so [`WIRE_VERSION`] is unchanged.
|
||||
#define PUNKTFUNK_ABI_VERSION 20
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -3125,6 +3134,22 @@ PunktfunkStatus punktfunk_connection_mode(const PunktfunkConnection *c,
|
||||
PunktfunkStatus punktfunk_connection_gamepad(const PunktfunkConnection *c, uint32_t *gamepad);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// The host's management-API port, from this session's `Welcome` — where its game library is
|
||||
// served (distinct from the streaming ports). `0` means the host did not advertise one: an older
|
||||
// host, or the standalone `punktfunk1-host` binary, which has no management API. Treat `0` as
|
||||
// "unknown" and fall back to your own default (47990), never as a port to dial.
|
||||
//
|
||||
// This exists so a client does NOT need mDNS to find the library. The port used to live only in
|
||||
// the host's mDNS TXT, so a host that had moved it off 47990 — the supported way to coexist with
|
||||
// a Sunshine fork, whose web UI owns that port — was reachable only where multicast worked. Read
|
||||
// this after connect and prefer it over any cached or default value. Safe any time after connect.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `port` is writable (NULL is skipped).
|
||||
PunktfunkStatus punktfunk_connection_mgmt_port(const PunktfunkConnection *c, uint16_t *port);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// The host capability bitfield the session's `Welcome` carried — a bitfield of
|
||||
// `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / `PUNKTFUNK_HOST_CAP_CLIPBOARD` /
|
||||
|
||||
@@ -189,7 +189,8 @@ package_punktfunk-host() {
|
||||
'mesa' 'libglvnd' 'libxkbcommon' 'wayland'
|
||||
'libavcodec.so' 'libavutil.so' 'libavfilter.so' 'libavdevice.so'
|
||||
'libavformat.so' 'libswscale.so' 'libswresample.so')
|
||||
optdepends=('pipewire-pulse: PulseAudio-API audio from games/apps (real `pulseaudio` also works)'
|
||||
optdepends=('rtkit: data-plane thread priority without a relogin (else the shipped LimitNICE drop-in applies from next login)'
|
||||
'pipewire-pulse: PulseAudio-API audio from games/apps (real `pulseaudio` also works)'
|
||||
'nvidia-utils: NVENC hardware encode + GPU EGL/CUDA zero-copy (REQUIRED to encode on NVIDIA)'
|
||||
'gamescope: per-session nested compositor backend (no desktop login needed) — needs >=3.16.22'
|
||||
'punktfunk-gamescope: HDR (10-bit BT.2020 PQ) streaming on the gamescope backend — attempted by default when installed'
|
||||
@@ -235,6 +236,12 @@ package_punktfunk-host() {
|
||||
install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf"
|
||||
# 32 MB UDP socket buffers (send-side headroom at high bitrate)
|
||||
install -Dm0644 "$R/scripts/99-punktfunk-net.conf" "$pkgdir/usr/lib/sysctl.d/99-punktfunk-net.conf"
|
||||
# Nice-limit headroom for the host's data-plane threads: raises the user-session RLIMIT_NICE so
|
||||
# pf-frame's setpriority() works on boxes without RealtimeKit (with rtkit the host never needs
|
||||
# it). A limit, not a grant — and NEVER a file capability on the host binary (see the KWin
|
||||
# identification note in punktfunk-host.install). Applies from the next login.
|
||||
install -Dm0644 "$R/packaging/linux/50-punktfunk-nice.conf" \
|
||||
"$pkgdir/usr/lib/systemd/system/user@.service.d/50-punktfunk-nice.conf"
|
||||
# systemd USER units (the host runs in the graphical session, not as root); repoint ExecStart.
|
||||
install -Dm0644 "$R/scripts/punktfunk-host.service" "$pkgdir/usr/lib/systemd/user/punktfunk-host.service"
|
||||
sed -i 's#%h/punktfunk/target/release/punktfunk-host#/usr/bin/punktfunk-host#' \
|
||||
|
||||
@@ -92,6 +92,11 @@ install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.
|
||||
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
|
||||
# and high-bitrate frames overflow it (send-side packet loss). systemd-sysctl applies it at boot.
|
||||
install -Dm0644 scripts/99-punktfunk-net.conf "$STAGE/usr/lib/sysctl.d/99-punktfunk-net.conf"
|
||||
# Nice-limit headroom for the host's data-plane threads: raises the user-session RLIMIT_NICE so
|
||||
# pf-frame's setpriority() works on boxes without RealtimeKit (with rtkit the host never needs
|
||||
# it). A limit, not a grant, and never a file capability on the host binary (KWin identification).
|
||||
install -Dm0644 packaging/linux/50-punktfunk-nice.conf \
|
||||
"$STAGE/usr/lib/systemd/system/user@.service.d/50-punktfunk-nice.conf"
|
||||
install -Dm0644 scripts/punktfunk-host.service "$STAGE/usr/lib/systemd/user/punktfunk-host.service"
|
||||
# The source unit's ExecStart points at the dev source tree; a packaged install has the binary at
|
||||
# /usr/bin. Rewrite it so a fresh apt install (no hand-rolled unit) starts the installed binary.
|
||||
@@ -237,6 +242,7 @@ Source: $PKG
|
||||
Package: $PKG
|
||||
Architecture: any
|
||||
Depends: \${shlibs:Depends}
|
||||
Recommends: rtkit
|
||||
EOF
|
||||
# In bundle mode the libav* live in FFMPEG_PREFIX/lib — not a standard loader path, and the
|
||||
# target/release binary carries no rpath (only the staged copy does) — so dpkg-shlibdeps can't
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Punktfunk: let the streaming host's data-plane threads renice themselves.
|
||||
#
|
||||
# The host raises its capture/encode/send and audio threads to nice -10/-5 so a CPU-saturating
|
||||
# game (or its shader-compile storm at launch) cannot deschedule them mid-stream. That call needs
|
||||
# CAP_SYS_NICE or a raised RLIMIT_NICE — and the host binary must never carry a file capability
|
||||
# (it would make the process unidentifiable to KWin and kill desktop streaming), so the limit is
|
||||
# the right lever. Desktops with RealtimeKit don't need this file (the host falls back to rtkit);
|
||||
# it covers rtkit-less installs, from the next login onward.
|
||||
#
|
||||
# This raises only the LIMIT for user sessions. Nothing is reprioritized by itself: a process
|
||||
# still has to call setpriority(), exactly as before.
|
||||
[Service]
|
||||
LimitNICE=-15
|
||||
@@ -497,6 +497,15 @@ in
|
||||
group = "root";
|
||||
};
|
||||
|
||||
# CPU-side thread priority for the host's data-plane threads (capture/encode/send and the
|
||||
# 5 ms audio loop) rides RealtimeKit: the host asks rtkit to renice the thread when a direct
|
||||
# setpriority() is refused — the same unprivileged broker PipeWire clients use, so nothing
|
||||
# ever enters the host's permitted set and the KWin identification above stays intact.
|
||||
# NixOS is the one distro family where rtkit is not a given, hence the default here;
|
||||
# mkDefault so an operator who runs without rtkit can turn it off (the host then keeps its
|
||||
# pre-0.29 best-effort no-op behaviour, a pacing cost only).
|
||||
security.rtkit.enable = mkDefault true;
|
||||
|
||||
systemd.user.services.punktfunk-host = {
|
||||
description = "punktfunk GameStream + punktfunk/1 streaming host";
|
||||
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
||||
@@ -649,7 +658,11 @@ in
|
||||
# below went on promising the behaviour it removes.
|
||||
unitConfig.StartLimitIntervalSec = 0;
|
||||
environment = {
|
||||
PUNKTFUNK_MGMT_URL = "https://127.0.0.1:47990";
|
||||
# PUNKTFUNK_MGMT_URL is deliberately absent: the host publishes the port it actually bound
|
||||
# to ~/.config/punktfunk/mgmt-endpoint (mgmt::publish_endpoint), sourced below, and the
|
||||
# server falls back to https://127.0.0.1:47990 on its own when that file does not exist.
|
||||
# Hardcoding it here would have to out-rank the file, which is a directive-ordering
|
||||
# question in the generated unit — so we simply do not create the conflict.
|
||||
PORT = "47992";
|
||||
HOST = "0.0.0.0";
|
||||
# Serve HTTPS with the host's own identity cert (the anchor native clients already pin) and
|
||||
@@ -663,6 +676,9 @@ in
|
||||
EnvironmentFile = [
|
||||
"%h/.config/punktfunk/mgmt-token"
|
||||
"-%h/.config/punktfunk/web-password"
|
||||
# The host's effective mgmt URL — see the `environment` note above. Optional: absent on
|
||||
# a host predating mgmt::publish_endpoint, and the server default covers that.
|
||||
"-%h/.config/punktfunk/mgmt-endpoint"
|
||||
];
|
||||
ExecStart = "${cfg.web.package}/bin/punktfunk-web-server";
|
||||
# `always`, not `on-failure`: a console that exits 0 has still stopped serving, and
|
||||
|
||||
@@ -123,6 +123,11 @@ Requires: wireplumber
|
||||
# made the host uninstallable for anyone running real PulseAudio, which serves those games just
|
||||
# as well. Fedora installs pipewire-pulseaudio by default, so the default box is unaffected.
|
||||
Recommends: pipewire-pulseaudio
|
||||
# The data-plane threads renice themselves through RealtimeKit when the direct setpriority() is
|
||||
# refused (thread_qos — the host binary can never carry CAP_SYS_NICE, see the %%files note).
|
||||
# Weak-dep: Fedora desktops ship rtkit anyway, and without it the user@.service.d LimitNICE
|
||||
# drop-in below still covers the direct path from the next login.
|
||||
Recommends: rtkit
|
||||
Requires: opus
|
||||
Requires: libei
|
||||
# FFmpeg runtime with NVENC (RPM Fusion). Weak-dep so the package installs even if
|
||||
@@ -371,6 +376,12 @@ sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#%{_datadir}/%{name}/
|
||||
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
|
||||
%{buildroot}%{_datadir}/applications/io.unom.Punktfunk.Host.desktop
|
||||
|
||||
# Scheduling headroom for the host's data-plane threads (see the no-caps note in %%files): raise
|
||||
# the user-session nice hard limit so pf-frame's setpriority() also works where RealtimeKit isn't
|
||||
# running. A limit, not a grant — takes effect at the user's next login.
|
||||
install -Dm0644 packaging/linux/50-punktfunk-nice.conf \
|
||||
%{buildroot}%{_unitdir}/user@.service.d/50-punktfunk-nice.conf
|
||||
|
||||
# Status tray: the per-user SNI icon + its XDG autostart entry (self-gating: --autostart exits
|
||||
# silently for users who don't run a host) + the hicolor status icons it names.
|
||||
install -Dm0755 target/release/punktfunk-tray %{buildroot}%{_bindir}/punktfunk-tray
|
||||
@@ -526,8 +537,10 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
|
||||
# why neither prctl(PR_SET_DUMPABLE, 1) nor systemd AmbientCapabilities= rescues it.
|
||||
#
|
||||
# The cost of not having it is pacing only: pf-zerocopy walks REALTIME -> HIGH -> default when a
|
||||
# priority class is refused, and pf-frame's thread nice is a best-effort no-op. That is exactly
|
||||
# how 0.25.0 behaved, which is the behaviour that worked.
|
||||
# priority class is refused, and pf-frame's thread nice falls back to RealtimeKit (the same
|
||||
# unprivileged broker PipeWire clients use — no capability enters the permitted set, so the KWin
|
||||
# identification above is untouched) and to the user@.service.d LimitNICE drop-in shipped below.
|
||||
# Only on a box with neither does it remain the best-effort no-op 0.25.0 shipped with.
|
||||
#
|
||||
# rpm applies file capabilities from package metadata, so a package built WITHOUT %caps() installs
|
||||
# the binary with none and an upgrade from 0.26.0-1 clears it — no scriptlet needed.
|
||||
@@ -553,6 +566,8 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
|
||||
# Debugging the WORKER (not the host): a capability makes it AT_SECURE, so the loader ignores
|
||||
# LD_LIBRARY_PATH/LD_PRELOAD for it and core dumps are suppressed by default.
|
||||
%caps(cap_sys_nice=ep) %{_bindir}/punktfunk-encode-worker
|
||||
%dir %{_unitdir}/user@.service.d
|
||||
%{_unitdir}/user@.service.d/50-punktfunk-nice.conf
|
||||
%{_bindir}/punktfunk-tray
|
||||
%{_udevrulesdir}/60-punktfunk.rules
|
||||
%dir %{_libexecdir}/punktfunk
|
||||
|
||||
@@ -21,6 +21,21 @@
|
||||
# ship a `punktfunk-gamestream` firewalld service / ufw profile for exactly this).
|
||||
#PUNKTFUNK_GAMESTREAM=1
|
||||
|
||||
# Where the management API listens (default 0.0.0.0:47990). Two uses:
|
||||
# * 127.0.0.1:47990 keeps it off the LAN — at the cost of paired clients browsing your library.
|
||||
# * MOVING THE PORT is how you share a machine with Sunshine/Apollo/Vibeshine: 47990 is their web
|
||||
# UI as well as our management API, and with PUNKTFUNK_GAMESTREAM off it is the ONLY port the
|
||||
# two still share. Nothing else needs editing — clients learn the port from discovery and the
|
||||
# web console reads it from ~/.config/punktfunk/mgmt-endpoint, which the host rewrites on start.
|
||||
# Running two Moonlight-compatible hosts at once is still unsupported; see the troubleshooting
|
||||
# page. On Windows also see PUNKTFUNK_NO_ISOLATE — the display topology is the second conflict.
|
||||
#PUNKTFUNK_MGMT_BIND=0.0.0.0:47991
|
||||
|
||||
# The native punktfunk/1 (QUIC) control port clients connect on. Default 9777. Clients discover it
|
||||
# over mDNS, and a host added by hand keeps whatever port it was added with, so moving this is safe
|
||||
# on both sides. A typo here is a startup ERROR rather than a silent fall back to 9777.
|
||||
#PUNKTFUNK_NATIVE_PORT=9778
|
||||
|
||||
# Video source (GameStream/Moonlight sessions only): `virtual` creates a per-client virtual
|
||||
# output at the client's exact resolution+refresh (the flagship mode, and the default);
|
||||
# `portal` captures an existing monitor.
|
||||
|
||||
@@ -25,7 +25,15 @@ Type=simple
|
||||
# creates it first, but a manual operator may inject PUNKTFUNK_UI_PASSWORD another way).
|
||||
EnvironmentFile=%h/.config/punktfunk/mgmt-token
|
||||
EnvironmentFile=-%h/.config/punktfunk/web-password
|
||||
Environment=PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
|
||||
# The host's ACTUAL mgmt port: `serve` writes this file (mgmt::publish_endpoint) with the port it
|
||||
# really bound, so moving the listener — the fix for sharing a box with a Sunshine fork, which owns
|
||||
# 47990 as its web UI — needs no edit here. Optional ('-'): an older host never wrote it, and the
|
||||
# server's own built-in default (https://127.0.0.1:47990, util/auth.ts) then applies unchanged.
|
||||
#
|
||||
# Deliberately NOT paired with an `Environment=PUNKTFUNK_MGMT_URL=` default line: whether a file or
|
||||
# an Environment= assignment wins is a question of directive order, and the answer differs between
|
||||
# this hand-written unit and the one the NixOS module generates. One source, no precedence puzzle.
|
||||
EnvironmentFile=-%h/.config/punktfunk/mgmt-endpoint
|
||||
Environment=PORT=47992
|
||||
Environment=HOST=0.0.0.0
|
||||
# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the
|
||||
|
||||
@@ -313,6 +313,17 @@ if [ "$SUDO_OK" = 1 ]; then
|
||||
| sudo tee /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
|
||||
sudo sysctl -q -p /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
|
||||
ok "UDP socket buffers raised to 32 MB (persisted)"
|
||||
# Nice-limit headroom for the host's data-plane threads (audio/send): without it (or rtkit,
|
||||
# which SteamOS does not guarantee) the per-thread renice silently no-ops and a busy game can
|
||||
# deschedule the 5 ms audio loop. SteamOS's /usr is read-only, so unlike the packaged installs
|
||||
# this lands in /etc — same drop-in, same effect, from the next login. NEVER a file capability
|
||||
# on the host binary (see the setcap note above — KWin identification).
|
||||
if [ -f "$SRC/packaging/linux/50-punktfunk-nice.conf" ]; then
|
||||
sudo install -Dm644 "$SRC/packaging/linux/50-punktfunk-nice.conf" \
|
||||
/etc/systemd/system/user@.service.d/50-punktfunk-nice.conf
|
||||
sudo systemctl daemon-reload || true
|
||||
ok "nice-limit drop-in installed (data-plane thread priority; applies from next login)"
|
||||
fi
|
||||
if [ -f "$SRC/scripts/60-punktfunk.rules" ]; then
|
||||
sudo install -m644 "$SRC/scripts/60-punktfunk.rules" /etc/udev/rules.d/60-punktfunk.rules
|
||||
sudo udevadm control --reload-rules && sudo udevadm trigger || true
|
||||
|
||||
@@ -12,6 +12,12 @@ PUNKTFUNK_UI_PASSWORD=change-me
|
||||
# Management API the console proxies to. It serves HTTPS (the host's own identity cert) and
|
||||
# requires auth (mTLS or the bearer below). Keep this loopback — the login-gated web server is
|
||||
# the only path to it.
|
||||
#
|
||||
# ON A PACKAGED INSTALL YOU DO NOT SET THIS. The host writes the port it actually bound to
|
||||
# ~/.config/punktfunk/mgmt-endpoint in this same KEY=VALUE form, and the shipped units source that
|
||||
# file — so a host moved off 47990 (PUNKTFUNK_MGMT_BIND, e.g. to coexist with a Sunshine fork whose
|
||||
# web UI owns that port) carries the console with it. This line is for dev, where you run the two
|
||||
# halves by hand. Setting it explicitly always wins over the file.
|
||||
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
|
||||
|
||||
# REQUIRED: bearer token for the management API, injected server-side by the /api proxy (never
|
||||
|
||||
@@ -105,7 +105,13 @@ export function uiPassword(): string {
|
||||
* loopback hop via Bun's per-request `tls` option (routes/api/[...].ts, util/forward.ts). There is
|
||||
* deliberately no process-wide NODE_TLS_REJECT_UNAUTHORIZED — see .env.example. */
|
||||
export function mgmtUrl(): string {
|
||||
return process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
|
||||
// Blank counts as UNSET, which `??` alone would not do. On a packaged install this value comes
|
||||
// from ~/.config/punktfunk/mgmt-endpoint (written by the host's `serve` with the port it really
|
||||
// bound, so a host moved off 47990 to coexist with a Sunshine fork carries the console with it),
|
||||
// sourced as a systemd EnvironmentFile. An empty or truncated file would otherwise set the
|
||||
// variable to "" and send every proxy hop to a URL that cannot parse.
|
||||
const url = process.env.PUNKTFUNK_MGMT_URL?.trim();
|
||||
return url ? url : "https://127.0.0.1:47990";
|
||||
}
|
||||
|
||||
/** Bearer token for the management API, injected server-side. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user