Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2dc011200 | ||
|
|
48eeae7527 | ||
|
|
0df4ca957f | ||
|
|
13aa11355e | ||
|
|
e989d7457f | ||
|
|
b05bb1dd48 | ||
|
|
2898f6b049 | ||
|
|
4eb4e3465b | ||
|
|
44cd5bfd81 | ||
|
|
8977228a4b | ||
|
|
6a82a602a1 | ||
|
|
fc3b2d0328 | ||
|
|
1c1fd7d9bc | ||
|
|
1c60e641b3 | ||
|
|
eda4b7ebd2 | ||
|
|
730ac43169 | ||
|
|
c9a76287d8 | ||
|
|
cbd3d02817 | ||
|
|
2be444b329 | ||
|
|
669a1bc0ce | ||
|
|
8ff6fe6093 | ||
|
|
1758266bda | ||
|
|
d5fb1e4479 | ||
|
|
ea3c9e1202 | ||
|
|
685c4bd99a | ||
|
|
0519b057d5 | ||
|
|
33b029695f | ||
|
|
1f6f01cb76 | ||
|
|
fade2f7af3 | ||
|
|
6e4cc335c5 | ||
|
|
d9662c010d | ||
|
|
2b0913cf53 | ||
|
|
1280f697be | ||
|
|
3b39710a5a | ||
|
|
19243c30b4 | ||
|
|
dfcffcdd50 | ||
|
|
f2b5b3e567 | ||
|
|
6d7e6f71c0 | ||
|
|
9e3fba10c1 | ||
|
|
fd4f032d20 | ||
|
|
892e683f0e | ||
|
|
7af6c323d0 | ||
|
|
76e6618b84 | ||
|
|
7d2a8778d1 | ||
|
|
6007bc42cd | ||
|
|
16d54b73a1 | ||
|
|
d60b1dda29 | ||
|
|
d7fa5847f1 | ||
|
|
e473a4be7b | ||
|
|
675030935a | ||
|
|
21d9190324 | ||
|
|
7ae8866a5c | ||
|
|
a8099e0f5b | ||
|
|
b4b24f8b57 | ||
|
|
c23fc84bef | ||
|
|
0e5a059098 | ||
|
|
674b16d8eb | ||
|
|
d801cb72f2 | ||
|
|
b03acc9153 | ||
|
|
ace01f06a2 | ||
|
|
e4ec4cec31 | ||
|
|
245173a731 | ||
|
|
230d253b06 | ||
|
|
def215ae8e | ||
|
|
dfcc530ee7 | ||
|
|
6b5307618f | ||
|
|
cdacd5636e | ||
|
|
47f01149bf | ||
|
|
20568d988f | ||
|
|
2b81bd286f |
@@ -248,7 +248,9 @@ jobs:
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: The console must exist (cache hit or fresh build)
|
||||
|
||||
+15
-3
@@ -339,8 +339,19 @@ jobs:
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# RETRIED, like every other single-shot network call in CI (scripts/ci/retry.sh's header
|
||||
# has the why: this box runs many jobs in parallel and drops packets under that load).
|
||||
# `bun install` streams download-and-extract, so a tarball truncated mid-stream surfaces
|
||||
# as `error: Fail extracting tarball for "<pkg>"` — which reads like a corrupt package and
|
||||
# is not one. Measured 2026-08-20: run 19630's docs-site died that way on
|
||||
# @rolldown/binding-linux-x64-gnu (8.3 MB) while the web job installed the same registry
|
||||
# in the same run, and run 19632 installed the identical lockfile seven minutes later. The
|
||||
# tarball's sha512 matches the lockfile and both bun 1.3.13 and 1.3.14 extract it from
|
||||
# disk, so there was never anything wrong with the package. 3 attempts (10s+20s backoff),
|
||||
# not retry.sh's usual 5: a genuinely stale lockfile fails deterministically here, and
|
||||
# 30s is enough to ride out a load burst without making that wait a minute and a half.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
# Build first: it generates the orval API client + paraglide messages that
|
||||
# typechecking imports.
|
||||
- name: Build
|
||||
@@ -368,8 +379,9 @@ jobs:
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
|
||||
- uses: actions/checkout@v4
|
||||
# Retried — see the web job above; this is the job the flake was measured on.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
# Build first: fumadocs-mdx emits the .source typegen the typecheck imports.
|
||||
- name: Build
|
||||
run: bun run build
|
||||
@@ -417,7 +429,7 @@ jobs:
|
||||
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
|
||||
# actions/checkout needs all three (see the web job).
|
||||
- name: Install git + node + CA certs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
|
||||
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
|
||||
|
||||
@@ -231,7 +231,9 @@ jobs:
|
||||
# scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job
|
||||
# neither consumes nor commits, whose only effect here is to make the install depend on
|
||||
# bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless.
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
|
||||
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
|
||||
|
||||
@@ -61,3 +61,9 @@ jobs:
|
||||
punktfunk-host detect-conflicts
|
||||
- name: Re-running is a no-op install
|
||||
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
|
||||
- name: --uninstall takes the packages and the repo off again
|
||||
run: |
|
||||
sh scripts/install.sh --yes --uninstall
|
||||
! command -v punktfunk-host
|
||||
! test -e /etc/apt/sources.list.d/punktfunk.list -o -e /etc/yum.repos.d/punktfunk.repo
|
||||
! grep -q '^\[punktfunk\]' /etc/pacman.conf 2>/dev/null
|
||||
|
||||
@@ -111,3 +111,20 @@ jobs:
|
||||
name: punktfunk-linux-client-screenshots
|
||||
path: clients/linux/screenshots
|
||||
retention-days: 30
|
||||
|
||||
# The artifact above is browser-only (Gitea's API doesn't serve v3 artifacts), which
|
||||
# blocked reusing these shots for the docs. Publish them to the generic package registry
|
||||
# too — fixed version `ci`, delete-then-PUT so each run overwrites, anonymous GET on a
|
||||
# public repo:
|
||||
# https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci/<scene>.png
|
||||
- name: Publish screenshots to the package registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
BASE="https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci"
|
||||
for f in clients/linux/screenshots/*.png; do
|
||||
name=$(basename "$f")
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$name" || true
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" --upload-file "$f" "$BASE/$name"
|
||||
echo "published $BASE/$name"
|
||||
done
|
||||
|
||||
@@ -37,15 +37,18 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Both installs retried: bun's download-and-extract is single-shot, and a truncated tarball
|
||||
# reads as `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job
|
||||
# is the worst place to lose to a dropped packet — the tag is already pushed.
|
||||
- name: Build the SDK (file:../sdk dependency source)
|
||||
working-directory: sdk
|
||||
run: |
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: plugin-kit
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
|
||||
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
|
||||
|
||||
@@ -176,7 +176,9 @@ jobs:
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
|
||||
|
||||
@@ -39,8 +39,11 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job is the
|
||||
# worst place to lose to a dropped packet — the tag is already pushed.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
@@ -40,8 +40,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
# --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen
|
||||
# explicitly since build-storybook has no prebuild hook of its own.
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
- name: Generate API client + i18n messages
|
||||
run: bun run codegen
|
||||
# Pulls the matching Chromium build + the apt libs it needs (root in-container).
|
||||
|
||||
@@ -56,8 +56,12 @@
|
||||
#
|
||||
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
|
||||
#
|
||||
# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
|
||||
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
|
||||
# Publishes THREE artifacts per arch (x64 + ARM64) to Gitea's generic package registry, all packed
|
||||
# from one assembled layout:
|
||||
# punktfunk-client-setup_<arch>.exe — Inno Setup per-user installer, the DEFAULT download
|
||||
# (stable path Steam can launch: overlay + Big Picture work)
|
||||
# punktfunk-client-windows_<arch>-portable.zip — the same file set, no installer
|
||||
# punktfunk-client-windows_<arch>.msix — kept for Microsoft Store compatibility
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
@@ -283,6 +287,28 @@ jobs:
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
|
||||
|
||||
# The DEFAULT download: a per-user Inno Setup exe + a portable zip, packed from the layout
|
||||
# the MSIX step just assembled. The MSIX shape (WindowsApps ACLs, alias-only activation)
|
||||
# breaks Steam's non-Steam-game picker, the Steam overlay injection and Big Picture launch;
|
||||
# the installer's stable %LOCALAPPDATA%\Programs\Punktfunk path is the fix. The MSIX stays
|
||||
# published for Microsoft Store compatibility. Same signing env as the MSIX step above.
|
||||
- name: Pack + sign installer + portable zip
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
env:
|
||||
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
|
||||
AZURE_CODESIGNING_ACCOUNT: unomsigning
|
||||
AZURE_CODESIGNING_PROFILE: unom-io
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
& clients/windows/packaging/pack-client-installer.ps1 `
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-LayoutDir ${{ matrix.td }}\msix\layout -OutDir ${{ matrix.td }}\installer
|
||||
|
||||
- name: Publish to Gitea generic registry
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
@@ -301,7 +327,10 @@ jobs:
|
||||
# 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 $_) }
|
||||
# The installer + portable zip (the default download; docs point at these alias URLs).
|
||||
if ($env:CLIENT_SETUP_PATH) { $aliasNames[$env:CLIENT_SETUP_PATH] = "punktfunk-client-setup_${{ matrix.arch }}.exe" }
|
||||
if ($env:CLIENT_ZIP_PATH) { $aliasNames[$env:CLIENT_ZIP_PATH] = "$($env:PKG)_${{ matrix.arch }}-portable.zip" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH, $env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||
@@ -324,10 +353,11 @@ jobs:
|
||||
Put $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
|
||||
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
|
||||
# the race, and x64/arm64 filenames differ so the assets don't collide.
|
||||
- name: Attach MSIX to the Gitea release (stable tags only)
|
||||
# On a real release, also attach the installer + portable zip + MSIX (+ its .cer) to the
|
||||
# unified Gitea Release. Both arch legs attach to the same release concurrently — the
|
||||
# helper's create-or-fetch handles the race, and x64/arm64 filenames differ so the assets
|
||||
# don't collide.
|
||||
- name: Attach client artifacts to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
@@ -335,6 +365,6 @@ jobs:
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
foreach ($f in @($env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH, $env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
|
||||
+836
-50
@@ -12,18 +12,486 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.31.2
|
||||
|
||||
10 commits since v0.31.1 (6 non-merge), counted at the tip this was cut from.
|
||||
|
||||
**Nothing versioned moves, and this time nothing versioned even changes shape.** `WIRE_VERSION`
|
||||
stays **2**, the C ABI stays **25**, and so do the driver protocol, the gamepad channel, the plugin
|
||||
index schema and the host event schema. `include/punktfunk_core.h` is **byte-identical to the
|
||||
v0.31.1 tag** — unlike the last two releases, which each added a `#define` — and `pf-driver-proto`
|
||||
shows no diff either. No route is added or removed, no `#[repr(C)]` struct moves, and neither
|
||||
`@punktfunk/host` (0.1.5) nor `@punktfunk/plugin-kit` (0.4.4) is re-cut. An embedder can take this
|
||||
release without recompiling anything, and a packager has one thing to notice: the Windows firewall
|
||||
rules below.
|
||||
|
||||
`api/openapi.json` changes in **documentation only** — two `description` strings on `HostInfo`, no
|
||||
route, schema, field or type — plus the `info.version` stamp. That documentation change is
|
||||
load-bearing, though, because it records a behaviour change: `local_ip` is now read per request.
|
||||
|
||||
The release is entirely fix-shaped. Three of the six non-merge commits are the same class of fault —
|
||||
the host using the wrong local address — reached from three directions: the data socket's source
|
||||
address (#367), the advertised address after a cold boot (#366), and the firewall rules that
|
||||
admitted anyone to the ports those addresses point at (#368). The fourth is an Android regression
|
||||
from v0.31.1 (#365); the remaining two are a refactor and a test in support of #366.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.31.1 | v0.31.2 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged. No message added, removed or re-shaped; `DeliveryReport` (`0x0B`) from v0.31.1 is the most recent addition and is untouched |
|
||||
| C ABI | 25 | **25** | unchanged. `include/punktfunk_core.h` has **no diff at all** against the v0.31.1 tag — not even a constant |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.31.1 tag |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged; no file under the gamepad backends is touched by this release |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
|
||||
| `api/openapi.json` | 0.31.1 | **0.31.2** | **description-only**, plus the stamp (`info.version` is `CARGO_PKG_VERSION`). The two `HostInfo` strings that change are quoted under **`Host::local_ip`** below; no route, schema, required-field or type differs. Re-stamped here, not regenerated — `punktfunk-host` does not build on macOS; the document itself was regenerated in #366 on a runner where `openapi_document_is_complete_and_checked_in` executes. `api/` and `docs-site/public/` are byte-identical to each other |
|
||||
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files, and `packaging/gamescope/PKGBUILD` still declares `pfhdr8` after the v0.31.1 correction |
|
||||
| `@punktfunk/host` (SDK) | 0.1.5 | **0.1.5** | unchanged; nothing under `sdk/` moved |
|
||||
| `@punktfunk/plugin-kit` | 0.4.4 | **0.4.4** | unchanged; nothing under `plugin-kit/` moved. 0.4.4 remains the registry's `latest` |
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None.** No wire change, no ABI change, no driver-protocol change, no plugin-contract change, no
|
||||
API-surface change. Every 0.31.x host, client, driver and plugin keeps interoperating in both
|
||||
directions with no re-pairing and no rebuild.
|
||||
|
||||
Two **behaviour** changes that break no build but change what a machine does:
|
||||
|
||||
- **Windows `service install` now scopes every inbound rule to a program.** The five fixed-port
|
||||
rules gain `program=<exe>` while keeping their `localport=`. If you provision firewall rules
|
||||
yourself rather than letting `service install` do it, the equivalent is `program=` on each; if you
|
||||
do nothing, `service install` re-runs on every upgrade and rewrites them for you. **Externally
|
||||
visible:** 5353 is punktfunk's alone now, so anything else on the machine that was reachable on
|
||||
mDNS through punktfunk's any-program rule needs its own rule.
|
||||
- **`HostInfo.local_ip` is no longer static for the life of the process.** It was a field
|
||||
snapshotted at `Host::detect()`; it is a method that re-reads on every request. A consumer that
|
||||
cached it once at startup was caching a value that could be `127.0.0.1` forever (see below) and
|
||||
should poll instead. The two `description` strings in `api/openapi.json` say so.
|
||||
|
||||
### Windows: the fixed-port firewall rules admitted any program on the machine
|
||||
|
||||
`service install` added `dir=in action=allow` rules carrying only `localport=`. A rule of that shape
|
||||
admits **any process** that binds the port — GameStream (47984/47989/47998-48010/48010), the native
|
||||
plane (9777), mgmt (47990), mDNS (5353) and the console pair (47992/47993). Binding a high port on
|
||||
Windows requires no elevation, so an unprivileged program could take any of them and become
|
||||
LAN-reachable simply by binding first, and **silently**: our rule is precisely what suppresses the
|
||||
"Allow this app to communicate on…" prompt that would otherwise be the only way in.
|
||||
|
||||
Every rule is now scoped to the executable that actually listens on it, keeping the ports — program
|
||||
**and** port is strictly tighter than either alone. The host rules name the host exe (resolved once
|
||||
via `current_exe()` and shared with the data-plane rule, which already worked this way and is the
|
||||
pattern the rest now follow); the console rules name the bundled `<app>/bun/bun.exe` the supervisor
|
||||
spawns. `fw_add_rule_args` is the new single constructor for the whole shape.
|
||||
|
||||
The old argument for leaving them unscoped — "an install whose recorded exe path later moves still
|
||||
has its fixed ports open" — does not hold: `service install` re-runs the whole remove-then-add on
|
||||
every upgrade, so the path is refreshed rather than left stale.
|
||||
|
||||
Fallbacks are deliberate and **asymmetric**. A fixed-port rule whose program cannot be resolved
|
||||
falls back to the old any-program form, because a looser rule still streams and no rule is a black
|
||||
screen. The data-plane rule instead **skips**: it has no `localport=` to fall back to, so a
|
||||
program-less version of it would not be a looser rule but an open host. The installer prints the
|
||||
5353 note only when the scoping actually happened — claiming it while the rules are still wide open
|
||||
would be worse than saying nothing.
|
||||
|
||||
Reported by a user on 2026-08-21, immediately after the source-IP fix below cleared their black
|
||||
screen.
|
||||
|
||||
### The data socket binds the address the control plane arrived on
|
||||
|
||||
`bind_data_socket` bound `0.0.0.0:0`, so the kernel chose the video source address from the routing
|
||||
table, **independently of the address the client's control connection actually arrived on**. The
|
||||
client's data socket is `connect`ed to the host IP it dialled, so its kernel drops every datagram
|
||||
from any other source — in the kernel, before userspace, where nothing counts it.
|
||||
|
||||
On a host with two live paths to the client (Ethernet and Wi-Fi both up on the same LAN; a
|
||||
VPN/overlay adapter claiming the route) that is a permanent black screen with every gauge green: the
|
||||
hole-punch still arrives so the host logs `punched=true`, `loss_ppm` stays 0 because there are no
|
||||
packets to see gaps in, and QUIC — which quinn pins to the right local address — carries control,
|
||||
audio and input perfectly. `from_socket_punch` already documented the mirror of this assumption for
|
||||
the *client's* source IP; the host side was never checked.
|
||||
|
||||
The socket now binds `Connection::local_ip()` (unmapping an IPv4-mapped v6 address so it can still
|
||||
`connect` to a v4 peer), falling back to the wildcard **loudly** when that is unavailable.
|
||||
|
||||
Two diagnostics changed with it, because the field session's log could not answer the question:
|
||||
|
||||
- the `data plane bound` line carries the socket's post-`connect` `local=` address — the source the
|
||||
kernel will actually stamp — and WARNs when it differs from the address the control plane arrived
|
||||
on.
|
||||
- the black-screen ERROR no longer asserts "This is a PATH problem, not decode" and no longer names
|
||||
`punched=false` as *the* fingerprint. It fired with `punched=true` in the field, contradicting its
|
||||
own advice and sending an investigation to the firewall. It now branches on what the bring-up line
|
||||
says, and admits its counter is incremented after decrypt and replay checks, so a session whose
|
||||
every datagram failed to open reports the same zero as one that received nothing.
|
||||
|
||||
### `Host::local_ip` re-reads, and mDNS adverts follow it
|
||||
|
||||
`Host::detect()` snapshotted the LAN address once at process start and every consumer read that
|
||||
frozen field forever. On a cold boot the host wins the race against the network — the Windows
|
||||
service is registered `AutoStart` with no dependencies — so `primary_local_ip()`'s route probe to
|
||||
8.8.8.8 failed with `ENETUNREACH` and the loopback fallback stuck for the life of the process.
|
||||
Restarting the host re-ran `detect()` on a live network, which is the workaround users found.
|
||||
|
||||
Four surfaces broke together off that one field: both mDNS adverts (`_punktfunk._udp`,
|
||||
`_nvstream._tcp`) published `127.0.0.1` as their A record; `session_url_xml()` handed Moonlight
|
||||
`rtsp://127.0.0.1:48010` after `/launch`; `wol::wake_macs()` found no interface for loopback and
|
||||
dropped the `mac` TXT record, silently disabling Wake-on-LAN; and `HostInfo.local_ip` reported
|
||||
loopback to the web console.
|
||||
|
||||
Fixed at the choke point rather than per caller:
|
||||
|
||||
- `primary_local_ip()` never returns loopback or the unspecified address. When the route probe
|
||||
fails it falls back to `first_lan_ipv4` — the first non-loopback interface address, which exists
|
||||
as soon as the NIC is configured even if the default route is not installed yet, the common shape
|
||||
of the boot race. It is split out so a test can assert the one thing that matters: it never hands
|
||||
back the loopback `get_if_addrs` also reports.
|
||||
- `Host::local_ip` becomes a method that re-reads instead of a field that freezes. A `connect(2)` on
|
||||
an unconnected UDP socket sends no packets and costs nothing beside the HTTP response it is
|
||||
serialized into.
|
||||
- mDNS records are **pushed, not polled**: a live advert re-registers when the routed address
|
||||
changes (`discovery::advertise_live`, shared by both service types). It polls the routed address
|
||||
rather than subscribing to the daemon's `IpAdd` events because the boot race usually resolves
|
||||
without one — the NIC often has its address before we register, and only the route lands late.
|
||||
|
||||
This also covers the sibling cases that were never reported: DHCP handing out a different lease, and
|
||||
a host moved between Wi-Fi and Ethernet.
|
||||
|
||||
The re-announce loop's stop signal is now the `mpsc` channel it already sleeps on, rather than an
|
||||
`Arc<AtomicBool>` plus a `Drop` impl: the `Advert` dropping its sender wakes the thread immediately
|
||||
instead of leaving it to notice a flag up to `IP_RECHECK` (10 s) later.
|
||||
|
||||
### Android: the button correction is gated on named triggers, not declared keys
|
||||
|
||||
v0.31.1 corrected button positions for pads Android has no key layout for, and gated it on
|
||||
`hasKeys(BUTTON_C, BUTTON_Z)`. That gate answers for what a device **declares**, not what it
|
||||
reports: `hid-input` allocates `BTN_A + n` straight through for every button in the descriptor, so
|
||||
`BTN_C` (`0x132`) and `BTN_Z` (`0x135`) are set on **any** pad declaring six or more buttons —
|
||||
including a standard-layout pad that never presses either. The signal was therefore identical on the
|
||||
pad that needs correcting and the pad that does not, and no tightening of it could have separated
|
||||
them.
|
||||
|
||||
Two field reports on 2026-08-21 (a GameSir G8+ and an "Xbox Wireless Controller" over Bluetooth) had
|
||||
X answering Y, Y answering LB, and both shoulders answering menu buttons — exactly what
|
||||
`GENERIC_XBOX` does to scancodes `0x133`/`0x134`/`0x136`/`0x137`. It is the same pad model on both
|
||||
sides of the bug: an Elite Series 2 needed the correction on a Fire TV, and another was broken by it
|
||||
here.
|
||||
|
||||
What separates them is the **axes**. A HID gamepad describes its triggers either as the
|
||||
Accelerator/Brake usages — which become `ABS_GAS`/`ABS_BRAKE`, names Android has words for — or as
|
||||
two generic axes on `ABS_Z`/`ABS_RZ`, which it does not. A descriptor well-formed enough to name its
|
||||
triggers puts its buttons at the standard positions too. It is also the firmware line on the pad in
|
||||
the report: an Xbox Wireless Controller reports GAS/BRAKE after its firmware update and Z/Rz before
|
||||
it, and only the older one was ever wrong.
|
||||
|
||||
`padButtons` now takes `namedTriggers` and answers `NATIVE` whenever it is set — no correction of
|
||||
any kind, on buttons or axes, for a pad Android already reads. `padMap` computed that fact one line
|
||||
below and only ever spent it on the axes; it now decides both. `hasKeys` stays for the narrower
|
||||
question it can answer — *which* straight-through order, once the axes have established there is
|
||||
one — where a false positive costs nothing. This is the same discriminator Moonlight uses
|
||||
(`ControllerHandler`, `gasRange == null` beside the `"Xbox Wireless Controller"` name); v0.31.1
|
||||
cited its tables and then replaced its discriminator, which is where this came in.
|
||||
|
||||
`PadButtonsTest` is at 16 cases, 3 new: the gate holds for every vendor/declaration combination, the
|
||||
four reported buttons stay themselves, and the report-order choice past the gate is unchanged.
|
||||
|
||||
**Not fixed here:** a DualSense report filed alongside these, with Triangle dead in both the client
|
||||
UI and the stream. A button reaching neither is one `buttonBit` maps to nothing, which no branch of
|
||||
the correction produces for Triangle.
|
||||
|
||||
---
|
||||
|
||||
## v0.31.1
|
||||
|
||||
30 commits since v0.31.0 (19 non-merge), counted at the tip this was cut from.
|
||||
|
||||
**No versioned surface moves.** `WIRE_VERSION` stays **2**, the C ABI stays **25**, and so do the
|
||||
driver protocol, the gamepad channel, the plugin index schema and the host event schema. No C
|
||||
function changed its signature, no `#[repr(C)]` struct grew a field, and `include/punktfunk_core.h`
|
||||
gains exactly one line: a `#define` for a new control-message type byte. An embedder rebuilds
|
||||
against the new header and is done; a packager has one thing to notice (the Windows firewall rule
|
||||
below) and one thing to be glad of (the Arch compositor package finally declares the level it
|
||||
builds).
|
||||
|
||||
Two surfaces grow, both by pure addition: the **management API** gains
|
||||
`PUT /library/provider/{provider}/running` and its three schemas, and **`@punktfunk/plugin-kit`
|
||||
goes 0.4.3 → 0.4.4** to export the client call for it. A plugin that adopts neither is unaffected,
|
||||
and an older host answers the route with a 404 that means "this host tracks games by scanning".
|
||||
|
||||
One control message is **added** to the wire — `DeliveryReport`, type byte **`0x0B`** — which is a
|
||||
`#define`, not an ABI step, exactly as `PipelineGap` (`0x0A`) was at v0.30.0. It takes a free byte
|
||||
in its block rather than lengthening an existing message, deliberately: see below.
|
||||
|
||||
Two behaviour changes are worth reading before you package or embed this release: on Windows,
|
||||
`service install` now provisions a **program-scoped inbound UDP rule** for the host executable, and
|
||||
the **GameStream plane's default virtual-Xbox backend flips from the XUSB companion to the UMDF HID
|
||||
pad**, matching what the native plane has done since 2026-08-09.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.31.0 | v0.31.1 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged. One additive control message, `DeliveryReport` (`0x0B`), which an older peer does not parse and does not need — see **The wire** below |
|
||||
| C ABI | 25 | **25** | unchanged. `include/punktfunk_core.h` differs from the v0.31.0 tag by one `#define` (`PUNKTFUNK_MSG_DELIVERY_REPORT = 11`, under `PUNKTFUNK_FEATURE_QUIC`), which is a constant, not a declaration. Rust-only addition in `punktfunk-core`: `client::NO_VIDEO_RETRY` is now public beside `client::FLUSH_COOLDOWN` |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.31.0 tag |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged. What changed is which *backend* the GameStream plane picks, not the channel — see **Windows: the GameStream plane builds the pad games can see** |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
|
||||
| `api/openapi.json` | 0.31.0 | **0.31.1** | one route **added** — `PUT /library/provider/{provider}/running` plus its three schemas — and the stamp moved with the crate (`info.version` is `CARGO_PKG_VERSION`). Regenerated in #361 and re-stamped here; nothing else in the document differs. `api/` and `docs-site/public/` are byte-identical to each other |
|
||||
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files. ⚠ **`packaging/gamescope/PKGBUILD` is fixed here**: it declared `pfhdr7` while patch 0010 stamps `+pfhdr8` into the banner, so pacman saw no upgrade at all — see below |
|
||||
| `@punktfunk/host` (SDK) | 0.1.5 | **0.1.5** | unchanged; nothing under `sdk/` moved |
|
||||
| `@punktfunk/plugin-kit` | 0.4.3 | **0.4.4** | cut, for `ProviderClient.reportRunning` and its two types: they were reachable only through the deep `./reconcile.js` path, because `index.ts` re-exports an explicit list rather than a star, so no plugin could import them from the package root. Tagged `plugin-kit-v0.4.4` and **published** — the registry's `latest` (0.4.2 is still skipped there, as it has been since v0.30.0). The playnite plugin deliberately does *not* depend on it, calling the route through the untyped host seam so it was never gated on this publish |
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None.** No wire change, no ABI change, no driver-protocol change, no plugin-contract change. Every
|
||||
0.31.0 host, client, driver and plugin keeps interoperating in both directions with no re-pairing.
|
||||
|
||||
Two **behaviour** changes that break no build but change what a machine does:
|
||||
|
||||
- **Windows `service install` adds a second firewall rule.** `Punktfunk UDP (data plane)` —
|
||||
`dir=in action=allow protocol=UDP program=<host exe>`, on the same profile set the port rules use.
|
||||
`service uninstall` deletes it by name. If you provision firewall rules yourself instead of
|
||||
letting `service install` do it, you need the equivalent, or your hosts keep the black-picture
|
||||
failure below. Program-scoped rather than port-scoped by design: the data plane binds `0.0.0.0:0`,
|
||||
and a pinned port inside 47998-48010 would collide with Sunshine/Apollo.
|
||||
- **`PUNKTFUNK_XBOX_BACKEND` now governs both planes on Windows, and the GameStream plane's default
|
||||
moves to the HID pad.** `PUNKTFUNK_XBOX_BACKEND=xusb` reverts both planes together; it previously
|
||||
reverted only the native one, because `windows_xbox_hid` was `pub(super)` and unreachable from
|
||||
`gamestream/control.rs`. It is `pub(crate)` now, with one definition and one name.
|
||||
|
||||
### A provider plugin can report which of its titles are running
|
||||
|
||||
The host derives liveness by **scanning**, which needs something recognizable on disk. A
|
||||
Playnite-launched emulated game, a manually added one, or a library plugin that records no install
|
||||
directory has none — and its launch is a `playnite://` hand-off, so the host holds no process
|
||||
either. The lease went `Untracked`: the exit was never noticed, `session_on_game_exit` could not
|
||||
fire, and `POST /game/end` had nothing to aim at.
|
||||
|
||||
**`PUT /library/provider/{provider}/running`** takes a provider's *complete* running set (with the
|
||||
pid where it knows one) — declarative and idempotent like the reconcile beside it, so a missed event
|
||||
or a plugin restart self-corrects rather than drifting. `crate::runstate` holds it and **expires it
|
||||
after 90 s unless restated**, which is what makes it safe for a live provider to hold a session open
|
||||
for a game the host cannot see: a plugin that dies stops counting and the host falls back to
|
||||
scanning. The route is the plugin lane's, like the reconcile, and carries **no new authority** — the
|
||||
host maps `external_id` through the catalog, so a provider can only speak about entries it
|
||||
published. An unknown id is *counted, not refused*, because a report legitimately races its own
|
||||
reconcile and 400-ing the batch would discard the liveness of every other running title.
|
||||
|
||||
**`LeaseKind::Reported`** is the lease that follows. `open` reaches it when the spec is empty and a
|
||||
provider speaks for the id, and — load-bearing on Windows, where every launch is a hand-off by
|
||||
construction — the three shim reclassification paths now fall back to it where they fell to
|
||||
`Untracked`. Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the exit.
|
||||
Unlike `procscan::running_hint`, which may only ever *delay* an exit (Steam's registry flag survives
|
||||
an unclean one), a fresh report is decisive in both directions. A reported pid joins the termination
|
||||
ladders on the same terms as a spawned one: re-resolved and start-time-pinned at the moment of use.
|
||||
|
||||
Client side, `ProviderClient.reportRunning` is exported from the plugin-kit root in 0.4.4 (see the
|
||||
table). A **404 from an older host means "this host tracks games by scanning"** — it is not an error
|
||||
a plugin should retry.
|
||||
|
||||
### The wire: `DeliveryReport` (`0x0B`)
|
||||
|
||||
`LossReport` carries `loss_ppm`, which is a ratio over the packets that **arrived** — so a flawless
|
||||
link and a link delivering nothing both report `0`. A host reading total silence as perfection
|
||||
decayed adaptive FEC to its floor and logged confident wording about the client's network.
|
||||
|
||||
Clients now also send `DeliveryReport`, carrying the session's received-packet count. It is a **new
|
||||
type byte, not a field appended to `LossReport`**: that message is length-checked exactly, so
|
||||
lengthening it would make every shipped host reject the loss reports its FEC runs on. Send policy is
|
||||
deliberately sparse — every window while the count is zero, once when the first packets land, then
|
||||
never — because an older host warns per unknown message type and must not be flooded across a good
|
||||
session.
|
||||
|
||||
`client::NO_VIDEO_RETRY` (the client got nothing) and `client::FLUSH_COOLDOWN` (the client is
|
||||
drowning) were both 2000 ms, so the host's cadence classifier could not tell two opposite faults
|
||||
apart and named the wrong one out loud. `NO_VIDEO_RETRY` moves into `punktfunk-core` beside
|
||||
`FLUSH_COOLDOWN` at **2600 ms**, and both sides now compare against the shared constant rather than
|
||||
against a local copy.
|
||||
|
||||
### Windows: the data plane was never open, on any host
|
||||
|
||||
The firewall rules `service install` writes are `localport=`-scoped (47998-48010, 9777, 5353), and
|
||||
the media data plane binds an **ephemeral** port per session. No such rule can cover it, so Windows
|
||||
Firewall dropped the client's hole-punch on **every session on every Windows host** — `punched=false`
|
||||
on the "data plane bound" line, in all six sessions across two field logs, including sessions that
|
||||
appeared to work. Video then fell back to blind-sending at the address the client *reported*; where
|
||||
the path needed the flow opened client-first, the control plane stayed healthy and the picture never
|
||||
arrived. One field host sent 1,919 frames into a black screen while blaming the client.
|
||||
|
||||
Diagnosis changed with it: it now leads with the delivery count (zero is an **error** naming the data
|
||||
plane; a confirmed count keeps the old confident wording; a client that cannot answer gets a warning
|
||||
that says so), and a punch that never arrives is its own warning rather than a debug field on an info
|
||||
line.
|
||||
|
||||
### Windows: the GameStream plane builds the pad games can see
|
||||
|
||||
There are two virtual Xbox backends on Windows and they are not interchangeable to a game. The XUSB
|
||||
companion registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID collection (`pf_xusb.inx`:
|
||||
"a non-HID UMDF2 driver", `Class = System`), so Steam's hidapi enumeration, SDL, RawInput,
|
||||
DirectInput, `joy.cpl` and WGI/GameInput cannot see it at all — only classic `XInputGetState` can.
|
||||
The native plane moved to the real HID pad as its default in `bd5735b8` for exactly that reason.
|
||||
|
||||
`gamestream/control.rs` had bound `crate::inject::gamepad` since the first gamepad commit, when that
|
||||
name meant uinput and Windows had no second backend; Windows later gave the same name the XUSB
|
||||
companion, so this plane inherited it by module-name coincidence rather than by decision. Every
|
||||
Moonlight-compatible session since has presented a pad most games cannot enumerate. A `SessionPads`
|
||||
enum is now the one place this plane picks a backend, reading the same knob the native plane reads.
|
||||
The HID pad's rich-feedback plane is dropped rather than plumbed: an Xbox pad has no lightbar or
|
||||
adaptive triggers, and GameStream's rumble message (`0x010B`) carries the two handle motors only.
|
||||
|
||||
### Android: buttons resolved from the scancode
|
||||
|
||||
Android names a pad's buttons through a **key layout file** matched on VID/PID; a pad with no
|
||||
matching file falls back to AOSP's `Generic.kl`, which assigns keycodes by **scancode position**
|
||||
(`0x130`→`BUTTON_A`, `0x131`→`BUTTON_B`, …). A HID gamepad with no kernel driver numbers its buttons
|
||||
`1..n` in its own report order, so every keycode past the first divergence is somebody else's button.
|
||||
AOSP ships no layout for the Elite Series 2 over Bluetooth (`045e:0b05`) on any version, and the
|
||||
DualSense's (`054c:0ce6`) postdates Fire OS and requires `CONFIG_HID_PLAYSTATION`, which a Fire TV
|
||||
kernel has not.
|
||||
|
||||
`Gamepad.padKeyCode(event)` is a drop-in for `event.keyCode` and **every** pad reader now goes
|
||||
through it — the streaming branch, the Skia console shell's probe, the older Compose navigation, and
|
||||
the Controllers tester. Two guards keep it off pads that already work: the correction applies only
|
||||
where the delivered keycode is what `Generic.kl` would have said, and which report order to read is
|
||||
decided from what the device *declares* (a pad numbering straight through claims `BUTTON_C` and
|
||||
`BUTTON_Z`, keycodes no real controller has a button for) rather than from a model table. Axes get
|
||||
the same treatment, with trigger rest position measured from the device's own range instead of
|
||||
assumed. The Xbox Bluetooth product ids (One S, Elite Series 2 and its Core) join the identity table.
|
||||
|
||||
Also here: `pads()` filters on `looksLikeController` (the source claim **and** hardware behind it)
|
||||
rather than on `isPad`, which kept the console UI pinned on for any device merely claiming the
|
||||
gamepad source class; and the `ASurfaceControl` layer's destination rect is now read per-present from
|
||||
a packed atomic on the session handle (new JNI symbol `nativeVideoSurfaceSize`, fed from every
|
||||
`surfaceChanged`) rather than captured once at `surfaceCreated`, which is why the picture sat at the
|
||||
origin once the bars and cutout grew the view.
|
||||
|
||||
### gamescope and the takeover
|
||||
|
||||
- **`packaging/gamescope/PKGBUILD` moves `pfhdr7` → `pfhdr8`.** The banner has said `+pfhdr8` since
|
||||
patch 0010 (the seat's stub keyboard carrying the compiled `XKB_DEFAULT_*` keymap), and the host
|
||||
probes the banner for `>= 8` on the keymap path — but pacman compares `pkgver-pkgrel`, read
|
||||
`3.16.25.pfhdr7-1` on both v0.30.0 and v0.31.0, and **offered no upgrade at all**. deb and rpm
|
||||
derive their version from the binary banner and moved by themselves; Arch is the only channel that
|
||||
hardcodes it. This is the mismatch the v0.31.0 table flagged as pre-existing.
|
||||
- **The in-stream session-select gate is armed again.** v0.31.0's takeover stopped stopping the
|
||||
display manager and started idling the autologin session (`c2f5e91b`), which also deleted the two
|
||||
lines the old path carried (`record_session_select_baseline()`, `STOPPED_DM = Some(dm)`);
|
||||
`38a0f54b` then removed every remaining writer, leaving `honor_session_select_switch` unreachable.
|
||||
Bazzite/SteamOS never noticed — their `os-session-select` writes no sentinel and
|
||||
`is_steam_htpc_platform()` defaults the mid-stream watcher on. `ID=nobara` matches no HTPC default
|
||||
and its ChimeraOS-layout `os-session-select` **does** write the sentinel, so on Nobara a mid-stream
|
||||
"Switch to Desktop" went entirely unhandled. `takeover_idled()` now reads `IDLE_DROPIN_ARMED`, the
|
||||
idle drop-in re-baselines the sentinel, and `STOPPED_DM` is documented as adoption-only state for a
|
||||
takeover stranded by a pre-0.31.0 host. Both hand-back paths also restore the box's own Game Mode
|
||||
unit, which neither did — a mid-stream switch is not a disconnect, so the disconnect sweep never
|
||||
reached the `ExecStart=/usr/bin/sleep infinity` drop-in.
|
||||
- **Nix shipped a wrapper with no target.** nixpkgs wraps this package: the real ELF is
|
||||
`bin/.gamescope-wrapped` and `bin/gamescope` is a makeWrapper launcher. The prune
|
||||
(`find $out/bin -mindepth 1 ! -name gamescope -delete`) deleted the compositor and kept the
|
||||
launcher — measured at 16 KB. That single line explains the empty `--version` output and the
|
||||
"missing `+pfhdr` marker", both of which had been attributed to the build sandbox and to upstream.
|
||||
The prune keeps the target now and the guard asserts on the **wrapped ELF**. Separately,
|
||||
`packaging/nix/gamescope.nix` now pins `src` to `5fb8dce4` like every other channel — it was the
|
||||
only one patching whatever version nixpkgs happened to carry, which broke `host.gamescopeHdr`
|
||||
(default true) builds outright when nixpkgs shipped 3.16.24.
|
||||
|
||||
### Everything else an integrator might notice
|
||||
|
||||
- **`pf-console-ui`:** `ConsoleOptions.fallback_ui` (new, threaded to `Ctx` beside `deck`) gates the
|
||||
Android-only "Controller-optimized UI" row, written through `extra` under
|
||||
`android.gamepad_ui_enabled`; it is true only for the Android touch shell. Down on the carousel
|
||||
opens Settings (`▼` is a new hint glyph — the `▲` triangle inverted, not a second draw routine),
|
||||
and the host options menu gains a Library row on the same terms `Y` offers it (saved **and**
|
||||
paired), replacing the menu rather than stacking on it. Both exist because a TV remote emits only
|
||||
Move/Confirm/Back.
|
||||
- **`scripts/ci/docs-undocumented-env-baseline.txt`** gains `PUNKTFUNK_MSG_DELIVERY_REPORT`.
|
||||
`check-docs-drift.sh` scans for `PUNKTFUNK_*` identifiers and cannot tell an operator knob from a
|
||||
cbindgen-exported `#define`; every other `PUNKTFUNK_MSG_*` is already baselined beside it.
|
||||
- **`clients/probe`** reads the new delivery counter.
|
||||
- **`trust::Settings::extra` is `#[serde(flatten)]`**, so `android.*` keys are **top-level** keys of
|
||||
the settings document, beside `width` and `codec`. `ConsoleJson` wrote and read them nested under
|
||||
an `"extra"` object, which serde stored under the literal key `"extra"` — so no console row ever
|
||||
found `android.gamepad_ui_enabled`, every Android-only row (low latency, phone rumble/gyro, SC2 and
|
||||
DualSense capture, the console-UI mode picker) read its own default, and the value the console
|
||||
saved came back to Kotlin unchanged, so `applySettings` raised no callback. Fixed in #362, which is
|
||||
what makes the `feat` above work at all. A store written by the nesting build carries the dead
|
||||
wrapper and drops it on the next write. The new test pins the shape from **both sides**: a
|
||||
round-trip alone could not catch this, because both halves agreed on the same wrong nesting.
|
||||
- **`pf-console-ui` focus halo / `panel_highlight` radii.** A rounded rect grown by `d` keeps its
|
||||
corners concentric with the original only if its radius grows by `d` too; both helpers kept the
|
||||
card's own radius, so the halo read as a squared-off outline at the four corners. `drop_shadow`
|
||||
only offsets and was already right; the collections plate uses `RRect::with_outset`, which adjusts
|
||||
radii itself. Every card path goes through the two fixed helpers.
|
||||
|
||||
### Verification status
|
||||
|
||||
Gates run on the release tree (this MacBook, rustc/rustfmt per `rust-toolchain.toml`):
|
||||
`cargo fmt --all --check` clean; `cargo metadata --offline` ok with the `Cargo.lock` diff
|
||||
versions-only (36/36 lines); `cargo test -p punktfunk-core --lib` **273 passed**; the C ABI harness
|
||||
(`tests/c_abi.rs`) **passed**, reporting `abi_version=25` and four frames round-tripped byte-exact
|
||||
through lossy loopback — it did **not** run on the v0.31.0 cut, so this is the first cut since ABI 25
|
||||
where a C compiler has actually built the generated header; `scripts/ci/check-docs-drift.sh` clean;
|
||||
the android.yml Play notes gate run verbatim — 442/500 characters and not byte-identical to any prior
|
||||
release's; both openapi copies `cmp` identical, both stamped 0.31.1; notes voice scan clean.
|
||||
|
||||
⚠ **`api/openapi.json` was re-stamped here, not regenerated.** The document itself was regenerated
|
||||
in #361 (with the new route and its three schemas) on a runner where
|
||||
`openapi_document_is_complete_and_checked_in` actually executes; this commit moves only
|
||||
`info.version`, which utoipa fills from `CARGO_PKG_VERSION`. `punktfunk-host` does not build on
|
||||
macOS, so that test could not be re-run here — but `0.31.0` appears nowhere else in either copy, so
|
||||
regeneration would produce this byte-for-byte. If it ever fails on this commit, regenerate with
|
||||
`cargo run -p punktfunk-host -- openapi > api/openapi.json` and `cp` to `docs-site/public/`.
|
||||
|
||||
⚠ **Verified by reading only** — compiled nowhere available to the cutting host: everything under
|
||||
`crates/punktfunk-host` (Windows and Linux arms alike), `packaging/nix/gamescope.nix`, and the
|
||||
Android/Kotlin half. That includes `crate::runstate` and the new route; its own tests turned up a
|
||||
collision on their first run in an environment that executes them (all three shared the provider id
|
||||
`playnite` and cleared the process-global table between cases, so parallel scheduling flipped their
|
||||
answers) — fixed in #361 by giving each test ids only it uses and retiring the blunt `reset()`. The Windows GameStream pad change was checked on `.133` when it landed
|
||||
(`cargo check` and `cargo clippy -p punktfunk-host -- -D warnings` clean, both compiling arms), and
|
||||
`windows-host.yml` has **no `pull_request` trigger**, so a PR will not re-check that arm.
|
||||
|
||||
⚠ **`audit.yml` is red on main and this release does not fix it.** `cargo audit` reports
|
||||
**RUSTSEC-2026-0258** (`h2` 0.4.15, "unbounded empty DATA frames", published 2026-08-17, fixed in
|
||||
0.4.16); `h2` is transitive through `hyper`. It **predates this cut** — the same job failed on
|
||||
`669a1bc0` and on the v0.31.0 tag commit — so it is not a regression here, and it was deliberately
|
||||
**not** bundled into the release commit: `cargo update -p h2 --precise 0.4.16` bumps h2 in eleven
|
||||
lock lines but also rewrites several `windows-sys` references downward (0.61.2 → 0.59.0/0.52.0) on
|
||||
the pinned 1.96.0 toolchain, and re-resolving the graph for the Windows build is not a change to
|
||||
make inside a version bump that cannot be compiled for Windows on the cutting host. It wants its own
|
||||
commit and its own CI.
|
||||
|
||||
⚠ **Not confirmed on glass:** the Android scancode remap (the reporter's Fire TV Stick 4K Max is the
|
||||
test that settles it), the Windows data-plane firewall rule in a field session, and the
|
||||
Moonlight-compatible HID pad — the log line to look for there is
|
||||
`virtual Xbox pad created (Windows UMDF HID)` where it used to say
|
||||
`virtual Xbox 360 created (Windows XUSB companion)`.
|
||||
|
||||
---
|
||||
|
||||
## v0.31.0
|
||||
|
||||
90 commits since v0.30.0 (65 non-merge).
|
||||
170 commits since v0.30.0 (113 non-merge), counted at the tip this was cut from.
|
||||
|
||||
Nothing versioned moves. `WIRE_VERSION` stays **2**, the C ABI stays **24** — `include/punktfunk_core.h`
|
||||
is byte-identical to the v0.30.0 tag — the driver protocol, gamepad channel and plugin index schema
|
||||
are all unchanged, and no `trust::Settings` field, capability bit or control-message type byte was
|
||||
added. Every 0.30.x host, client, driver and plugin keeps interoperating in both directions, with no
|
||||
re-pairing.
|
||||
One versioned surface moves, additively: the **C ABI goes 24 → 25**, a single new symbol
|
||||
(`punktfunk_set_log_callback`) that lets an embedder hear the core's own log lines. Nothing else
|
||||
does — `WIRE_VERSION` stays **2**, the driver protocol, gamepad channel and plugin index schema are
|
||||
unchanged, and no `trust::Settings` field, capability bit or control-message type byte was added.
|
||||
No existing C function changed its signature or behaviour and no `#[repr(C)]` struct grew a field,
|
||||
so an embedder that adopts nothing rebuilds against the new header and is done. Every 0.30.x host,
|
||||
client, driver and plugin keeps interoperating in both directions, with no re-pairing.
|
||||
|
||||
What did move is beneath the versioned surfaces, and three parts of it are worth a packager's or
|
||||
embedder's attention: the Linux host package installs **three new system files** (a udev rule, a
|
||||
Beneath the versioned surfaces, four things are worth a packager's or embedder's attention: the
|
||||
**Windows client's default download changes** to a per-user installer plus a portable zip, with the
|
||||
MSIX kept for the Store; the Linux host package installs **three new system files** (a udev rule, a
|
||||
WirePlumber policy and an ALSA UCM drop-in) that the DualSense audio path depends on; the Linux
|
||||
desktop-audio capture **flipped topology by default** (`PUNKTFUNK_STREAM_SINK` unset now means a
|
||||
host-owned `null-audio-sink`, with `=stream` a one-release escape hatch to the 0.30 shape); and the
|
||||
@@ -35,7 +503,7 @@ three ABIs, which removes the Compose screenshot scenes.
|
||||
| | v0.30.0 | v0.31.0 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged |
|
||||
| C ABI | 24 | **24** | unchanged — `include/punktfunk_core.h` is byte-identical to the v0.30.0 tag; the only new `pub` items in `punktfunk-core` are three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`), Rust-only, no `pub const` for cbindgen to pick up |
|
||||
| C ABI | 24 | **25** | one additive step: v25 adds `punktfunk_set_log_callback` and the `PunktfunkLogCb` typedef (below). No existing declaration moved and no struct grew a field. Also new in `punktfunk-core`, Rust-only: three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`) |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
|
||||
@@ -43,20 +511,39 @@ three ABIs, which removes the Compose screenshot scenes.
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
|
||||
| `api/openapi.json` | 0.29.0 | **0.29.0** | unchanged — no management-API surface moved this cycle; both copies (`api/` and `docs-site/public/`) are byte-identical to each other and to the tag |
|
||||
| `api/openapi.json` | 0.29.0 | **0.31.0** | **the stamp only** — no management-API surface moved this cycle. The file had been left at 0.29.0 while the crate was already 0.31.0; #337's regenerate-and-diff caught it and it was regenerated, which is a one-line change to both copies. `api/` and `docs-site/public/` are byte-identical to each other |
|
||||
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files. ⚠ `packaging/gamescope/PKGBUILD` still says `pfhdr7` — pre-existing at v0.30.0, not a regression this cycle, but the Arch package builds a binary the host's `>= 8` probe rejects for the keymap path |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged in `package.json` — but `sdk/src/config.ts` and `runner-cli.ts` changed (the `mgmt-endpoint` fix below), so a `sdk-v0.1.5` cut is **owed**; plugins resolve the SDK from the registry and cannot pick the fix up until it ships |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.5** | cut — `sdk/src/config.ts` and `runner-cli.ts` carry the `mgmt-endpoint` fix below, and plugins resolve the SDK from the registry, so it could not reach them until it shipped |
|
||||
| `@punktfunk/plugin-kit` | 0.4.2 | **0.4.3** | cut, for the two `sync-engine.ts` changes that cannot reach a plugin any other way: `minInterval` (below) and the always-apply sync reasons (`startup`/`manual` publish even when the fingerprint matches, so a host-side art drop is recoverable by restarting rather than by deleting the plugin's cache). Note the registry skips 0.4.2: `plugin-kit-v0.4.2` was tagged but its publish never landed, and the tag is left where it is rather than moved |
|
||||
|
||||
⚠ The SDK and plugin-kit version independently of the app (`sdk-v*` / `plugin-kit-v*` tags,
|
||||
`sdk-publish.yml` / `plugin-kit-publish.yml`); this release commit does not bump them. Both have
|
||||
unpublished code changes, called out in the table so they are cut deliberately rather than
|
||||
discovered.
|
||||
`sdk-publish.yml` / `plugin-kit-publish.yml`), so their rows record what the registry holds, not
|
||||
what this tag ships. Both were cut during this cycle rather than left owed — a plugin resolves them
|
||||
from the registry, so a fix that never ships there never reaches one.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None on any versioned surface.** No wire change, no C ABI change, no driver-protocol change, no
|
||||
plugin-contract change. Four things are worth attention anyway; none breaks a build:
|
||||
**None that break a build.** No wire change, no driver-protocol change, no plugin-contract change.
|
||||
The C ABI moves 24 → 25 by **addition only**:
|
||||
|
||||
- **v25 — `punktfunk_set_log_callback(max_level, cb, user)`.** The core logs through `tracing`; an
|
||||
embedder that installs no Rust subscriber hears none of it — transport warnings, connection events,
|
||||
handshake notes — and a client log bundle carries the shell's half alone, which is exactly what an
|
||||
Apple TV field report turned out to be. The call registers a `log::Log` backend behind a C callback
|
||||
(`PunktfunkLogCb`: level, target, message, user), gated by `log::set_max_level` so anything above
|
||||
the ceiling costs no formatting; `NULL` detaches, and it answers `Unsupported` when another log
|
||||
backend already owns the process (`android_logger`). Both strings are borrowed for the call only,
|
||||
and an interior NUL drops the line rather than truncating it. `punktfunk-core` now declares
|
||||
`tracing`'s `log` feature explicitly — it had been on transitively via quinn, which an ABI promise
|
||||
must not rest on. An embedder that never calls it is byte-compatible with v24; see
|
||||
`docs/embedding-the-c-abi.md` §2.6.
|
||||
- **One header comment was wrong and is corrected, with no signature change:**
|
||||
`punktfunk_connect_ex10`'s summary still stated the pre-2026-08-16 rule that only a format other
|
||||
than 48000/16 requests the lossless plane. Any non-zero format at all does, 48000/16 included —
|
||||
which is what its own warning already said and what the code always did. Embedders reading the
|
||||
summary were reading the old rule.
|
||||
|
||||
Five more things are worth attention; none breaks a build:
|
||||
|
||||
- **`refactor(android)!` — the Compose console is deleted.** `pf-console-ui` (the Skia shell the
|
||||
desktop session binary draws) is now Android's console on arm64-v8a, x86_64 **and** armeabi-v7a;
|
||||
@@ -78,6 +565,11 @@ plugin-contract change. Four things are worth attention anyway; none breaks a bu
|
||||
monitors disabled for the session now (closes #284).
|
||||
- **Three new system files in the Linux host package** — the DualSense audio path does not work
|
||||
without them. Downstream repackagers: see the packaging section.
|
||||
- **The Windows client's default download is a per-user installer, not the MSIX.** The MSIX stays,
|
||||
for the Store; the installer and a portable zip are what the download page now offers, and the
|
||||
release carries `punktfunk-client-setup_<arch>.exe` and `..._<arch>-portable.zip` alongside it.
|
||||
Anyone scripting against the MSIX asset name is unaffected; anyone scripting against "the Windows
|
||||
client download" gets a different artifact. See the Windows client section.
|
||||
|
||||
### DualSense audio and haptics on Linux: five faults, and the files they needed
|
||||
|
||||
@@ -295,6 +787,25 @@ gains two direct deps already in the graph.
|
||||
`frameRatePowerSavingsBalanced`) raises the render-range floor — so the ineffective pins were
|
||||
removed again and `pf.present` gained the cadence loop's late-permille / jitter / cushion /
|
||||
re-anchors / qDepth.
|
||||
- **Colour tagging, which the SurfaceView path never had to do.** MediaCodec tags its own window
|
||||
buffers; with `AImageReader` → `ASurfaceControl` the transaction is the only carrier, and a
|
||||
dataspace of 0 means `setBufferDataSpace` is never called. Two consequences, both fixed inside the
|
||||
cycle: **HDR** was seeded from a hardcoded `BT2020_ITU_PQ` guess and then overwritten by whatever
|
||||
the codec echoed on the first output-format change — a decoder that omits color-transfer (common)
|
||||
echoes None, clobbering the dataspace to 0 before the first present, so P010 buffers composited as
|
||||
sRGB, and an HLG stream was mis-seeded PQ. The initial dataspace now derives from `client.color`
|
||||
(PQ vs HLG, range) and a format change only *refines* it when the codec actually reports an HDR
|
||||
transfer, never resets it — the SurfaceView path's semantics. And **SDR** was untagged entirely:
|
||||
a limited-range BT.709 buffer read as full range shows black (16) as grey, so SDR now maps to
|
||||
`ADATASPACE_BT709` and every ASC buffer is tagged.
|
||||
- **One owner for the system bars.** Console → stream rides an `AnimatedContent` cross-fade, so the
|
||||
outgoing console shell stays composed until the fade ends and its
|
||||
`onDispose { show(systemBars()) }` fired *after* `StreamScreen`'s hide — parking the status and
|
||||
gesture bars over the video for the whole session. Hide/show now lives once in `App.kt`, keyed on
|
||||
the resolved intent (streaming or console fronting = immersive, touch shell = bars back), and both
|
||||
screens' per-screen bar management is deleted.
|
||||
- **Idle gates** (from the console-ui sweep): the reachability sweep only probes while the console is
|
||||
attached, and the render thread drops to half rate after 60 s without input.
|
||||
|
||||
### Hyprland / sway: `topology: exclusive` (closes #284)
|
||||
|
||||
@@ -314,20 +825,92 @@ with non-legacy parsers"). `primary` stays extend and warns distinctly. ⚠ **Th
|
||||
exercised on a live sway** — no box in the fleet runs one; both argv shapes are pinned by tests and
|
||||
the read-back turns a wrong guess into a warning naming the outputs. Six new unit tests.
|
||||
|
||||
### Gaming Mode takeover: the mask was the relogin storm
|
||||
### Gaming Mode takeover: it no longer touches the display manager at all
|
||||
|
||||
On an SDDM-autologin box the runtime mask the takeover laid sat in SDDM's relogin path, so every
|
||||
autologin failed in milliseconds and `Relogin=true` has no backoff: 962 logind sessions in 3.7 min,
|
||||
system buttons re-scanned 5,688×, udev `change` at ~20/s, iio-sensor-proxy crash-looping ~16
|
||||
starts/s, load 26 on 12 cores — and Wine's bus driver, re-enumerating udev per event, read the pad at
|
||||
~1.4 Hz. `dm_plan` loses its `mask` input and `dm_survives_masked_unit`; the mask is laid **only after
|
||||
the stop has landed** and every restore path unmasks before restarting; a planned DM stop that does
|
||||
not land now **fails the takeover** and the caller degrades to ATTACH. `skip` is `!any_live` on every
|
||||
flavor; `any_live` now counts `deactivating` and `reloading`. New `DmHelperError::shape()`;
|
||||
`watch_for_relogin_storm()` (two `read_dir`s of `/run/systemd/sessions` 5 s apart, ERROR above 1/s,
|
||||
detect-only); `systemctl_system` captures stderr at DEBUG (the "requires interactive authentication"
|
||||
line was going to the journal on the *successful* path). `cargo test -p pf-vdisplay --lib gamescope`
|
||||
52 passed, 1 ignored.
|
||||
This landed in two steps within the cycle, and the second retired the first — read the end state.
|
||||
|
||||
**The storm.** On an SDDM-autologin box the runtime mask the takeover laid sat in SDDM's relogin
|
||||
path, so every autologin failed in milliseconds and `Relogin=true` has no backoff: 962 logind
|
||||
sessions in 3.7 min, system buttons re-scanned 5,688×, udev `change` at ~20/s, iio-sensor-proxy
|
||||
crash-looping ~16 starts/s, load 26 on 12 cores — and Wine's bus driver, re-enumerating udev per
|
||||
event, read the pad at ~1.4 Hz. Masking without stopping the display manager is not a weaker
|
||||
defence; it is the storm's engine.
|
||||
|
||||
**Then stopping the DM proved wrong too.** With no display manager there is nothing on the box able
|
||||
to start a desktop session, so Steam's own "Switch to Desktop" sat on its modal until a reboot
|
||||
(field report 2026-08-18, `.41`). It could not even be detected and worked around: on a
|
||||
steamos-manager box every trace of that switch is written by the component we had just stopped —
|
||||
the `~/.config/steamos-session-select` sentinel is never written (that is the ChimeraOS/Nobara
|
||||
layout), `/var/lib/sddm/state.conf` only advances when sddm actually *starts* a session,
|
||||
`get-default-login-mode` stays `game` for a non-persistent switch, and `graphical-session.target`
|
||||
going inactive fires at takeover time as well.
|
||||
|
||||
**End state: idle the autologin, leave the display manager alone.** The takeover drops a unit
|
||||
override over the `gamescope-session-plus@` template replacing `ExecStart` with a process that
|
||||
sleeps. The autologin still *succeeds*, so there is no failed unit to relogin against; the session
|
||||
runs nothing, so Steam is free; and the DM is alive, so the box can service its own session switch.
|
||||
No privilege, no DM-flavour matrix, no detection. Measured on `.41` in both directions: takeover
|
||||
leaves `steam` down, `sddm` active, the unit `active (running)` with `NRestarts=0`; the switch that
|
||||
used to hang brings Plasma up in ~10 s; the restore puts Steam back within 5 s. The drop-in lives
|
||||
under `$XDG_RUNTIME_DIR` (a copy outliving the host would be a box whose Game Mode silently does
|
||||
nothing), is swept unconditionally at startup, and its removal sits above every early return in the
|
||||
restore — the desktop-active return is exactly the path that would leak it. The restore *restarts*
|
||||
rather than starts, because `start` on an active-but-idle unit is a no-op that would log success
|
||||
over it.
|
||||
|
||||
With nothing stopping a display manager any more, the whole chain built to survive doing so is
|
||||
deleted: `try_stop_display_manager`, `ensure_host_survives_dm_stop`, `host_is_under_user_manager`,
|
||||
`cgroup_under_user_manager`, `linger_enabled` and `dm_plan`'s mask input — 142 lines out, 17 in.
|
||||
**Two shipped facts became false and are corrected:** the takeover no longer has to stop the display
|
||||
manager, and it no longer needs the `punktfunk` group (the docs and the shipped Bazzite `host.env`
|
||||
both said it did). That group still gates the usbip nodes the virtual Steam Deck pad attaches
|
||||
through, which is what the advice now narrows to. Kept from the first step: `any_live` counts
|
||||
`deactivating` and `reloading` (a unit mid-teardown used to read as a dead leftover, so a box that
|
||||
*is* in gaming mode sampled as idle); `DmHelperError::shape()`; `watch_for_relogin_storm()` (two
|
||||
`read_dir`s of `/run/systemd/sessions` 5 s apart, ERROR above 1/s, detect-only, and it states that
|
||||
no audio, input or PipeWire measurement taken during a storm is valid); and `systemctl_system`
|
||||
capturing stderr at DEBUG, since that verb is *expected* to fail on an unprivileged host and its
|
||||
"requires interactive authentication" line was going to the journal on the successful path.
|
||||
|
||||
### KWin 6.6 creates our virtual output disabled, and refuses to stream it
|
||||
|
||||
On KWin ≥ 6.6 `streamVirtualOutput` creates the output on the backend and then hands
|
||||
`workspace()->findOutput(output)` to the stream — null for an output the workspace does not manage
|
||||
(`wantsToManage` = `isEnabled() && !isNonDesktop()`). An output KWin creates **disabled** is
|
||||
therefore refused with "Could not find output", translated into the session's language and logged
|
||||
nowhere, because disabling an output is a perfectly valid configuration that applies successfully.
|
||||
6.4/6.5 passed the backend output straight through and streamed it either way. It repeats forever:
|
||||
the host asks for a *stable* per-client output name precisely so KWin persists that client's scale
|
||||
and mode against it, so a stored configuration naming it `enabled: false` is reapplied to every
|
||||
future session for that client — and the user cannot fix it in System Settings, because the output
|
||||
only exists for the few milliseconds the request is alive. The host now enables the output and
|
||||
retries. Related, from the same investigation: a **translated** KWin refusal used to burn all 8
|
||||
retries because the match was against KWin's message rather than our own prefix.
|
||||
|
||||
### Windows client: a per-user installer and a portable zip, because Steam must spawn the exe
|
||||
|
||||
A user report — launching through Big Picture does not work and the Steam overlay never appears —
|
||||
turned out to be nothing to do with the app being UWP (it is full-trust Win32 under MSIX too) and
|
||||
everything to do with the MSIX install **shape**: the exe lives under the ACL'd `WindowsApps`
|
||||
directory that Steam's non-Steam-game picker cannot browse, and alias / `shell:AppsFolder`
|
||||
activation defeats the overlay's injection. Steam has to spawn the exe itself, from a normal path.
|
||||
|
||||
- **`punktfunk-client.iss`** — a per-user Inno Setup install (no UAC) to
|
||||
`%LOCALAPPDATA%\Programs\Punktfunk`, re-creating in `HKCU` what the MSIX manifest granted: the
|
||||
`punktfunk://` scheme, the Start entries, and `{app}` on the user PATH for the `punktfunk` CLI. It
|
||||
fetches the Windows App Runtime when missing.
|
||||
- **`pack-client-installer.ps1`** consumes `pack-msix.ps1`'s layout (one assembly, three artifacts),
|
||||
signs the four exes individually and emits `setup.exe` plus a portable zip — same signing backends
|
||||
and fail-closed-on-tags rule as its siblings, and no `.cer`, because an exe runs untrusted.
|
||||
- **`windows-client.yml`** packs after the MSIX and publishes/attaches the new artifacts;
|
||||
canary/latest aliases are `punktfunk-client-setup_<arch>.exe` and `..._<arch>-portable.zip`.
|
||||
- **`deeplink.rs`**: `write_shortcut` targets the app-execution alias only under package identity —
|
||||
an unpackaged install has no alias but does have a stable path, so it targets `current_exe()`.
|
||||
`has_package_identity()` is now shared with `main.rs`'s AppUserModelID probe.
|
||||
- Uninstall is `Settings → Apps → Installed apps` (per-user, no admin prompt) or
|
||||
`unins000.exe /VERYSILENT`; a portable unzip registers nothing and is deleted by hand. Documented
|
||||
in install-client (with a "Launching through Steam" section), channels, clients, uninstall, and
|
||||
both copies of `platforms.json`.
|
||||
|
||||
### Windows host: two session-killers
|
||||
|
||||
@@ -351,6 +934,123 @@ line was going to the journal on the *successful* path). `cargo test -p pf-vdisp
|
||||
instead of `launching` forever. Fixture in `a_pid_only_launch_reports_its_exit` widened 4 → 8 s
|
||||
(it passed only because of the bug); new ignored test drives the field report.
|
||||
|
||||
### `scripts/install.sh`: a guided Linux host install (preview)
|
||||
|
||||
Plain POSIX `sh`, dash-clean, `curl -fsSL https://punktfunk.unom.io/install.sh | sh`. Detect the
|
||||
distro from os-release (apt / dnf / pacman / rpm-ostree→sysext; NixOS, SteamOS, Windows and unknown
|
||||
distros get a one-line pointer and stop; Debian 12 / Ubuntu 24.04 / Mint 22 / Fedora 45 hit the
|
||||
documented floors with the right docs link) → install using the `data/platforms.json` lines
|
||||
**verbatim** (channel and the Fedora group are edited into the string at run time) → run
|
||||
`punktfunk-host detect-conflicts` (exit 1 = an active Sunshine-family host) → offer to keep both by
|
||||
moving the management API port (`PUNKTFUNK_MGMT_BIND`, default 47991, which the firewall step then
|
||||
opens) → input group (`ujust` on Bazzite) → optional `punktfunk` group, GameStream compat and shared
|
||||
clipboard, all defaulting to no → firewalld/ufw profiles → enable host + console (+ the plugin
|
||||
runner where it is not) → optional linger → verify (unit active, UDP 9777 bound) and print the
|
||||
console URL, the password command and the pairing steps.
|
||||
|
||||
`--dry-run` prints every command and changes nothing; `--uninstall` reverses the install and the
|
||||
service enable per family (user units off first, then only the punktfunk packages actually
|
||||
installed, then the repo — config, groups and firewall stay, as `/docs/uninstall` states). Every
|
||||
prompt has a `PUNKTFUNK_INSTALL_*` environment twin so `--yes` (or no terminal) runs unattended, and
|
||||
stdin is never read, because under `curl | sh` stdin *is* the script. Re-running is safe. The
|
||||
end-of-run check catches the two NVIDIA silent failures on every family — no driver at all, and a
|
||||
module the kernel refused to load under Secure Boot — via an `nvidia-smi` probe pointing at the
|
||||
troubleshooting anchor.
|
||||
|
||||
It is labelled **PREVIEW** on purpose: the per-distro docs pages remain the documented default until
|
||||
it has mileage. CI runs it: a new `installer-smoke.yml` exercises install and `--uninstall` per
|
||||
package family, and `check-docs-drift.sh` gate 7 runs the 16-file os-release detection matrix
|
||||
through the real script under `--dry-run` on every push. One bug fixed by the first smoke run: the
|
||||
`/dev/tty` probe used `-r`/`-w`, which answer yes in a container that has the node but no
|
||||
controlling terminal, so the redirect failed — it opens the device instead now.
|
||||
|
||||
### One home per fact: `data/platforms.json`, and CI gates against drift
|
||||
|
||||
Install commands, repo URLs and port numbers had drifted across four surfaces. They now live in
|
||||
`data/platforms.json` and nowhere else: the docs-site install pages quote it through an
|
||||
`<Install platform="…"/>` MDX component reading a byte-identical snapshot at
|
||||
`docs-site/src/data/platforms.json` (the Docker build context is `docs-site/` alone, the same
|
||||
arrangement `openapi.json` uses), `<Ports/>` renders the port table from it, the website download
|
||||
page vendors it, and `install.sh` runs it. `scripts/ci/check-docs-drift.sh` gates the parse, the
|
||||
snapshot sync, undocumented `PUNKTFUNK_*` knobs (against a checked-in baseline) and the detection
|
||||
matrix; `check-docs-links.sh` covers dead links.
|
||||
|
||||
⚠ **Two consequences for whoever cuts this release.** The website vendors `platforms.json` and only
|
||||
refreshes when someone runs `bun run sync-platforms` in punktfunk-website and commits — the release
|
||||
flow in `docs/releases/README.md` gained that step, and `platforms.json` **did** change this cycle
|
||||
(the Windows client download). And the `.gitea/PULL_REQUEST_TEMPLATE.md` now asks the one question
|
||||
CI cannot: did a user-facing fact change, and is the page that owns it updated in the same PR.
|
||||
|
||||
### Clients can send their logs to the host, on every platform that has a console
|
||||
|
||||
0.30 shipped "Send logs to host" on the Gaming Mode console alone and named the Apple and Android
|
||||
legs as follow-ups. Both landed here.
|
||||
|
||||
- **Apple** — a `ClientLog` drop-in for `Logger(subsystem: "io.unom.punktfunk", category:)` with the
|
||||
same call shape, writing os_log *and* a process-global ring bounded at 4096 lines / 768 KiB (under
|
||||
the host's 1 MiB cap), stamped wall-clock ISO-8601 so a bundle lines up with the host log;
|
||||
`.debug` stays out of the ring, which is the Steam Deck DPB lesson applied in advance. 13 `Logger`
|
||||
declarations swapped. `MgmtTransport`/`MgmtConnection` POST a length-framed body on the same
|
||||
pooled, pinned mTLS connection; `SendLogs.toHost` requires identity and pinned fingerprint, the
|
||||
same gates as the library. Reachable from the host card's context menu and the gamepad host
|
||||
options. Paired with ABI v25 above, the Swift client finally hears the core's own lines too
|
||||
(`core.<crate>`, info ceiling by default, `PUNKTFUNK_CORE_LOG_LEVEL` raises it).
|
||||
- **Android** — `pf-client-core`'s logring RING half (note/render/wallclock, std-only) is
|
||||
Android-enabled, with `send_to_host` still desktop-gated alongside the ureq fetches; `wallclock`
|
||||
moves in from the session's ring layer so every feeder stamps lines identically. `JNI_OnLoad`
|
||||
installs a `RingTee`, so every `log` record goes to logcat **and** into the ring in the desktop
|
||||
ring layer's line shape; `nativeRenderLogs(header)` hands Kotlin the rendered bundle, and the
|
||||
upload rides the client's own mTLS.
|
||||
|
||||
### A provider plugin can report which of its titles are **running**
|
||||
|
||||
New: `PUT /api/v1/library/provider/{provider}/running`, body
|
||||
`{"running":[{"external_id":"…","pid":1234}]}` — the **live** counterpart to the static `detect`
|
||||
hints a reconcile carries. `detect` says *how to recognize* a title's process; this says *it is
|
||||
running now*, and carries the pid where the provider knows one. Additive: no existing route,
|
||||
payload or behaviour changes, and a host with no reporting plugin behaves exactly as before.
|
||||
|
||||
It exists because one class of title could never be tracked at all. The host derives liveness by
|
||||
scanning (`procscan` + `DetectSpec`), which needs something recognizable on disk — an install
|
||||
directory, an executable, a Steam reaper. A Playnite-launched emulated game, a manually added one,
|
||||
or a library plugin that records no install directory has none of that, and its launch is a
|
||||
`playnite://` hand-off, so the host holds no process either: the lease went `Untracked`, its exit
|
||||
was never noticed, `session_on_game_exit` could not fire, and `POST /game/end` had nothing to aim
|
||||
at. Playnite knew the whole time — it starts the game, tracks it in the mode the person configured,
|
||||
and fires an event on both edges carrying the pid. That was being thrown away.
|
||||
|
||||
- **Declarative and idempotent**, like the reconcile beside it: the body is the provider's
|
||||
**complete** running set, so a missed event, a plugin restart or an install mid-game self-correct
|
||||
on the next report instead of drifting. Absent from the set = stopped.
|
||||
- **Reports expire** (`crate::runstate::REPORT_TTL`, 90 s; the answer carries `ttl_s`). This is what
|
||||
makes it safe for a live provider to hold a streaming session open for a game the host cannot
|
||||
see: a plugin that dies with a game running stops counting shortly after and the host falls back
|
||||
to scanning. Reporters must restate well inside the window.
|
||||
- **New `gamelease::LeaseKind::Reported`** — a lease with no process signal of its own, tracked by
|
||||
what its provider says. `open` reaches it when the spec is empty and a provider speaks for the id;
|
||||
the shim-reclassification paths (every Windows launch is a hand-off by construction) fall back to
|
||||
it too, where they previously fell to `Untracked`. Phase 1 accepts "running" as the game
|
||||
appearing; phase 2 treats "stopped" as the exit, and — unlike `procscan::running_hint`, which may
|
||||
only ever *delay* an exit because Steam's registry flag survives an unclean exit — a fresh
|
||||
provider report is decisive in both directions. A reported pid joins the termination ladders on
|
||||
the same terms as a spawned one (re-resolved and start-time-pinned at the moment of use).
|
||||
- **Route authority**: the plugin lane, like the reconcile (`mgmt::auth::plugin_may_access`, and its
|
||||
exhaustive classification table). No new authority — the host maps `external_id` through the
|
||||
catalog, so a provider can only ever speak about entries it published; an unknown id is *counted*,
|
||||
not refused, because a report legitimately races its own reconcile and 400-ing the batch would
|
||||
throw away the liveness of every other running title.
|
||||
- **`@punktfunk/plugin-kit`: `ProviderClient.reportRunning(providerId, running)`**, returning
|
||||
`{matched, unknown, ttlS}`; a 404 from an older host means "this host tracks games by scanning".
|
||||
Version bumped to **0.4.4** — **unpublished, `plugin-kit-v0.4.4` owed.**
|
||||
|
||||
The Playnite half lives in `punktfunk-plugin-playnite` (**0.4.5**, exporter **0.4.0**): the C#
|
||||
exporter hooks Playnite's `OnGameStarted`/`OnGameStopped`/`OnGameStartupCancelled` and writes a
|
||||
small `punktfunk-running.json` beside the library export, re-stamped every 30 s and *deleted* when
|
||||
Playnite closes; the plugin polls it and restates the set to this route. It calls the route through
|
||||
the kit's untyped host seam rather than `reportRunning`, deliberately — depending on the method
|
||||
would make that repo unbuildable until the kit publishes, for the same request. Needs a host
|
||||
carrying this route; an older one 404s and the plugin carries on without it.
|
||||
|
||||
### Everything else an integrator might notice
|
||||
|
||||
- **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin,
|
||||
@@ -402,44 +1102,130 @@ line was going to the journal on the *successful* path). `cargo test -p pf-vdisp
|
||||
(screenshot harness only).
|
||||
- **New environment variables:** `PUNKTFUNK_PAD_SINK_VOLUME` (`=0` skips both pad-sink pins),
|
||||
`PUNKTFUNK_DUALSENSE_USBIP_GRACE_MS` (pad-arrival grace), `PUNKTFUNK_USBIP_TRACE` (byte-level
|
||||
USB/IP trace prefix, off by default), and the three Apple screenshot-harness hooks above.
|
||||
USB/IP trace prefix, off by default), `PUNKTFUNK_CORE_LOG_LEVEL` (Apple: raises the ABI v25 log
|
||||
sink's ceiling above its info default), the three Apple screenshot-harness hooks above, and nine
|
||||
`PUNKTFUNK_INSTALL_*` twins for `install.sh`'s prompts (`_YES`, `_CHANNEL`, `_GAMESTREAM`,
|
||||
`_CLIPBOARD`, `_PUNKTFUNK_GROUP`, `_LINGER`, `_MGMT_PORT`, `_DRY_RUN`, `_OS_RELEASE`).
|
||||
`PUNKTFUNK_STREAM_SINK` gained the `stream` value and is documented for the first time.
|
||||
- **A Steam Deck never learned a host's wake MAC, so Wake-on-LAN was skipped there in silence.**
|
||||
Every wake gate reads `!host.mac.is_empty()`, and the MAC only ever reached the store through
|
||||
`trust::learn_mac`, whose two callers were the GTK and WinUI hosts pages — neither of which runs
|
||||
in Gaming Mode. Rather than add the missing call twice, the three per-field learners (`learn_mac`,
|
||||
`learn_os`, `learn_mgmt_port`) collapse into one `learn_from_advert`, called wherever an advert
|
||||
meets a saved record: both desktop hosts pages, the console home, and the CLI's `discover`.
|
||||
Remembering one call is not something a front-end can half-do; remembering three is what produced
|
||||
this (#322).
|
||||
- **`HostRow` gains `clipboard_sync`** (`#[serde(default)]`) and `ConsoleCmd` two variants,
|
||||
`BindProfile` and `SetClipboard` — additive and default-tolerant. From the 2026-08-19 console-ui
|
||||
sweep, which also brought touch deferred-tap and drag-to-scroll to the console (a swipe across the
|
||||
settings list used to cycle whatever value it landed on, because `MenuList` presses focus *and*
|
||||
activate), Controller haptics/speaker rows, and two Android idle gates (the reachability sweep
|
||||
only probes while the console is attached, and the render thread halves its rate after 60 s
|
||||
without input).
|
||||
- **Cancelling a connect returns the console immediately.** The takeover could only be dismissed by
|
||||
a session phase coming back from the embedder and nothing guaranteed one would: Android's shell
|
||||
sent no phase at all on the cancelled path, and the desktop shell waited on a pump parked inside
|
||||
the blocking `NativeClient::connect*`, which had no abort — 15 s on a normal dial, **185 s** on a
|
||||
request-access connect the host holds pending approval. The private `connect_*` inner fn takes a
|
||||
trailing `cancel: Option<Arc<AtomicBool>>`; not exported through the C ABI.
|
||||
- **A portable Playnite's covers survive the art confinement.** A Playnite unzipped outside the
|
||||
users base keeps its library beside the exe, so every cover it exports sits outside every default
|
||||
art root: the games synced and all **70** covers were dropped, with `PUNKTFUNK_LIBRARY_ART_ROOTS`
|
||||
the only way out. The Playnite install dirs are art roots now, exactly as Steam's install root
|
||||
already was, and `playnite_install_dirs` learned to find a portable copy at all — it registers no
|
||||
uninstall entry and sits under no profile, but it does register the `playnite://` handler, which
|
||||
is the very registration the launch path already follows. So a portable install also gets its
|
||||
Fullscreen launcher tile, which it never had. The confinement is not loosened: roots come from the
|
||||
host's own registry and filesystem probes, never from the plugin lane that supplies the art path.
|
||||
Paired with the plugin-kit fix below, a fixed host no longer needs a cache file deleted.
|
||||
- **`plugin-kit`: `startup` and `manual` sync reasons always publish.** The fingerprint says the
|
||||
plugin would compute the same entries again; it does *not* say the host still holds them — and the
|
||||
host may accept a payload and store less of it (an art path outside its roots is stripped and the
|
||||
games kept, deliberately, because a cover must not cost a library). Once that happened the
|
||||
fingerprint was a permanent "no changes", and the only way out was deleting the plugin's cache
|
||||
file, which is exactly the advice a portable-Playnite library with 70 dropped covers was given.
|
||||
The two triggers with a person behind them now always apply.
|
||||
- **Nix:** nixpkgs bumped because its gamescope 3.16.24 no longer took our patch 0009 (the publish
|
||||
tier was red on every build); `enableWsi` is a nixpkgs *function argument* defaulting to false, so
|
||||
the plain derivation shipped a compositor with **no WSI layer at all** and nothing under it could
|
||||
obtain an HDR10 swapchain — our own postInstall assertion caught it. Also: the prune makes `$out`
|
||||
writable first (reshade installs read-only), the bun builds are serialised and the OOM is measured
|
||||
against the real 7 GiB cgroup cap rather than guessed at, and a dispatch opt-in compared against
|
||||
the string `"true"` silently skipped when the API delivered a real JSON boolean — the step was
|
||||
skipped and the job still reported success.
|
||||
- **New packaging payload (Linux host, rpm/deb/arch; nix where noted):** `scripts/60-punktfunk.rules`
|
||||
(+2 sound rules), `scripts/60-punktfunk-dualsense.conf` (WirePlumber, also nix),
|
||||
`scripts/alsa-ucm2/…` (UCM drop-in, **not** nix). Bazzite sysext inherits all three from the RPMs.
|
||||
- **Docs:** `AGENTS.md` + `docs/agents/` (issue tracker is Gitea via the `gitea` MCP server; the
|
||||
five triage labels; single-context domain docs). A host audio-source comment corrected
|
||||
(`pw_impl_node_set_driver` marks props changed but leaves the flush to the next info emission).
|
||||
- **CI:** Nix publish job records `df` after the build as well as before.
|
||||
- **CI:** the Nix publish job records `df` after the build as well as before; the
|
||||
`linux-client-screenshots` run publishes its PNGs to the generic package registry as well as the
|
||||
v3 artifact store (which is browser-only, so nothing could reuse the shots for the docs — that is
|
||||
how the get-started track got its fifth screenshot, a client's host list); and **every Linux
|
||||
`bun install` is now wrapped in `scripts/ci/retry.sh`**. That last one is a real failure, not
|
||||
tidying: `bun install` streams download-and-extract, so a tarball truncated by the runner's
|
||||
packet loss under parallel load surfaces as `error: Fail extracting tarball for "<pkg>"` and
|
||||
names a package that is perfectly intact — measured on run 19630, where docs-site died on
|
||||
`@rolldown/binding-linux-x64-gnu` while the web job installed the same registry in the same run
|
||||
and run 19632 installed the identical lockfile seven minutes later. The tarball's sha512 matches
|
||||
the lockfile and bun 1.3.13 and 1.3.14 both extract it from disk, so neither the package nor the
|
||||
floating `oven/bun:1` bump was ever at fault. `retry.sh`'s header had already diagnosed this
|
||||
class and said to wrap every single-shot network command; `bun install` was the one still
|
||||
unwrapped. Three attempts rather than the usual five, so a genuinely stale lockfile still fails
|
||||
fast under `--frozen-lockfile`.
|
||||
- **The web console's Virtual displays page** put the Streamed-screen and session-lifetime cards
|
||||
below the tab shell, so both rendered on both tabs; they are policy surfaces and now sit inside
|
||||
the Configuration tab, leaving the Live tab as the live list plus arrangement.
|
||||
|
||||
### Verification status
|
||||
|
||||
Gates run on the release tree (this MacBook, rustc/rustfmt 1.96.0 per `rust-toolchain.toml`):
|
||||
`cargo fmt --all --check` clean — **after** a whitespace-only commit on the release branch: two files
|
||||
(`pf-console-ui/src/screens/controllers.rs`, `punktfunk-host/src/audio/linux/pad_card_volume.rs`)
|
||||
had landed on main formatted differently from rustfmt 1.96.0, so `ci.yml`'s Format step was red on
|
||||
the tip this is cut from; `cargo metadata --offline` ok with the `Cargo.lock` diff versions-only
|
||||
(36/36 lines); `cargo test -p punktfunk-core` **272 passed** in the unit suite; the android.yml Play
|
||||
notes gate run verbatim — 498/500 characters and not byte-identical to any prior release's; both
|
||||
openapi copies `cmp` identical and unchanged since the tag; `include/punktfunk_core.h` regenerated
|
||||
by the build and `git diff` clean against the tag.
|
||||
`cargo fmt --all --check` clean; `cargo metadata --offline` ok with the `Cargo.lock` diff
|
||||
versions-only (36/36 lines); `cargo test -p punktfunk-core --lib` **273 passed**; the android.yml
|
||||
Play notes gate run verbatim — 456/500 characters and not byte-identical to any prior release's;
|
||||
both openapi copies `cmp` identical, both stamped 0.31.0; notes voice scan clean outside the
|
||||
For developers section.
|
||||
|
||||
⚠ **The C ABI harness (`tests/c_abi.rs`) did not run on this cut**: it links the staticlib with
|
||||
`-lopus` and this machine has no libopus (`ld: library 'opus' not found`), which is an environment
|
||||
gap, not a code fault. The header it exercises is byte-identical to v0.30.0's, where the harness
|
||||
passed (261 + 1 + 8), and nothing in `punktfunk-core`'s C surface changed. The CI runner is its
|
||||
first execution for this tag.
|
||||
⚠ **This release was cut more than once.** The first cut (`601f040f`, merged as #320) was never
|
||||
tagged, and 41 more non-merge commits landed on top of it — the Windows client installer, the
|
||||
guided Linux installer, the docs overhaul, ABI v25, the KWin 6.6 repair and the takeover's final
|
||||
shape among them; a handful more (the Virtual displays tab fix, the fifth get-started screenshot)
|
||||
arrived while the second cut was being written. This section, the version table and the notes are
|
||||
all re-measured on the latest tip; where the cuts disagreed, the earlier text was **rewritten
|
||||
rather than appended to**, because none of
|
||||
the intervening work ever shipped. Specifically: the "C ABI unchanged / header byte-identical"
|
||||
claim is gone (it is 25 now), the openapi row moved off 0.29.0, the SDK and plugin-kit rows record
|
||||
cuts that have happened rather than cuts that were owed, and the Gaming Mode takeover section
|
||||
describes idling the autologin rather than stopping the display manager — a within-cycle correction
|
||||
no user could have seen.
|
||||
|
||||
⚠ **Verified by reading only** — compiled nowhere available to the cutting host: the Windows runner
|
||||
log redirect (`scripting-run.cmd`), the tray's `Option<u16>` port on Windows, and the sway half of
|
||||
`topology: exclusive` (no live sway in the fleet, as with #283).
|
||||
⚠ **The C ABI harness (`tests/c_abi.rs`) did not run on this cut**, and this time the header *did*
|
||||
change: it links the staticlib with `-lopus` and this machine has no libopus (`ld: library 'opus'
|
||||
not found`), which is an environment gap, not a code fault. `punktfunk_set_log_callback` is
|
||||
therefore compiled by cbindgen and by the Rust unit tests here, but the generated header has not
|
||||
been compiled by a C compiler on this cut — the CI runner is its first. Worth naming because ABI 25
|
||||
is the one versioned surface that moved.
|
||||
|
||||
⚠ **Verified by reading only** — compiled nowhere available to the cutting host: the Windows client
|
||||
installer and portable zip (`punktfunk-client.iss`, `pack-client-installer.ps1` — the pack step is a
|
||||
Windows runner's), the Windows runner log redirect (`scripting-run.cmd`), the tray's `Option<u16>`
|
||||
port on Windows, and the sway half of `topology: exclusive` (no live sway in the fleet, as with
|
||||
#283).
|
||||
|
||||
⚠ **Not verified on hardware by this cut**, named rather than left to be discovered: the null-sink
|
||||
capture topology's on-glass validation (pw-top showing our sink at the top of its own group, 5 min
|
||||
of loud audio at `delivered_pct=100 gaps=0` on a box where a hardware sink also runs) was still owed
|
||||
when it landed; the 96 kbps speaker lane was judged on glass by ear only; and the Android
|
||||
`ASurfaceControl` path was verified on one device (Nothing Phone 3) — the fallback presenter is
|
||||
byte-for-byte the 0.30 one.
|
||||
when it landed; the 96 kbps speaker lane was judged on glass by ear only; the Android
|
||||
`ASurfaceControl` path was verified on one device (Nothing Phone 3), with the fallback presenter
|
||||
byte-for-byte the 0.30 one; the Mac Accessibility intercept (the tap ahead of Spotlight, inside the
|
||||
sandbox) needs a granted Accessibility switch the dev machine does not have; and `install.sh` is
|
||||
smoke-tested per package family in CI containers but is shipped **preview** precisely because it has
|
||||
no real-box mileage, Bazzite above all.
|
||||
|
||||
⚠ **Owed outside this repository:** `data/platforms.json` changed this cycle (the Windows client
|
||||
download), and the website's download page vendors a copy that only refreshes when someone runs
|
||||
`bun run sync-platforms` in punktfunk-website and commits — step 1 of `docs/releases/README.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+36
-36
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -2343,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3032,7 +3032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3050,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3073,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3083,7 +3083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3109,7 +3109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3122,7 +3122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3136,11 +3136,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3169,14 +3169,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3191,7 +3191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3199,7 +3199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3211,7 +3211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3220,7 +3220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3253,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3264,7 +3264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3275,7 +3275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3487,7 +3487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3497,7 +3497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"anyhow",
|
||||
@@ -3521,7 +3521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pf-client-core",
|
||||
@@ -3554,7 +3554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3572,7 +3572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3605,7 +3605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3614,7 +3614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3684,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3698,7 +3698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3722,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.31.0"
|
||||
version = "0.31.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+128
-3
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.31.0"
|
||||
"version": "0.31.2"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
@@ -1860,6 +1860,69 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/provider/{provider}/running": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Report which of a provider's titles are running",
|
||||
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
|
||||
"operationId": "reportProviderRunning",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "path",
|
||||
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The report was accepted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningAccepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider id or payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/scanners": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -6625,7 +6688,7 @@
|
||||
},
|
||||
"HostInfo": {
|
||||
"type": "object",
|
||||
"description": "Host identity and advertised capabilities (static for the life of the process).",
|
||||
"description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
|
||||
"required": [
|
||||
"hostname",
|
||||
"uniqueid",
|
||||
@@ -6671,7 +6734,7 @@
|
||||
},
|
||||
"local_ip": {
|
||||
"type": "string",
|
||||
"description": "Best-effort primary LAN IP."
|
||||
"description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
|
||||
},
|
||||
"os": {
|
||||
"type": "string",
|
||||
@@ -7792,6 +7855,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningAccepted": {
|
||||
"type": "object",
|
||||
"description": "The result of a liveness report.",
|
||||
"required": [
|
||||
"matched",
|
||||
"unknown",
|
||||
"ttl_s"
|
||||
],
|
||||
"properties": {
|
||||
"matched": {
|
||||
"type": "integer",
|
||||
"description": "How many reported titles matched an entry this provider currently publishes.",
|
||||
"minimum": 0
|
||||
},
|
||||
"ttl_s": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
|
||||
"minimum": 0
|
||||
},
|
||||
"unknown": {
|
||||
"type": "integer",
|
||||
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningInput": {
|
||||
"type": "object",
|
||||
"description": "Request body for `reportProviderRunning`.",
|
||||
"properties": {
|
||||
"running": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RunningTitle"
|
||||
},
|
||||
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReleaseDisplayRequest": {
|
||||
"type": "object",
|
||||
"description": "Request body for `releaseDisplay`.",
|
||||
@@ -7846,6 +7949,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RunningTitle": {
|
||||
"type": "object",
|
||||
"description": "One running title in a provider's liveness report.",
|
||||
"required": [
|
||||
"external_id"
|
||||
],
|
||||
"properties": {
|
||||
"external_id": {
|
||||
"type": "string",
|
||||
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
|
||||
},
|
||||
"pid": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuntimeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -96,7 +96,11 @@ internal fun ControllersScreen(
|
||||
InputDevice.getDeviceIds()
|
||||
.toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
.filter { !it.isVirtual && !Gamepad.isPad(it) }
|
||||
// Everything real that is NOT counted as a controller — including a device that claims
|
||||
// a pad source with no pad hardware behind it, which the Gamepads list above now
|
||||
// rejects. One list or the other, never neither: this screen is where someone looks
|
||||
// when the client's idea of "a pad is attached" disagrees with the room.
|
||||
.filter { !it.isVirtual && !Gamepad.looksLikeController(it) }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val im = context.getSystemService(InputManager::class.java)
|
||||
@@ -136,14 +140,19 @@ internal fun ControllersScreen(
|
||||
// Read ONCE, up front: the test can end inside this very event, and the release that
|
||||
// ended it still has to be swallowed here — see the B branch below.
|
||||
val consume = consuming
|
||||
// The CORRECTED keycode, so this screen shows the button the stream will send and not
|
||||
// the one Android guessed for a pad it has no key layout for — the two differ on every
|
||||
// controller [Gamepad.padKeyCode] exists for, and a tester that disagrees with the
|
||||
// stream is worse than no tester. The raw pair is still reported in "Last input".
|
||||
val code = Gamepad.padKeyCode(event)
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
held[event.keyCode] = true
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
|
||||
held[code] = true
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
held[event.keyCode] = false
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
held[code] = false
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
bHeld = false
|
||||
if (consume) {
|
||||
if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) {
|
||||
@@ -167,23 +176,43 @@ internal fun ControllersScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
|
||||
// Raw scancode AND keycode, plus the correction when one fired: this line is what a
|
||||
// field report needs to pin an unmapped pad's report order without the device in hand.
|
||||
val raw = KeyEvent.keyCodeToString(event.keyCode).removePrefix("KEYCODE_")
|
||||
val fixed = KeyEvent.keyCodeToString(code).removePrefix("KEYCODE_")
|
||||
lastInput = "${event.device?.name}: scan 0x%X · %s%s".format(
|
||||
event.scanCode,
|
||||
raw,
|
||||
if (code != event.keyCode) " → $fixed" else "",
|
||||
)
|
||||
consume
|
||||
}
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
// Through the device's resolved map, exactly as `Gamepad.AxisMapper` reads it while
|
||||
// streaming — on a pad Android has no key layout for, the right stick and the triggers
|
||||
// are not on the axes their names suggest.
|
||||
val map = Gamepad.padMap(event.device)
|
||||
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
|
||||
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
|
||||
axes["RX"] = event.getAxisValue(MotionEvent.AXIS_Z)
|
||||
axes["RY"] = event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||
axes["LT"] = maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
)
|
||||
axes["RT"] = maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
)
|
||||
axes["RX"] = event.getAxisValue(map.rightStickX)
|
||||
axes["RY"] = event.getAxisValue(map.rightStickY)
|
||||
axes["LT"] = if (map.leftTrigger == Gamepad.AXIS_NONE) {
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
)
|
||||
} else {
|
||||
map.level(event.getAxisValue(map.leftTrigger))
|
||||
}
|
||||
axes["RT"] = if (map.rightTrigger == Gamepad.AXIS_NONE) {
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
)
|
||||
} else {
|
||||
map.level(event.getAxisValue(map.rightTrigger))
|
||||
}
|
||||
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
consuming
|
||||
@@ -689,6 +718,16 @@ private fun PadRow(info: PadInfo, gamepadSetting: Int) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Only when a correction is actually in force: on a pad Android has a key layout for
|
||||
// there is nothing to say, and a line that says "normal" on every device teaches
|
||||
// nobody anything. Named rather than merely flagged, so a field report can quote it.
|
||||
padButtonsNote(info.buttons)?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (info.canRumble) {
|
||||
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
|
||||
} else {
|
||||
@@ -784,6 +823,12 @@ internal data class PadInfo(
|
||||
val controllerNumber: Int,
|
||||
val resolvedPref: Int,
|
||||
val canRumble: Boolean,
|
||||
/**
|
||||
* The report order this pad's buttons were resolved to ([Gamepad.padButtons]). Defaults to
|
||||
* the pad Android already knows, which is what a screenshot scene wants and what the note
|
||||
* under the card stays silent about.
|
||||
*/
|
||||
val buttons: Gamepad.PadButtons = Gamepad.PadButtons.NATIVE,
|
||||
val dev: InputDevice? = null,
|
||||
)
|
||||
|
||||
@@ -793,6 +838,7 @@ internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
|
||||
forwarded = isForwarded(dev),
|
||||
controllerNumber = dev.controllerNumber,
|
||||
resolvedPref = Gamepad.prefFor(dev),
|
||||
buttons = Gamepad.padMap(dev).buttons, // via padMap so the list refresh reuses the cache
|
||||
canRumble = deviceHasVibrator(dev),
|
||||
dev = dev,
|
||||
)
|
||||
@@ -823,6 +869,20 @@ internal fun testRumble(dev: InputDevice) {
|
||||
}
|
||||
|
||||
/** Identity line: VID:PID + the source classes Android assigned. */
|
||||
/**
|
||||
* What to say about a pad whose buttons had to be resolved from their scancodes because Android
|
||||
* has no key layout for it — null for a pad it does know, which needs no explanation.
|
||||
*/
|
||||
private fun padButtonsNote(buttons: Gamepad.PadButtons): String? = when (buttons) {
|
||||
Gamepad.PadButtons.NATIVE -> null
|
||||
Gamepad.PadButtons.GENERIC_SONY ->
|
||||
"Android has no button layout for this controller — read as a PlayStation pad"
|
||||
Gamepad.PadButtons.GENERIC_XBOX ->
|
||||
"Android has no button layout for this controller — read as an Xbox pad"
|
||||
Gamepad.PadButtons.SONY_MODERN ->
|
||||
"Android has no button layout for this controller — face buttons corrected"
|
||||
}
|
||||
|
||||
private fun deviceDetail(dev: InputDevice): String =
|
||||
"%04X:%04X · %s".format(dev.vendorId, dev.productId, sourcesLabel(dev.sources))
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -96,7 +97,7 @@ fun GamepadNavEffect(
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
when (Gamepad.padKeyCode(ev)) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
|
||||
@@ -202,7 +203,7 @@ fun GamepadNavEffect2D(
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
when (Gamepad.padKeyCode(ev)) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
|
||||
|
||||
@@ -616,7 +616,7 @@ class MainActivity : ComponentActivity() {
|
||||
// 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.padButtonBit(event.keyCode, event.flags)
|
||||
val bit = Gamepad.padButtonBit(Gamepad.padKeyCode(event), 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
|
||||
@@ -708,8 +708,10 @@ class MainActivity : ComponentActivity() {
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
||||
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
|
||||
// already move focus on their own, so they fall through to super untouched.
|
||||
when (event.keyCode) {
|
||||
// already move focus on their own, so they fall through to super untouched. Read
|
||||
// through [Gamepad.padKeyCode] so a pad Android has no key layout for reaches the
|
||||
// menus on the right buttons too, not only the stream.
|
||||
when (Gamepad.padKeyCode(event)) {
|
||||
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
|
||||
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
|
||||
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
|
||||
|
||||
@@ -940,6 +940,17 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
// The view's CURRENT pixel size, for the ASurfaceControl layer's
|
||||
// destination rect. It is reported here and not only at
|
||||
// surfaceCreated because the view grows a frame or two after the
|
||||
// stream screen appears — hiding the system bars and switching on
|
||||
// cutout drawing both resize it, and neither recreates the surface.
|
||||
// A layer left on the start-up rect paints the picture small, in the
|
||||
// top-left corner. The view's own size, not the buffer geometry in
|
||||
// `width`/`height`: the layer composites in the view's space.
|
||||
NativeBridge.nativeVideoSurfaceSize(
|
||||
handle, this@apply.width, this@apply.height,
|
||||
)
|
||||
// Re-assert the frame-rate vote: a buffer-geometry change can reset
|
||||
// the surface's frame-rate setting on some OEM builds, silently
|
||||
// dropping the 120 Hz pin mid-stream. Mirrors the native hint's
|
||||
|
||||
@@ -317,15 +317,20 @@ internal object ConsoleJson {
|
||||
j.put("invert_scroll", s.invertScroll)
|
||||
j.put("pad_haptics", s.padHaptics)
|
||||
j.put("pad_speaker", if (s.padSpeaker) "pad" else "off")
|
||||
// Android-only rows ride `extra` (WP5 gives them RowIds); nothing on the desktop reads them.
|
||||
val extra = j.optJSONObject("extra") ?: JSONObject()
|
||||
extra.put("android.low_latency", s.lowLatencyMode)
|
||||
extra.put("android.rumble_on_phone", s.rumbleOnPhone)
|
||||
extra.put("android.gyro_on_phone", s.gyroOnPhone)
|
||||
extra.put("android.sc2_capture", s.sc2Capture)
|
||||
extra.put("android.ds_capture", s.dsCapture)
|
||||
extra.put("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
j.put("extra", extra)
|
||||
// Android-only rows ride `Settings::extra`, which is `#[serde(flatten)]` — so they are
|
||||
// TOP-LEVEL keys of this document, not a nested `extra` object. Nesting them put the
|
||||
// whole object into the map under the literal key "extra", where no console row could
|
||||
// read it and every value the console wrote came straight back as the one we had sent.
|
||||
j.put("android.low_latency", s.lowLatencyMode)
|
||||
j.put("android.rumble_on_phone", s.rumbleOnPhone)
|
||||
j.put("android.gyro_on_phone", s.gyroOnPhone)
|
||||
j.put("android.sc2_capture", s.sc2Capture)
|
||||
j.put("android.ds_capture", s.dsCapture)
|
||||
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
|
||||
// A store written by the nesting build carries the stale wrapper; drop it rather than
|
||||
// round-trip a copy of these keys that nothing reads for the life of the install.
|
||||
j.remove("extra")
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -335,7 +340,8 @@ internal object ConsoleJson {
|
||||
*/
|
||||
fun applySettings(s: Settings, j: JSONObject): Settings {
|
||||
fun str(k: String, cur: String) = j.optString(k, cur).ifEmpty { cur }
|
||||
val extra = j.optJSONObject("extra") ?: JSONObject()
|
||||
// The `android.*` keys are TOP-LEVEL here, not nested: `Settings::extra` is
|
||||
// `#[serde(flatten)]`, so the console writes them beside `width` and `codec`.
|
||||
return s.copy(
|
||||
width = j.optInt("width", s.width),
|
||||
height = j.optInt("height", s.height),
|
||||
@@ -372,13 +378,14 @@ internal object ConsoleJson {
|
||||
"off" -> false
|
||||
else -> s.padSpeaker
|
||||
},
|
||||
lowLatencyMode = extra.optBoolean("android.low_latency", s.lowLatencyMode),
|
||||
rumbleOnPhone = extra.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
|
||||
gyroOnPhone = extra.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
|
||||
sc2Capture = extra.optBoolean("android.sc2_capture", s.sc2Capture),
|
||||
dsCapture = extra.optBoolean("android.ds_capture", s.dsCapture),
|
||||
gamepadUiMode = extra.optString("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
lowLatencyMode = j.optBoolean("android.low_latency", s.lowLatencyMode),
|
||||
rumbleOnPhone = j.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
|
||||
gyroOnPhone = j.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
|
||||
sc2Capture = j.optBoolean("android.sc2_capture", s.sc2Capture),
|
||||
dsCapture = j.optBoolean("android.ds_capture", s.dsCapture),
|
||||
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
.ifEmpty { s.gamepadUiMode },
|
||||
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,9 @@ object SkiaConsole {
|
||||
val opts = JSONObject()
|
||||
.put("device_name", deviceName(app))
|
||||
.put("gpu_cache_bytes", gpuCacheBytes(app))
|
||||
// The touch shell exists as a fallback on phones/tablets but not on a TV —
|
||||
// gates the console's own "Controller-optimized UI" off switch.
|
||||
.put("fallback_ui", !io.unom.punktfunk.isTvDevice(app))
|
||||
.put("settings", ConsoleJson.settings(initial, base))
|
||||
.put("profiles", JSONArray(ConsoleJson.profiles(profiles)))
|
||||
.put("known_hosts", JSONObject(ConsoleJson.knownHosts(knownHostStore.all())))
|
||||
|
||||
@@ -159,7 +159,12 @@ fun SkiaConsoleShell(
|
||||
if (ev.action != KeyEvent.ACTION_DOWN && ev.action != KeyEvent.ACTION_UP) return@probe false
|
||||
val fromPad = ev.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
if (fromPad) {
|
||||
val bit = when (ev.keyCode) {
|
||||
// The CORRECTED keycode: a pad Android has no key layout for delivers its buttons
|
||||
// under other buttons' names, so read raw this console answered ✕ with whatever
|
||||
// sat in BUTTON_A's scancode slot. Same resolution the stream uses — the console
|
||||
// and the game must not disagree about which button a user pressed.
|
||||
val code = Gamepad.padKeyCode(ev)
|
||||
val bit = when (code) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> 0
|
||||
KeyEvent.KEYCODE_BUTTON_B -> 1
|
||||
KeyEvent.KEYCODE_BUTTON_X -> 2
|
||||
@@ -179,7 +184,7 @@ fun SkiaConsoleShell(
|
||||
}
|
||||
return@probe true
|
||||
}
|
||||
val dbit = when (ev.keyCode) {
|
||||
val dbit = when (code) {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> 0
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> 1
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> 2
|
||||
@@ -191,7 +196,7 @@ fun SkiaConsoleShell(
|
||||
padState.push(handle)
|
||||
return@probe true
|
||||
}
|
||||
if (ev.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
|
||||
NativeBridge.nativeConsoleMenu(handle, 0) // ▲ opens the tile's options on Home
|
||||
return@probe true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.console.ConsoleJson
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Android-only console settings ride `trust::Settings::extra`, which is `#[serde(flatten)]`:
|
||||
* they are TOP-LEVEL keys of the settings document, beside `width` and `codec`.
|
||||
*
|
||||
* They were written and read nested under an `"extra"` object instead. Serde put that whole
|
||||
* object into the map under the literal key `"extra"`, so no console row ever found
|
||||
* `android.gamepad_ui_enabled` — and the value the console saved came back to Kotlin as the one
|
||||
* Kotlin had just sent. On glass that was a "Controller-optimized UI" switch you could turn off
|
||||
* with nothing happening: the console stayed up, because the setting never moved.
|
||||
*/
|
||||
class ConsoleSettingsExtraTest {
|
||||
@Test
|
||||
fun androidKeysAreWrittenFlat() {
|
||||
val j = ConsoleJson.settings(Settings(gamepadUiEnabled = false, lowLatencyMode = false), null)
|
||||
assertTrue("the console reads this key at the top level", j.has("android.gamepad_ui_enabled"))
|
||||
assertFalse(j.getBoolean("android.gamepad_ui_enabled"))
|
||||
assertFalse(j.getBoolean("android.low_latency"))
|
||||
assertFalse("a nested wrapper is what serde swallows whole", j.has("extra"))
|
||||
}
|
||||
|
||||
/** A store written by the nesting build must not keep echoing its dead wrapper. */
|
||||
@Test
|
||||
fun aStaleNestedWrapperIsDropped() {
|
||||
val base = JSONObject().put(
|
||||
"extra",
|
||||
JSONObject().put("android.gamepad_ui_enabled", true),
|
||||
)
|
||||
assertFalse(ConsoleJson.settings(Settings(gamepadUiEnabled = false), base).has("extra"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theConsolesOwnSaveIsReadBack() {
|
||||
val saved = JSONObject()
|
||||
.put("android.gamepad_ui_enabled", false)
|
||||
.put("android.gamepad_ui_mode", GAMEPAD_UI_ALWAYS)
|
||||
.put("android.ds_capture", false)
|
||||
val next = ConsoleJson.applySettings(Settings(), saved)
|
||||
assertFalse("turning the console off must reach the store", next.gamepadUiEnabled)
|
||||
assertEquals(GAMEPAD_UI_ALWAYS, next.gamepadUiMode)
|
||||
assertFalse(next.dsCapture)
|
||||
}
|
||||
|
||||
/** Both halves against each other — the shape only holds if they agree. */
|
||||
@Test
|
||||
fun theRoundTripKeepsEveryAndroidRow() {
|
||||
val want = Settings(
|
||||
gamepadUiEnabled = false,
|
||||
gamepadUiMode = GAMEPAD_UI_ALWAYS,
|
||||
lowLatencyMode = false,
|
||||
rumbleOnPhone = true,
|
||||
gyroOnPhone = true,
|
||||
sc2Capture = false,
|
||||
dsCapture = false,
|
||||
)
|
||||
val got = ConsoleJson.applySettings(Settings(), ConsoleJson.settings(want, null))
|
||||
assertEquals(want.gamepadUiEnabled, got.gamepadUiEnabled)
|
||||
assertEquals(want.gamepadUiMode, got.gamepadUiMode)
|
||||
assertEquals(want.lowLatencyMode, got.lowLatencyMode)
|
||||
assertEquals(want.rumbleOnPhone, got.rumbleOnPhone)
|
||||
assertEquals(want.gyroOnPhone, got.gyroOnPhone)
|
||||
assertEquals(want.sc2Capture, got.sc2Capture)
|
||||
assertEquals(want.dsCapture, got.dsCapture)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package io.unom.punktfunk.kit
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -127,8 +128,12 @@ object Gamepad {
|
||||
|
||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
// The Bluetooth revisions (0x02E0/0x02FD Xbox One S, 0x0B05/0x0B22 Elite Series 2 and its
|
||||
// Core) are here for the same reason as the wired ones: they are the pads a couch actually
|
||||
// pairs to a TV box, and without them an Elite streams under the Xbox 360 identity.
|
||||
private val PID_XBOXONE = setOf(
|
||||
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
|
||||
0x02D1, 0x02DD, 0x02E0, 0x02E3, 0x02EA, 0x02FD,
|
||||
0x0B00, 0x0B05, 0x0B12, 0x0B13, 0x0B20, 0x0B22,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -188,9 +193,53 @@ object Gamepad {
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
}
|
||||
|
||||
/** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */
|
||||
fun pads(): List<InputDevice> =
|
||||
InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) }
|
||||
/**
|
||||
* True when [dev] is a controller someone can actually hold: a pad source ([isPad]) that is a
|
||||
* REAL device carrying real pad hardware — a stick, a HAT, or the A/B face buttons.
|
||||
*
|
||||
* [isPad] alone answers "did this event come from a pad source", which is the right question
|
||||
* for ROUTING an event and the wrong one for "is a controller attached". Devices publish
|
||||
* inputs that claim `SOURCE_GAMEPAD`/`SOURCE_JOYSTICK` while being no such thing — OEM
|
||||
* game-mode overlays and the gaming-phone shoulder triggers among them — and one of those is
|
||||
* enough to pin the console UI on forever: a pad that was never there cannot disconnect, so
|
||||
* "With a controller" has no way back to the touch UI.
|
||||
*
|
||||
* The capability probe is what separates them: a source class is a claim, a stick or a face
|
||||
* button is hardware. It is not a complete defence — an OEM device that declares `BTN_GAMEPAD`
|
||||
* and a pair of axes is indistinguishable from a pad at this layer — so the master switch stays
|
||||
* the guaranteed way out. `isVirtual` only means "device id < 0" (the platform's own synthetic
|
||||
* device), which is worth excluding but catches none of the above.
|
||||
*/
|
||||
fun looksLikeController(dev: InputDevice?): Boolean {
|
||||
val d = dev ?: return false
|
||||
return looksLikeController(
|
||||
padSource = isPad(d),
|
||||
virtual = d.isVirtual,
|
||||
hasStick = d.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_JOYSTICK) != null ||
|
||||
d.getMotionRange(MotionEvent.AXIS_HAT_X, InputDevice.SOURCE_JOYSTICK) != null,
|
||||
// `hasKeys` answers for the DEVICE, so a pad with no sticks at all (an arcade stick,
|
||||
// a d-pad-only pad) still counts.
|
||||
hasFaceButtons = d.hasKeys(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B)
|
||||
.any { it },
|
||||
)
|
||||
}
|
||||
|
||||
/** [looksLikeController]'s decision, over plain facts — the seam its truth table is tested at
|
||||
* (an [InputDevice] cannot be built off a device). */
|
||||
fun looksLikeController(
|
||||
padSource: Boolean,
|
||||
virtual: Boolean,
|
||||
hasStick: Boolean,
|
||||
hasFaceButtons: Boolean,
|
||||
): Boolean = padSource && !virtual && (hasStick || hasFaceButtons)
|
||||
|
||||
/**
|
||||
* All connected controllers, in system enumeration order — the devices that answer "is a pad
|
||||
* attached", so the filter is [looksLikeController] rather than the looser [isPad].
|
||||
*/
|
||||
fun pads(): List<InputDevice> = InputDevice.getDeviceIds().toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
.filter { looksLikeController(it) }
|
||||
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? = pads().firstOrNull()
|
||||
@@ -293,6 +342,334 @@ object Gamepad {
|
||||
else -> BTN_BACK
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Controllers Android has no key layout for
|
||||
//
|
||||
// Android turns a pad's raw evdev scancode into a `KeyEvent.keyCode` through a KEY LAYOUT
|
||||
// file matched on USB VID/PID (`Vendor_054c_Product_0ce6.kl` & co.). A pad with no matching
|
||||
// file falls back to AOSP's `Generic.kl`, which assigns keycodes by SCANCODE POSITION —
|
||||
// `0x130`→BUTTON_A, `0x131`→BUTTON_B, `0x132`→BUTTON_C, and so on up. That is only right if
|
||||
// the pad's buttons happen to sit at the positions the file assumes, and a HID gamepad with
|
||||
// no kernel driver behind it numbers its buttons 1..n straight through IN ITS OWN REPORT
|
||||
// ORDER — so every keycode after the first divergence is somebody else's button.
|
||||
//
|
||||
// Reported from a Fire TV Stick 4K Max (2026-08-20): a DualSense and an Xbox Elite Series 2,
|
||||
// both over Bluetooth, both identified correctly but with buttons landing on the wrong
|
||||
// actions ("L1 being L2"). Neither has a layout there — AOSP ships none for the Elite
|
||||
// Series 2 over Bluetooth (`045e:0b05`) on ANY version, and the DualSense's
|
||||
// (`054c:0ce6`) both postdates Fire OS and carries `requires_kernel_config
|
||||
// CONFIG_HID_PLAYSTATION`, which a Fire TV kernel does not have. A DualSense reporting
|
||||
// straight through puts L2 on `0x136`, which `Generic.kl` calls BUTTON_L1: the reported
|
||||
// symptom exactly.
|
||||
//
|
||||
// The fix is to resolve buttons from the SCANCODE, which is the pad's own report position and
|
||||
// is immune to the layout file — the same reason [Keymap.toVk] reads `scanCode` for keyboards.
|
||||
// Two things keep it from breaking a pad that already works:
|
||||
//
|
||||
// 1. Nothing is corrected on a pad that names its triggers ([padButtons]). A descriptor
|
||||
// well-formed enough to call them Accelerator/Brake puts its buttons at the standard
|
||||
// positions too, and that is the fact — not the model — that separates the two firmwares
|
||||
// of the SAME Xbox pad, only the older of which needs any of this.
|
||||
// 2. Past that gate the correction still applies ONLY where the delivered keycode is what
|
||||
// `Generic.kl` would have said ([genericKeyCode]). A different keycode means a
|
||||
// device-specific layout IS in force and knows this pad better than we do.
|
||||
//
|
||||
// Moonlight carries the same two tables AND the same gate (`ControllerHandler`'s
|
||||
// `isNonStandardDualShock4` / `isNonStandardXboxBtController`, the latter on `gasRange == null`),
|
||||
// which is why both pads work there on the same box.
|
||||
//
|
||||
// The first cut of this asked `hasKeys(BUTTON_C, BUTTON_Z)` on its own, on the reasoning that a
|
||||
// pad numbering straight through reaches keycodes no controller has a button for. It does — but
|
||||
// so does every pad that merely DECLARES six buttons, because `hid-input` allocates `BTN_A + n`
|
||||
// straight through for the whole descriptor whether or not the pad ever presses them. That fired
|
||||
// the correction on pads Android was already reading correctly (2026-08-21: an Xbox pad
|
||||
// answering X with Y, Y with LB, and both shoulders with a menu button), and it could not have
|
||||
// done otherwise: the signal is identical on the firmware that needs correcting and the one that
|
||||
// does not. Declaration is not report order. Only the axes tell them apart.
|
||||
|
||||
/** [MotionEvent] axis id meaning "this pad has no such axis" — see [PadMap]. */
|
||||
const val AXIS_NONE = -1
|
||||
|
||||
/**
|
||||
* The report order a controller's buttons are numbered in, and with it which scancode carries
|
||||
* which physical button. Resolved once per device by [padButtons] from what the device
|
||||
* declares; [correct] then maps one scancode to the keycode it should have produced.
|
||||
*/
|
||||
enum class PadButtons {
|
||||
/**
|
||||
* The keycode Android delivered is already right — a device-specific key layout is in
|
||||
* force, or the generic one happens to agree. [correct] changes nothing.
|
||||
*/
|
||||
NATIVE,
|
||||
|
||||
/**
|
||||
* A Sony pad numbering straight through with no kernel driver behind it: □ ✕ ○ △ L1 R1
|
||||
* L2 R2 Create Options L3 R3 PS, i.e. `0x130`..`0x13c` in that order. The analog trigger
|
||||
* value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold to keycodes
|
||||
* [buttonBit] deliberately drops — the wire carries the axis, never both.
|
||||
*/
|
||||
GENERIC_SONY,
|
||||
|
||||
/**
|
||||
* An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS, i.e.
|
||||
* `0x130`..`0x139`. Also the fallback for an unbranded pad, which near-universally
|
||||
* clones the Xbox layout — the same assumption [styleFor] makes for its glyphs.
|
||||
*/
|
||||
GENERIC_XBOX,
|
||||
|
||||
/**
|
||||
* A Sony pad WITH a kernel driver (`hid-playstation` / `hid-sony`) but still no key
|
||||
* layout — the combination an Android 11 box on a 5.10 kernel lands in. Such a driver
|
||||
* emits the modern Linux gamepad codes, where `0x133` is BTN_NORTH (△) and `0x134` is
|
||||
* BTN_WEST (□); `Generic.kl` reads those two as BUTTON_X and BUTTON_Y, so exactly the
|
||||
* face pair comes out swapped and nothing else is wrong.
|
||||
*/
|
||||
SONY_MODERN,
|
||||
;
|
||||
|
||||
/**
|
||||
* The keycode scancode [scan] should have produced, given Android delivered [keyCode].
|
||||
*
|
||||
* Returns [keyCode] untouched unless it is precisely what [genericKeyCode] would have
|
||||
* said for [scan] — anything else is a device-specific layout's answer, which outranks
|
||||
* this table. That guard is what makes the correction idempotent and safe to run on
|
||||
* every pad: it can only ever fire where Android was guessing in the first place.
|
||||
*/
|
||||
fun correct(scan: Int, keyCode: Int): Int {
|
||||
if (this == NATIVE) return keyCode
|
||||
if (keyCode != genericKeyCode(scan)) return keyCode
|
||||
val fixed = when (this) {
|
||||
GENERIC_SONY -> when (scan) {
|
||||
0x130 -> KeyEvent.KEYCODE_BUTTON_X // □
|
||||
0x131 -> KeyEvent.KEYCODE_BUTTON_A // ✕
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_B // ○
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // △
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_L2 // analog: AXIS_RX
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_R2 // analog: AXIS_RY
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_SELECT // Create / Share
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_START // Options
|
||||
0x13a -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x13b -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE // PS
|
||||
// 0x13d touchpad click / 0x13e mute: no wire button, dropped as before.
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
GENERIC_XBOX -> when (scan) {
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_X
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_SELECT // View
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_START // Menu
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
else -> keyCode // 0x130 A / 0x131 B already agree
|
||||
}
|
||||
// Only the face pair; every other row of Generic.kl is right for these codes.
|
||||
SONY_MODERN -> when (scan) {
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // BTN_NORTH = △
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_X // BTN_WEST = □
|
||||
else -> keyCode
|
||||
}
|
||||
NATIVE -> keyCode
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AOSP `Generic.kl`'s gamepad rows — the layout Android falls back to when no device-specific
|
||||
* key layout matches the pad's VID/PID. Scancodes outside it answer [KeyEvent.KEYCODE_UNKNOWN],
|
||||
* which never equals a real delivered keycode, so [PadButtons.correct]'s guard leaves those
|
||||
* events alone.
|
||||
*/
|
||||
fun genericKeyCode(scan: Int): Int = when (scan) {
|
||||
0x130 -> KeyEvent.KEYCODE_BUTTON_A
|
||||
0x131 -> KeyEvent.KEYCODE_BUTTON_B
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_C
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_X
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_Y
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_Z
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_L2
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_R2
|
||||
0x13a -> KeyEvent.KEYCODE_BUTTON_SELECT
|
||||
0x13b -> KeyEvent.KEYCODE_BUTTON_START
|
||||
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE
|
||||
0x13d -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x13e -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* How one controller must be read: its button report order plus the axes its right stick and
|
||||
* analog triggers actually arrive on. Resolved once per device by [padMap].
|
||||
*/
|
||||
class PadMap(
|
||||
val buttons: PadButtons,
|
||||
val rightStickX: Int = MotionEvent.AXIS_Z,
|
||||
val rightStickY: Int = MotionEvent.AXIS_RZ,
|
||||
/**
|
||||
* The trigger axes, or [AXIS_NONE] for a pad Android already names them on — that case
|
||||
* keeps folding LTRIGGER with BRAKE and RTRIGGER with GAS by max, which is what pads that
|
||||
* report one pair, the other, or both have always needed.
|
||||
*/
|
||||
val leftTrigger: Int = AXIS_NONE,
|
||||
val rightTrigger: Int = AXIS_NONE,
|
||||
/** Those trigger axes rest at −1 rather than 0, measured off the device's own range. */
|
||||
val triggersSigned: Boolean = false,
|
||||
) {
|
||||
/** One resolved trigger axis value, folded to the 0..1 the wire scale expects. */
|
||||
fun level(v: Float): Float = if (triggersSigned) (v + 1f) / 2f else v
|
||||
}
|
||||
|
||||
/** The map every pad with a key layout uses: Android's own names, unchanged. */
|
||||
private val NATIVE_MAP = PadMap(PadButtons.NATIVE)
|
||||
|
||||
/**
|
||||
* Resolved [PadMap]s, keyed by [InputDevice.getDescriptor] — the device's stable identity
|
||||
* hash, so a pad that reconnects is recognised and a model resolves once for the process.
|
||||
* Nothing here depends on a live connection, so entries never need evicting.
|
||||
*/
|
||||
private val padMaps = ConcurrentHashMap<String, PadMap>()
|
||||
|
||||
/**
|
||||
* Which report order [dev]'s buttons follow — [namedTriggers] is whether the pad reports its
|
||||
* triggers under a name Android knows (see [padMap]), and [declaresCZ] whether it declares
|
||||
* BUTTON_C and BUTTON_Z.
|
||||
*
|
||||
* `namedTriggers` decides it, and a pad that has them is [PadButtons.NATIVE] whatever else it
|
||||
* says. A HID gamepad describes its triggers either as the Accelerator/Brake usages, which
|
||||
* become `ABS_GAS`/`ABS_BRAKE` and axis names Android has words for, or as two more generic
|
||||
* axes on `ABS_Z`/`ABS_RZ`, which it does not — and a report descriptor well-formed enough to
|
||||
* name its triggers puts its buttons at the standard positions too, the ones `Generic.kl`
|
||||
* already reads correctly. It is the same fact Moonlight decides this on (`gasRange == null`
|
||||
* beside the `"Xbox Wireless Controller"` name), and it is the one that separates the two
|
||||
* firmwares of the SAME pad: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE
|
||||
* after its firmware update and Z/Rz before it, and only the older one needs correcting.
|
||||
*
|
||||
* `declaresCZ` cannot make that call and must never be asked to. `hasKeys` answers for what a
|
||||
* device DECLARES, not what it reports: `hid-input` allocates `BTN_A + n` straight through for
|
||||
* every button in the descriptor, so BTN_C (`0x132`) and BTN_Z (`0x135`) are set on any pad
|
||||
* declaring six or more — a standard-layout pad that never presses either included. Read alone
|
||||
* it fired the correction on pads whose buttons were already right, which is how an Xbox pad
|
||||
* came to answer X with Y and Y with LB (field reports, 2026-08-21). It stays as the narrower
|
||||
* question it can answer — WHICH straight-through order, once `namedTriggers` has established
|
||||
* there is one — where a false positive costs nothing.
|
||||
*/
|
||||
fun padButtons(dev: InputDevice, namedTriggers: Boolean): PadButtons {
|
||||
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
|
||||
return padButtons(namedTriggers, dev.vendorId == VID_SONY, declaresCZ = has[0] && has[1])
|
||||
}
|
||||
|
||||
/** [padButtons]'s choice over plain facts — the seam its truth table is tested at (an
|
||||
* [InputDevice] cannot be built off a device). */
|
||||
fun padButtons(namedTriggers: Boolean, sony: Boolean, declaresCZ: Boolean): PadButtons = when {
|
||||
namedTriggers -> PadButtons.NATIVE
|
||||
declaresCZ && sony -> PadButtons.GENERIC_SONY
|
||||
declaresCZ -> PadButtons.GENERIC_XBOX
|
||||
sony -> PadButtons.SONY_MODERN
|
||||
else -> PadButtons.NATIVE
|
||||
}
|
||||
|
||||
/**
|
||||
* The [PadMap] for [dev] — its button report order and the axes its right stick and triggers
|
||||
* arrive on, resolved once per device model and cached.
|
||||
*
|
||||
* Axes get the same treatment as buttons: a pad Android has a layout for names its triggers
|
||||
* LTRIGGER/RTRIGGER (or BRAKE/GAS, or BRAKE/THROTTLE) and is left exactly as it was. A pad
|
||||
* with NONE of those names is one Android never mapped, and its triggers are sitting on two
|
||||
* raw axes under the names the HID report gave them. Which two depends on the same report
|
||||
* order the buttons did:
|
||||
*
|
||||
* - a Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry = left stick, right
|
||||
* stick, then the triggers — so the right stick is already right and only the triggers
|
||||
* (`AXIS_RX`/`AXIS_RY`) are missed;
|
||||
* - every other such pad puts the right stick on Rx/Ry and the triggers on Z/Rz, which is
|
||||
* the shape that makes pulling a trigger swing the right stick.
|
||||
*
|
||||
* Whether those axes idle at −1 is MEASURED from the device's own range rather than assumed,
|
||||
* so a pad that reports an honest 0..1 is not rescaled to a permanent half-pull.
|
||||
*/
|
||||
fun padMap(dev: InputDevice?): PadMap {
|
||||
if (dev == null) return NATIVE_MAP
|
||||
padMaps[dev.descriptor]?.let { return it }
|
||||
fun has(a: Int) = axis(dev, a) != null
|
||||
val named = (has(MotionEvent.AXIS_LTRIGGER) && has(MotionEvent.AXIS_RTRIGGER)) ||
|
||||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_GAS)) ||
|
||||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_THROTTLE))
|
||||
val buttons = padButtons(dev, namedTriggers = named)
|
||||
val rx = axis(dev, MotionEvent.AXIS_RX)
|
||||
val hasRxRy = rx != null && has(MotionEvent.AXIS_RY)
|
||||
// Whichever pair the fallback is about to pick, ask THAT one where it rests.
|
||||
val restsNegative = if (buttons == PadButtons.GENERIC_SONY) {
|
||||
(rx?.min ?: 0f) < -0.5f
|
||||
} else {
|
||||
(axis(dev, MotionEvent.AXIS_Z)?.min ?: 0f) < -0.5f
|
||||
}
|
||||
val map = padMap(buttons, namedTriggers = named, hasRxRy = hasRxRy, restsNegative = restsNegative)
|
||||
padMaps[dev.descriptor] = map
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* The axis half of [padMap], decided from four facts about the device so it can be pinned
|
||||
* without one — see `PadButtonsTest`. [namedTriggers] is whether the pad calls its triggers
|
||||
* anything Android knows (LTRIGGER/RTRIGGER, BRAKE/GAS, BRAKE/THROTTLE); if it does, nothing
|
||||
* here applies and the pad is read exactly as it always was. [restsNegative] is measured off
|
||||
* whichever axis pair the fallback picks, never assumed.
|
||||
*/
|
||||
fun padMap(
|
||||
buttons: PadButtons,
|
||||
namedTriggers: Boolean,
|
||||
hasRxRy: Boolean,
|
||||
restsNegative: Boolean,
|
||||
): PadMap = when {
|
||||
namedTriggers || !hasRxRy -> PadMap(buttons)
|
||||
// X, Y, Z, Rz, Rx, Ry = left stick, right stick, triggers. The sticks already read right.
|
||||
buttons == PadButtons.GENERIC_SONY -> PadMap(
|
||||
buttons,
|
||||
leftTrigger = MotionEvent.AXIS_RX,
|
||||
rightTrigger = MotionEvent.AXIS_RY,
|
||||
triggersSigned = restsNegative,
|
||||
)
|
||||
// Right stick on Rx/Ry and triggers on Z/Rz — the shape in which reading Z/Rz as the
|
||||
// right stick makes pulling a trigger swing it.
|
||||
else -> PadMap(
|
||||
buttons,
|
||||
rightStickX = MotionEvent.AXIS_RX,
|
||||
rightStickY = MotionEvent.AXIS_RY,
|
||||
leftTrigger = MotionEvent.AXIS_Z,
|
||||
rightTrigger = MotionEvent.AXIS_RZ,
|
||||
triggersSigned = restsNegative,
|
||||
)
|
||||
}
|
||||
|
||||
/** [dev]'s range for one joystick [axis], under either source class a pad reports on. */
|
||||
private fun axis(dev: InputDevice, axis: Int): InputDevice.MotionRange? =
|
||||
dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK)
|
||||
?: dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD)
|
||||
|
||||
/**
|
||||
* The keycode [event] should have carried, given the controller it came from — [event]'s own
|
||||
* keycode for every pad Android has a key layout for, and the scancode's true button for one
|
||||
* it does not (see the block comment above [PadButtons]).
|
||||
*
|
||||
* A drop-in for `event.keyCode` at every gamepad reader: the console UI's navigation, the
|
||||
* Controllers screen's tester, and the streaming branch all route through it, so a mis-mapped
|
||||
* pad is fixed in the menus and in the game at once. Events from anything that is not a
|
||||
* controller, and events with no scancode (soft keyboards, synthetic events), pass through
|
||||
* untouched.
|
||||
*/
|
||||
fun padKeyCode(event: KeyEvent): Int {
|
||||
val dev = event.device ?: return event.keyCode
|
||||
if (event.scanCode == 0 || !isPad(dev)) return event.keyCode
|
||||
return padMap(dev).buttons.correct(event.scanCode, event.keyCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -306,7 +683,12 @@ object Gamepad {
|
||||
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
|
||||
* folds the event straight in without re-qualifying it.
|
||||
*/
|
||||
class AxisMapper(private val handle: Long, private val pad: Int) {
|
||||
class AxisMapper(
|
||||
private val handle: Long,
|
||||
private val pad: Int,
|
||||
/** Which axes this controller's right stick and triggers arrive on — see [padMap]. */
|
||||
private val map: PadMap = NATIVE_MAP,
|
||||
) {
|
||||
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
||||
private val last = IntArray(6) { Int.MIN_VALUE }
|
||||
private var hatX = 0 // -1 / 0 / +1
|
||||
@@ -317,30 +699,18 @@ object Gamepad {
|
||||
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
||||
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
||||
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
|
||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
|
||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
|
||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(map.rightStickX)))
|
||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(map.rightStickY)))
|
||||
|
||||
// Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
|
||||
// with max, the same fold as the Controllers screen probe, so a pad that reports
|
||||
// only one pair and a pad that reports both behave identically; 0..1 → 0..255.
|
||||
sendAxis(
|
||||
AXIS_LT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
),
|
||||
),
|
||||
)
|
||||
sendAxis(
|
||||
AXIS_RT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
),
|
||||
),
|
||||
)
|
||||
// only one pair and a pad that reports both behave identically; 0..1 → 0..255. A pad
|
||||
// reporting NONE of those names is one Android has no key layout for, and [map]
|
||||
// carries the raw axes its triggers really landed on instead.
|
||||
val lt = resolved(event, map.leftTrigger, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)
|
||||
val rt = resolved(event, map.rightTrigger, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)
|
||||
sendAxis(AXIS_LT, trigger(lt))
|
||||
sendAxis(AXIS_RT, trigger(rt))
|
||||
|
||||
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
|
||||
// tap (press+release inside one batch window) lives only in the historical samples — the
|
||||
@@ -383,6 +753,17 @@ object Gamepad {
|
||||
hatY = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One trigger's 0..1 value: [resolvedAxis] when this pad needed one resolved for it,
|
||||
* else the max of the two names Android gives a trigger it does know.
|
||||
*/
|
||||
private fun resolved(event: MotionEvent, resolvedAxis: Int, named: Int, alias: Int): Float =
|
||||
if (resolvedAxis == AXIS_NONE) {
|
||||
maxOf(event.getAxisValue(named), event.getAxisValue(alias))
|
||||
} else {
|
||||
map.level(event.getAxisValue(resolvedAxis))
|
||||
}
|
||||
|
||||
private fun sendAxis(id: Int, v: Int) {
|
||||
if (last[id] == v) return
|
||||
last[id] = v
|
||||
|
||||
@@ -605,7 +605,7 @@ class GamepadRouter(
|
||||
// for the slot's life; the sensor path reads it on every sample.
|
||||
val slot = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index),
|
||||
Gamepad.AxisMapper(handle, index, Gamepad.padMap(dev)),
|
||||
NativeBridge.nativePadMotionReaches(handle, pref),
|
||||
)
|
||||
slots[dev.id] = slot
|
||||
|
||||
@@ -298,6 +298,18 @@ object NativeBridge {
|
||||
surfaceH: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Re-report the video SurfaceView's on-screen pixel size — call it from every `surfaceChanged`.
|
||||
*
|
||||
* The ASurfaceControl present backend composites the picture into exactly this rectangle, and
|
||||
* the view grows AFTER [nativeStartVideo] has run: the stream screen hides the system bars and
|
||||
* switches the window to draw into the display cutout a frame or two later, and neither
|
||||
* recreates the surface. Without this the layer keeps painting at its start-up size in the
|
||||
* corner of a now-bigger surface. Non-positive values are ignored. No-op on a `0` handle;
|
||||
* cheap (one atomic store), UI-safe.
|
||||
*/
|
||||
external fun nativeVideoSurfaceSize(handle: Long, width: Int, height: Int)
|
||||
|
||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||
external fun nativeStopVideo(handle: Long)
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of [Gamepad.PadButtons.correct] — the scancode resolution for controllers Android
|
||||
* has no key layout for. Only `KeyEvent`'s compile-time-inlined keycode constants are involved, so
|
||||
* no Android runtime is needed. Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*
|
||||
* The regression it pins is a field report from a Fire TV Stick 4K Max (2026-08-20): a DualSense
|
||||
* and an Xbox Elite Series 2, both over Bluetooth, both identified correctly but with buttons
|
||||
* landing on the wrong actions — "L1 being L2". Neither pad has a key layout on that box (AOSP
|
||||
* ships none for `045e:0b05` at all, and the DualSense's requires `CONFIG_HID_PLAYSTATION`), so
|
||||
* both fall back to `Generic.kl`, which names keycodes by scancode POSITION. A pad with no kernel
|
||||
* driver numbers its HID buttons 1..n straight through in its own report order, so every keycode
|
||||
* after the first divergence belongs to a different button.
|
||||
*
|
||||
* The table below is the pad's physical button on the left and where `Generic.kl` put it on the
|
||||
* right; the assertions read it back the other way.
|
||||
*/
|
||||
class PadButtonsTest {
|
||||
|
||||
private fun sony(scan: Int) =
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
|
||||
private fun xbox(scan: Int) =
|
||||
Gamepad.PadButtons.GENERIC_XBOX.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
|
||||
/**
|
||||
* The exact report: a DualSense's L2 sits at scancode `0x136`, which `Generic.kl` calls
|
||||
* BUTTON_L1 — so pulling L2 read as a shoulder press, and L1 (at `0x134`, read as BUTTON_Y)
|
||||
* read as a face button.
|
||||
*/
|
||||
@Test
|
||||
fun `a DualSense's shoulders stop being each other's buttons`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, sony(0x134)) // L1, delivered as BUTTON_Y
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, sony(0x135)) // R1, delivered as BUTTON_Z
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L2, sony(0x136)) // L2, delivered as BUTTON_L1
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R2, sony(0x137)) // R2, delivered as BUTTON_R1
|
||||
}
|
||||
|
||||
/** ✕ is the bottom button — the one A means everywhere else — and □ is the left one. */
|
||||
@Test
|
||||
fun `a DualSense's face buttons land on their Xbox positions`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, sony(0x130)) // □
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_A, sony(0x131)) // ✕
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_B, sony(0x132)) // ○
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, sony(0x133)) // △
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/Options/L3/R3/PS. Select in particular: without this it arrived as BUTTON_THUMBL,
|
||||
* which took the exit, mic and stats chords with it — every one of them is built on Select.
|
||||
*/
|
||||
@Test
|
||||
fun `a DualSense's menu buttons and stick clicks are themselves`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, sony(0x138)) // Create
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, sony(0x139)) // Options
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, sony(0x13a)) // L3
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, sony(0x13b)) // R3
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_MODE, sony(0x13c)) // PS
|
||||
}
|
||||
|
||||
/** The touchpad click and mute have no wire button; they must resolve to nothing, not to R3. */
|
||||
@Test
|
||||
fun `a DualSense's touchpad and mute are dropped rather than mistaken`() {
|
||||
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13d))
|
||||
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13e))
|
||||
assertEquals(0, Gamepad.buttonBit(sony(0x13d)))
|
||||
}
|
||||
|
||||
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
|
||||
@Test
|
||||
fun `an Xbox pad numbering straight through keeps its own layout`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_A, xbox(0x130))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_B, xbox(0x131))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, xbox(0x132))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, xbox(0x133))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, xbox(0x134))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, xbox(0x135))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, xbox(0x136)) // View
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, xbox(0x137)) // Menu
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, xbox(0x138))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, xbox(0x139))
|
||||
}
|
||||
|
||||
/** `hid-playstation` emits the modern Linux codes, where only the face pair reads swapped. */
|
||||
@Test
|
||||
fun `a driver-backed Sony pad has only its face pair corrected`() {
|
||||
val m = Gamepad.PadButtons.SONY_MODERN
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, m.correct(0x133, KeyEvent.KEYCODE_BUTTON_X)) // △
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, m.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y)) // □
|
||||
for (scan in listOf(0x130, 0x131, 0x136, 0x137, 0x13a, 0x13b, 0x13c)) {
|
||||
assertEquals(Gamepad.genericKeyCode(scan), m.correct(scan, Gamepad.genericKeyCode(scan)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard that makes all of this safe to run on every pad: a keycode that is NOT what
|
||||
* `Generic.kl` would have said came from a device-specific key layout, which knows this
|
||||
* controller better than any table here. Correcting it would break a pad that works.
|
||||
*/
|
||||
@Test
|
||||
fun `a keycode a device layout already resolved is never second-guessed`() {
|
||||
// AOSP's DualSense layout puts △ on BUTTON_Y itself. Every profile must leave it be.
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, p.correct(0x133, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
}
|
||||
// Same for a scancode outside the generic gamepad block entirely — a pad's Back key.
|
||||
assertEquals(
|
||||
KeyEvent.KEYCODE_BACK,
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(158, KeyEvent.KEYCODE_BACK),
|
||||
)
|
||||
}
|
||||
|
||||
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
|
||||
@Test
|
||||
fun `correction is idempotent`() {
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
for (scan in 0x130..0x13e) {
|
||||
val once = p.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
assertEquals(once, p.correct(scan, once))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The axis half. A pad that names its triggers something Android knows is read exactly as it
|
||||
* always was — this is the branch that must NOT fire on the pads that already work.
|
||||
*/
|
||||
@Test
|
||||
fun `a pad that names its triggers is read unchanged`() {
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
val map = Gamepad.padMap(p, namedTriggers = true, hasRxRy = true, restsNegative = true)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
|
||||
assertEquals(Gamepad.AXIS_NONE, map.leftTrigger)
|
||||
assertEquals(Gamepad.AXIS_NONE, map.rightTrigger)
|
||||
}
|
||||
// Same when there is no Rx/Ry to fall back to in the first place.
|
||||
val none = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = false, restsNegative = false)
|
||||
assertEquals(Gamepad.AXIS_NONE, none.leftTrigger)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry — left stick, right
|
||||
* stick, then the triggers. Only the triggers were being missed; the sticks already read
|
||||
* right and must be left alone.
|
||||
*/
|
||||
@Test
|
||||
fun `an unmapped Sony pad keeps its sticks and gains its triggers`() {
|
||||
val map = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
|
||||
assertEquals(MotionEvent.AXIS_RX, map.leftTrigger)
|
||||
assertEquals(MotionEvent.AXIS_RY, map.rightTrigger)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every other unmapped pad is the opposite way round: right stick on Rx/Ry, triggers on Z/Rz.
|
||||
* Reading Z/Rz as the right stick there is what makes pulling a trigger swing it — so the two
|
||||
* pairs must never be mixed up, which is the whole point of pinning them.
|
||||
*/
|
||||
@Test
|
||||
fun `an unmapped Xbox-layout pad has its stick and triggers the other way round`() {
|
||||
for (p in listOf(Gamepad.PadButtons.GENERIC_XBOX, Gamepad.PadButtons.SONY_MODERN)) {
|
||||
val map = Gamepad.padMap(p, namedTriggers = false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(MotionEvent.AXIS_RX, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RY, map.rightStickY)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.leftTrigger)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightTrigger)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A trigger axis that idles at −1 is rescaled; one that idles at 0 must NOT be, or it would
|
||||
* read as a permanent half-pull. Which it is gets measured off the device, never assumed —
|
||||
* both the DualSense's raw RX/RY and the Xbox pad's Z/Rz report an honest 0..1.
|
||||
*/
|
||||
@Test
|
||||
fun `only a trigger that idles negative is rescaled`() {
|
||||
val signed = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = true)
|
||||
assertEquals(0f, signed.level(-1f), 1e-6f)
|
||||
assertEquals(0.5f, signed.level(0f), 1e-6f)
|
||||
assertEquals(1f, signed.level(1f), 1e-6f)
|
||||
|
||||
val unsigned = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(0f, unsigned.level(0f), 1e-6f)
|
||||
assertEquals(1f, unsigned.level(1f), 1e-6f)
|
||||
}
|
||||
|
||||
/** A pad Android does know is untouched, which is most of them. */
|
||||
@Test
|
||||
fun `a pad with a key layout is left alone`() {
|
||||
for (scan in 0x130..0x13e) {
|
||||
val generic = Gamepad.genericKeyCode(scan)
|
||||
assertEquals(generic, Gamepad.PadButtons.NATIVE.correct(scan, generic))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression that made this gate necessary (field reports, 2026-08-21): an Xbox Wireless
|
||||
* Controller and a GameSir G8+, both with their buttons at the standard positions and both
|
||||
* corrected anyway, because `hasKeys` says BUTTON_C and BUTTON_Z for any pad that DECLARES six
|
||||
* buttons — `hid-input` allocates the whole descriptor `BTN_A + n` straight through whether the
|
||||
* pad ever presses them or not. Naming the triggers is what tells the two apart.
|
||||
*/
|
||||
@Test
|
||||
fun `a pad that names its triggers is never corrected, whatever it declares`() {
|
||||
for (sony in listOf(false, true)) {
|
||||
for (declaresCZ in listOf(false, true)) {
|
||||
assertEquals(
|
||||
Gamepad.PadButtons.NATIVE,
|
||||
Gamepad.padButtons(namedTriggers = true, sony = sony, declaresCZ = declaresCZ),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The four buttons the field reports named, on a pad whose report order is already standard:
|
||||
* X answering Y, Y answering LB, and both shoulders answering a menu button. NATIVE is what
|
||||
* keeps them themselves — the correction tables are right for the pads they are for, and this
|
||||
* is about not reaching one of them.
|
||||
*/
|
||||
@Test
|
||||
fun `an Xbox pad at the standard positions keeps X, Y and its shoulders`() {
|
||||
val native = Gamepad.PadButtons.NATIVE
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, native.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, native.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, native.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, native.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
|
||||
// What the old heuristic did to each of them, kept here so the difference stays visible.
|
||||
val wrong = Gamepad.PadButtons.GENERIC_XBOX
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, wrong.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, wrong.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, wrong.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, wrong.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
|
||||
}
|
||||
|
||||
/** Past the gate, which straight-through order to read is still the question it always was. */
|
||||
@Test
|
||||
fun `an unnamed-trigger pad still resolves its report order`() {
|
||||
fun order(sony: Boolean, declaresCZ: Boolean) =
|
||||
Gamepad.padButtons(namedTriggers = false, sony = sony, declaresCZ = declaresCZ)
|
||||
assertEquals(Gamepad.PadButtons.GENERIC_SONY, order(sony = true, declaresCZ = true))
|
||||
assertEquals(Gamepad.PadButtons.GENERIC_XBOX, order(sony = false, declaresCZ = true))
|
||||
assertEquals(Gamepad.PadButtons.SONY_MODERN, order(sony = true, declaresCZ = false))
|
||||
assertEquals(Gamepad.PadButtons.NATIVE, order(sony = false, declaresCZ = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The truth table behind "is a controller attached" — the question the console UI's
|
||||
* "With a controller" mode is answered by. A false positive here is not cosmetic: it pins the
|
||||
* console UI on with no pad in the room, and no setting short of turning the whole thing off can
|
||||
* dismiss it, because the phantom pad never disconnects.
|
||||
*/
|
||||
class PadPresenceTest {
|
||||
|
||||
/** A real pad: the source class plus hardware behind it, in either of the two shapes. */
|
||||
@Test
|
||||
fun realPadsCount() {
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
// An arcade stick / d-pad-only pad — buttons, no analog stick.
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = false, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
// A wheel or flight stick — axes, no A/B.
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = true, hasFaceButtons = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The gaming-phone shoulder triggers and OEM game-mode overlays: a virtual device wearing the
|
||||
* gamepad source class. This is the field report — the console UI that could not be dismissed. */
|
||||
@Test
|
||||
fun virtualDevicesAreNotControllers() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = true, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A device that claims a pad source with nothing behind it is not a pad either. */
|
||||
@Test
|
||||
fun aSourceClaimWithoutHardwareIsNotAController() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = false, hasFaceButtons = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** And a keyboard/mouse with sticks it never reports on the joystick source stays out. */
|
||||
@Test
|
||||
fun nonPadSourcesNeverCount() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = false, virtual = false, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ struct CreateOptions {
|
||||
device_name: String,
|
||||
/// Skia's resource budget, bytes (Kotlin sizes it from `ActivityManager.memoryClass`).
|
||||
gpu_cache_bytes: usize,
|
||||
/// Whether the touch shell exists as a fallback (phones/tablets; false on a TV) —
|
||||
/// gates the console-off settings row. Default false: absent means don't offer it.
|
||||
#[serde(default)]
|
||||
fallback_ui: bool,
|
||||
/// The settings snapshot the shell starts from (`pf_client_core::trust::Settings` JSON).
|
||||
settings: pf_client_core::trust::Settings,
|
||||
/// The profile catalog as `[[id, name], …]`.
|
||||
@@ -150,6 +154,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleCrea
|
||||
let console_opts = ConsoleOptions {
|
||||
device_name: opts.device_name,
|
||||
deck: false,
|
||||
fallback_ui: opts.fallback_ui,
|
||||
store: Some(store.clone()),
|
||||
platform: Platform::Android,
|
||||
gpu_cache_bytes: opts.gpu_cache_bytes.max(16 << 20),
|
||||
|
||||
@@ -142,21 +142,21 @@ pub(super) struct AscBackend {
|
||||
impl AscBackend {
|
||||
/// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then
|
||||
/// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the
|
||||
/// negotiated decode size; `panel_hz` the mode-table panel rate (seeds the learner);
|
||||
/// negotiated decode size; `surface_size` the LIVE view size the layer composites into;
|
||||
/// `panel_hz` the mode-table panel rate (seeds the learner);
|
||||
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn create(
|
||||
window: &NativeWindow,
|
||||
src_w: i32,
|
||||
src_h: i32,
|
||||
surface_w: i32,
|
||||
surface_h: i32,
|
||||
surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
panel_hz: i32,
|
||||
dataspace: i32,
|
||||
source_hz: u32,
|
||||
priority: PresentPriority,
|
||||
) -> Option<AscBackend> {
|
||||
let layer = Layer::create(window, surface_w, surface_h)?;
|
||||
let layer = Layer::create(window, surface_size)?;
|
||||
let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE
|
||||
| ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
|
||||
let reader = match ImageReader::new_with_usage(
|
||||
|
||||
@@ -96,8 +96,7 @@ pub(super) fn run_async(
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
@@ -199,8 +198,7 @@ pub(super) fn run_async(
|
||||
&window,
|
||||
mode.width as i32,
|
||||
mode.height as i32,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size,
|
||||
panel_hz,
|
||||
initial_ds,
|
||||
mode.refresh_hz,
|
||||
|
||||
@@ -91,7 +91,14 @@ const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(
|
||||
|
||||
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
|
||||
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
|
||||
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
|
||||
///
|
||||
/// ⚠ Taken from core, NOT a local number. `FLUSH_COOLDOWN` (the jump-to-live rate limit) is 2000 ms,
|
||||
/// and the host classifies a keyframe-recovery cadence by matching a cooldown's period ±10 % to
|
||||
/// decide WHICH client failure it is looking at. The two are opposites — "I have received nothing"
|
||||
/// versus "I am drowning in frames I cannot drain" — so while this was also 2000 ms the host
|
||||
/// confidently reported the wrong one, and a black-screen field case was diagnosed as a slow decoder
|
||||
/// for days (2026-08-20). Keeping the value in core is what stops the two drifting back together.
|
||||
const NO_VIDEO_RETRY: std::time::Duration = punktfunk_core::client::NO_VIDEO_RETRY;
|
||||
|
||||
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
||||
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
||||
@@ -133,12 +140,12 @@ pub(crate) struct DecodeOptions {
|
||||
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
|
||||
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
|
||||
pub panel_hz: i32,
|
||||
/// The video `SurfaceView`'s on-screen pixel size (the aspect-fitted display footprint), from
|
||||
/// Kotlin at `surfaceCreated`. The ASurfaceControl backend composites its layer in this
|
||||
/// coordinate space — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin
|
||||
/// couldn't read it yet, and the backend falls back to the window buffer size.
|
||||
pub surface_w: i32,
|
||||
pub surface_h: i32,
|
||||
/// The video `SurfaceView`'s LIVE on-screen pixel size (the aspect-fitted display footprint),
|
||||
/// packed by [`crate::session::pack_surface_size`] and re-reported by Kotlin on every
|
||||
/// `surfaceChanged`. The ASurfaceControl backend composites its layer in this coordinate space
|
||||
/// — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin couldn't read it
|
||||
/// yet, and the backend falls back to the window buffer size.
|
||||
pub surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||
|
||||
@@ -24,6 +24,7 @@ use ndk::hardware_buffer::HardwareBuffer;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use std::ffi::c_void;
|
||||
use std::os::fd::{FromRawFd, OwnedFd, RawFd};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{mpsc, Arc};
|
||||
|
||||
use super::async_loop::DecodeEvent;
|
||||
@@ -276,9 +277,14 @@ unsafe extern "C" fn on_complete(context: *mut c_void, stats: *mut ASurfaceTrans
|
||||
pub(super) struct Layer {
|
||||
api: Api,
|
||||
sc: Arc<ScHandle>,
|
||||
/// Destination rectangle (the SurfaceView's pixel size) — the buffer is scaled to fill it.
|
||||
dest_w: i32,
|
||||
dest_h: i32,
|
||||
/// The SurfaceView's LIVE pixel size, packed by `pack_surface_size` and re-read before every
|
||||
/// present — the destination rectangle the buffer is scaled to fill. Live rather than captured
|
||||
/// because the view resizes under a surface that is never recreated (see `dest`).
|
||||
surface_size: Arc<AtomicU64>,
|
||||
/// Fallback destination for as long as `surface_size` is still `0` (Kotlin hadn't measured the
|
||||
/// view when video started): the window's own buffer geometry, the best remaining guess.
|
||||
fallback_w: i32,
|
||||
fallback_h: i32,
|
||||
/// `true` once the first transaction has made the layer visible + set its z-order + frame rate.
|
||||
configured: bool,
|
||||
}
|
||||
@@ -287,13 +293,16 @@ impl Layer {
|
||||
/// Create the compositor layer over `window` (the SurfaceView's `ANativeWindow`), or `None` on
|
||||
/// API < 29 / a null layer — the caller then uses the SurfaceView presenter.
|
||||
///
|
||||
/// `dest_w/h` are the SurfaceView's **on-screen pixel size** — the coordinate space the child
|
||||
/// layer is composited into, which is the display footprint of the (aspect-fitted) video view,
|
||||
/// NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer geometry in a
|
||||
/// rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) — using it shrank
|
||||
/// the picture to the top-left corner. A non-positive `dest_w/h` (Kotlin couldn't read the view
|
||||
/// yet) falls back to that buffer size as the best remaining guess.
|
||||
pub(super) fn create(window: &NativeWindow, dest_w: i32, dest_h: i32) -> Option<Layer> {
|
||||
/// `surface_size` carries the SurfaceView's **on-screen pixel size** — the coordinate space the
|
||||
/// child layer is composited into, which is the display footprint of the (aspect-fitted) video
|
||||
/// view, NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer
|
||||
/// geometry in a rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) —
|
||||
/// using it shrank the picture to the top-left corner. It is read fresh on every present
|
||||
/// because that view RESIZES mid-stream under a surface that is never recreated: the stream
|
||||
/// screen hides the system bars and switches on cutout drawing a frame or two after
|
||||
/// `surfaceCreated`, and each one grows it. An empty `surface_size` (Kotlin hadn't measured the
|
||||
/// view yet) falls back to the buffer size as the best remaining guess.
|
||||
pub(super) fn create(window: &NativeWindow, surface_size: Arc<AtomicU64>) -> Option<Layer> {
|
||||
let api = Api::resolve()?;
|
||||
// SAFETY: `window.ptr()` is the live `ANativeWindow` the decode thread owns; the name is a
|
||||
// static NUL-terminated string; the call returns null on failure (checked).
|
||||
@@ -303,20 +312,11 @@ impl Layer {
|
||||
log::warn!("asc: createFromWindow returned null — falling back to SurfaceView");
|
||||
return None;
|
||||
}
|
||||
let dest_w = if dest_w > 0 {
|
||||
dest_w
|
||||
} else {
|
||||
window.width().max(1)
|
||||
};
|
||||
let dest_h = if dest_h > 0 {
|
||||
dest_h
|
||||
} else {
|
||||
window.height().max(1)
|
||||
};
|
||||
let fallback_w = window.width().max(1);
|
||||
let fallback_h = window.height().max(1);
|
||||
log::info!(
|
||||
"asc: layer created, dest {dest_w}x{dest_h} (window buffer {}x{})",
|
||||
window.width(),
|
||||
window.height(),
|
||||
"asc: layer created, dest {:?} (window buffer {fallback_w}x{fallback_h})",
|
||||
crate::session::unpack_surface_size(surface_size.load(Ordering::Relaxed)),
|
||||
);
|
||||
Some(Layer {
|
||||
sc: Arc::new(ScHandle {
|
||||
@@ -324,12 +324,20 @@ impl Layer {
|
||||
release: api.ac_release,
|
||||
}),
|
||||
api,
|
||||
dest_w,
|
||||
dest_h,
|
||||
surface_size,
|
||||
fallback_w,
|
||||
fallback_h,
|
||||
configured: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The destination rectangle for this present: the live view size, or the window's buffer
|
||||
/// geometry while Kotlin has reported nothing.
|
||||
fn dest(&self) -> (i32, i32) {
|
||||
crate::session::unpack_surface_size(self.surface_size.load(Ordering::Relaxed))
|
||||
.unwrap_or((self.fallback_w, self.fallback_h))
|
||||
}
|
||||
|
||||
/// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes
|
||||
/// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot
|
||||
/// completion that reports the real latch + the previous buffer's release fence on `ev_tx`,
|
||||
@@ -370,11 +378,12 @@ impl Layer {
|
||||
right: src_w.max(1),
|
||||
bottom: src_h.max(1),
|
||||
};
|
||||
let (dest_w, dest_h) = self.dest();
|
||||
let dst = ARect {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: self.dest_w,
|
||||
bottom: self.dest_h,
|
||||
right: dest_w,
|
||||
bottom: dest_h,
|
||||
};
|
||||
(self.api.txn_set_geometry)(txn, sc, &src, &dst, TRANSFORM_IDENTITY);
|
||||
if dataspace != 0 {
|
||||
|
||||
@@ -50,8 +50,7 @@ pub(super) fn run_sync(
|
||||
panel_hz: _,
|
||||
// The ASurfaceControl backend is async-loop only; the sync loop renders straight to the
|
||||
// SurfaceView, so it never needs the view's on-screen size.
|
||||
surface_w: _,
|
||||
surface_h: _,
|
||||
surface_size: _,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
|
||||
@@ -470,6 +470,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
access_seq: std::sync::atomic::AtomicU32::new(0),
|
||||
// Reported by Kotlin at `surfaceCreated` and on every resize after it.
|
||||
surface_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
};
|
||||
Box::into_raw(Box::new(handle)) as jlong
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ mod probe;
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -87,6 +87,37 @@ pub(crate) struct SessionHandle {
|
||||
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
|
||||
/// (the host's expiry warnings) arrived without holding a blocking event thread.
|
||||
pub(crate) access_seq: AtomicU32,
|
||||
/// The video `SurfaceView`'s LIVE on-screen pixel size ([`pack_surface_size`]), written by
|
||||
/// `nativeStartVideo` and by every `nativeVideoSurfaceSize` the `surfaceChanged` callback
|
||||
/// sends, read by the ASurfaceControl presenter before each present.
|
||||
///
|
||||
/// Shared and live rather than a start-time parameter because the view RESIZES under a surface
|
||||
/// that is never recreated: hiding the system bars and switching the window to
|
||||
/// `LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` both happen a frame or two AFTER `surfaceCreated`,
|
||||
/// and each one grows the video view. A destination rect captured once at creation then keeps
|
||||
/// compositing the picture at its old, smaller size anchored at the layer's origin — the
|
||||
/// "stream in the top-left corner" field report. `0` = nothing reported yet, and the layer
|
||||
/// falls back to the window's buffer geometry.
|
||||
pub surface_size: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// Pack a surface's pixel size into one `u64` — so the presenter reads width and height as a
|
||||
/// single atomic load and can never see a torn pair (a new width against an old height).
|
||||
/// Non-positive values pack as `0`, the "not reported yet" sentinel.
|
||||
pub(crate) fn pack_surface_size(w: i32, h: i32) -> u64 {
|
||||
if w <= 0 || h <= 0 {
|
||||
return 0;
|
||||
}
|
||||
((w as u64) << 32) | (h as u64 & 0xffff_ffff)
|
||||
}
|
||||
|
||||
/// The inverse of [`pack_surface_size`]: `None` for the `0` sentinel.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub(crate) fn unpack_surface_size(packed: u64) -> Option<(i32, i32)> {
|
||||
if packed == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((((packed >> 32) as u32) as i32, (packed as u32) as i32))
|
||||
}
|
||||
|
||||
struct VideoThread {
|
||||
@@ -160,3 +191,29 @@ fn parse_hex32(s: &str) -> Option<[u8; 32]> {
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pack_surface_size, unpack_surface_size};
|
||||
|
||||
/// The pair the presenter reads as one atomic load must survive the round trip — including a
|
||||
/// size wider than a signed 16-bit value, which every panel this runs on now is.
|
||||
#[test]
|
||||
fn surface_size_round_trips() {
|
||||
assert_eq!(
|
||||
unpack_surface_size(pack_surface_size(2800, 1260)),
|
||||
Some((2800, 1260))
|
||||
);
|
||||
assert_eq!(unpack_surface_size(pack_surface_size(1, 1)), Some((1, 1)));
|
||||
}
|
||||
|
||||
/// "Not reported yet" — and anything nonsensical — is the one sentinel, so the layer falls back
|
||||
/// to the window's buffer geometry rather than composing into an empty rectangle.
|
||||
#[test]
|
||||
fn non_positive_sizes_are_the_sentinel() {
|
||||
assert_eq!(pack_surface_size(0, 0), 0);
|
||||
assert_eq!(pack_surface_size(1920, 0), 0);
|
||||
assert_eq!(pack_surface_size(-1, 1080), 0);
|
||||
assert_eq!(unpack_surface_size(0), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
let client = h.client.clone();
|
||||
let sd = shutdown.clone();
|
||||
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
|
||||
|
||||
// Seed the live view size with what the view measures right now; `surfaceChanged` keeps it
|
||||
// current from here on (the bars hide and the cutout mode changes AFTER this call).
|
||||
h.surface_size.store(
|
||||
super::pack_surface_size(surface_w, surface_h),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
let opts = crate::decode::DecodeOptions {
|
||||
decoder_name: decoder,
|
||||
ll_feature,
|
||||
@@ -80,8 +87,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz: panel_fps,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size: h.surface_size.clone(),
|
||||
};
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-decode".into())
|
||||
@@ -93,6 +99,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoSurfaceSize(handle, width, height)` — the video `SurfaceView`'s
|
||||
/// on-screen pixel size, re-reported on every `surfaceChanged`.
|
||||
///
|
||||
/// The ASurfaceControl presenter composites its child layer into exactly this rectangle, and the
|
||||
/// view resizes UNDER a surface that is never recreated: the stream screen hides the system bars
|
||||
/// and asks to draw into the display cutout a frame or two after `surfaceCreated`, both of which
|
||||
/// grow it. Without this the layer would keep painting the picture at its start-up size, in the
|
||||
/// corner of a bigger surface. Non-positive values are ignored (they'd blank the picture).
|
||||
/// No-op on a `0` handle. Stored whether or not video is running — the next `nativeStartVideo`
|
||||
/// then starts from a measured view rather than the window's guess. Not android-gated: pure `jni`
|
||||
/// + an atomic store, so it links on the host build too.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSurfaceSize(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
width: jni::sys::jint,
|
||||
height: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
let packed = super::pack_surface_size(width, height);
|
||||
if handle == 0 || packed == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.surface_size
|
||||
.store(packed, std::sync::atomic::Ordering::Relaxed);
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
|
||||
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
|
||||
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
|
||||
|
||||
+18
-5
@@ -370,15 +370,26 @@ from the config directory for a true factory reset."
|
||||
.unwrap_or(DISCOVER_DEFAULT_SECS)
|
||||
.min(DISCOVER_MAX_SECS);
|
||||
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
|
||||
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
|
||||
// never hands their ids back. `load` would mint ids for a pre-mint store and save them —
|
||||
// a write from a read-only verb, and one that races the `hosts list` a caller is very
|
||||
// likely running at the same moment (the Decky panel issues both together).
|
||||
// `read`, not `load`: this verb never hands a record's id back, so it has no business
|
||||
// MINTING one. `load` would mint ids for a pre-mint store and save them, racing the
|
||||
// `hosts list` a caller is very likely running at the same moment (the Decky panel issues
|
||||
// both together) — after which the ids one of them already handed out no longer resolve.
|
||||
let known = KnownHosts::read();
|
||||
let rows: Vec<(
|
||||
&pf_client_core::discovery::DiscoveredHost,
|
||||
Option<&KnownHost>,
|
||||
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
|
||||
// The one write this verb does make, and why it doesn't contradict the above: an advert
|
||||
// is the only place a host's wake MAC is ever published, and this verb is the only one
|
||||
// the Decky panel runs that ever sees one. Without it a Deck in Gaming Mode never learns
|
||||
// a MAC at all and Wake-on-LAN cannot fire, with nothing to show for it (#322).
|
||||
// `learn_from_advert` mints nothing either, and writes only when an advert genuinely
|
||||
// taught the record something new — so a steady-state panel refresh touches no disk.
|
||||
for (d, saved) in &rows {
|
||||
if let Some(k) = saved {
|
||||
trust::learn_from_advert(&k.fp_hex, &k.addr, k.port, &d.mac, &d.os, d.mgmt_port);
|
||||
}
|
||||
}
|
||||
if has(args, "--json") {
|
||||
let hosts: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
@@ -733,7 +744,9 @@ from the config directory for a true factory reset."
|
||||
};
|
||||
let host = &known.hosts[i];
|
||||
if host.mac.is_empty() {
|
||||
eprintln!("no Wake-on-LAN address known for {} — connect to it once while it's awake so the client can learn it", host.name);
|
||||
// A MAC is learned from the host's mDNS advert, never from a connect — say so, since
|
||||
// "connect to it once" sent at least one Deck owner looking in the wrong place (#322).
|
||||
eprintln!("no Wake-on-LAN address known for {} — run `punktfunk discover` while it's awake (the Deck panel does this every time it opens) so the client learns it from the host's advert", host.name);
|
||||
return UNRESOLVED;
|
||||
}
|
||||
if !has(args, "--wait") {
|
||||
|
||||
@@ -1087,33 +1087,20 @@ impl HostsPage {
|
||||
// Online = advertising on mDNS OR proven reachable by the last probe sweep.
|
||||
let online = self.adverts.values().any(|a| matches(k, a))
|
||||
|| self.probed.get(&saved_key(k)).copied().unwrap_or(false);
|
||||
// Learn this host's wake MAC(s) from its live advert while it's online.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && !a.mac.is_empty())
|
||||
{
|
||||
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||
}
|
||||
// Same for its OS chain — the icon then survives the host going offline.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && !a.os.is_empty())
|
||||
{
|
||||
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);
|
||||
}
|
||||
// Learn what this host's live advert teaches while it's online: its wake MAC(s),
|
||||
// its OS chain (so the icon survives it going offline), and its management port
|
||||
// — the last one not cosmetic, since a host that moved off 47990 loses its
|
||||
// library the moment mDNS is unavailable and the advert is the only place the
|
||||
// real port ever lived.
|
||||
if let Some(a) = self.adverts.values().find(|a| matches(k, a)) {
|
||||
crate::trust::learn_from_advert(
|
||||
&k.fp_hex,
|
||||
&k.addr,
|
||||
k.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
saved.push_back(HostCard {
|
||||
connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()),
|
||||
|
||||
@@ -53,9 +53,9 @@ use punktfunk_core::config::Role;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::packet::FLAG_PROBE;
|
||||
use punktfunk_core::quic::{
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, Hello, LossReport,
|
||||
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start,
|
||||
Welcome,
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, DeliveryReport, Hello,
|
||||
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate,
|
||||
Start, Welcome,
|
||||
};
|
||||
use punktfunk_core::transport::UdpTransport;
|
||||
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
|
||||
@@ -987,10 +987,18 @@ async fn session(args: Args) -> Result<()> {
|
||||
let mut ls = send;
|
||||
let lp = loss_ppm.clone();
|
||||
let df = dropped_frames.clone();
|
||||
// Delivery truth for the host's dead-data-plane check: report what actually landed on the
|
||||
// wire, so the probe reproduces a real client's answer rather than the "cannot answer"
|
||||
// sentinel — which is exactly what makes it usable for testing that path.
|
||||
let rxp = rx_wire_packets.clone();
|
||||
tokio::spawn(async move {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut last_report = std::time::Instant::now();
|
||||
let mut last_dropped = 0u64;
|
||||
// Mirrors the real clients' rule (see `pump/data.rs`): report the delivery count every
|
||||
// window while it is zero, once when the first packets land, then stop — so a host that
|
||||
// predates the message is not flooded with "unknown control message" on a good session.
|
||||
let mut delivery_confirmed = false;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let d = df.load(Relaxed);
|
||||
@@ -1007,6 +1015,25 @@ async fn session(args: Args) -> Result<()> {
|
||||
if last_report.elapsed() >= std::time::Duration::from_millis(750) {
|
||||
last_report = std::time::Instant::now();
|
||||
let v = lp.swap(u32::MAX, Relaxed);
|
||||
// Independent of whether there is a fresh loss sample: "no fresh sample" is
|
||||
// exactly the shape a dead data plane has, so gating it on one would silence
|
||||
// it in the state it exists to report.
|
||||
let received = rxp.load(Relaxed);
|
||||
if received == 0 || !delivery_confirmed {
|
||||
delivery_confirmed = received > 0;
|
||||
if io::write_msg(
|
||||
&mut ls,
|
||||
&DeliveryReport {
|
||||
packets_received: received,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break; // control stream gone
|
||||
}
|
||||
}
|
||||
if v != u32::MAX
|
||||
&& io::write_msg(&mut ls, &LossReport { loss_ppm: v }.encode())
|
||||
.await
|
||||
|
||||
@@ -800,11 +800,21 @@ 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)
|
||||
// Write down everything the advert teaches while the host is visible: the mgmt
|
||||
// port (so this console keeps working against a moved one once it is not), the
|
||||
// OS chain, and the wake MAC — which matters most here, because this console and
|
||||
// the Decky panel are the only surfaces a Deck in Gaming Mode ever runs, and a
|
||||
// record that never learned a MAC can never be woken. 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);
|
||||
if let Some(a) = advert {
|
||||
pf_client_core::trust::learn_from_advert(
|
||||
&h.fp_hex,
|
||||
&h.addr,
|
||||
h.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
let row = HostRow {
|
||||
key: key.clone(),
|
||||
|
||||
@@ -33,11 +33,14 @@ the fast **`punktfunk/1`** protocol.
|
||||
hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream
|
||||
re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host.
|
||||
|
||||
Builds and ships for both **x64** and **ARM64** as a signed **MSIX**.
|
||||
Builds and ships for both **x64** and **ARM64**, three ways from one layout: a signed **installer**
|
||||
(the default — a per-user setup.exe whose stable install path Steam can launch, so the Steam
|
||||
overlay and Big Picture work), a **portable zip**, and a signed **MSIX** (kept for Microsoft Store
|
||||
compatibility).
|
||||
|
||||
## Get it
|
||||
|
||||
Install the signed MSIX from the package registry — see
|
||||
Install the signed installer from the package registry — see
|
||||
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
|
||||
A stock [Moonlight](https://moonlight-stream.org/) client also works over GameStream if you prefer.
|
||||
|
||||
@@ -58,7 +61,7 @@ punktfunk-client --headless --speed-test --connect host[:port] # probe burst
|
||||
```
|
||||
|
||||
> `CARGO_HOME` must be an ASCII path — non-ASCII characters break SDL3's MSVC precompiled-header
|
||||
> build. Packaging (MSIX manifest, signing) lives in [`packaging/`](packaging/).
|
||||
> build. Packaging (MSIX manifest, the Inno Setup installer, signing) lives in [`packaging/`](packaging/).
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -79,7 +82,7 @@ src/
|
||||
trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse
|
||||
probe.rs · wol.rs speed probe · Wake-on-LAN
|
||||
logfile.rs log tee to %LOCALAPPDATA%
|
||||
packaging/ MSIX manifest, signing, pack script
|
||||
packaging/ MSIX manifest + Inno Setup installer, signing, pack scripts
|
||||
```
|
||||
|
||||
## Manual smoke checklist
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
# punktfunk Windows client — MSIX packaging
|
||||
# punktfunk Windows client — packaging
|
||||
|
||||
The Windows client ships as **signed MSIX** packages so Windows boxes get a real package (Start
|
||||
tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from
|
||||
[`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml) to Gitea's
|
||||
The Windows client ships **three ways, packed from one assembled layout** by CI
|
||||
([`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml)) to Gitea's
|
||||
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
|
||||
touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels).
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels):
|
||||
|
||||
1. **Inno Setup installer** (`punktfunk-client-setup_<arch>.exe`) — the **default download**. A
|
||||
per-user, no-UAC install to `%LOCALAPPDATA%\Programs\Punktfunk`. It exists because the MSIX
|
||||
install shape breaks the top user-reported flows: the exe lands under the ACL'd
|
||||
`C:\Program Files\WindowsApps`, which Steam's *Add a Non-Steam Game* picker can't browse, and
|
||||
the alias/`shell:AppsFolder` activation defeats the Steam overlay's injection and Big Picture
|
||||
launch — Steam must spawn the exe itself from a normal path. `punktfunk-client.iss` +
|
||||
`pack-client-installer.ps1`; it re-creates the manifest's declarative grants per-user
|
||||
(`punktfunk://` in HKCU Classes, Start shortcuts, `{app}` on the user PATH for the
|
||||
`punktfunk` CLI) and fetches the Windows App Runtime when missing.
|
||||
2. **Portable zip** (`punktfunk-client-windows_<arch>-portable.zip`) — the same signed file set,
|
||||
nothing registered.
|
||||
3. **Signed MSIX** (`punktfunk-client-windows_<arch>.msix`) — kept for **Microsoft Store**
|
||||
compatibility. Everything below the fold documents this path.
|
||||
|
||||
`pack-msix.ps1` assembles the layout and packs the MSIX; `pack-client-installer.ps1` then consumes
|
||||
that same `layout/` for the installer + zip (and signs the four exes individually — the MSIX only
|
||||
signs its container).
|
||||
|
||||
# MSIX packaging
|
||||
|
||||
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
|
||||
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Pack + sign the punktfunk Windows client as an Inno Setup setup.exe (the default download) and a
|
||||
portable .zip, from the layout pack-msix.ps1 already assembled.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs AFTER pack-msix.ps1 in the same job and consumes its $OutDir\layout verbatim — one assembly,
|
||||
three artifacts (.msix, setup.exe, portable .zip). Why the installer exists at all: the MSIX
|
||||
install shape (WindowsApps ACLs + alias-only activation) breaks Steam's non-Steam-game picker,
|
||||
the Steam overlay's injection, and Big Picture launching — see punktfunk-client.iss's header.
|
||||
|
||||
Steps:
|
||||
1. stage the runtime file set from -LayoutDir (drops AppxManifest.xml + the tile Assets),
|
||||
2. sign the four exes individually (the MSIX only signs its container),
|
||||
3. zip the stage -> the portable build,
|
||||
4. ISCC punktfunk-client.iss over the same stage, sign the setup.exe,
|
||||
5. emit CLIENT_SETUP_PATH / CLIENT_ZIP_PATH to GITHUB_ENV for the publish step.
|
||||
|
||||
Signing backend precedence is identical to pack-msix.ps1 / pack-host-installer.ps1 (Azure
|
||||
Artifact Signing -> supplied .pfx -> ephemeral self-signed; fail closed on v* tags). No .cer is
|
||||
exported here: unlike an MSIX, a plain exe RUNS regardless of signer trust — an untrusted
|
||||
signature only costs a SmartScreen warning, so canary self-signed builds need nothing imported.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File pack-client-installer.ps1 -Version 0.2.137.0 -Arch x64 `
|
||||
-LayoutDir C:\t\msix\layout -OutDir C:\t\installer
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, same as the MSIX
|
||||
[Parameter(Mandatory = $true)][string]$LayoutDir, # pack-msix.ps1's $OutDir\layout
|
||||
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64',
|
||||
[string]$OutDir = (Join-Path (Split-Path -Parent $LayoutDir) 'installer'),
|
||||
# Subject for the EPHEMERAL self-signed fallback only; Azure/pfx carry their own subjects.
|
||||
[string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE",
|
||||
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret
|
||||
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
|
||||
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT,
|
||||
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT,
|
||||
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE,
|
||||
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB,
|
||||
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto',
|
||||
[switch]$NoSign # skip signing (local debug)
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
# Keep the "check $LASTEXITCODE myself" model (see pack-host-installer.ps1): pwsh 7.4 must not
|
||||
# turn a non-zero native exit into a terminating error before Sign-File's timestamp retry runs.
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
if ($Version -notmatch '^\d+\.\d+\.\d+\.\d+$') {
|
||||
throw "Version must be 4-part numeric (Major.Minor.Build.Revision); got '$Version'."
|
||||
}
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$iss = Join-Path $here 'punktfunk-client.iss'
|
||||
|
||||
# --- locate ISCC (Inno Setup) + signtool (Windows SDK) — same finders as the sibling scripts ---
|
||||
function Find-Iscc {
|
||||
foreach ($p in @(
|
||||
'C:\Program Files (x86)\Inno Setup 6\ISCC.exe',
|
||||
'C:\Program Files\Inno Setup 6\ISCC.exe')) {
|
||||
if (Test-Path $p) { return $p }
|
||||
}
|
||||
$c = Get-Command iscc -ErrorAction SilentlyContinue
|
||||
if ($c) { return $c.Source }
|
||||
throw "ISCC.exe (Inno Setup 6, any 6.x) not found - install it (choco install innosetup -y)."
|
||||
}
|
||||
function Find-SdkTool([string]$name) {
|
||||
$root = 'C:\Program Files (x86)\Windows Kits\10\bin'
|
||||
$hit = Get-ChildItem -Path $root -Recurse -Filter $name -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -match '\\(10\.0\.\d+\.\d+)\\x64\\' } |
|
||||
Sort-Object { [version]([regex]::Match($_.FullName, '\\(10\.0\.\d+\.\d+)\\x64\\').Groups[1].Value) } |
|
||||
Select-Object -Last 1
|
||||
if (-not $hit) { throw "$name not found under $root - install the Windows 10/11 SDK." }
|
||||
$hit.FullName
|
||||
}
|
||||
function Find-AzureDlib([string]$Explicit) {
|
||||
if ($Explicit) {
|
||||
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
|
||||
return (Resolve-Path $Explicit).Path
|
||||
}
|
||||
$roots = @(
|
||||
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
|
||||
'C:\trusted-signing\microsoft.trusted.signing.client'
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
|
||||
Sort-Object LastWriteTime | Select-Object -Last 1
|
||||
if (-not $hit) {
|
||||
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
|
||||
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
|
||||
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
|
||||
}
|
||||
$hit.FullName
|
||||
}
|
||||
$iscc = Find-Iscc
|
||||
Write-Host "ISCC: $iscc"
|
||||
|
||||
# --- stage the runtime file set (the portable layout = what the installer lays down) ----------
|
||||
# Explicit list, not a wildcard copy: the MSIX layout also holds AppxManifest.xml and the tile
|
||||
# Assets, which mean nothing outside a package (the exes embed their icons via build.rs).
|
||||
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'punktfunk.exe',
|
||||
'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
||||
$stage = Join-Path $OutDir 'portable'
|
||||
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $stage | Out-Null
|
||||
foreach ($f in $required) {
|
||||
$src = Join-Path $LayoutDir $f
|
||||
if (-not (Test-Path $src)) { throw "missing '$f' in $LayoutDir (did pack-msix.ps1 run first?)" }
|
||||
Copy-Item $src (Join-Path $stage $f) -Force
|
||||
}
|
||||
$licSrc = Join-Path $LayoutDir 'licenses'
|
||||
if (-not (Test-Path $licSrc)) { throw "missing licenses\ in $LayoutDir (did pack-msix.ps1 run first?)" }
|
||||
Copy-Item $licSrc (Join-Path $stage 'licenses') -Recurse -Force
|
||||
|
||||
# --- signing backend, same precedence + fail-closed rule as pack-msix.ps1 ---------------------
|
||||
$requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/tags/v*' }
|
||||
else { [Convert]::ToBoolean($RequireSignedCert) }
|
||||
if ($NoSign -and $requireCert) {
|
||||
throw "release build ($env:GITHUB_REF) with -NoSign - refusing to publish an unsigned installer."
|
||||
}
|
||||
$pfxPath = Join-Path $OutDir 'signing.pfx'
|
||||
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
|
||||
$signMode = 'none'
|
||||
$signtool = $null
|
||||
if (-not $NoSign) {
|
||||
$signtool = Find-SdkTool 'signtool.exe'
|
||||
Write-Host "signtool: $signtool"
|
||||
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
|
||||
$signMode = 'azure'
|
||||
$AzureDlib = Find-AzureDlib $AzureDlib
|
||||
@{
|
||||
Endpoint = $AzureEndpoint
|
||||
CodeSigningAccountName = $AzureAccount
|
||||
CertificateProfileName = $AzureProfile
|
||||
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
|
||||
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
|
||||
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
|
||||
if (-not [Environment]::GetEnvironmentVariable($v)) {
|
||||
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
|
||||
"DefaultAzureCredential; without the service-principal trio it falls through to " +
|
||||
"an interactive login that cannot complete on a runner and hangs the build.")
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($PfxBase64) {
|
||||
$signMode = 'pfx'
|
||||
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
|
||||
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
|
||||
}
|
||||
elseif ($requireCert) {
|
||||
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 - " +
|
||||
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
|
||||
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
|
||||
}
|
||||
else {
|
||||
$signMode = 'selfsigned'
|
||||
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
|
||||
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
|
||||
$tmp = New-SelfSignedCertificate -Type Custom -Subject $Publisher `
|
||||
-KeyUsage DigitalSignature -FriendlyName 'punktfunk client installer (self-signed)' `
|
||||
-CertStoreLocation 'Cert:\CurrentUser\My' `
|
||||
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}')
|
||||
$sec = ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText
|
||||
Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null
|
||||
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
|
||||
}
|
||||
}
|
||||
|
||||
# Timestamp policy matches the sibling scripts: best-effort for a long-lived .pfx, MANDATORY under
|
||||
# Azure signing (those leaf certs expire in ~3 days; untimestamped signatures die with them).
|
||||
function Sign-File([string]$Path) {
|
||||
if ($NoSign) { return }
|
||||
if ($signMode -eq 'azure') {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
|
||||
$ts = 'http://timestamp.acs.microsoft.com'
|
||||
}
|
||||
else {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
$ts = 'http://timestamp.digicert.com'
|
||||
}
|
||||
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $Path))
|
||||
if ($LASTEXITCODE -eq 0) { return }
|
||||
if ($signMode -eq 'azure') {
|
||||
throw ("timestamped sign failed for $Path ($LASTEXITCODE) - NOT retrying without a timestamp. " +
|
||||
"An Azure signing cert is valid for ~3 days; an untimestamped signature would go " +
|
||||
"untrusted within days of release.")
|
||||
}
|
||||
Write-Warning "timestamped sign failed for $Path - retrying without a timestamp"
|
||||
& $signtool ($signArgs + @($Path))
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
# --- sign the inner exes, zip the stage (portable build), then build + sign the installer ------
|
||||
foreach ($f in $required | Where-Object { $_ -like '*.exe' }) {
|
||||
Sign-File (Join-Path $stage $f)
|
||||
}
|
||||
|
||||
$zip = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}-portable.zip"
|
||||
if (Test-Path $zip) { Remove-Item $zip -Force }
|
||||
Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip
|
||||
Write-Host "==> portable zip: $zip"
|
||||
|
||||
# Stage the .iss + branding next to each other under $OutDir: ISCC is a 32-bit process, and on the
|
||||
# SYSTEM-profile runner WOW64 redirection breaks reads from the checkout path (see
|
||||
# pack-host-installer.ps1's staging note) — everything ISCC touches must live under C:\t.
|
||||
$issLocal = Join-Path $OutDir 'punktfunk-client.iss'
|
||||
Copy-Item -LiteralPath $iss -Destination $issLocal -Force
|
||||
$brandSrc = (Resolve-Path (Join-Path $here '..\..\..\packaging\windows\branding')).Path
|
||||
$brandStage = Join-Path $OutDir 'branding'
|
||||
if (Test-Path $brandStage) { Remove-Item $brandStage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $brandStage | Out-Null
|
||||
Copy-Item (Join-Path $brandSrc '*.bmp') $brandStage -Force
|
||||
Copy-Item (Join-Path $brandSrc 'punktfunk.ico') $brandStage -Force
|
||||
|
||||
$defines = @(
|
||||
"/DMyAppVersion=$Version",
|
||||
"/DArch=$Arch",
|
||||
"/DLayoutDir=$stage",
|
||||
"/DBrandingDir=$brandStage",
|
||||
"/DOutputDir=$OutDir"
|
||||
)
|
||||
Write-Host "==> ISCC $($defines -join ' ') $issLocal"
|
||||
& $iscc @defines $issLocal
|
||||
if ($LASTEXITCODE -ne 0) { throw "ISCC failed ($LASTEXITCODE)" }
|
||||
|
||||
$setup = Join-Path $OutDir "punktfunk-client-setup-${Version}_${Arch}.exe"
|
||||
if (-not (Test-Path $setup)) { throw "expected installer not produced: $setup" }
|
||||
Sign-File $setup
|
||||
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==> installer: $setup"
|
||||
if ($signMode -eq 'azure') {
|
||||
Write-Host "==> signed by a publicly trusted CA."
|
||||
}
|
||||
elseif ($signMode -ne 'none') {
|
||||
Write-Host "==> $signMode-signed: the exe still runs everywhere; expect a SmartScreen prompt on canary builds."
|
||||
}
|
||||
if ($env:GITHUB_ENV) {
|
||||
"CLIENT_SETUP_PATH=$setup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CLIENT_ZIP_PATH=$zip" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
; punktfunk Windows CLIENT installer (Inno Setup 6) — the default download.
|
||||
;
|
||||
; A classic per-user setup.exe, NOT because MSIX failed technically (the app is full-trust Win32
|
||||
; either way) but because the MSIX install SHAPE breaks the most-reported use case: the exe lands
|
||||
; under the ACL'd C:\Program Files\WindowsApps, which Steam's "Add a Non-Steam Game" picker cannot
|
||||
; browse and whose activation path defeats the overlay's GameOverlayRenderer64.dll injection —
|
||||
; Steam has to spawn the process itself from a normal path for the overlay (and a Big Picture
|
||||
; launch) to work. This installs to {userpf}\Punktfunk: user-writable-visible, no UAC, and a
|
||||
; stable path Steam can target. The MSIX is kept for Microsoft Store compatibility
|
||||
; (clients/windows/packaging/pack-msix.ps1 — both are packed from the same layout every build).
|
||||
;
|
||||
; Built by pack-client-installer.ps1, e.g.:
|
||||
; ISCC.exe /DMyAppVersion=0.2.137.0 /DArch=x64 /DLayoutDir=C:\t\installer\portable \
|
||||
; /DBrandingDir=C:\t\installer\branding /DOutputDir=C:\t\installer punktfunk-client.iss
|
||||
;
|
||||
; What the MSIX manifest granted declaratively is re-created here per-user (all HKCU, so no
|
||||
; elevation and uninstall leaves nothing behind):
|
||||
; punktfunk:// protocol -> HKCU\Software\Classes\punktfunk (deeplink.rs positional parse)
|
||||
; Start entries -> {userprograms} shortcuts (Punktfunk + Punktfunk Console)
|
||||
; punktfunk.exe CLI alias -> {app} appended to the HKCU PATH (Playnite importer shells to it)
|
||||
; punktfunk-client.exe alias -> unnecessary: deeplink.rs targets current_exe() when unpackaged
|
||||
; Microsoft.WindowsAppRuntime.2 PackageDependency
|
||||
; -> download + run the runtime installer when missing ([Code])
|
||||
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "0.0.0.0"
|
||||
#endif
|
||||
#ifndef Arch
|
||||
#define Arch "x64"
|
||||
#endif
|
||||
#ifndef LayoutDir
|
||||
#define LayoutDir "."
|
||||
#endif
|
||||
#ifndef BrandingDir
|
||||
#define BrandingDir "..\..\..\packaging\windows\branding"
|
||||
#endif
|
||||
#ifndef OutputDir
|
||||
#define OutputDir "."
|
||||
#endif
|
||||
; The unpackaged app resolves an INSTALLED Windows App SDK runtime via the bootstrap DLL
|
||||
; (windows-reactor pins WINDOWSAPPSDK_RELEASE_MAJORMINOR = 0x20000; the MSIX manifest's
|
||||
; PackageDependency floor is 2.2 — keep the two in sync with packaging/AppxManifest.xml).
|
||||
#define AppRuntimeUrl "https://aka.ms/windowsappsdk/2.2/latest/windowsappruntimeinstall-" + Arch + ".exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{52464E61-68A1-4621-B6B3-5B8BBB823D1A}
|
||||
AppName=Punktfunk
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher=unom
|
||||
AppPublisherURL=https://git.unom.io/unom/punktfunk
|
||||
; Per-user, no UAC: {userpf} = %LOCALAPPDATA%\Programs. A browsable, stable path is the point —
|
||||
; see the header (Steam overlay / Big Picture).
|
||||
DefaultDirName={userpf}\Punktfunk
|
||||
PrivilegesRequired=lowest
|
||||
DisableProgramGroupPage=yes
|
||||
UsePreviousAppDir=yes
|
||||
; Same floor as the MSIX manifest's TargetDeviceFamily MinVersion (10.0.17763).
|
||||
MinVersion=10.0.17763
|
||||
#if Arch == "arm64"
|
||||
ArchitecturesAllowed=arm64
|
||||
ArchitecturesInstallIn64BitMode=arm64
|
||||
#else
|
||||
ArchitecturesAllowed=x64
|
||||
ArchitecturesInstallIn64BitMode=x64
|
||||
#endif
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename=punktfunk-client-setup-{#MyAppVersion}_{#Arch}
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
; Modern branded wizard, same version gate as the host installer (punktfunk-host.iss).
|
||||
#if VER >= EncodeVer(6,6,0)
|
||||
WizardStyle=modern dynamic windows11
|
||||
#else
|
||||
WizardStyle=modern
|
||||
#endif
|
||||
SetupIconFile={#BrandingDir}\punktfunk.ico
|
||||
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
|
||||
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
|
||||
UninstallDisplayName=Punktfunk {#MyAppVersion}
|
||||
UninstallDisplayIcon={app}\punktfunk-client.exe
|
||||
; {app} goes on the USER PATH (see [Registry] + PathNeedsAdd/RemoveAppFromPath below) so the
|
||||
; documented `punktfunk hosts list` / `punktfunk launch` one-liners work by name — same contract
|
||||
; the MSIX's punktfunk.exe app-execution alias provided. Broadcasts WM_SETTINGCHANGE.
|
||||
ChangesEnvironment=yes
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Create a Desktop shortcut"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
; The staged MSIX layout, minus the package-only bits (AppxManifest.xml, the tile Assets — the
|
||||
; exes embed their own icons via build.rs winresource). pack-client-installer.ps1 signs the four
|
||||
; exes individually before ISCC runs; the .msix signs only its container, so this cannot be
|
||||
; skipped by "the MSIX build already signed them".
|
||||
Source: "{#LayoutDir}\punktfunk-client.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk-session.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk-console.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\Microsoft.WindowsAppRuntime.Bootstrap.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\SDL3.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\resources.pri"; DestDir: "{app}"; Flags: ignoreversion
|
||||
; MIT/Apache + the client-scoped THIRD-PARTY-NOTICES — same payload the MSIX carries.
|
||||
Source: "{#LayoutDir}\licenses\*"; DestDir: "{app}\licenses"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
; Flat Start-menu entries, mirroring the MSIX's two Application tiles.
|
||||
Name: "{userprograms}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"
|
||||
Name: "{userprograms}\Punktfunk Console"; Filename: "{app}\punktfunk-console.exe"; \
|
||||
Comment: "Controller-driven couch interface for TVs and HTPCs"
|
||||
Name: "{userdesktop}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"; Tasks: desktopicon
|
||||
|
||||
[Registry]
|
||||
; The punktfunk:// scheme (design/client-deep-links.md §4.2) — the registry twin of the MSIX
|
||||
; manifest's windows.protocol extension. Protocol activation delivers the URI as "%1" on the
|
||||
; command line, so this lands in the same positional URL parse in main() that the packaged
|
||||
; activation does. HKCU + uninsdeletekey: nothing survives uninstall.
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; \
|
||||
ValueData: "URL:Punktfunk stream link"; Flags: uninsdeletekey
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk\DefaultIcon"; ValueType: string; \
|
||||
ValueData: "{app}\punktfunk-client.exe,0"
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk\shell\open\command"; ValueType: string; \
|
||||
ValueData: """{app}\punktfunk-client.exe"" ""%1"""
|
||||
; Put {app} on the USER PATH so `punktfunk` (the headless CLI) is runnable by name. Appended to
|
||||
; {olddata} and guarded by PathNeedsAdd so a repair/upgrade never appends a duplicate. NOT
|
||||
; uninsdeletevalue — that would delete the whole Path value; the uninstaller surgically removes
|
||||
; just our entry (RemoveAppFromPath). expandsz preserves %VAR%-style entries other software put here.
|
||||
Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; \
|
||||
ValueData: "{olddata};{app}"; Check: PathNeedsAdd(ExpandConstant('{app}'))
|
||||
|
||||
[Code]
|
||||
const
|
||||
EnvKey = 'Environment'; { the HKCU per-user environment key }
|
||||
|
||||
{ Is the install dir missing from the user PATH? Guards the [Registry] append so a repair or
|
||||
upgrade can't add a second copy. Semicolon-delimited, case-insensitive — a path that merely
|
||||
CONTAINS ours as a substring doesn't count as a match. (Same helper as punktfunk-host.iss,
|
||||
retargeted from the HKLM machine key to HKCU.) }
|
||||
function PathNeedsAdd(Param: String): Boolean;
|
||||
var
|
||||
OrigPath: String;
|
||||
begin
|
||||
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
|
||||
begin
|
||||
Result := True; { no Path value at all - the append creates it }
|
||||
exit;
|
||||
end;
|
||||
Result := Pos(';' + Uppercase(Param) + ';', ';' + Uppercase(OrigPath) + ';') = 0;
|
||||
end;
|
||||
|
||||
{ Remove exactly our install-dir entry from the user PATH on uninstall, leaving every other entry
|
||||
(and their order) intact. Entry-by-entry rebuild, never a substring delete. }
|
||||
procedure RemoveAppFromPath;
|
||||
var
|
||||
OrigPath, NewPath, Entry: String;
|
||||
Target: String;
|
||||
P: Integer;
|
||||
begin
|
||||
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
|
||||
exit;
|
||||
Target := Uppercase(ExpandConstant('{app}'));
|
||||
NewPath := '';
|
||||
OrigPath := OrigPath + ';';
|
||||
repeat
|
||||
P := Pos(';', OrigPath);
|
||||
Entry := Trim(Copy(OrigPath, 1, P - 1));
|
||||
OrigPath := Copy(OrigPath, P + 1, Length(OrigPath));
|
||||
if (Entry <> '') and (Uppercase(Entry) <> Target) then
|
||||
begin
|
||||
if NewPath <> '' then NewPath := NewPath + ';';
|
||||
NewPath := NewPath + Entry;
|
||||
end;
|
||||
until OrigPath = '';
|
||||
RegWriteExpandStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', NewPath);
|
||||
end;
|
||||
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
begin
|
||||
if CurUninstallStep = usPostUninstall then
|
||||
RemoveAppFromPath;
|
||||
end;
|
||||
|
||||
{ The Windows App SDK runtime the bootstrap DLL resolves at launch (the unpackaged twin of the
|
||||
MSIX's PackageDependency). Probe per-user via Get-AppxPackage; when missing, fetch Microsoft's
|
||||
runtime installer and run it quietly — it registers Store-signed framework packages, which
|
||||
needs no elevation. Every failure path is NON-FATAL and ends in the same message the docs
|
||||
carry, because the app itself reports the missing runtime on first launch too. }
|
||||
function AppRuntimeMissing(): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
{ exit 0 = found, 1 = missing; a powershell failure (rc <> 0/1) counts as missing - the
|
||||
download below is idempotent and the runtime installer no-ops when it is present. }
|
||||
if not Exec('powershell.exe',
|
||||
'-NoProfile -ExecutionPolicy Bypass -Command "if (Get-AppxPackage -Name Microsoft.WindowsAppRuntime.2*) { exit 0 } else { exit 1 }"',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||
begin
|
||||
Result := True;
|
||||
exit;
|
||||
end;
|
||||
Result := ResultCode <> 0;
|
||||
end;
|
||||
|
||||
procedure EnsureAppRuntime;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
Installer: String;
|
||||
begin
|
||||
if not AppRuntimeMissing() then
|
||||
exit;
|
||||
Installer := 'windowsappruntimeinstall.exe';
|
||||
try
|
||||
DownloadTemporaryFile('{#AppRuntimeUrl}', Installer, '', nil);
|
||||
if not Exec(ExpandConstant('{tmp}\' + Installer), '--quiet', '',
|
||||
SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
|
||||
RaiseException('runtime installer exit code ' + IntToStr(ResultCode));
|
||||
except
|
||||
SuppressibleMsgBox(
|
||||
'The Windows App Runtime 2.x could not be installed automatically.' + #13#10 + #13#10 +
|
||||
'Punktfunk needs it to start. Install it from ' + #13#10 +
|
||||
'https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads' + #13#10 +
|
||||
'and then launch Punktfunk normally.',
|
||||
mbInformation, MB_OK, IDOK);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
{ On upgrade a running shell/stream locks the exes; kill them best-effort so the copy succeeds.
|
||||
taskkill matches the image NAME, so "punktfunk.exe" hits only the CLI, not the host service. }
|
||||
if CurStep = ssInstall then
|
||||
Exec(ExpandConstant('{sys}\taskkill.exe'),
|
||||
'/F /IM punktfunk-client.exe /IM punktfunk-session.exe /IM punktfunk-console.exe /IM punktfunk.exe',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
{ ssPostInstall, NOT a wizard-page hook: silent installs (winget-style /VERYSILENT) show no
|
||||
pages, and skipping the runtime there would ship an app that cannot start. This step runs on
|
||||
every install mode, and SuppressibleMsgBox keeps the failure path unattended-safe. }
|
||||
if CurStep = ssPostInstall then
|
||||
EnsureAppRuntime;
|
||||
end;
|
||||
@@ -700,31 +700,23 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.iter()
|
||||
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false);
|
||||
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
|
||||
// it once it sleeps (no-op / no disk write when unchanged).
|
||||
if let Some(a) = hosts.iter().find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
&& !h.mac.is_empty()
|
||||
}) {
|
||||
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||
}
|
||||
// Same for its OS chain — the tile's mark then survives the host going offline.
|
||||
if let Some(a) = hosts.iter().find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) && !h.os.is_empty()
|
||||
}) {
|
||||
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
|
||||
// Learn what this host's live advert teaches while it's online: its wake MAC(s) (so we
|
||||
// can wake it once it sleeps), its OS chain (so the tile's mark survives it going
|
||||
// offline), and its management port — the last load-bearing rather than cosmetic, as
|
||||
// a host moved off 47990 loses its library entirely once mDNS is gone unless we write
|
||||
// the port down. No-op, and no disk write, when unchanged.
|
||||
if let Some(a) = 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)
|
||||
.find(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
{
|
||||
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
|
||||
crate::trust::learn_from_advert(
|
||||
&k.fp_hex,
|
||||
&k.addr,
|
||||
k.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
let can_wake = !online && !k.mac.is_empty();
|
||||
let menu = {
|
||||
|
||||
@@ -203,14 +203,30 @@ pub(crate) fn queue(url: String) {
|
||||
INBOX.lock().unwrap().push(url);
|
||||
}
|
||||
|
||||
/// Whether this process runs with MSIX package identity. Decides how a shortcut must target us
|
||||
/// (`write_shortcut` below) and whether the process may stamp its own AppUserModelID
|
||||
/// (`set_app_user_model_id` in main.rs).
|
||||
pub(crate) fn has_package_identity() -> bool {
|
||||
use windows::Win32::appmodel::GetCurrentPackageFullName;
|
||||
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
|
||||
// SAFETY: `GetCurrentPackageFullName` with `len = 0` and no buffer is the documented identity
|
||||
// PROBE — it writes nothing and only reports whether this process is packaged.
|
||||
unsafe {
|
||||
let mut len: u32 = 0;
|
||||
GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a `.lnk` on the Desktop that launches this URL, and return its path.
|
||||
///
|
||||
/// The shortcut targets the app execution alias with the URL as an ARGUMENT, rather than being
|
||||
/// a `.url` internet shortcut. Both would work while the scheme is registered; only this one
|
||||
/// still works if it isn't, because it invokes the client directly — which is the whole point
|
||||
/// of a shortcut being a container for a URL rather than a second launch mechanism
|
||||
/// (design/client-deep-links.md §5). Targeting the alias (not the package path) is what keeps
|
||||
/// it valid across updates, since the install path changes and the alias doesn't.
|
||||
/// The shortcut targets the client exe with the URL as an ARGUMENT, rather than being a `.url`
|
||||
/// internet shortcut. Both would work while the scheme is registered; only this one still works
|
||||
/// if it isn't, because it invokes the client directly — which is the whole point of a shortcut
|
||||
/// being a container for a URL rather than a second launch mechanism
|
||||
/// (design/client-deep-links.md §5). Which exe reference is durable depends on how we were
|
||||
/// installed: under MSIX the install path changes on every update but the app execution alias
|
||||
/// doesn't, so packaged runs target the alias; the Inno Setup / portable installs have no alias
|
||||
/// but a stable install dir, so unpackaged runs target the absolute exe path.
|
||||
pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBuf, String> {
|
||||
use windows::core::{Interface, HSTRING};
|
||||
use windows::Win32::combaseapi::{CoCreateInstance, CoInitializeEx};
|
||||
@@ -223,6 +239,15 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
|
||||
.map(|p| std::path::PathBuf::from(p).join("Desktop"))
|
||||
.map_err(|_| "USERPROFILE isn't set".to_string())?;
|
||||
let path = desktop.join(format!("{}.lnk", file_name(label)));
|
||||
// Alias when packaged, absolute path when not — see the doc comment above.
|
||||
let target = if has_package_identity() {
|
||||
"punktfunk-client.exe".to_string()
|
||||
} else {
|
||||
std::env::current_exe()
|
||||
.map_err(|e| format!("current exe: {e}"))?
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
// SAFETY: COM calls on this thread's apartment. `CoCreateInstance` returns an owned interface
|
||||
// checked by `?`, and every setter below takes a borrowed `HSTRING`/`PCWSTR` that outlives its
|
||||
// synchronous call; nothing here dereferences a pointer the caller supplied.
|
||||
@@ -233,7 +258,7 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
|
||||
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED as u32);
|
||||
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)
|
||||
.map_err(|e| format!("shell link: {e}"))?;
|
||||
link.SetPath(&HSTRING::from("punktfunk-client.exe"))
|
||||
link.SetPath(&HSTRING::from(target.as_str()))
|
||||
.ok()
|
||||
.map_err(|e| format!("shortcut target: {e}"))?;
|
||||
link.SetArguments(&HSTRING::from(url))
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct DiscoveredHost {
|
||||
/// persisted like `mac`. Empty if absent (older host).
|
||||
pub os: String,
|
||||
/// The management API's port from the mDNS `mgmt` TXT — where the game library is served.
|
||||
/// Persisted like `mac` (`trust::learn_mgmt_port`), and load-bearing rather than cosmetic:
|
||||
/// Persisted like `mac` (`trust::learn_from_advert`), and load-bearing rather than cosmetic:
|
||||
/// a host moved off 47990 loses its library once mDNS is gone unless we write this down.
|
||||
/// `None` if absent (older host) — resolve via `library::DEFAULT_MGMT_PORT`.
|
||||
pub mgmt_port: Option<u16>,
|
||||
|
||||
@@ -173,18 +173,12 @@ fn main() {
|
||||
/// processes are left alone. Must run before any window exists.
|
||||
#[cfg(windows)]
|
||||
fn set_app_user_model_id() {
|
||||
use windows::Win32::appmodel::GetCurrentPackageFullName;
|
||||
use windows::Win32::shobjidl_core::SetCurrentProcessExplicitAppUserModelID;
|
||||
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
|
||||
// SAFETY: `GetCurrentPackageFullName` is called with `len = 0` and no buffer, which is the
|
||||
// documented identity PROBE — it writes nothing and only reports whether this process is
|
||||
// packaged; `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
|
||||
if deeplink::has_package_identity() {
|
||||
return; // packaged (or indeterminate) — leave the identity alone
|
||||
}
|
||||
// SAFETY: `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
|
||||
unsafe {
|
||||
let mut len: u32 = 0;
|
||||
// No buffer: just probe whether the process has package identity.
|
||||
if GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE {
|
||||
return; // packaged (or indeterminate) — leave the identity alone
|
||||
}
|
||||
// Must stay in sync with pf-presenter's win32.rs, or the windows stop grouping.
|
||||
let _ = SetCurrentProcessExplicitAppUserModelID(windows::core::w!("unom.punktfunk.client"));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
//! still load via a serde alias in core.
|
||||
|
||||
pub use pf_client_core::trust::{
|
||||
hex, learn_mac, learn_mgmt_port, learn_os, load_or_create_identity, pair_error_message,
|
||||
parse_hex32, KnownHost, KnownHosts, Settings,
|
||||
hex, learn_from_advert, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
|
||||
KnownHosts, Settings,
|
||||
};
|
||||
|
||||
@@ -675,10 +675,10 @@ pub fn forget_placeholder(addr: &str, port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The record [`learn_mac`]/[`learn_os`] should write what an advert taught them onto:
|
||||
/// the fingerprint match if there is one, else whatever the address resolves to. Fingerprint
|
||||
/// FIRST — a single pass that took "either" would hand a stale record at the same address the
|
||||
/// data the live host advertised, purely because it came earlier in the file.
|
||||
/// The record an advert's lesson should land on: the fingerprint match if there is one, else
|
||||
/// whatever the address resolves to. Fingerprint FIRST — a single pass that took "either" would
|
||||
/// hand a stale record at the same address the data the live host advertised, purely because it
|
||||
/// came earlier in the file.
|
||||
fn learn_target<'a>(
|
||||
known: &'a mut KnownHosts,
|
||||
fp_hex: &str,
|
||||
@@ -692,61 +692,62 @@ fn learn_target<'a>(
|
||||
known.hosts.get_mut(i)
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
|
||||
/// is online, matched 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.
|
||||
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
||||
if mac.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.mac == mac {
|
||||
return;
|
||||
}
|
||||
h.mac = mac.to_vec();
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's OS-identity chain from its live advert (mDNS `os` 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.
|
||||
pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) {
|
||||
if os.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.os == os {
|
||||
return;
|
||||
}
|
||||
h.os = os.to_string();
|
||||
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.
|
||||
/// Copy everything an advert can teach onto a saved record — wake MAC(s), OS-identity chain,
|
||||
/// management port — and report whether anything actually moved, so the caller writes only when
|
||||
/// there is something to write. Pure (no disk, no clock), which is what makes it testable.
|
||||
///
|
||||
/// 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;
|
||||
/// A field the advert does not carry is left alone, never cleared: an older host simply omits the
|
||||
/// TXT, and forgetting a MAC already learned would cost the user their wake.
|
||||
fn apply_advert(h: &mut KnownHost, mac: &[String], os: &str, mgmt_port: Option<u16>) -> bool {
|
||||
let mut changed = false;
|
||||
if !mac.is_empty() && h.mac != mac {
|
||||
h.mac = mac.to_vec();
|
||||
changed = true;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
if !os.is_empty() && h.os != os {
|
||||
h.os = os.to_string();
|
||||
changed = true;
|
||||
}
|
||||
// 0 is how "not advertised" reaches us from a caller whose own type has no `Option`.
|
||||
if mgmt_port.is_some_and(|p| p != 0 && h.mgmt_port != Some(p)) {
|
||||
h.mgmt_port = mgmt_port;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Write down everything a live advert teaches the saved record it matched — wake MAC(s), OS
|
||||
/// chain, management port — matched by fingerprint or address. No-op, and no disk write, when
|
||||
/// the record already says all three, so a surface can call this on every discovery tick.
|
||||
///
|
||||
/// ONE call rather than three. Each field used to be learned by its own function, which meant
|
||||
/// every front-end had to remember all three, and only the two desktop hosts pages ever did:
|
||||
/// the console home and the headless CLI learned the management port alone. On a Steam Deck,
|
||||
/// whose Gaming Mode runs nothing but those two, that left every saved host with no MAC forever
|
||||
/// — and every wake gate in the codebase reads `!mac.is_empty()` against this record, so
|
||||
/// Wake-on-LAN there could not fire at all, with no error to show for it (#322).
|
||||
///
|
||||
/// [`KnownHosts::read`], not [`KnownHosts::load`]: `punktfunk discover` calls this, and that verb
|
||||
/// is deliberately not an id-minter (see [`KnownHosts::read`] for the race that avoids). Learning
|
||||
/// a MAC is no reason to become one.
|
||||
///
|
||||
/// Takes the three learned fields rather than a `DiscoveredHost` because there are two of those
|
||||
/// — core's and the WinUI shell's verbatim port — and this has to serve both.
|
||||
pub fn learn_from_advert(
|
||||
fp_hex: &str,
|
||||
addr: &str,
|
||||
port: u16,
|
||||
mac: &[String],
|
||||
os: &str,
|
||||
mgmt_port: Option<u16>,
|
||||
) {
|
||||
let mut known = KnownHosts::read();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.mgmt_port == Some(mgmt_port) {
|
||||
return;
|
||||
if apply_advert(h, mac, os, mgmt_port) {
|
||||
let _ = known.save();
|
||||
}
|
||||
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
|
||||
@@ -785,7 +786,7 @@ 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
|
||||
/// This is the mDNS-free path, and the one that matters most: [`learn_from_advert`] 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.
|
||||
@@ -2293,6 +2294,33 @@ mod tests {
|
||||
assert!(learn_target(&mut k, &fp('e'), "10.0.0.9", 9777).is_none());
|
||||
}
|
||||
|
||||
/// What an advert carries lands on the record; what it omits is left alone; and a repeat of
|
||||
/// the same advert reports no change — which is what lets every surface call this on every
|
||||
/// discovery tick without churning the store.
|
||||
#[test]
|
||||
fn apply_advert_learns_what_it_carries_and_keeps_what_it_omits() {
|
||||
let mut h = KnownHost::default();
|
||||
let mac = vec!["aa:bb:cc:dd:ee:ff".to_string()];
|
||||
assert!(apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
|
||||
assert_eq!(h.mac, mac);
|
||||
assert_eq!(h.os, "linux/arch");
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// The same advert a tick later: nothing moved, so there is nothing to persist.
|
||||
assert!(!apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
|
||||
// An older host advertises none of the three. Clearing a learned MAC here is exactly what
|
||||
// would cost the user their wake, so an absent field must never overwrite a known one.
|
||||
assert!(!apply_advert(&mut h, &[], "", None));
|
||||
assert_eq!(h.mac, mac);
|
||||
assert_eq!(h.os, "linux/arch");
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// 0 is how "not advertised" reaches us from a consumer that has no Option — not a port.
|
||||
assert!(!apply_advert(&mut h, &[], "", Some(0)));
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// A host that genuinely moved: the new value wins.
|
||||
assert!(apply_advert(&mut h, &[], "", Some(47992)));
|
||||
assert_eq!(h.mgmt_port, Some(47992));
|
||||
}
|
||||
|
||||
/// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is
|
||||
/// presentation state, so a dangling one is never an error surface.
|
||||
#[test]
|
||||
|
||||
@@ -148,6 +148,10 @@ pub(crate) enum HintKey {
|
||||
/// there isn't (the library grid spends up on rows) the same menu hangs off
|
||||
/// [`HintKey::Tertiary`] instead; the button differs, the word "Options" does not.
|
||||
Up,
|
||||
/// ▼ — the home carousel's other spare direction, which opens Settings. Advertised in
|
||||
/// place of [`HintKey::Tertiary`] where no pad is attached, because that is exactly the
|
||||
/// device that has no X to press: a TV remote is a D-pad, OK and Back.
|
||||
Down,
|
||||
Key(&'static str),
|
||||
}
|
||||
|
||||
@@ -272,7 +276,7 @@ fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
|
||||
match resolved(key, style) {
|
||||
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
|
||||
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k,
|
||||
Resolved::Up => BADGE_D * k,
|
||||
Resolved::Up | Resolved::Down => BADGE_D * k,
|
||||
Resolved::Key(text) => keycap_w(fonts, text, k),
|
||||
}
|
||||
}
|
||||
@@ -294,6 +298,9 @@ enum Resolved {
|
||||
/// The d-pad's up — drawn the same in every style, because it is a direction rather
|
||||
/// than a button whose label changes with the pad.
|
||||
Up,
|
||||
/// The d-pad's down — the same triangle stood on its head, and style-free for the
|
||||
/// same reason [`Resolved::Up`] is.
|
||||
Down,
|
||||
Key(&'static str),
|
||||
}
|
||||
|
||||
@@ -317,6 +324,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
HintKey::Shoulders => Resolved::Key("Tab"),
|
||||
HintKey::Adjust => Resolved::Adjust,
|
||||
HintKey::Up => Resolved::Up,
|
||||
HintKey::Down => Resolved::Down,
|
||||
HintKey::Key(t) => Resolved::Key(t),
|
||||
};
|
||||
}
|
||||
@@ -327,6 +335,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
HintKey::Secondary => Resolved::Badge(Face::Y),
|
||||
HintKey::Shoulders => Resolved::Shoulders,
|
||||
HintKey::Adjust => Resolved::Adjust,
|
||||
HintKey::Down => Resolved::Down,
|
||||
HintKey::Up => Resolved::Up,
|
||||
HintKey::Key(t) => Resolved::Key(t),
|
||||
}
|
||||
@@ -394,17 +403,23 @@ fn draw_glyph(
|
||||
pen += w + 3.0 * k;
|
||||
}
|
||||
}
|
||||
Resolved::Up => {
|
||||
// ▲ — one solid triangle in a badge-sized slot.
|
||||
g @ (Resolved::Up | Resolved::Down) => {
|
||||
// ▲ / ▼ — one solid triangle in a badge-sized slot, the same triangle either
|
||||
// way up: apex toward the direction it names, base at the other end.
|
||||
let r = BADGE_D * k / 2.0;
|
||||
let (cx, cyf) = ((x + r) as f32, cy as f32);
|
||||
let (tw, th) = ((5.5 * k) as f32, (4.5 * k) as f32);
|
||||
let mut up = PathBuilder::new();
|
||||
up.move_to((cx, cyf - th));
|
||||
up.line_to((cx - tw, cyf + th));
|
||||
up.line_to((cx + tw, cyf + th));
|
||||
up.close();
|
||||
canvas.draw_path(&up.detach(), &fill(fg(0.85)));
|
||||
let (apex, base) = if matches!(g, Resolved::Down) {
|
||||
(cyf + th, cyf - th)
|
||||
} else {
|
||||
(cyf - th, cyf + th)
|
||||
};
|
||||
let mut tri = PathBuilder::new();
|
||||
tri.move_to((cx, apex));
|
||||
tri.line_to((cx - tw, base));
|
||||
tri.line_to((cx + tw, base));
|
||||
tri.close();
|
||||
canvas.draw_path(&tri.detach(), &fill(fg(0.85)));
|
||||
}
|
||||
Resolved::Adjust => {
|
||||
// ◀ ▶ — two small solid triangles.
|
||||
|
||||
@@ -63,6 +63,10 @@ pub(crate) struct Ctx<'a> {
|
||||
pub pads: &'a [PadInfo],
|
||||
/// Steam Deck: never draw our keyboard — Steam's types via SDL text input.
|
||||
pub deck: bool,
|
||||
/// The host app has another interface to fall back to when the console is switched
|
||||
/// off (an Android phone/tablet's touch shell) — see
|
||||
/// [`crate::shell::ConsoleOptions::fallback_ui`]. Gates the console-off settings row.
|
||||
pub fallback_ui: bool,
|
||||
/// The name the HOST stores this client under when pairing (the machine's
|
||||
/// hostname, resolved by the binary).
|
||||
pub device_name: &'a str,
|
||||
|
||||
@@ -396,6 +396,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads,
|
||||
deck,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -301,6 +302,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -323,6 +325,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -941,6 +941,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -378,6 +378,7 @@ mod tests {
|
||||
platform,
|
||||
pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -429,6 +430,7 @@ mod tests {
|
||||
platform,
|
||||
pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
}
|
||||
|
||||
@@ -206,7 +206,17 @@ impl HomeScreen {
|
||||
}
|
||||
_ => Some(MenuPulse::Boundary),
|
||||
},
|
||||
MenuEvent::Move(_) => None,
|
||||
// Down is Settings — the same screen X opens. The carousel is horizontal, so
|
||||
// down is the other free direction, and it is the only route to Settings on a
|
||||
// device whose input has no face buttons: an Android TV remote is a D-pad, OK
|
||||
// and Back, and X never arrives. (Apple hit this on the Siri Remote too, and
|
||||
// answered it by moving rows out to the ordinary Settings app.)
|
||||
MenuEvent::Move(MenuDir::Down) => {
|
||||
fx.push(Screen::Settings(super::settings::SettingsScreen::new(
|
||||
ctx.store,
|
||||
)));
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +289,15 @@ impl HomeScreen {
|
||||
{
|
||||
hints.push(Hint::new(HintKey::Up, "Options"));
|
||||
}
|
||||
hints.push(Hint::new(HintKey::Tertiary, "Settings"));
|
||||
// Name the route this device actually has. With no pad attached the legend is
|
||||
// already speaking keyboard, and the one input that reaches here with neither a
|
||||
// pad NOR letter keys is a TV remote — for which X is not a button that exists.
|
||||
// Down opens Settings for everyone; only the advertisement changes.
|
||||
hints.push(if ctx.pads.is_empty() {
|
||||
Hint::new(HintKey::Down, "Settings")
|
||||
} else {
|
||||
Hint::new(HintKey::Tertiary, "Settings")
|
||||
});
|
||||
hints.push(Hint::new(HintKey::Back, "Quit"));
|
||||
hints
|
||||
}
|
||||
@@ -859,6 +877,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -885,6 +904,67 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// Everything this screen offers must be reachable from a D-pad, OK and Back alone —
|
||||
/// an Android TV remote has no face buttons, so Settings (X) and the options menu
|
||||
/// would otherwise be unreachable there. Up is the menu, down is Settings, and the
|
||||
/// legend names the direction rather than X when nothing is plugged in.
|
||||
#[test]
|
||||
fn a_remote_reaches_settings_and_options_without_face_buttons() {
|
||||
let mut settings = ctx_settings();
|
||||
let hosts = [host("paired", true, true, false)];
|
||||
let pads: Vec<pf_client_core::menu_nav::PadInfo> = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Android,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: true,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = HomeScreen::new();
|
||||
|
||||
// Down opens the same screen X opens.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::Settings(_))),
|
||||
"down must open Settings"
|
||||
);
|
||||
// Up still opens the host's own menu — the library hangs off that menu now.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::HostOptions(_))),
|
||||
"up must open the host options menu"
|
||||
);
|
||||
// With no pad the legend advertises the direction, not a button that isn't there.
|
||||
assert!(
|
||||
s.hints(&ctx).iter().any(|h| h.key == HintKey::Down),
|
||||
"a padless device is told about down"
|
||||
);
|
||||
// With a pad it goes back to naming X, which is faster to press.
|
||||
let pads = vec![pf_client_core::menu_nav::PadInfo {
|
||||
name: "Pad".into(),
|
||||
key: "045e:028e:Pad".into(),
|
||||
pref: punktfunk_core::config::GamepadPref::Xbox360,
|
||||
steam_virtual: false,
|
||||
battery: None,
|
||||
detail: "045E:028E · gamepad".into(),
|
||||
forwarded: true,
|
||||
rumble: false,
|
||||
}];
|
||||
ctx.pads = &pads;
|
||||
assert!(
|
||||
s.hints(&ctx).iter().any(|h| h.key == HintKey::Tertiary),
|
||||
"a pad is told about X"
|
||||
);
|
||||
}
|
||||
|
||||
/// A pinned card's A-press is a connect WITH its profile (one-off), titled so the
|
||||
/// connecting takeover says which settings are coming (§5.2a).
|
||||
#[test]
|
||||
@@ -908,6 +988,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -932,6 +1013,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -2218,6 +2218,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ use skia_safe::{Canvas, Rect};
|
||||
enum Action {
|
||||
Wake,
|
||||
SendLogs,
|
||||
/// Open this host's game library — the same shelf the home carousel's Y opens, offered
|
||||
/// here because Y is a face button and a TV remote has none. Saved-and-paired only,
|
||||
/// exactly like that Y (an unpaired host has no shelf to fetch).
|
||||
Library,
|
||||
CopyLink,
|
||||
Edit,
|
||||
/// Choose the profile the host's primary tile connects with (opens the
|
||||
@@ -154,6 +158,12 @@ impl OptionsScreen {
|
||||
if host.paired && host.online {
|
||||
a.push(Action::SendLogs);
|
||||
}
|
||||
// The shelf, on the same terms the carousel's Y offers it. Ahead of Copy link
|
||||
// because it is the one row here that goes somewhere rather than acting on the
|
||||
// host — and on a remote-only device it is the ONLY way to the library.
|
||||
if host.paired && host.saved {
|
||||
a.push(Action::Library);
|
||||
}
|
||||
a.extend([
|
||||
Action::CopyLink,
|
||||
Action::Edit,
|
||||
@@ -171,6 +181,7 @@ impl OptionsScreen {
|
||||
match a {
|
||||
Action::Wake => "Wake host".into(),
|
||||
Action::SendLogs => "Send logs to host".into(),
|
||||
Action::Library => "Library".into(),
|
||||
Action::CopyLink => "Copy link".into(),
|
||||
Action::Edit => "Edit\u{2026}".into(),
|
||||
Action::BindProfile => "Default profile\u{2026}".into(),
|
||||
@@ -234,7 +245,7 @@ impl OptionsScreen {
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
ListMsg::Activate => {
|
||||
self.run(action, ctx.store, fx);
|
||||
self.run(action, ctx, fx);
|
||||
pulse
|
||||
}
|
||||
}
|
||||
@@ -257,7 +268,8 @@ impl OptionsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self, action: Action, store: &dyn crate::store::SettingsStore, fx: &mut Outbox) {
|
||||
fn run(&mut self, action: Action, ctx: &Ctx, fx: &mut Outbox) {
|
||||
let store = ctx.store;
|
||||
let key = self.host_key().to_string();
|
||||
match action {
|
||||
Action::Wake => {
|
||||
@@ -289,6 +301,24 @@ impl OptionsScreen {
|
||||
}
|
||||
fx.pop();
|
||||
}
|
||||
// Same two steps the home carousel's Y takes: ask for the shelf, then open it
|
||||
// on the epoch read BEFORE the command drains, so the screen can tell its own
|
||||
// fetch's titles from the ones already in the model. `replace`, not push — the
|
||||
// menu has said its piece, and Back from the shelf belongs on the carousel
|
||||
// rather than on a menu about the host you just left.
|
||||
Action::Library => {
|
||||
let host = self.host();
|
||||
fx.cmds.push(ConsoleCmd::FetchLibrary {
|
||||
addr: host.addr.clone(),
|
||||
mgmt: host.mgmt_port,
|
||||
fp_hex: host.fp_hex.clone(),
|
||||
});
|
||||
let epoch = ctx.library.fetch_epoch();
|
||||
fx.replace(Screen::Library(super::library::LibraryScreen::new(
|
||||
self.host(),
|
||||
epoch,
|
||||
)));
|
||||
}
|
||||
Action::Edit => fx.replace(Screen::AddHost(super::add_host::AddHostScreen::edit(
|
||||
self.host(),
|
||||
))),
|
||||
@@ -407,6 +437,27 @@ mod tests {
|
||||
use crate::model::ProfileChip;
|
||||
use crate::screens::Nav;
|
||||
|
||||
/// Activate one row. `run` reads the store, and — for Library — the shared library's
|
||||
/// fetch epoch; nothing else in this menu touches the context, so one throwaway is
|
||||
/// enough for every action test here.
|
||||
fn run_action(s: &mut OptionsScreen, action: Action, fx: &mut Outbox) {
|
||||
let mut settings = pf_client_core::trust::Settings::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
s.run(action, &ctx, fx);
|
||||
}
|
||||
|
||||
fn host() -> HostRow {
|
||||
HostRow {
|
||||
key: "aa".into(),
|
||||
@@ -510,6 +561,38 @@ mod tests {
|
||||
assert_eq!(s.host_key(), "aa");
|
||||
}
|
||||
|
||||
/// The shelf is on this menu, which is the only route to it that survives a device with
|
||||
/// no face buttons: home's Y opens it too, but an Android TV remote has no Y. Offered on
|
||||
/// the same terms that Y is (saved AND paired), and it REPLACES the menu, so Back from
|
||||
/// the shelf lands on the carousel rather than on a menu about the host just left.
|
||||
#[test]
|
||||
fn the_library_hangs_off_the_menu_for_a_padless_device() {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
assert!(s
|
||||
.actions(crate::platform::Platform::Android)
|
||||
.contains(&Action::Library));
|
||||
|
||||
let mut fx = Outbox::default();
|
||||
run_action(&mut s, Action::Library, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.cmds.first(), Some(ConsoleCmd::FetchLibrary { .. })),
|
||||
"opening the shelf asks for it first"
|
||||
);
|
||||
match fx.nav {
|
||||
Some(Nav::Replace(screen)) => assert!(matches!(*screen, Screen::Library(_))),
|
||||
_ => panic!("expected the shelf to replace the menu"),
|
||||
}
|
||||
|
||||
// An unpaired host has no shelf to fetch — the row is absent, not inert.
|
||||
let unpaired = OptionsScreen::for_host(&HostRow {
|
||||
paired: false,
|
||||
..host()
|
||||
});
|
||||
assert!(!unpaired
|
||||
.actions(crate::platform::Platform::Android)
|
||||
.contains(&Action::Library));
|
||||
}
|
||||
|
||||
/// "Default profile…" swaps the menu for the chooser — a Replace like Edit's, and for
|
||||
/// the same reason — addressed to the HOST's plain key even from rows that carry a
|
||||
/// composite one.
|
||||
@@ -520,7 +603,7 @@ mod tests {
|
||||
.actions(crate::platform::Platform::Desktop)
|
||||
.contains(&Action::BindProfile));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::BindProfile, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::BindProfile, &mut fx);
|
||||
match fx.nav {
|
||||
Some(crate::screens::Nav::Replace(screen)) => match *screen {
|
||||
Screen::BindProfile(b) => assert_eq!(b.host_name(), "Desk"),
|
||||
@@ -537,7 +620,7 @@ mod tests {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
assert!(s.label(Action::Clipboard).ends_with("Off"));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Clipboard, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetClipboard {
|
||||
@@ -551,7 +634,7 @@ mod tests {
|
||||
});
|
||||
assert!(s.label(Action::Clipboard).ends_with("On"));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Clipboard, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetClipboard {
|
||||
@@ -569,12 +652,12 @@ mod tests {
|
||||
s.list.cursor = i;
|
||||
let mut fx = Outbox::default();
|
||||
|
||||
s.run(Action::Forget, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Forget, &mut fx);
|
||||
assert!(fx.cmds.is_empty(), "the first press only arms");
|
||||
assert!(s.armed);
|
||||
assert!(s.label(Action::Forget).contains("press again"));
|
||||
|
||||
s.run(Action::Forget, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Forget, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::ForgetHost { key: "aa".into() }],
|
||||
@@ -597,6 +680,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -659,7 +743,7 @@ mod tests {
|
||||
OptionsScreen::for_game(&host(), &game()),
|
||||
] {
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::CopyLink, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::CopyLink, &mut fx);
|
||||
assert!(matches!(fx.nav, Some(Nav::Pop)));
|
||||
assert!(fx.toast.is_some());
|
||||
}
|
||||
|
||||
@@ -497,6 +497,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "living-room-deck",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -537,6 +538,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "deck",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -233,6 +233,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -274,6 +275,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -104,6 +104,12 @@ enum RowId {
|
||||
Sc2Passthrough,
|
||||
/// DualSense raw-USB capture (touchpad, motion, adaptive triggers).
|
||||
DsCapture,
|
||||
/// Whether the console UI fronts the app at all — the touch settings' switch
|
||||
/// (`Settings.gamepadUiEnabled`), reachable from inside the console it turns off.
|
||||
/// Only offered where there is another interface to fall back to
|
||||
/// ([`Ctx::fallback_ui`]): on a TV or the desktop session this console is the only
|
||||
/// UI, and an off switch would strand the user in nothing.
|
||||
GamepadUi,
|
||||
/// When the console UI fronts the app: with a controller attached, or always.
|
||||
GamepadUiMode,
|
||||
/// The platform's connected-controllers view (an action row — opens a native screen).
|
||||
@@ -121,6 +127,7 @@ mod android_keys {
|
||||
pub const SC2: &str = "android.sc2_capture";
|
||||
pub const DS_CAPTURE: &str = "android.ds_capture";
|
||||
pub const GAMEPAD_UI_MODE: &str = "android.gamepad_ui_mode";
|
||||
pub const GAMEPAD_UI: &str = "android.gamepad_ui_enabled";
|
||||
}
|
||||
|
||||
/// The Android console-UI mode's stored values (`GamepadUi.kt`).
|
||||
@@ -245,6 +252,7 @@ const TABS: [(&str, &[RowId]); 7] = [
|
||||
RowId::Stats,
|
||||
RowId::Fullscreen,
|
||||
RowId::AutoWake,
|
||||
RowId::GamepadUi,
|
||||
RowId::GamepadUiMode,
|
||||
RowId::Licenses,
|
||||
],
|
||||
@@ -384,7 +392,7 @@ impl SettingsScreen {
|
||||
.1
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| row_on(*id, ctx.platform) && row_applies(*id, ctx.settings))
|
||||
.filter(|id| row_on(*id, ctx.platform) && row_applies(*id, ctx))
|
||||
.collect();
|
||||
}
|
||||
if self.profiles.is_empty() {
|
||||
@@ -675,6 +683,7 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
|
||||
| RowId::PhoneGyro
|
||||
| RowId::Sc2Passthrough
|
||||
| RowId::DsCapture
|
||||
| RowId::GamepadUi
|
||||
| RowId::GamepadUiMode
|
||||
| RowId::Controllers
|
||||
| RowId::Licenses
|
||||
@@ -694,9 +703,22 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
|
||||
fn row_applies(id: RowId, ctx: &Ctx) -> bool {
|
||||
match id {
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
RowId::SmoothBuffer => ctx.settings.present_priority == "smooth",
|
||||
// The console-off switch needs somewhere for "off" to land: only clients with a
|
||||
// fallback interface (an Android phone/tablet's touch shell) get the row — on a TV
|
||||
// this console is the only UI, and off would strand the user (the touch settings'
|
||||
// subtitle even promises "A TV always uses it").
|
||||
RowId::GamepadUi => ctx.fallback_ui,
|
||||
// The same two conditions the mode decides anything under: a TV is in console mode
|
||||
// whatever the mode says (`GamepadUi.kt`: the tv term alone satisfies the OR), and
|
||||
// while the switch above is off nothing fronts the console at all. Hidden rather
|
||||
// than dimmed, like the touch screen's picker, and it sits directly below the row
|
||||
// that drops it so the cursor is never under anything that moves.
|
||||
RowId::GamepadUiMode => {
|
||||
ctx.fallback_ui && extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -962,9 +984,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"DualSense over USB",
|
||||
on_off(extra_bool(s, android_keys::DS_CAPTURE, true)).into(),
|
||||
),
|
||||
RowId::GamepadUi => (
|
||||
None,
|
||||
"Controller-optimized UI",
|
||||
on_off(extra_bool(s, android_keys::GAMEPAD_UI, true)).into(),
|
||||
),
|
||||
RowId::GamepadUiMode => (
|
||||
None,
|
||||
"Controller UI",
|
||||
// The touch screen's word for the same picker, which now sits under the same
|
||||
// switch it does there — "Controller UI" beside "Controller-optimized UI"
|
||||
// would be two rows a reader has to tell apart by their tails.
|
||||
"Show it",
|
||||
label_for(
|
||||
&GAMEPAD_UI_MODES,
|
||||
extra_str(s, android_keys::GAMEPAD_UI_MODE, "connected"),
|
||||
@@ -1145,9 +1175,14 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
"Capture a wired DualSense directly (touchpad, motion, adaptive triggers). \
|
||||
Needs the USB grant when the pad is plugged in."
|
||||
}
|
||||
RowId::GamepadUi => {
|
||||
"Front the app with this console instead of the touch interface. Off returns \
|
||||
to the touch home immediately — switch it back on there."
|
||||
}
|
||||
RowId::GamepadUiMode => {
|
||||
"When this console fronts the app: whenever a controller is attached, or \
|
||||
always. The touch settings' \"Controller-optimized UI\" switch turns it off."
|
||||
always — for a device that lives docked to a TV. The switch above turns it \
|
||||
off altogether."
|
||||
}
|
||||
RowId::Controllers => "Connected controllers, their grants and a rumble/haptics test.",
|
||||
RowId::Licenses => "The open-source licences this app ships under.",
|
||||
@@ -1375,6 +1410,7 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
RowId::PhoneGyro => toggle_extra(s, android_keys::PHONE_GYRO, false, delta, wrap),
|
||||
RowId::Sc2Passthrough => toggle_extra(s, android_keys::SC2, true, delta, wrap),
|
||||
RowId::DsCapture => toggle_extra(s, android_keys::DS_CAPTURE, true, delta, wrap),
|
||||
RowId::GamepadUi => toggle_extra(s, android_keys::GAMEPAD_UI, true, delta, wrap),
|
||||
RowId::GamepadUiMode => {
|
||||
let mut v = extra_str(s, android_keys::GAMEPAD_UI_MODE, "connected").to_string();
|
||||
step_str(&GAMEPAD_UI_MODES, &mut v, delta, wrap).map(|()| {
|
||||
@@ -1504,6 +1540,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1538,6 +1575,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1603,6 +1641,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1652,6 +1691,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1686,6 +1726,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1727,6 +1768,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1757,6 +1799,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1793,6 +1836,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1867,6 +1911,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1900,6 +1945,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1931,6 +1977,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1960,6 +2007,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2009,6 +2057,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2060,6 +2109,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2105,6 +2155,7 @@ pub(super) mod tests {
|
||||
RowId::Sc2Passthrough,
|
||||
RowId::DsCapture,
|
||||
RowId::Controllers,
|
||||
RowId::GamepadUi,
|
||||
RowId::GamepadUiMode,
|
||||
RowId::Licenses,
|
||||
]
|
||||
@@ -2147,6 +2198,9 @@ pub(super) mod tests {
|
||||
extra_str(ctx.settings, android_keys::GAMEPAD_UI_MODE, "connected"),
|
||||
"always"
|
||||
);
|
||||
assert!(extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true));
|
||||
assert!(adjust(RowId::GamepadUi, 1, true, ctx));
|
||||
assert!(!extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true));
|
||||
// Only `extra` moved.
|
||||
let mut after = ctx.settings.clone();
|
||||
after.extra = before.extra.clone();
|
||||
@@ -2154,6 +2208,34 @@ pub(super) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// The console-off switch exists only where there is a fallback interface for "off"
|
||||
/// to land in, and the mode row under it only where the mode decides anything: not on
|
||||
/// a TV (always console, whatever the mode says) and not while the switch is off.
|
||||
#[test]
|
||||
fn console_off_switch_needs_a_fallback_ui() {
|
||||
with_ctx(|ctx| {
|
||||
ctx.platform = crate::platform::Platform::Android;
|
||||
// A TV: no off switch (it would strand the user), and no mode row either —
|
||||
// `gamepadUiActive`'s tv term satisfies the OR on its own.
|
||||
assert!(
|
||||
!row_applies(RowId::GamepadUi, ctx),
|
||||
"a TV offers no off switch"
|
||||
);
|
||||
assert!(!row_applies(RowId::GamepadUiMode, ctx));
|
||||
// A phone or tablet with the console on: both rows.
|
||||
ctx.fallback_ui = true;
|
||||
assert!(row_applies(RowId::GamepadUi, ctx));
|
||||
assert!(row_applies(RowId::GamepadUiMode, ctx));
|
||||
// Switched off: the switch stays (it is the way back), the mode row goes.
|
||||
set_extra_bool(ctx.settings, android_keys::GAMEPAD_UI, false);
|
||||
assert!(row_applies(RowId::GamepadUi, ctx));
|
||||
assert!(
|
||||
!row_applies(RowId::GamepadUiMode, ctx),
|
||||
"the mode row decides nothing while the switch above it is off"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_row_has_exactly_one_tab() {
|
||||
let mut seen: Vec<RowId> = Vec::new();
|
||||
@@ -2168,9 +2250,9 @@ pub(super) mod tests {
|
||||
// 2026-08 sweep found them bridged but unreachable) later passes added, minus the
|
||||
// game-library toggle: this screen never read it, and the library is offered on any
|
||||
// paired host now.
|
||||
// 35 desktop rows + the eight Android-only ones (design android-skia-console-port.md
|
||||
// D3): six `extra`-backed settings and two platform-screen action rows.
|
||||
assert_eq!(seen.len(), 43, "{seen:?}");
|
||||
// 35 desktop rows + the nine Android-only ones (design android-skia-console-port.md
|
||||
// D3): seven `extra`-backed settings and two platform-screen action rows.
|
||||
assert_eq!(seen.len(), 44, "{seen:?}");
|
||||
assert!(seen.contains(&RowId::Palette));
|
||||
assert!(seen.contains(&RowId::ReduceMotion));
|
||||
assert!(seen.contains(&RowId::AudioFormat));
|
||||
@@ -2209,6 +2291,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2250,6 +2333,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2295,6 +2379,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2373,6 +2458,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -183,6 +183,11 @@ pub struct ConsoleOptions {
|
||||
pub device_name: String,
|
||||
/// Steam Deck: Steam's keyboard types (SDL text input); ours never draws.
|
||||
pub deck: bool,
|
||||
/// Whether the host app has another interface to fall back to when the console is
|
||||
/// switched off — an Android phone/tablet's touch shell. Shows the console-off switch
|
||||
/// on the settings screen; false where this console is the only UI there is (the
|
||||
/// desktop session, an Android TV), where offering "off" would strand the user.
|
||||
pub fallback_ui: bool,
|
||||
/// Where settings persist and the profile catalog comes from. `None` = the desktop
|
||||
/// file store (`pf_client_core::trust`), which is what the Vulkan session wants and the
|
||||
/// only store there is on Linux/Windows; every other host must supply one.
|
||||
@@ -203,6 +208,7 @@ impl ConsoleOptions {
|
||||
ConsoleOptions {
|
||||
device_name,
|
||||
deck,
|
||||
fallback_ui: false,
|
||||
store: None,
|
||||
platform: Platform::Desktop,
|
||||
gpu_cache_bytes: DEFAULT_GPU_CACHE_BYTES,
|
||||
@@ -247,6 +253,8 @@ pub(crate) struct Shell {
|
||||
hosts_gen: u64,
|
||||
device_name: String,
|
||||
deck: bool,
|
||||
/// See [`ConsoleOptions::fallback_ui`].
|
||||
fallback_ui: bool,
|
||||
pub(crate) in_stream: bool,
|
||||
connecting: Option<Connecting>,
|
||||
/// The last host title a connect was raised for, kept past the connect itself so
|
||||
@@ -352,6 +360,7 @@ impl Shell {
|
||||
hosts_gen: u64::MAX,
|
||||
device_name: opts.device_name,
|
||||
deck: opts.deck,
|
||||
fallback_ui: opts.fallback_ui,
|
||||
in_stream: false,
|
||||
connecting: None,
|
||||
last_connect_title: None,
|
||||
@@ -888,6 +897,7 @@ impl Shell {
|
||||
platform: self.platform,
|
||||
pads: &self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: &self.device_name,
|
||||
t: self.t0.elapsed().as_secs_f64(),
|
||||
};
|
||||
@@ -951,6 +961,10 @@ impl Shell {
|
||||
// navigation but "open this tile's menu". Without this the context menu —
|
||||
// and with it the only way to copy a host's link — is pad-only.
|
||||
crate::glyphs::HintKey::Up => Some(MenuEvent::Move(MenuDir::Up)),
|
||||
// ▼ is the same kind of hint: a direction that steers nothing, because
|
||||
// the only screen publishing it is the home carousel, where down means
|
||||
// "open Settings". A finger must be able to press what it advertises.
|
||||
crate::glyphs::HintKey::Down => Some(MenuEvent::Move(MenuDir::Down)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ev) = ev {
|
||||
@@ -970,6 +984,7 @@ impl Shell {
|
||||
platform: self.platform,
|
||||
pads: &self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: &self.device_name,
|
||||
t: self.t0.elapsed().as_secs_f64(),
|
||||
};
|
||||
|
||||
@@ -172,6 +172,7 @@ impl Shell {
|
||||
platform: self.platform,
|
||||
pads: &self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: &self.device_name,
|
||||
t,
|
||||
glyphs: self.glyphs,
|
||||
@@ -331,6 +332,8 @@ struct LayerEnv<'a> {
|
||||
platform: crate::platform::Platform,
|
||||
pads: &'a [PadInfo],
|
||||
deck: bool,
|
||||
/// See [`crate::shell::ConsoleOptions::fallback_ui`] — a screen's row set can ask.
|
||||
fallback_ui: bool,
|
||||
device_name: &'a str,
|
||||
t: f64,
|
||||
glyphs: GlyphStyle,
|
||||
@@ -365,6 +368,7 @@ impl LayerEnv<'_> {
|
||||
platform: self.platform,
|
||||
pads: self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: self.device_name,
|
||||
t: self.t,
|
||||
};
|
||||
|
||||
@@ -402,10 +402,11 @@ fn a_replace_carries_the_screen_it_replaced() {
|
||||
assert!(matches!(s.stack.last(), Some(Screen::HostOptions(_))));
|
||||
finish_motion(&mut s);
|
||||
|
||||
// Walk to "Edit…" and take it. The first fixture host is paired and online and cannot
|
||||
// wake, so its menu is [Send logs, Copy link, Edit…, Forget, Cancel] — Edit is two down.
|
||||
// Pressed exactly rather than searched, so that reordering the menu fails HERE instead of
|
||||
// quietly landing this test's Confirm on "Forget".
|
||||
// Walk to "Edit…" and take it. The first fixture host is paired, saved and online and
|
||||
// cannot wake, so its menu is [Send logs, Library, Copy link, Edit…, …] — Edit is three
|
||||
// down. Pressed exactly rather than searched, so that reordering the menu fails HERE
|
||||
// instead of quietly landing this test's Confirm on something destructive.
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
|
||||
@@ -396,9 +396,16 @@ pub(crate) fn panel_highlight(canvas: &Canvas, rect: Rect, corner: f32, k: f32)
|
||||
),
|
||||
None,
|
||||
));
|
||||
canvas.draw_rrect(RRect::new_rect_xy(inset, corner * k, corner * k), &p);
|
||||
// Concentric, the same rule the halo states: pulled in by half a unit, so the radius
|
||||
// comes in by half a unit too or the lit edge crosses the panel's own corner arc.
|
||||
let r = ((corner - 0.5) * k).max(0.0);
|
||||
canvas.draw_rrect(RRect::new_rect_xy(inset, r, r), &p);
|
||||
}
|
||||
|
||||
/// How far [`focus_halo`] is grown past the card on every side, in design units. Both the
|
||||
/// rect AND the corner radius take it — see the draw there.
|
||||
const HALO_OUTSET: f32 = 4.0;
|
||||
|
||||
/// An accent-tinted glow under the focused card — the palette-aware mark that says "this
|
||||
/// one" from across a room, where a 2 % scale difference says nothing at all. Drawn behind
|
||||
/// [`drop_shadow`], and only ever for the ONE focused tile, so it costs a single extra
|
||||
@@ -439,8 +446,13 @@ pub(crate) fn focus_halo(canvas: &Canvas, rect: Rect, corner: f32, k: f32, f: f3
|
||||
// it overran the coverflow's 58 dp focused-to-neighbour gap, and since the strip paints
|
||||
// farthest-first the focused card's corona landed on top of its neighbours — which is
|
||||
// what made every card look like it was glowing.
|
||||
let spread = rect.with_outset((4.0 * k, 4.0 * k));
|
||||
canvas.draw_rrect(RRect::new_rect_xy(spread, corner * k, corner * k), &p);
|
||||
let spread = rect.with_outset((HALO_OUTSET * k, HALO_OUTSET * k));
|
||||
// Concentric: a shape grown by `d` on every side keeps its corners parallel to the
|
||||
// original's only if its radius grows by `d` too (the two arcs then share a centre).
|
||||
// Reusing the card's own radius left the halo squarer than the card it sits under, so
|
||||
// it read as a misaligned outline at the four corners and a clean glow along the edges.
|
||||
let r = (corner + HALO_OUTSET) * k;
|
||||
canvas.draw_rrect(RRect::new_rect_xy(spread, r, r), &p);
|
||||
}
|
||||
|
||||
pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alpha: f32) {
|
||||
|
||||
@@ -116,11 +116,16 @@ static MANAGED_LAUNCH: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
/// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects.
|
||||
static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// The display-manager unit we stopped for the takeover (any DM that drove a LIVE gaming session
|
||||
/// is stopped for the stream — see [`dm_plan`]), so the restore brings the box back via
|
||||
/// A display-manager unit stopped for a takeover, so the restore brings the box back via
|
||||
/// `reset-failed` + `restart` of the DM instead of a `--user start` of the gamescope unit (which
|
||||
/// cannot work on a mask-fragile flavor: without a DM login session there is no seat, so gamescope
|
||||
/// never gets DRM master — live-proven on the Nobara repro VM 2026-07-24).
|
||||
///
|
||||
/// ⚠ **Adoption-only since 0.31.0**: the takeover idles the box's autologin session
|
||||
/// ([`install_idle_dropin`]) and leaves the DM up, so nothing in this process ever writes this any
|
||||
/// more — only [`restore_takeover_on_startup`], for a takeover stranded by a host old enough to
|
||||
/// have stopped one. It is therefore NOT the marker of a live takeover; [`takeover_idled`] is.
|
||||
/// Reading it as that marker is what silently unreachable-d the in-stream switch gate in 0.31.0.
|
||||
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Whether this takeover runtime-masked the [`STOPPED_AUTOLOGIN`] units ([`mask_unit`]) — i.e.
|
||||
@@ -136,12 +141,16 @@ static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None
|
||||
static AUTOLOGIN_MASKED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
|
||||
/// "Switch to Desktop" detector compares against. Steam's session-select script writes
|
||||
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
|
||||
/// display-manager checks — so it advances even under a DM-stop takeover, where the script's
|
||||
/// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*;
|
||||
/// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is
|
||||
/// therefore the one durable trace of the user's switch request.
|
||||
/// "Switch to Desktop" detector compares against. The ChimeraOS-layout `os-session-select`
|
||||
/// (Nobara, ChimeraOS) writes `~/.config/steamos-session-select` unconditionally in its USER pass,
|
||||
/// before any of its display-manager checks, so an advanced mtime after a capture loss is the one
|
||||
/// durable trace of the user's switch request — the switch itself leaves nothing else behind that
|
||||
/// this host can see.
|
||||
///
|
||||
/// ⚠ Bazzite/SteamOS write NO sentinel: their `os-session-select` is a thin wrapper over
|
||||
/// `steamosctl` D-Bus calls. The detector is therefore inert there by construction, which is
|
||||
/// exactly right — those platforms default the mid-stream session watcher ON
|
||||
/// ([`is_steam_htpc_platform`]) and follow the switch with it instead.
|
||||
///
|
||||
/// Two levels of `Option`, because "no baseline" and "no sentinel" mean opposite things:
|
||||
/// * **outer `None`** — never baselined (no takeover this host lifetime). Nothing can read as an
|
||||
@@ -674,21 +683,28 @@ fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<Virtual
|
||||
if steamos_session_present() {
|
||||
return create_managed_session_steamos(mode, hdr);
|
||||
}
|
||||
// In-stream "Switch to Desktop" under a DM-stop takeover: the user's session-select inside
|
||||
// the streamed game mode advanced the sentinel, but its config rewrite was a silent no-op
|
||||
// (every write branch needs the DM running, and the takeover stopped it) — so without this,
|
||||
// the capture loss it caused would just relaunch game mode ("thrown back in", field-tested
|
||||
// 2026-07-24). Honor the request instead: restore the DM and replay the switch.
|
||||
let dm_takeover = STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if let Some(dm) = dm_takeover {
|
||||
if session_select_requested() {
|
||||
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
honor_session_select_switch(dm);
|
||||
return Err(anyhow!(
|
||||
"the user switched the box to the desktop session — display manager restored; \
|
||||
re-detection follows the desktop compositor as it comes up"
|
||||
));
|
||||
}
|
||||
// In-stream "Switch to Desktop": the user's session-select inside the streamed game mode
|
||||
// advanced the sentinel, so the box is on its way to a desktop session. Without this, the
|
||||
// capture loss that switch causes just relaunches game mode over the booting desktop — the
|
||||
// "thrown back in" field report of 2026-07-24, and again on Nobara 2026-08-20.
|
||||
//
|
||||
// ⚠ Gated on the IDLED takeover, not on [`STOPPED_DM`]. Until 0.31.0 the takeover stopped the
|
||||
// display manager, and setting that static was what armed this gate; the idled takeover
|
||||
// replaced both the stop and the static ([`install_idle_dropin`]) and nothing re-armed the
|
||||
// gate, so this branch became unreachable on every box. Bazzite did not notice — its
|
||||
// `os-session-select` is a `steamosctl` D-Bus call that writes no sentinel, and its session
|
||||
// watcher is on by default ([`is_steam_htpc_platform`]) so the stream follows the switch
|
||||
// anyway. The ChimeraOS-layout distros are the ones that lost their handling: their
|
||||
// `os-session-select` DOES write the sentinel, and `ID=nobara` matches no HTPC default.
|
||||
if takeover_idled() && session_select_requested() {
|
||||
// `take`, so an adopted DM stop is consumed exactly once — see
|
||||
// [`honor_session_select_switch`] for why a 0.31.0 takeover has none to consume.
|
||||
let adopted_dm = std::mem::take(&mut *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
honor_session_select_switch(adopted_dm);
|
||||
return Err(anyhow!(
|
||||
"the user switched the box to the desktop session — the box's own game mode is handed \
|
||||
back; re-detection follows the desktop compositor as it comes up"
|
||||
));
|
||||
}
|
||||
// Post-honor grace: while the selected desktop boots, a managed relaunch would win the race
|
||||
// (gamescope+Steam start faster than KWin) and a delivering pipeline ends the rebuild's
|
||||
@@ -1349,19 +1365,34 @@ fn install_idle_dropin() -> Result<()> {
|
||||
.parent()
|
||||
.context("the idle drop-in path has no parent directory")?;
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"[Service]\nExecStart=\nExecStart={} infinity\n",
|
||||
sleep_binary()
|
||||
),
|
||||
)
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
std::fs::write(&path, idle_dropin_body(sleep_binary()))
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The idle drop-in's body (the unit-testable core of [`install_idle_dropin`]).
|
||||
///
|
||||
/// The **empty `ExecStart=` comes first and is load-bearing**: `ExecStart` is a list-valued
|
||||
/// directive, so a drop-in that only adds a line APPENDS to the box's own — which would run the
|
||||
/// real gamescope session *and* the sleep, i.e. exactly the Steam-fighting session the takeover
|
||||
/// exists to get out of the way, with no symptom pointing here. The reset is what replaces it.
|
||||
fn idle_dropin_body(sleep_bin: &str) -> String {
|
||||
format!("[Service]\nExecStart=\nExecStart={sleep_bin} infinity\n")
|
||||
}
|
||||
|
||||
/// Does THIS host hold the box's game mode idled right now? The successor to "did we stop the
|
||||
/// display manager" as the marker of a live managed takeover, and so what arms the in-stream
|
||||
/// switch gate in [`create_managed_session`].
|
||||
///
|
||||
/// Reads [`IDLE_DROPIN_ARMED`] — this process's own memory — deliberately, unlike
|
||||
/// [`remove_idle_dropin`]: a drop-in on disk that we did not write belongs to a dead host, and
|
||||
/// honoring a "switch" against someone else's takeover would hand back a box we never took.
|
||||
fn takeover_idled() -> bool {
|
||||
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Remove the idle drop-in so the box's own Game Mode runs for real again; reports whether one was
|
||||
/// there. Deliberately NOT gated on [`IDLE_DROPIN_ARMED`] — the flag is this process's memory, and
|
||||
/// the drop-in outliving a host that died is exactly the case that has to be swept.
|
||||
@@ -2282,10 +2313,36 @@ fn switch_ends_mask_window(kind: super::ActiveKind) -> bool {
|
||||
}
|
||||
|
||||
/// The host's mid-stream session watcher calls this on every switch it confirms; see
|
||||
/// [`switch_ends_mask_window`] for which ones actually lift the mask.
|
||||
/// [`switch_ends_mask_window`] for which ones end the takeover's hold on the box's own game mode.
|
||||
///
|
||||
/// This is the SECOND of the two ways a box can leave our takeover mid-stream — the sentinel
|
||||
/// detector in [`create_managed_session`] is the other — and both owe the box the same hand-back.
|
||||
/// The watcher is the one that covers Bazzite/SteamOS, where it is on by default
|
||||
/// ([`is_steam_htpc_platform`]) and no sentinel is ever written; the detector covers the
|
||||
/// ChimeraOS-layout distros, which are the reverse. Fixing only one leaves the other's boxes
|
||||
/// holding an idled game mode.
|
||||
pub fn release_autologin_mask(switched_to: super::ActiveKind) {
|
||||
if switch_ends_mask_window(switched_to) {
|
||||
lift_autologin_mask();
|
||||
if !switch_ends_mask_window(switched_to) {
|
||||
return;
|
||||
}
|
||||
lift_autologin_mask();
|
||||
// The idle drop-in is the mask's successor and inherits its whole hazard: it replaces the
|
||||
// box's game-mode `ExecStart` with a sleep, and a switch to a desktop is exactly where that
|
||||
// stops being ours to hold. Left on, the user's "Return to Gaming Mode" starts a unit that
|
||||
// only sleeps — the same barred way back this function's mask lift exists to prevent, and
|
||||
// measured in that state on Bazzite `.41` 2026-08-20 (`ExecStart=/usr/bin/sleep infinity`
|
||||
// still on the unit after a completed switch to KDE).
|
||||
//
|
||||
// Deliberately NOT a full [`clear_takeover`]: the takeover outlives this window, exactly as
|
||||
// the mask lift's own note says. The box may come back to game mode, and the disconnect
|
||||
// restore still owes [`STOPPED_AUTOLOGIN`] a start. All this says is "the box's own game mode
|
||||
// runs for real again".
|
||||
if remove_idle_dropin() {
|
||||
tracing::info!(
|
||||
switched_to = ?switched_to,
|
||||
"gamescope: the box left our game session for a desktop — removed the takeover's idle \
|
||||
drop-in so its own Game Mode runs for real again"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2754,11 +2811,12 @@ fn session_select_mtime() -> Option<std::time::SystemTime> {
|
||||
|
||||
/// Record the sentinel baseline, so a LATER write (the user's in-stream "Switch to Desktop") is
|
||||
/// distinguishable from the switch that led into this session. Taken at **takeover** (the moment
|
||||
/// [`STOPPED_DM`] is set, which is what arms the honor gate) and again at a successful launch: the
|
||||
/// switch INTO game mode writes the sentinel on its way in, and that write must never read as a
|
||||
/// request to go back out. Baselining only at launch left the window in between — a takeover whose
|
||||
/// launch failed, then a client retry inside the restore debounce — reading a months-old sentinel
|
||||
/// as a live request and pushing the box to the desktop the user never asked for.
|
||||
/// the idle drop-in goes in, which is what arms the honor gate — see [`takeover_idled`]) and again
|
||||
/// at a successful launch: the switch INTO game mode writes the sentinel on its way in, and that
|
||||
/// write must never read as a request to go back out. Baselining only at launch left the window in
|
||||
/// between — a takeover whose launch failed, then a client retry inside the restore debounce —
|
||||
/// reading a months-old sentinel as a live request and pushing the box to the desktop the user
|
||||
/// never asked for.
|
||||
fn record_session_select_baseline() {
|
||||
*SESSION_SELECT_BASELINE
|
||||
.lock()
|
||||
@@ -2801,11 +2859,11 @@ fn sentinel_advanced(
|
||||
///
|
||||
/// The caller then refuses managed relaunches for [`SWITCH_HONOR_GRACE`] so the capture-loss
|
||||
/// re-detection follows the desktop compositor once it's up instead of racing it.
|
||||
fn honor_session_select_switch(dm: String) {
|
||||
fn honor_session_select_switch(adopted_dm: Option<String>) {
|
||||
tracing::info!(
|
||||
%dm,
|
||||
"gamescope: in-stream session-select detected — restoring the display manager and \
|
||||
switching the box to the desktop session"
|
||||
adopted_dm = ?adopted_dm,
|
||||
"gamescope: in-stream session-select detected — handing the box's own game mode back and \
|
||||
following the desktop session the user selected"
|
||||
);
|
||||
// Consume the takeover state up front: from here on the box is the DM's again. The mask goes
|
||||
// FIRST and while the unit list still exists — this path discards that list, and it is the only
|
||||
@@ -2818,7 +2876,43 @@ fn honor_session_select_switch(dm: String) {
|
||||
clear_takeover();
|
||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
|
||||
if let Err(e) = restore_display_manager(&dm) {
|
||||
// Give the box its own Game Mode back before anything else can ask for it. The takeover
|
||||
// replaced that session's `ExecStart` with a sleep ([`install_idle_dropin`]), and a switch is
|
||||
// the one exit that used to leave it behind: the disconnect restore sweeps it, but a switch is
|
||||
// not a disconnect. Without this the user's next "Return to Gaming Mode" starts a unit that
|
||||
// does nothing at all — measured on the Nobara VM 2026-08-20, and on glass it is
|
||||
// indistinguishable from broken hardware.
|
||||
if remove_idle_dropin() {
|
||||
tracing::info!(
|
||||
"gamescope: removed the takeover's idle drop-in — the box's own Game Mode runs for \
|
||||
real again"
|
||||
);
|
||||
}
|
||||
// Only an ADOPTED takeover still owes a display-manager restore. 0.31.0 leaves the DM up for
|
||||
// exactly this reason, so by the time we get here the OS's own switch has already done the
|
||||
// whole job — config rewrite and relogin (measured end to end on the Nobara VM). A takeover
|
||||
// inherited from a host old enough to have STOPPED the DM has not: for it the switch really
|
||||
// was the silent no-op that every write branch of `os-session-select` becomes without a
|
||||
// running DM, so that one still has to be replayed.
|
||||
if let Some(dm) = adopted_dm {
|
||||
replay_switch_under_restored_dm(&dm);
|
||||
}
|
||||
record_session_select_baseline();
|
||||
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Restore a display manager an ADOPTED takeover stopped, then replay the user's switch under it —
|
||||
/// every verb live-validated on the Nobara repro VM:
|
||||
/// 1. start the DM (its autologin heads back into game mode briefly — the config still names it);
|
||||
/// 2. run the distro's own `os-session-select desktop` as the user (its internal pkexec is
|
||||
/// `allow_any`-authorized), which rewrites the DM autologin config to the desktop session;
|
||||
/// 3. stop the autologin gamescope unit — the login session exits, and `Relogin=true` relogs
|
||||
/// into the now-selected desktop.
|
||||
///
|
||||
/// Reachable only from [`honor_session_select_switch`], and only for a takeover inherited from a
|
||||
/// pre-0.31.0 host: nothing stops a display manager any more.
|
||||
fn replay_switch_under_restored_dm(dm: &str) {
|
||||
if let Err(e) = restore_display_manager(dm) {
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
reason = %e,
|
||||
@@ -2831,7 +2925,7 @@ fn honor_session_select_switch(dm: String) {
|
||||
// Budgeted: this is a 10 s loop, and a single unbounded `is-active` against a system
|
||||
// manager that is itself mid-restart would consume the whole window in one tick.
|
||||
let active = crate::proc::output_within(
|
||||
Command::new("systemctl").args(["is-active", &dm]),
|
||||
Command::new("systemctl").args(["is-active", dm]),
|
||||
UNIT_STATE_BUDGET,
|
||||
)
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
|
||||
@@ -2888,8 +2982,6 @@ fn honor_session_select_switch(dm: String) {
|
||||
session instead of switching to the desktop"
|
||||
);
|
||||
}
|
||||
record_session_select_baseline();
|
||||
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
||||
@@ -2979,6 +3071,13 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
// switch.
|
||||
if plan.dm_relogins {
|
||||
install_idle_dropin().context("idling the box's autologin game session for the stream")?;
|
||||
// Baseline the switch sentinel HERE, not only at a successful launch: arming the idle
|
||||
// drop-in is what arms the honor gate in [`create_managed_session`], so from this instant
|
||||
// an unbaselined sentinel would read as an in-stream "Switch to Desktop" — including the
|
||||
// write left by the switch that just brought the box INTO game mode. A successful launch
|
||||
// re-baselines (tighter still). This moved here from the display-manager stop that 0.31.0
|
||||
// retired; losing it with that stop is what left the gate unarmed.
|
||||
record_session_select_baseline();
|
||||
}
|
||||
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
|
||||
let mut stopped = Vec::new();
|
||||
@@ -5233,9 +5332,10 @@ mod tests {
|
||||
use super::{
|
||||
any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned,
|
||||
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
|
||||
game_hz, gamescope_output_size, hdr_args, is_steam_launch, mask_unit, missing_flags,
|
||||
mode_mismatch, nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
|
||||
script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
|
||||
game_hz, gamescope_output_size, hdr_args, idle_dropin_body, idle_dropin_path,
|
||||
install_idle_dropin, is_steam_launch, mask_unit, missing_flags, mode_mismatch,
|
||||
nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
|
||||
remove_idle_dropin, script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
|
||||
switch_ends_mask_window, takeover_state_is_live, unmask_unit, xwayland_refusal_marker,
|
||||
BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan,
|
||||
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT,
|
||||
@@ -5434,6 +5534,25 @@ mod tests {
|
||||
assert!(!sentinel_advanced(Some(Some(t0)), None));
|
||||
}
|
||||
|
||||
/// `ExecStart` is list-valued, so the reset line is the whole mechanism: without it the
|
||||
/// drop-in APPENDS the sleep to the box's own session command and both run — the takeover
|
||||
/// would then be fighting the very Steam it set out to free, and nothing on the box would say
|
||||
/// why. Pins the reset, its order, and that the resolved `sleep` is the one that gets run.
|
||||
#[test]
|
||||
fn idle_dropin_replaces_exec_start_rather_than_appending() {
|
||||
let body = idle_dropin_body("/usr/bin/sleep");
|
||||
assert_eq!(
|
||||
body, "[Service]\nExecStart=\nExecStart=/usr/bin/sleep infinity\n",
|
||||
"{body}"
|
||||
);
|
||||
let lines: Vec<&str> = body.lines().collect();
|
||||
assert_eq!(lines[1], "ExecStart=", "the reset must come first: {body}");
|
||||
// The path is resolved per box ([`sleep_binary`]) and must reach the unit verbatim — a
|
||||
// bare `sleep` would depend on the unit's PATH, and an ExecStart that fails to EXECUTE is
|
||||
// the failing unit the display manager relogin-loops against.
|
||||
assert!(idle_dropin_body("/bin/sleep").contains("ExecStart=/bin/sleep infinity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_wrapper_script_shapes() {
|
||||
let relay = std::path::Path::new("/run/user/1000/pf-ei");
|
||||
@@ -5661,10 +5780,32 @@ mod tests {
|
||||
release_autologin_mask(crate::ActiveKind::None);
|
||||
assert_eq!(is_enabled(), "masked-runtime");
|
||||
|
||||
// The idle drop-in is the mask's successor and shares this exact window, so it has to come
|
||||
// off with it. A takeover that leaves it on has replaced the box's game-mode `ExecStart`
|
||||
// with a sleep — a "Return to Gaming Mode" that starts and does nothing, which is the same
|
||||
// barred way back, measured on Bazzite `.41` 2026-08-20.
|
||||
install_idle_dropin().expect("arm the takeover's idle drop-in");
|
||||
assert!(idle_dropin_path().exists());
|
||||
|
||||
// Mid-stream, with the box still ours: the mask is doing its job and must stay. `Gaming` is
|
||||
// what our own managed session reads as, and `None` is one momentarily down between
|
||||
// relaunches — lifting on either would void the mask for the whole stream.
|
||||
release_autologin_mask(crate::ActiveKind::Gaming);
|
||||
release_autologin_mask(crate::ActiveKind::None);
|
||||
assert_eq!(is_enabled(), "masked-runtime");
|
||||
assert!(
|
||||
idle_dropin_path().exists(),
|
||||
"the idle drop-in must survive a switch that is not to a desktop"
|
||||
);
|
||||
|
||||
// The user switched the box to its own desktop mid-stream: the window is over, and the way
|
||||
// back into game mode has to be clear before they ask for it.
|
||||
release_autologin_mask(crate::ActiveKind::DesktopKde);
|
||||
assert_ne!(is_enabled(), "masked-runtime");
|
||||
assert!(
|
||||
!idle_dropin_path().exists(),
|
||||
"the idle drop-in outlived the switch — the box's Game Mode is a sleep now"
|
||||
);
|
||||
// The restart list SURVIVES the lift: the mask's lifetime is shorter than the takeover's,
|
||||
// and the disconnect restore still owes these units a `start`.
|
||||
assert_eq!(STOPPED_AUTOLOGIN.lock().unwrap().as_slice(), [PROBE]);
|
||||
@@ -5673,6 +5814,7 @@ mod tests {
|
||||
assert_ne!(is_enabled(), "masked-runtime");
|
||||
|
||||
unmask_unit(PROBE);
|
||||
remove_idle_dropin();
|
||||
STOPPED_AUTOLOGIN.lock().unwrap().clear();
|
||||
*AUTOLOGIN_MASKED.lock().unwrap() = false;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,16 @@ use zkde::zkde_screencast_unstable_v1::ZkdeScreencastUnstableV1 as Screencast;
|
||||
const POINTER_METADATA: u32 = 4;
|
||||
const POINTER_EMBEDDED: u32 = 2;
|
||||
|
||||
/// Marks the one KWin refusal a retry can clear: the disabled-output repair ran and changed the
|
||||
/// box between attempts ([`kwin_output_mgmt::enable_disabled_output`]).
|
||||
///
|
||||
/// It is load-bearing in TWO places and both are easy to break. The opener keys on it to skip the
|
||||
/// `KWin virtual output failed` wrapper below — and that wrapper's prefix is exactly what the
|
||||
/// host's `is_permanent_build_error` matches to short-circuit the retry loop, so a repaired
|
||||
/// refusal carrying it would be classified permanent and the retry that consumes the repair would
|
||||
/// never run. It is also the human-readable half of the message; keep it a phrase, not a code.
|
||||
const REPAIRED_HINT: &str = "enabled it over output management";
|
||||
|
||||
/// The name we give the created output; KWin exposes it to output-management as `Virtual-<name>`.
|
||||
const VOUT_NAME: &str = "punktfunk";
|
||||
|
||||
@@ -268,6 +278,10 @@ impl VirtualDisplay for KwinDisplay {
|
||||
.context("spawn KWin virtual-output thread")?;
|
||||
match setup_rx.recv_timeout(OPENER_BUDGET) {
|
||||
Ok(Ok(v)) => Ok((v, stop)),
|
||||
// Repaired: report it as-is. The wrapper below would prepend the phrase the host
|
||||
// reads as "permanent, do not retry", and this is the one refusal whose retry is
|
||||
// the entire point — the repair only fixes the NEXT request.
|
||||
Ok(Err(e)) if e.contains(REPAIRED_HINT) => bail!("{e}"),
|
||||
// KWin's reason is TRANSLATED into the session's language, so it is often
|
||||
// unsearchable for the person reading the log. Say what it means once, here.
|
||||
Ok(Err(e)) => bail!(
|
||||
@@ -1793,14 +1807,41 @@ fn run(
|
||||
);
|
||||
|
||||
// Pump events until KWin reports the node id (or an error, or the budget).
|
||||
let node_id = await_created(
|
||||
//
|
||||
// A refusal here is where the KWin >= 6.6 disabled-output trap lands, and it is repairable
|
||||
// FROM INSIDE THIS SCOPE and nowhere else: KWin destroys the output when our stream is
|
||||
// destroyed, so the connection has to stay up while we enable it (see
|
||||
// [`kwin_output_mgmt::enable_disabled_output`] for why the output is still alive at all, and
|
||||
// why enabling it fixes the NEXT request rather than this one).
|
||||
let node_id = match await_created(
|
||||
&conn,
|
||||
&mut queue,
|
||||
&mut state,
|
||||
stop,
|
||||
"stream_virtual_output",
|
||||
started,
|
||||
)?;
|
||||
) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
// `Virtual-<name>` is the address KWin exposes our output under (the same prefix the
|
||||
// topology path resolves against).
|
||||
match crate::kwin_output_mgmt::enable_disabled_output(&format!("Virtual-{name}")) {
|
||||
// Deliberately does NOT carry the "KWin virtual output failed" prefix: that string
|
||||
// is what marks a KWin refusal PERMANENT for the session's retry loop, and this is
|
||||
// the one refusal where something DID change between attempts. Retrying is the
|
||||
// whole point of repairing.
|
||||
Some(repaired) => bail!(
|
||||
"KWin created the virtual output disabled and refused to stream it ({e}); \
|
||||
{REPAIRED_HINT} (head {repaired}) — the retry picks up the configuration \
|
||||
KWin just persisted"
|
||||
),
|
||||
// Nothing to repair (no such head, already enabled, or the apply was refused):
|
||||
// the refusal stands, and its own prefix keeps it permanent so the session fails
|
||||
// fast instead of burning the retry budget on an unchanged box.
|
||||
None => return Err(e),
|
||||
}
|
||||
}
|
||||
};
|
||||
setup_tx
|
||||
.send(Ok(node_id))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
@@ -1274,6 +1274,76 @@ pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
|
||||
complete
|
||||
}
|
||||
|
||||
/// Enable a virtual output KWin created but left DISABLED, addressed by the `Virtual-<name>`
|
||||
/// prefix it exposes ours under. Returns the head's name when one matched, was disabled, and the
|
||||
/// enable applied.
|
||||
///
|
||||
/// This is the repair for the KWin ≥ 6.6 refusal (`"Could not find output"`, translated into the
|
||||
/// session's language). `streamVirtualOutput` there creates the output on the backend and then
|
||||
/// hands `workspace()->findOutput(output)` to the stream — and that returns null for an output the
|
||||
/// workspace does not manage, which `wantsToManage` defines as `isEnabled() && !isNonDesktop()`.
|
||||
/// KWin 6.4/6.5 passed the backend output straight through, so a disabled one streamed anyway;
|
||||
/// from 6.6 it is a hard refusal, and one that repeats forever: the host asks for a STABLE
|
||||
/// per-client name so KWin persists that client's scale and mode, and a stored setup naming it
|
||||
/// `enabled: false` is therefore reapplied to every future session.
|
||||
///
|
||||
/// Two properties of KWin make the repair possible, both verified against Plasma/6.7:
|
||||
///
|
||||
/// * `sendFailed` only sends the event — it does not emit `finished`, and `removeVirtualOutput` is
|
||||
/// wired to `finished`. So the disabled output stays alive for exactly as long as the caller
|
||||
/// holds its (failed) stream open, which is the window this runs in.
|
||||
/// * `WaylandServer::handleOutputAdded` offers EVERY backend output to the output-device registry,
|
||||
/// gating only placeholders and non-desktop ones. A disabled output has no `wl_output` — that
|
||||
/// side is gated on the workspace — but it is addressable over `kde_output_management_v2`.
|
||||
///
|
||||
/// Enabling it through output management is a user-applied configuration, so KWin persists it
|
||||
/// against that output's identity: the caller's next `stream_virtual_output` under the same name
|
||||
/// finds a stored setup that enables it. Which is why the caller must RETRY after this returns
|
||||
/// `Some` — the request that failed cannot be salvaged, only the one after it.
|
||||
pub(crate) fn enable_disabled_output(prefix: &str) -> Option<String> {
|
||||
let mut sess = Session::open("enable_disabled").ok()?;
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
// Newest-wins, exactly as the supersede resolve elsewhere in this file: a reconnect can leave
|
||||
// a predecessor of the same name briefly announced, and enabling THAT one repairs an output
|
||||
// that is already going away.
|
||||
let dev = sess
|
||||
.state
|
||||
.devices
|
||||
.values()
|
||||
.filter(|d| d.name.as_deref().is_some_and(|n| n.starts_with(prefix)) && d.proxy.is_some())
|
||||
.max_by_key(|d| (d.global, d.seq))
|
||||
.cloned()?;
|
||||
let name = dev.name.clone()?;
|
||||
if dev.enabled {
|
||||
// Not the shape we repair. Say so rather than applying a no-op config that would `applied`
|
||||
// successfully and read as a fix — the caller decides whether to retry on this.
|
||||
tracing::debug!(
|
||||
%name,
|
||||
"KWin output management: our virtual output is already enabled — nothing to repair"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let proxy = dev.proxy.as_ref()?;
|
||||
let config = sess.new_config();
|
||||
config.enable(proxy, 1);
|
||||
let ok = sess.apply(&config, deadline);
|
||||
config.destroy();
|
||||
if !ok {
|
||||
tracing::warn!(
|
||||
%name,
|
||||
reason = ?sess.state.failure_reason,
|
||||
"KWin output management: could not enable the virtual output KWin created disabled"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
tracing::info!(
|
||||
%name,
|
||||
"KWin output management: KWin created our virtual output DISABLED and refused to stream \
|
||||
it; enabled it — KWin persists that, so the retry's request comes back enabled"
|
||||
);
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// Position the output identified by `uuid` at `(x, y)` in the desktop layout, in-process. Returns
|
||||
/// `true` if applied; `false` tells the caller to fall back to `kscreen-doctor`.
|
||||
pub(crate) fn set_position(uuid: &str, x: i32, y: i32) -> bool {
|
||||
|
||||
@@ -215,6 +215,7 @@ include = ["PunktfunkEndReason"]
|
||||
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
|
||||
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
|
||||
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
|
||||
"MSG_DELIVERY_REPORT" = "PUNKTFUNK_MSG_DELIVERY_REPORT"
|
||||
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
|
||||
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
|
||||
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! `CtrlRequest` (the embedder's control-stream requests) and `Negotiated` (the handshake result).
|
||||
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::quic::{ClipControl, ClipOffer, ColorInfo, LossReport, ProbeRequest, RfiRequest};
|
||||
use crate::quic::{
|
||||
ClipControl, ClipOffer, ColorInfo, DeliveryReport, LossReport, ProbeRequest, RfiRequest,
|
||||
};
|
||||
|
||||
/// A control-stream request the embedder makes on the open handshake stream: a mode switch or a
|
||||
/// speed test. One outbound channel carries both so the worker's `select!` has a single writer
|
||||
@@ -15,6 +17,10 @@ pub(crate) enum CtrlRequest {
|
||||
/// forcing a full IDR. See [`RfiRequest`].
|
||||
Rfi(RfiRequest),
|
||||
Loss(LossReport),
|
||||
/// How many data-plane packets have reached us all session — sent straight after every
|
||||
/// [`CtrlRequest::Loss`], because `loss_ppm` is ambiguous at zero (no loss and no packets look
|
||||
/// identical) and only this separates them. See [`DeliveryReport`].
|
||||
Delivery(DeliveryReport),
|
||||
/// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's
|
||||
/// [`BitrateController`] when the user's bitrate setting is Automatic.
|
||||
SetBitrate(u32),
|
||||
|
||||
@@ -57,6 +57,21 @@ pub(crate) const FLUSH_AFTER: Duration = Duration::from_millis(250);
|
||||
/// the number, so the two can never drift apart.
|
||||
pub const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Spacing of a client's keyframe re-asks while it has received **no video at all** — the other
|
||||
/// reason a client asks on a perfectly fixed cadence, and the OPPOSITE fault to [`FLUSH_COOLDOWN`]'s
|
||||
/// (nothing arriving, versus more arriving than it can drain).
|
||||
///
|
||||
/// **Public, and deliberately a different value, for the same reason [`FLUSH_COOLDOWN`] is public.**
|
||||
/// While both were 2000 ms the host's recovery-cadence detector could not tell which failure it was
|
||||
/// looking at, and reported the confident wrong one: a 2026-08-20 field case where not one byte of
|
||||
/// video ever reached the client was diagnosed for days as a client too slow to keep up. Embedders
|
||||
/// own the no-video timer (it lives in each decode loop), so this is the value they must use — a
|
||||
/// local copy is exactly the drift that made the two indistinguishable in the first place.
|
||||
///
|
||||
/// The delivery count on [`crate::quic::LossReport`] settles it outright for clients new enough to
|
||||
/// send one; this keeps the period itself informative for those that are not.
|
||||
pub const NO_VIDEO_RETRY: Duration = Duration::from_millis(2600);
|
||||
|
||||
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
|
||||
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
|
||||
/// causes, and flushing helps neither: a **wall-clock step** (NTP mid-session on either end)
|
||||
|
||||
@@ -42,7 +42,7 @@ mod recovery;
|
||||
mod rumble;
|
||||
mod worker;
|
||||
|
||||
pub use self::frame_channel::FLUSH_COOLDOWN;
|
||||
pub use self::frame_channel::{FLUSH_COOLDOWN, NO_VIDEO_RETRY};
|
||||
pub use self::planes::AudioPacket;
|
||||
pub use self::probe::ProbeOutcome;
|
||||
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
|
||||
|
||||
@@ -11,9 +11,9 @@ use crate::abr::BitrateController;
|
||||
use crate::config::Role;
|
||||
use crate::packet::FLAG_PROBE;
|
||||
use crate::quic::{
|
||||
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync, Hello,
|
||||
LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncAdmit, ResyncGuard,
|
||||
ResyncStep, SetBitrate, Start, Welcome,
|
||||
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync,
|
||||
DeliveryReport, Hello, LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe,
|
||||
ResyncAdmit, ResyncGuard, ResyncStep, SetBitrate, Start, Welcome,
|
||||
};
|
||||
use crate::session::Session;
|
||||
use crate::transport::UdpTransport;
|
||||
|
||||
@@ -107,6 +107,7 @@ impl ControlTask {
|
||||
}
|
||||
CtrlRequest::Rfi(r) => r.encode(),
|
||||
CtrlRequest::Loss(r) => r.encode(),
|
||||
CtrlRequest::Delivery(r) => r.encode(),
|
||||
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
|
||||
CtrlRequest::ClockResync => {
|
||||
if clock_rtt_ns.is_none() {
|
||||
|
||||
@@ -77,6 +77,12 @@ impl DataPump {
|
||||
// size FEC to the link. Suppressed during a speed test (its FLAG_PROBE filler would skew it).
|
||||
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
||||
let mut last_report = Instant::now();
|
||||
// Has the host been told, once, that data-plane packets are reaching us? See the send site:
|
||||
// the delivery count is reported every window while it is ZERO (the state the host acts on)
|
||||
// and once more when the first packets land, then never again. A host that predates the
|
||||
// message logs "unknown control message" for each one, so a healthy session must not stream
|
||||
// them — one line per session is a fair price on an old host, eighty a minute is not.
|
||||
let mut delivery_confirmed = false;
|
||||
let (
|
||||
mut last_recovered,
|
||||
mut last_late,
|
||||
@@ -415,6 +421,27 @@ impl DataPump {
|
||||
);
|
||||
} else {
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
|
||||
// Rides with the loss report — it is what makes `loss_ppm = 0` readable at the
|
||||
// host, which cannot otherwise tell a flawless link from one delivering
|
||||
// nothing. The session TOTAL, not this window's, so one message stands on its
|
||||
// own. Deliberately inside the same arm: a discarded window is discarded
|
||||
// because the host was rebuilding or a probe distorted it, and staying silent
|
||||
// there keeps that contract exact. Nothing is lost — the state this reports
|
||||
// (no packets at all) produces no discards, so its windows always send.
|
||||
//
|
||||
// Sent every window while the count is ZERO, then ONCE when the first packets
|
||||
// land (so the host stops guessing and can name the other failure confidently),
|
||||
// then never again: a healthy session must not stream a message that older
|
||||
// hosts log as unknown on every arrival.
|
||||
// ponytail: only start-of-session death is covered. A path that dies MID-stream
|
||||
// leaves the count frozen above zero and silent, which the host still reads as
|
||||
// healthy — detecting that needs a stalled-counter check with its own timing,
|
||||
// worth adding if a mid-session case is ever reported.
|
||||
if should_report_delivery(st.packets_received, &mut delivery_confirmed) {
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Delivery(DeliveryReport {
|
||||
packets_received: st.packets_received,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Standing-latency bleed: close the detector's window with this report's loss
|
||||
// verdict and run its escalation ladder — re-sync first (free; a stale offset
|
||||
@@ -757,10 +784,58 @@ fn take_pipeline_gap(slot: &AtomicU32) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this report window owe the host a [`DeliveryReport`], and record that it has been told?
|
||||
///
|
||||
/// Every window while `packets_received` is ZERO — that is the state the host escalates on, and it
|
||||
/// must keep hearing it — then exactly ONCE more when the first packets land, so the host learns
|
||||
/// delivery works and can stop hedging its stall diagnosis. Silent after that: a host that predates
|
||||
/// the message logs every unknown control message, and a healthy hours-long session must not fill
|
||||
/// its log with them.
|
||||
fn should_report_delivery(packets_received: u64, confirmed: &mut bool) -> bool {
|
||||
let owed = packets_received == 0 || !*confirmed;
|
||||
*confirmed = packets_received > 0;
|
||||
owed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The host must keep hearing "zero" for as long as it is true (that is the black-screen
|
||||
/// signal), get exactly one confirmation when video starts, and then silence — the noise budget
|
||||
/// on an older host, which warns per unknown message, is what pays for the first two.
|
||||
#[test]
|
||||
fn the_delivery_count_is_reported_while_zero_then_once_more_and_never_again() {
|
||||
let mut confirmed = false;
|
||||
// Nothing arriving: reported every window, for as long as it stays true.
|
||||
for _ in 0..5 {
|
||||
assert!(
|
||||
should_report_delivery(0, &mut confirmed),
|
||||
"a dead data plane must be re-reported every window"
|
||||
);
|
||||
}
|
||||
// First packets land: one confirmation, so the host can name the other failure confidently.
|
||||
assert!(should_report_delivery(500, &mut confirmed));
|
||||
// Healthy from here: silent.
|
||||
for n in [900, 1_200, 90_000] {
|
||||
assert!(
|
||||
!should_report_delivery(n, &mut confirmed),
|
||||
"a healthy session must not stream delivery reports"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A session that never receives anything must never look confirmed, no matter how long it runs
|
||||
/// — the whole point is that the host keeps being told.
|
||||
#[test]
|
||||
fn a_session_that_receives_nothing_never_reports_itself_healthy() {
|
||||
let mut confirmed = false;
|
||||
for _ in 0..100 {
|
||||
assert!(should_report_delivery(0, &mut confirmed));
|
||||
assert!(!confirmed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pipeline_gap_is_taken_exactly_once() {
|
||||
let slot = AtomicU32::new(0);
|
||||
@@ -935,8 +1010,8 @@ mod tests {
|
||||
.expect("the window after the gap reports on schedule");
|
||||
assert!(
|
||||
matches!(reported, Some(CtrlRequest::Loss(_))),
|
||||
"the window after the gap must produce a loss report — an idle session's only \
|
||||
outbound request"
|
||||
"the window after the gap must produce a loss report — the first of the two requests \
|
||||
an idle session makes (the delivery count follows it)"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() >= Duration::from_millis(1_400),
|
||||
|
||||
@@ -97,6 +97,33 @@ pub struct LossReport {
|
||||
pub loss_ppm: u32,
|
||||
}
|
||||
|
||||
/// `client → host`, sent immediately after each [`LossReport`]: data-plane packets this client has
|
||||
/// received all session, cumulative.
|
||||
///
|
||||
/// ⚠ Exists because `loss_ppm` alone is **ambiguous at zero**: a client receiving a flawless stream
|
||||
/// and a client receiving *nothing at all* both report `loss_ppm = 0` — loss is a ratio over a
|
||||
/// window whose denominator is the packets that arrived, so no-packets is indistinguishable from
|
||||
/// no-loss. That ambiguity let a host decay adaptive FEC to its floor while the client sat behind a
|
||||
/// black screen having received zero bytes, and the host's own stall diagnosis blamed the client for
|
||||
/// "not sustaining the stream" it had never been sent (field 2026-08-20: a Windows host whose
|
||||
/// per-session data port was closed inbound, so the client's hole-punch never opened the return
|
||||
/// path). `0` while the host has sent frames is the one unambiguous statement of "the video data
|
||||
/// plane is not reaching me" — the control plane carrying this report is, by construction, healthy.
|
||||
///
|
||||
/// ⚠ A SEPARATE MESSAGE rather than a field appended to [`LossReport`], and that is load-bearing:
|
||||
/// `LossReport::decode` length-checks EXACTLY, so a longer report is rejected outright by every host
|
||||
/// already shipped — a new client would silently lose adaptive FEC against them. Mixed versions are
|
||||
/// normal here (the field case that motivated this ran a current host against a months-old client),
|
||||
/// so the compatible shape is a new type byte an older host simply ignores, exactly as it already
|
||||
/// ignores every other control message it predates.
|
||||
///
|
||||
/// Cumulative, not per-window, so a single message is self-contained; `u64` to match the counter it
|
||||
/// mirrors, with no saturation to reason about.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DeliveryReport {
|
||||
pub packets_received: u64,
|
||||
}
|
||||
|
||||
/// `client → host`, any time after [`Start`]: reconfigure the encoder to a new target bitrate
|
||||
/// without reconnecting — the mid-stream lever of adaptive bitrate. The host clamps the request
|
||||
/// exactly like [`Hello::bitrate_kbps`] (its `[MIN, MAX]` band; `0` → host default), answers with
|
||||
@@ -270,6 +297,8 @@ pub const MSG_SHARD_PAYLOAD_ACK: u8 = 0x09;
|
||||
/// and [`BitrateChanged`] already feed. Deliberately NOT in the 0x30 clock block — it carries a
|
||||
/// duration precisely so that no clock domain is involved.
|
||||
pub const MSG_PIPELINE_GAP: u8 = 0x0A;
|
||||
/// Type byte of [`DeliveryReport`].
|
||||
pub const MSG_DELIVERY_REPORT: u8 = 0x0B;
|
||||
/// Type byte of [`ProbeRequest`].
|
||||
pub const MSG_PROBE_REQUEST: u8 = 0x20;
|
||||
/// Type byte of [`ProbeResult`].
|
||||
@@ -436,6 +465,26 @@ impl LossReport {
|
||||
}
|
||||
}
|
||||
|
||||
impl DeliveryReport {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] packets_received[5..13]
|
||||
let mut b = Vec::with_capacity(13);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_DELIVERY_REPORT);
|
||||
b.extend_from_slice(&self.packets_received.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<DeliveryReport> {
|
||||
if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_DELIVERY_REPORT {
|
||||
return Err(PunktfunkError::InvalidArg("bad DeliveryReport"));
|
||||
}
|
||||
Ok(DeliveryReport {
|
||||
packets_received: u64::from_le_bytes(b[5..13].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SetBitrate {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] bitrate_kbps[5..9]
|
||||
@@ -1291,6 +1340,41 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_report_roundtrip() {
|
||||
for packets_received in [0u64, 1, 9_999, u32::MAX as u64 + 1, u64::MAX] {
|
||||
let r = DeliveryReport { packets_received };
|
||||
assert_eq!(DeliveryReport::decode(&r.encode()).unwrap(), r);
|
||||
}
|
||||
assert!(DeliveryReport::decode(&RequestKeyframe.encode()).is_err());
|
||||
assert!(DeliveryReport::decode(&LossReport { loss_ppm: 0 }.encode()).is_err());
|
||||
}
|
||||
|
||||
/// The delivery count MUST NOT ride on [`LossReport`]: that message is length-checked EXACTLY,
|
||||
/// so lengthening it would make every already-shipped host reject the loss reports its adaptive
|
||||
/// FEC runs on — a silent regression for a new client against an old host, which is the normal
|
||||
/// mixed-version case here (the field report that motivated this ran a current host against a
|
||||
/// months-old client). Its own type byte keeps `LossReport` byte-identical while an older host
|
||||
/// simply ignores the message it does not know.
|
||||
#[test]
|
||||
fn the_delivery_count_does_not_disturb_the_loss_report_wire_form() {
|
||||
let loss = LossReport { loss_ppm: 42 }.encode();
|
||||
assert_eq!(loss.len(), 9, "LossReport must stay the 9-byte wire form");
|
||||
assert_eq!(loss[4], MSG_LOSS_REPORT);
|
||||
|
||||
let delivery = DeliveryReport {
|
||||
packets_received: 0,
|
||||
}
|
||||
.encode();
|
||||
assert_ne!(
|
||||
delivery[4], MSG_LOSS_REPORT,
|
||||
"a distinct type byte is what makes an old host ignore it instead of failing"
|
||||
);
|
||||
// Neither can be silently mis-parsed as the other.
|
||||
assert!(LossReport::decode(&delivery).is_err());
|
||||
assert!(DeliveryReport::decode(&loss).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_loss_ppm_estimates_and_caps() {
|
||||
// No traffic → 0. A clean window (nothing recovered) → 0.
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
use anyhow::{Context, Result};
|
||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The native-protocol mDNS service type. Clients browse this to find punktfunk/1 hosts.
|
||||
pub const NATIVE_SERVICE: &str = "_punktfunk._udp.local.";
|
||||
@@ -81,9 +83,78 @@ pub(crate) fn dns_label(name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the mDNS daemon; dropping it unregisters the service.
|
||||
/// Holds the mDNS daemon; dropping it unregisters the service and stops the re-announce loop.
|
||||
pub struct Advert {
|
||||
_daemon: ServiceDaemon,
|
||||
/// Never sent on. Dropping it disconnects the channel the re-announce thread waits on, which
|
||||
/// wakes that thread immediately and ends it — so an `Advert` takes its loop with it instead
|
||||
/// of leaving one behind polling for a service nobody advertises.
|
||||
_stop: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
/// How often a live advert re-checks the address it is announcing.
|
||||
const IP_RECHECK: Duration = Duration::from_secs(10);
|
||||
|
||||
/// The address to advertise right now — loopback only while the machine still has none.
|
||||
fn current_ip() -> IpAddr {
|
||||
crate::gamestream::primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
|
||||
/// Register `build(ip)` for the host's current address, and re-register it whenever that address
|
||||
/// changes. Shared by both adverts ([`advertise_native`] and [`crate::gamestream::mdns`]).
|
||||
///
|
||||
/// mDNS records are PUSHED, not polled: whatever address was true at `register()` keeps being
|
||||
/// announced until something registers a newer one. The host process comes up during boot, which
|
||||
/// on a cold start is before the machine has an address — so the first registration could be
|
||||
/// `127.0.0.1`, and it stayed that way until the host was restarted by hand. `mdns-sd` documents a
|
||||
/// second `register()` of the same fullname as an update, so re-announcing is just calling it
|
||||
/// again.
|
||||
///
|
||||
/// Polls the *routed* address rather than subscribing to the daemon's `IpAdd` events, because the
|
||||
/// boot race usually resolves without one: the NIC often has its address before we register and
|
||||
/// only the default route lands late, so no interface event ever fires.
|
||||
pub(crate) fn advertise_live(
|
||||
service: &'static str,
|
||||
build: impl Fn(IpAddr) -> Result<ServiceInfo> + Send + 'static,
|
||||
) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
let registered = current_ip();
|
||||
daemon
|
||||
.register(build(registered)?)
|
||||
.with_context(|| format!("register {service} mDNS service"))?;
|
||||
|
||||
let (stop_tx, stop_rx) = mpsc::channel::<()>();
|
||||
let bg_daemon = daemon.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut announced = registered;
|
||||
// Doubles as the sleep: times out every `IP_RECHECK` to re-check, and returns
|
||||
// `Disconnected` the moment the `Advert` drops its sender, which ends the loop.
|
||||
while matches!(
|
||||
stop_rx.recv_timeout(IP_RECHECK),
|
||||
Err(mpsc::RecvTimeoutError::Timeout)
|
||||
) {
|
||||
let now = current_ip();
|
||||
if now == announced {
|
||||
continue;
|
||||
}
|
||||
match build(now)
|
||||
.and_then(|info| bg_daemon.register(info).context("re-register mDNS service"))
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::info!(service, from = %announced, to = %now, "host address changed — re-announced");
|
||||
announced = now;
|
||||
}
|
||||
// Leave the previous record standing and retry next tick rather than going dark.
|
||||
Err(e) => {
|
||||
tracing::warn!(service, error = %format!("{e:#}"), "mDNS re-announce failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Advert {
|
||||
_daemon: daemon,
|
||||
_stop: stop_tx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex);
|
||||
@@ -95,7 +166,6 @@ pub struct Advert {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn advertise_native(
|
||||
hostname: &str,
|
||||
ip: IpAddr,
|
||||
port: u16,
|
||||
fingerprint: &str,
|
||||
require_pairing: bool,
|
||||
@@ -103,14 +173,17 @@ pub fn advertise_native(
|
||||
mgmt_port: Option<u16>,
|
||||
os_chain: &str,
|
||||
) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
// `hostname` is the DISPLAY name (the instance label clients read back); the A-record target
|
||||
// has to be a legal DNS name, hence the separate sanitized label.
|
||||
let host_name = format!("{}.local.", dns_label(hostname));
|
||||
let mut props: HashMap<String, String> = HashMap::new();
|
||||
props.insert("proto".into(), NATIVE_PROTO.into());
|
||||
props.insert("fp".into(), fingerprint.to_string());
|
||||
props.insert(
|
||||
// Owned, because the record is rebuilt whenever the host's address changes — see
|
||||
// [`advertise_live`]. Everything except the address (and the MACs derived from it) is fixed,
|
||||
// so it is computed once here and moved into the builder.
|
||||
let instance = hostname.to_string();
|
||||
let mut fixed: HashMap<String, String> = HashMap::new();
|
||||
fixed.insert("proto".into(), NATIVE_PROTO.into());
|
||||
fixed.insert("fp".into(), fingerprint.to_string());
|
||||
fixed.insert(
|
||||
"pair".into(),
|
||||
if require_pairing {
|
||||
"required"
|
||||
@@ -119,31 +192,14 @@ pub fn advertise_native(
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
props.insert("id".into(), uniqueid.to_string());
|
||||
fixed.insert("id".into(), uniqueid.to_string());
|
||||
if let Some(mgmt) = mgmt_port {
|
||||
props.insert("mgmt".into(), mgmt.to_string());
|
||||
fixed.insert("mgmt".into(), mgmt.to_string());
|
||||
}
|
||||
// `os` — advisory OS-identity chain for the client's host-card icon (see module doc).
|
||||
if !os_chain.is_empty() {
|
||||
props.insert("os".into(), os_chain.to_string());
|
||||
fixed.insert("os".into(), os_chain.to_string());
|
||||
}
|
||||
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed NIC
|
||||
// first. A client persists these while the host is awake so it can send a Wake-on-LAN magic
|
||||
// packet to wake it later (when it's asleep and no longer advertising). Unauthenticated like
|
||||
// the rest of the advert, but a wrong MAC only makes a wake fail — the magic packet is inert
|
||||
// and the cert fingerprint still gates the actual connection. Omitted when none can be read.
|
||||
let macs = crate::wol::wake_macs(ip);
|
||||
if !macs.is_empty() {
|
||||
props.insert("mac".into(), macs.join(","));
|
||||
}
|
||||
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason WoL
|
||||
// silently fails.
|
||||
crate::wol::warn_if_not_armed(ip);
|
||||
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
|
||||
.context("build native mDNS ServiceInfo")?;
|
||||
daemon
|
||||
.register(service)
|
||||
.context("register native mDNS service")?;
|
||||
tracing::info!(
|
||||
service = "_punktfunk._udp",
|
||||
port,
|
||||
@@ -151,7 +207,26 @@ pub fn advertise_native(
|
||||
pair = if require_pairing { "required" } else { "optional" },
|
||||
"native punktfunk/1 mDNS advertising"
|
||||
);
|
||||
Ok(Advert { _daemon: daemon })
|
||||
advertise_live(NATIVE_SERVICE, move |ip| {
|
||||
let mut props = fixed.clone();
|
||||
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed
|
||||
// NIC first. A client persists these while the host is awake so it can send a
|
||||
// Wake-on-LAN magic packet to wake it later (when it's asleep and no longer advertising).
|
||||
// Unauthenticated like the rest of the advert, but a wrong MAC only makes a wake fail —
|
||||
// the magic packet is inert and the cert fingerprint still gates the actual connection.
|
||||
// Omitted when none can be read, which is what a host that came up before its network did
|
||||
// used to report forever.
|
||||
let macs = crate::wol::wake_macs(ip);
|
||||
if !macs.is_empty() {
|
||||
props.insert("mac".into(), macs.join(","));
|
||||
}
|
||||
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason
|
||||
// WoL silently fails. Re-checked on an address change because the routed NIC may be a
|
||||
// different one now.
|
||||
crate::wol::warn_if_not_armed(ip);
|
||||
ServiceInfo::new(NATIVE_SERVICE, &instance, &host_name, ip, port, props)
|
||||
.context("build native mDNS ServiceInfo")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -114,9 +114,18 @@ pub enum LeaseKind {
|
||||
Child,
|
||||
/// A launcher owns the game; it is recognized by its [`DetectSpec`].
|
||||
Matched,
|
||||
/// Nothing identifies this title's process — no detect signals and no child we own. Both
|
||||
/// lifetime behaviors stay inert for it, and the host says so once in the log rather than
|
||||
/// guessing.
|
||||
/// A launcher owns the game and **tells us** when it starts and stops
|
||||
/// ([`crate::runstate`]) — no process signal of our own.
|
||||
///
|
||||
/// The one lease kind whose liveness the host does not determine for itself, and the answer to
|
||||
/// a title that has nothing to scan for: Playnite launches an emulated or manually-added game
|
||||
/// through its own tracking and reports the edges, where the host could see only a
|
||||
/// `playnite://` forwarder exiting. Before this such a title was [`Untracked`](Self::Untracked)
|
||||
/// — the honest answer at the time, and a dead end.
|
||||
Reported,
|
||||
/// Nothing identifies this title's process — no detect signals, no child we own, and no
|
||||
/// provider reporting on it. Both lifetime behaviors stay inert for it, and the host says so
|
||||
/// once in the log rather than guessing.
|
||||
Untracked,
|
||||
}
|
||||
|
||||
@@ -126,6 +135,7 @@ impl LeaseKind {
|
||||
Self::Nested => "nested",
|
||||
Self::Child => "child",
|
||||
Self::Matched => "matched",
|
||||
Self::Reported => "reported",
|
||||
Self::Untracked => "untracked",
|
||||
}
|
||||
}
|
||||
@@ -387,6 +397,12 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
|
||||
LeaseKind::Child
|
||||
} else if !spec.is_empty() {
|
||||
LeaseKind::Matched
|
||||
} else if crate::runstate::speaks_for(game.id.as_deref()) {
|
||||
// Nothing to scan for, but the provider that published this title is reporting liveness for
|
||||
// it — so it is tracked after all. Asked once, here, rather than every poll: a lease's kind
|
||||
// is what decides whether it is watched at all, and a title that flipped kind mid-flight
|
||||
// would make both lifetime behaviors depend on a plugin's uptime.
|
||||
LeaseKind::Reported
|
||||
} else {
|
||||
LeaseKind::Untracked
|
||||
};
|
||||
@@ -551,6 +567,27 @@ fn watch(
|
||||
s.is_some_and(|p| !scanner.alive(&[p]).is_empty())
|
||||
};
|
||||
|
||||
// What this title's provider says about it, when one reports at all ([`crate::runstate`]) —
|
||||
// `None` on every host with no reporting plugin, which is what keeps all of this inert until
|
||||
// someone opts in. Re-read each poll rather than captured: the whole value of it is that it
|
||||
// changes while the lease is alive.
|
||||
let reported = || shared.game.id.as_deref().and_then(crate::runstate::opinion);
|
||||
|
||||
// What a `Child` lease falls back to once its child turns out to be a shim: the store's own
|
||||
// signals, else the provider's reporting, else nothing. The same ladder [`open`] walks, minus
|
||||
// the child that has just gone away — and the reason a hint-less Playnite title is tracked at
|
||||
// all on Windows, where the launch is `explorer.exe "playnite://…"` and therefore ALWAYS a
|
||||
// hand-off, so every such lease arrives here.
|
||||
let fallback_kind = || {
|
||||
if !shared.spec.is_empty() {
|
||||
LeaseKind::Matched
|
||||
} else if crate::runstate::speaks_for(shared.game.id.as_deref()) {
|
||||
LeaseKind::Reported
|
||||
} else {
|
||||
LeaseKind::Untracked
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Phase 1: wait for the game to show up. ----
|
||||
let start_deadline = spawned_at + START_GRACE;
|
||||
loop {
|
||||
@@ -567,8 +604,10 @@ fn watch(
|
||||
&& !spawned_up(&spawned)
|
||||
{
|
||||
spawned = None;
|
||||
if spawned_at.elapsed() < SHIM_WINDOW {
|
||||
if shared.spec.is_empty() {
|
||||
let quick = spawned_at.elapsed() < SHIM_WINDOW;
|
||||
kind = fallback_kind();
|
||||
if quick {
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
tracing::info!(
|
||||
title = %shared.game.title,
|
||||
"the launch command exited immediately (a launcher handing off) and this \
|
||||
@@ -582,11 +621,10 @@ fn watch(
|
||||
}
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
"the launch command handed off and exited — recognizing the game by its store \
|
||||
signals instead"
|
||||
kind = kind.as_str(),
|
||||
"the launch command handed off and exited — recognizing the game another way"
|
||||
);
|
||||
kind = LeaseKind::Matched;
|
||||
} else if shared.spec.is_empty() {
|
||||
} else if matches!(kind, LeaseKind::Untracked) {
|
||||
// It ran long enough to have BEEN the game, and nothing else identifies it.
|
||||
shared.was_running.store(true, Ordering::Relaxed);
|
||||
finish(&shared, &on_exit, "the launched process exited");
|
||||
@@ -604,31 +642,30 @@ fn watch(
|
||||
shared.forget_child();
|
||||
if quick && status.success() {
|
||||
// A launcher that handed the game off and exited. Fall back to recognizing
|
||||
// the game by its store's signals; with none, stop tracking entirely rather
|
||||
// than pretend the shim's exit was the game's.
|
||||
kind = if shared.spec.is_empty() {
|
||||
// the game by its store's signals (or its provider's reporting); with
|
||||
// neither, stop tracking entirely rather than pretend the shim's exit was
|
||||
// the game's.
|
||||
kind = fallback_kind();
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
tracing::info!(
|
||||
title = %shared.game.title,
|
||||
"the launch command exited immediately (a launcher handing off) and \
|
||||
this title has no detect signals — stopping game tracking for it"
|
||||
);
|
||||
LeaseKind::Untracked
|
||||
} else {
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
"the launch command handed off and exited — recognizing the game by \
|
||||
its store signals instead"
|
||||
);
|
||||
LeaseKind::Matched
|
||||
};
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
shared.set_state(GameState::Untracked);
|
||||
return;
|
||||
}
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
kind = kind.as_str(),
|
||||
"the launch command handed off and exited — recognizing the game \
|
||||
another way"
|
||||
);
|
||||
} else {
|
||||
// It ran long enough to have BEEN the game (or failed outright). Either way
|
||||
// the game is gone; only a success after a real run counts as "played".
|
||||
if shared.spec.is_empty() {
|
||||
kind = fallback_kind();
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
if spawned_at.elapsed() >= SHIM_WINDOW {
|
||||
shared.was_running.store(true, Ordering::Relaxed);
|
||||
finish(&shared, &on_exit, "the launched process exited");
|
||||
@@ -642,11 +679,7 @@ fn watch(
|
||||
Some(Err(e)) => {
|
||||
tracing::debug!(error = %e, "could not poll the launched child — falling back to scanning");
|
||||
child = None;
|
||||
kind = if shared.spec.is_empty() {
|
||||
LeaseKind::Untracked
|
||||
} else {
|
||||
LeaseKind::Matched
|
||||
};
|
||||
kind = fallback_kind();
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
shared.set_state(GameState::Untracked);
|
||||
return;
|
||||
@@ -680,7 +713,12 @@ fn watch(
|
||||
&& (child.is_some() || spawned.is_some())
|
||||
&& spawned_at.elapsed() >= SHIM_WINDOW;
|
||||
let live = scanner.find(&shared.spec, shared.launch_stamp);
|
||||
if !live.is_empty() || child_alive {
|
||||
// A provider saying so is as good as seeing it — better, for a title there is nothing to
|
||||
// see: it is the launcher that started the game telling us it did. This is the only way a
|
||||
// [`LeaseKind::Reported`] lease ever leaves this phase, and for a `Matched` one it just
|
||||
// gets there sooner than the scan would.
|
||||
let said_running = reported().is_some_and(|l| l.running);
|
||||
if !live.is_empty() || child_alive || said_running {
|
||||
known = live.clone();
|
||||
publish(&live);
|
||||
shared.was_running.store(true, Ordering::Relaxed);
|
||||
@@ -754,6 +792,27 @@ fn watch(
|
||||
gone_since = None;
|
||||
vetoed = false;
|
||||
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
|
||||
} else if let Some(said) = reported() {
|
||||
// Nothing of the game is visible to us, but its provider is still reporting on it — and
|
||||
// that report is decisive in BOTH directions, where `running_hint` below may only ever
|
||||
// delay an exit.
|
||||
//
|
||||
// The difference is what backs each claim. Steam's registry flag is a leftover that
|
||||
// survives an unclean exit, so believing it indefinitely produces a session that never
|
||||
// ends; a provider report is an event from the launcher that started the game, restated
|
||||
// continuously, and it stops counting the moment it goes stale
|
||||
// ([`crate::runstate::REPORT_TTL`]) — after which this branch simply stops being taken
|
||||
// and the scan-only path below resumes. So a *live* provider is allowed to hold the
|
||||
// session open for a game the host cannot see at all, which is the entire point for a
|
||||
// title with no detect signals, and a dead one costs at most one TTL.
|
||||
if said.running {
|
||||
gone_since = None;
|
||||
vetoed = false;
|
||||
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
|
||||
} else {
|
||||
finish(&shared, &on_exit, "its provider reported the game stopped");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by
|
||||
// the veto below — letting it run on is exactly what bounds the veto.
|
||||
@@ -909,7 +968,7 @@ fn terminate_blocking(shared: &LeaseShared) {
|
||||
"released the nested session's kept display to end its game"
|
||||
);
|
||||
}
|
||||
LeaseKind::Child | LeaseKind::Matched => {
|
||||
LeaseKind::Child | LeaseKind::Matched | LeaseKind::Reported => {
|
||||
#[cfg(target_os = "linux")]
|
||||
unix_term_ladder(shared);
|
||||
#[cfg(windows)]
|
||||
@@ -919,6 +978,26 @@ fn terminate_blocking(shared: &LeaseShared) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The process this lease's provider reports for its game, re-resolved and pinned to its start
|
||||
/// time, or `None`.
|
||||
///
|
||||
/// The reason the wire carries a pid at all: for a [`LeaseKind::Reported`] title the matcher finds
|
||||
/// nothing by construction, so without this "End" would have no target and would silently do
|
||||
/// nothing — the exact failure a spawned pid was folded into the Windows ladder to fix. Resolved at
|
||||
/// the moment of use rather than stored on the lease, so a report that has since gone stale, or a
|
||||
/// pid the kernel has since recycled, contributes nothing.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn reported_proc(shared: &LeaseShared) -> Option<crate::procscan::ProcRef> {
|
||||
let pid = shared
|
||||
.game
|
||||
.id
|
||||
.as_deref()
|
||||
.and_then(crate::runstate::opinion)
|
||||
.filter(|l| l.running)?
|
||||
.pid?;
|
||||
crate::procscan::resolve(pid)
|
||||
}
|
||||
|
||||
/// SIGTERM everything that belongs to the game, wait, then SIGKILL whatever ignored it.
|
||||
///
|
||||
/// Every pid is re-verified against its recorded start time immediately before each signal, so a pid
|
||||
@@ -942,11 +1021,22 @@ fn unix_term_ladder(shared: &LeaseShared) {
|
||||
// `OwnedChild::group_leader`) — never for a child sharing the host's own group.
|
||||
unsafe { libc::kill(target, sig) == 0 }
|
||||
};
|
||||
// Everything the matcher can find, plus the pid the provider reported (see `reported_proc`) —
|
||||
// which for a `Reported` lease is the only member of this set.
|
||||
let targets = || {
|
||||
let mut procs = scanner.find(&shared.spec, shared.launch_stamp);
|
||||
if let Some(p) = reported_proc(shared) {
|
||||
if !procs.iter().any(|q| q.pid == p.pid) {
|
||||
procs.push(p);
|
||||
}
|
||||
}
|
||||
procs
|
||||
};
|
||||
let signal_matched = |sig: i32| -> usize {
|
||||
// Re-scan and re-verify immediately before signalling, so a pid recycled since the last
|
||||
// sweep is never hit.
|
||||
scanner
|
||||
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
|
||||
.alive(&targets())
|
||||
.into_iter()
|
||||
// SAFETY: as above, for a single pid just re-verified to be the process we adopted.
|
||||
.filter(|p| unsafe { libc::kill(p.pid as i32, sig) == 0 })
|
||||
@@ -965,9 +1055,7 @@ fn unix_term_ladder(shared: &LeaseShared) {
|
||||
let deadline = Instant::now() + TERM_GRACE;
|
||||
while Instant::now() < deadline {
|
||||
std::thread::sleep(POLL);
|
||||
let still = scanner
|
||||
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
|
||||
.len();
|
||||
let still = scanner.alive(&targets()).len();
|
||||
// Signal 0 only probes for existence — the child (or its group) is gone once it fails.
|
||||
let child_gone = !signal_child(0);
|
||||
if still == 0 && child_gone {
|
||||
@@ -1000,11 +1088,19 @@ fn windows_term_ladder(shared: &LeaseShared) {
|
||||
let live = || {
|
||||
let mut procs = scanner.alive(&scanner.find(&shared.spec, shared.launch_stamp));
|
||||
// Re-verified like everything else, so a dead or recycled pid contributes nothing, and
|
||||
// de-duplicated: the matcher may well have found this same process by its image.
|
||||
if let Some(p) = shared.spawned {
|
||||
// de-duplicated: the matcher may well have found this same process by its image. The
|
||||
// provider's reported pid joins on the same terms, and for a `Reported` lease it is the
|
||||
// only thing here (see `reported_proc`).
|
||||
let mut fold = |p: crate::procscan::ProcRef| {
|
||||
if !scanner.alive(&[p]).is_empty() && !procs.iter().any(|q| q.pid == p.pid) {
|
||||
procs.push(p);
|
||||
}
|
||||
};
|
||||
if let Some(p) = shared.spawned {
|
||||
fold(p);
|
||||
}
|
||||
if let Some(p) = reported_proc(shared) {
|
||||
fold(p);
|
||||
}
|
||||
procs
|
||||
};
|
||||
@@ -1570,6 +1666,54 @@ mod tests {
|
||||
assert!(!l.shared().is_trackable());
|
||||
}
|
||||
|
||||
/// A title with nothing to scan for is tracked after all when its provider reports on it.
|
||||
///
|
||||
/// This is the Playnite case the static `detect` hints could never reach: an emulated game, a
|
||||
/// manually added one, a library plugin that records no install directory. The launch is a
|
||||
/// `playnite://` hand-off, so the host holds nothing; the spec is empty, so the matcher finds
|
||||
/// nothing; and the honest verdict used to be [`LeaseKind::Untracked`] — no exit detection, and
|
||||
/// `POST /game/end` with nothing to aim at. Playnite knew the whole time.
|
||||
#[test]
|
||||
fn a_reported_title_is_tracked_where_it_used_to_be_untracked() {
|
||||
// The same request with no provider reporting: unchanged, and the control for what follows.
|
||||
let l = open(
|
||||
req("playnite:lease-test", DetectSpec::default(), false),
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
|
||||
assert!(!l.shared().is_trackable());
|
||||
drop(l);
|
||||
|
||||
// A provider that speaks for the title — while reporting it NOT running, which is exactly
|
||||
// what a report looks like at the moment a game is launched. Trackability follows from the
|
||||
// provider *reporting*, not from what it currently says; a lease whose kind flipped with
|
||||
// the answer would make both lifetime behaviours depend on a plugin's timing.
|
||||
crate::runstate::report(
|
||||
"playnite-lease-test",
|
||||
["playnite:lease-test".to_string()].into_iter().collect(),
|
||||
std::collections::HashMap::new(),
|
||||
);
|
||||
let l = open(
|
||||
req("playnite:lease-test", DetectSpec::default(), false),
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Reported));
|
||||
assert!(
|
||||
l.shared().is_trackable(),
|
||||
"so its exit is noticed and `POST /game/end` has a target"
|
||||
);
|
||||
drop(l);
|
||||
crate::runstate::forget("playnite-lease-test");
|
||||
|
||||
// …and once the provider is gone, so is the tracking. Pinned because a report that outlived
|
||||
// its plugin is the one way this could hold a session open forever.
|
||||
let l = open(
|
||||
req("playnite:lease-test", DetectSpec::default(), false),
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untracked_lease_is_never_terminated() {
|
||||
let l = open(
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
use super::{AppState, CONTROL_PORT};
|
||||
use crate::inject::gamepad::GamepadManager;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::input::InputEvent;
|
||||
use punktfunk_core::input::{GamepadEvent, InputEvent};
|
||||
use punktfunk_core::quic::{classify, GrantClass, HdrMeta, GRANT_ALL};
|
||||
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
|
||||
use std::net::UdpSocket;
|
||||
@@ -229,6 +229,65 @@ fn permitted(mask: u32, class: GrantClass, drops: &mut GrantDrops) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The virtual Xbox pad a Moonlight session presents, and the one place this plane decides which
|
||||
/// backend builds it.
|
||||
///
|
||||
/// On Windows there are two, and they are not interchangeable to a game: the XUSB companion
|
||||
/// registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID collection, so Steam's hidapi
|
||||
/// enumeration, SDL, RawInput, DirectInput, `joy.cpl` and WGI/GameInput cannot see it at all —
|
||||
/// only classic `XInputGetState` can. The native plane made the HID pad its default on
|
||||
/// 2026-08-09 for exactly that reason; this plane kept constructing
|
||||
/// [`GamepadManager`](crate::inject::gamepad::GamepadManager) directly and so kept handing
|
||||
/// Moonlight clients a pad most games cannot enumerate. Both planes now read the same knob —
|
||||
/// `native::gamepad::windows_xbox_hid` (not an intra-doc link: it is `cfg(windows)`, so the link
|
||||
/// would not resolve on any other target) — so `PUNKTFUNK_XBOX_BACKEND=xusb` reverts both
|
||||
/// together and neither can drift again.
|
||||
///
|
||||
/// Everywhere else the choice does not exist: Linux has one uinput X-Box pad, and the stub
|
||||
/// backend on other platforms drops events.
|
||||
enum SessionPads {
|
||||
/// Linux uinput / the Windows XUSB companion — `crate::inject::gamepad`.
|
||||
Xusb(GamepadManager),
|
||||
/// The Windows UMDF HID Xbox pad, what the native plane builds by default.
|
||||
#[cfg(target_os = "windows")]
|
||||
Hid(crate::inject::xbox_windows::XboxWindowsManager),
|
||||
}
|
||||
|
||||
impl SessionPads {
|
||||
/// Build this session's pad manager, honoring the shared Windows backend knob.
|
||||
fn new() -> SessionPads {
|
||||
#[cfg(target_os = "windows")]
|
||||
if crate::native::gamepad::windows_xbox_hid() {
|
||||
return SessionPads::Hid(crate::inject::xbox_windows::XboxWindowsManager::new());
|
||||
}
|
||||
SessionPads::Xusb(GamepadManager::new())
|
||||
}
|
||||
|
||||
/// Apply one decoded controller event (create/destroy by mask, then state).
|
||||
fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match self {
|
||||
SessionPads::Xusb(m) => m.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
SessionPads::Hid(m) => m.handle(ev),
|
||||
}
|
||||
}
|
||||
|
||||
/// Service the pads' feedback protocol and relay changed rumble levels. Games block inside the
|
||||
/// kernel/driver handshake until answered, so call this every tick.
|
||||
///
|
||||
/// The HID pad's rich-feedback plane is discarded rather than plumbed: an Xbox pad has no
|
||||
/// lightbar or adaptive triggers to report, and GameStream has no vocabulary for one either —
|
||||
/// its rumble message (`0x010B`, [`super::gamepad::rumble_plaintext`]) carries the two handle
|
||||
/// motors and nothing else, which is also why the trigger levels are dropped at the call site.
|
||||
fn pump_rumble(&mut self, rumble: impl FnMut(u16, u16, u16, u16, u16)) {
|
||||
match self {
|
||||
SessionPads::Xusb(m) => m.pump_rumble(rumble),
|
||||
#[cfg(target_os = "windows")]
|
||||
SessionPads::Hid(m) => m.pump(rumble, |_| {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconcile the control port to the paired-client list: bound while at least one pairing
|
||||
/// exists, closed when none remain. Idempotent and race-free (see [`Gate::running`]); call it
|
||||
/// wherever the paired list changes — startup, pairing phase 4, unpair.
|
||||
@@ -362,7 +421,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
// by every outbound message (rumble + the HDR-mode signal): the GCM nonce is derived
|
||||
// from `seq`, so a per-message-type counter would reuse (key, nonce) pairs across
|
||||
// message types in the host direction.
|
||||
let mut pads = GamepadManager::new();
|
||||
let mut pads = SessionPads::new();
|
||||
// Pen/touch translator (SS_PEN/SS_TOUCH → virtual tablet / wire touch). Sent only
|
||||
// by clients that saw our SS_FF_PEN_TOUCH_EVENTS feature flag (rtsp.rs).
|
||||
let mut pointer = super::pen::GsPointer::new();
|
||||
@@ -480,7 +539,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
hdr_sent = false;
|
||||
// Unplug the session's virtual pads + tablet (destroying the
|
||||
// uinput pen releases any held tool/tip kernel-side).
|
||||
pads = GamepadManager::new();
|
||||
pads = SessionPads::new();
|
||||
pointer = super::pen::GsPointer::new();
|
||||
// Surface the session's enforcement-drop totals (WP13).
|
||||
drops.end_of_session();
|
||||
@@ -583,7 +642,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
detected = None;
|
||||
decrypt_fails = 0;
|
||||
hdr_sent = false;
|
||||
pads = GamepadManager::new();
|
||||
pads = SessionPads::new();
|
||||
pointer = super::pen::GsPointer::new();
|
||||
drops.end_of_session();
|
||||
}
|
||||
@@ -689,7 +748,7 @@ fn on_receive(
|
||||
detected: &mut Option<Scheme>,
|
||||
decrypt_fails: &mut u64,
|
||||
inj_tx: &Sender<InputEvent>,
|
||||
pads: &mut GamepadManager,
|
||||
pads: &mut SessionPads,
|
||||
pointer: &mut super::pen::GsPointer,
|
||||
grants: u32,
|
||||
drops: &mut GrantDrops,
|
||||
|
||||
@@ -3,37 +3,34 @@
|
||||
|
||||
use super::Host;
|
||||
use anyhow::{Context, Result};
|
||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||
use mdns_sd::ServiceInfo;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Holds the mDNS daemon; dropping it unregisters the service.
|
||||
pub struct Advert {
|
||||
_daemon: ServiceDaemon,
|
||||
}
|
||||
// One `Advert` for both service types: holds the mDNS daemon plus the re-announce loop that
|
||||
// keeps the record pointed at the host's current address.
|
||||
use crate::discovery::Advert;
|
||||
|
||||
const SERVICE: &str = "_nvstream._tcp.local.";
|
||||
|
||||
pub fn advertise(host: &Host) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
// Instance name = the display name (what Moonlight lists); A-record target = the sanitized
|
||||
// DNS label, so a free-text `PUNKTFUNK_HOST_NAME` can't produce an illegal record.
|
||||
let host_name = format!("{}.local.", crate::discovery::dns_label(&host.hostname));
|
||||
// No TXT records are required for Moonlight discovery; it resolves the A record and then
|
||||
// GETs /serverinfo for capabilities.
|
||||
let props: HashMap<String, String> = HashMap::new();
|
||||
let service = ServiceInfo::new(
|
||||
"_nvstream._tcp.local.",
|
||||
&host.hostname,
|
||||
&host_name,
|
||||
host.local_ip,
|
||||
host.http_port,
|
||||
props,
|
||||
)
|
||||
.context("build mDNS ServiceInfo")?;
|
||||
daemon.register(service).context("register mDNS service")?;
|
||||
let instance = host.hostname.clone();
|
||||
let port = host.http_port;
|
||||
tracing::info!(
|
||||
service = "_nvstream._tcp",
|
||||
port = host.http_port,
|
||||
port,
|
||||
host = %host_name,
|
||||
"mDNS advertising"
|
||||
);
|
||||
Ok(Advert { _daemon: daemon })
|
||||
// The advertised address is supplied per-registration so the record follows the host onto a
|
||||
// network that only came up after boot — see [`crate::discovery::advertise_live`].
|
||||
crate::discovery::advertise_live(SERVICE, move |ip| {
|
||||
// No TXT records are required for Moonlight discovery; it resolves the A record and then
|
||||
// GETs /serverinfo for capabilities.
|
||||
let props: HashMap<String, String> = HashMap::new();
|
||||
ServiceInfo::new(SERVICE, &instance, &host_name, ip, port, props)
|
||||
.context("build mDNS ServiceInfo")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,7 +138,6 @@ pub struct Host {
|
||||
pub hostname: String,
|
||||
/// Stable per-host id (persisted), echoed in serverinfo + matched on pairing.
|
||||
pub uniqueid: String,
|
||||
pub local_ip: IpAddr,
|
||||
pub http_port: u16,
|
||||
pub https_port: u16,
|
||||
/// OS identity chain (`windows` | `macos` | `linux[/<family>][/<id>]`), advertised in the
|
||||
@@ -155,13 +154,25 @@ impl Host {
|
||||
Ok(Host {
|
||||
hostname: hostname_string(),
|
||||
uniqueid: load_or_create_uniqueid()?,
|
||||
local_ip: primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
os_chain: os.chain.clone(),
|
||||
os_name: os.pretty.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Best-effort primary LAN IP, re-read on every call.
|
||||
///
|
||||
/// Deliberately NOT a field: [`Host::detect`] runs as the host process starts, which on a cold
|
||||
/// boot is before the machine has an address at all, and a snapshot taken there used to stick
|
||||
/// for the life of the process — the host then advertised itself over mDNS as `127.0.0.1`,
|
||||
/// handed Moonlight an `rtsp://127.0.0.1` session URL, and dropped its Wake-on-LAN MAC record,
|
||||
/// until someone restarted it by hand. Reading live costs a `connect(2)` on an unconnected UDP
|
||||
/// socket (no packets are sent), which is nothing beside the HTTP responses it is serialized
|
||||
/// into. Loopback here means "still no LAN address", not a stale one.
|
||||
pub fn local_ip(&self) -> IpAddr {
|
||||
primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
}
|
||||
|
||||
/// The stream parameters a client passes at `/launch`, shared with the RTSP + media stages.
|
||||
@@ -432,7 +443,7 @@ pub fn serve(
|
||||
tracing::info!(
|
||||
hostname = %state.host.hostname,
|
||||
uniqueid = %state.host.uniqueid,
|
||||
ip = %state.host.local_ip,
|
||||
ip = %state.host.local_ip(),
|
||||
native_port = native.port,
|
||||
require_pairing = native.require_pairing,
|
||||
gamestream,
|
||||
@@ -656,10 +667,43 @@ fn load_or_create_uniqueid() -> Result<String> {
|
||||
|
||||
/// Best-effort primary LAN IP: open a UDP socket "toward" a public address and read the
|
||||
/// local address the OS would route through. No packets are actually sent.
|
||||
fn primary_local_ip() -> Option<IpAddr> {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||
sock.connect("8.8.8.8:80").ok()?;
|
||||
sock.local_addr().ok().map(|a| a.ip())
|
||||
///
|
||||
/// Returns `None` — never loopback — when the machine has no LAN address yet, so callers have to
|
||||
/// decide what "unknown" means instead of silently inheriting `127.0.0.1`. During a cold boot the
|
||||
/// route probe fails outright (the host outruns DHCP: the Windows service is `AutoStart` with no
|
||||
/// network dependency), so it falls back to the first non-loopback interface address, which the
|
||||
/// NIC has as soon as it is configured even if the default route is not installed yet.
|
||||
pub(crate) fn primary_local_ip() -> Option<IpAddr> {
|
||||
let routed = UdpSocket::bind("0.0.0.0:0")
|
||||
.and_then(|sock| {
|
||||
sock.connect("8.8.8.8:80")?;
|
||||
sock.local_addr()
|
||||
})
|
||||
.ok()
|
||||
.map(|a| a.ip())
|
||||
.filter(|ip| usable_lan_ip(*ip));
|
||||
routed.or_else(first_lan_ipv4)
|
||||
}
|
||||
|
||||
/// First reachable IPv4 an interface holds, ignoring the routing table entirely.
|
||||
///
|
||||
/// Split out because this is the branch the boot race actually takes, and the one nothing would
|
||||
/// otherwise exercise: the route probe above needs a default route, which lands *after* the NIC
|
||||
/// has its address on a cold boot. Between those two moments the old code had no answer and fell
|
||||
/// back to loopback for good.
|
||||
fn first_lan_ipv4() -> Option<IpAddr> {
|
||||
if_addrs::get_if_addrs()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.map(|i| i.ip())
|
||||
.find(|ip| ip.is_ipv4() && usable_lan_ip(*ip))
|
||||
}
|
||||
|
||||
/// Is `ip` an address a client could actually reach this host on? Loopback and the unspecified
|
||||
/// address are both "we don't know yet" dressed up as an answer, and advertising either is the
|
||||
/// boot race that made a freshly-restarted host publish itself as `127.0.0.1`.
|
||||
fn usable_lan_ip(ip: IpAddr) -> bool {
|
||||
!ip.is_loopback() && !ip.is_unspecified()
|
||||
}
|
||||
|
||||
/// Where the paired-client allow-list persists (survives host restarts, like Sunshine).
|
||||
@@ -740,6 +784,52 @@ mod host_name_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_ip_tests {
|
||||
use super::{first_lan_ipv4, primary_local_ip, usable_lan_ip};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[test]
|
||||
fn loopback_and_unspecified_are_never_advertisable() {
|
||||
// The bug: a host that started before its network did advertised these as its address and
|
||||
// kept doing so for the life of the process.
|
||||
for unusable in [
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
|
||||
] {
|
||||
assert!(
|
||||
!usable_lan_ip(unusable),
|
||||
"{unusable} must not be advertised"
|
||||
);
|
||||
}
|
||||
for usable in [
|
||||
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 173)),
|
||||
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)),
|
||||
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)),
|
||||
] {
|
||||
assert!(usable_lan_ip(usable), "{usable} is reachable and must pass");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_no_address_rather_than_loopback() {
|
||||
// Holds on a networked box and on an isolated CI runner alike: either we found a real LAN
|
||||
// address, or we admit we have none. `None` is what lets `Host::local_ip()` and the mDNS
|
||||
// advert keep retrying instead of freezing a wrong answer in place.
|
||||
assert!(primary_local_ip().is_none_or(usable_lan_ip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_fallback_never_offers_loopback() {
|
||||
// The branch a cold boot takes, before the default route exists. It may legitimately find
|
||||
// nothing (a machine with no NIC up, e.g. an isolated CI container) — what it must never
|
||||
// do is hand back the loopback that `get_if_addrs` also reports.
|
||||
assert!(first_lan_ipv4().is_none_or(usable_lan_ip));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_tests {
|
||||
use super::*;
|
||||
@@ -748,7 +838,6 @@ mod session_tests {
|
||||
let host = Host {
|
||||
hostname: "test-host".into(),
|
||||
uniqueid: "deadbeef".into(),
|
||||
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
os_chain: "linux".into(),
|
||||
|
||||
@@ -250,7 +250,7 @@ async fn h_launch(
|
||||
fps = session.fps,
|
||||
rikeyid = session.rikeyid,
|
||||
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
|
||||
st.host.local_ip
|
||||
st.host.local_ip()
|
||||
);
|
||||
xml(session_url_xml(&st, "gamesession")).into_response()
|
||||
}
|
||||
@@ -405,7 +405,7 @@ fn gamestream_admission(
|
||||
fn session_url_xml(st: &AppState, tag: &str) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
|
||||
st.host.local_ip
|
||||
st.host.local_ip()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -485,13 +485,11 @@ fn error_xml() -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
fn test_state() -> Arc<AppState> {
|
||||
let host = super::super::Host {
|
||||
hostname: "t".into(),
|
||||
uniqueid: "id".into(),
|
||||
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
os_chain: "linux".into(),
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String {
|
||||
uniqueid = host.uniqueid,
|
||||
https_port = host.https_port,
|
||||
http_port = host.http_port,
|
||||
local_ip = host.local_ip,
|
||||
local_ip = host.local_ip(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,7 +205,6 @@ mod tests {
|
||||
let host = Host {
|
||||
hostname: "test".into(),
|
||||
uniqueid: "uid".into(),
|
||||
local_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
|
||||
http_port: 47989,
|
||||
https_port: 47984,
|
||||
os_chain: "linux".into(),
|
||||
|
||||
@@ -105,6 +105,9 @@ mod plugins;
|
||||
// session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a
|
||||
// platform with neither (macOS, which has no launch path either) the module is an empty shell.
|
||||
mod procscan;
|
||||
// The live half of the same binding: what a provider PLUGIN reports about its titles' liveness,
|
||||
// where `procscan` can only look at the process table.
|
||||
mod runstate;
|
||||
mod send_pacing;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/service.rs"]
|
||||
|
||||
@@ -372,6 +372,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
library::reconcile_provider_entries,
|
||||
library::delete_provider_entries
|
||||
))
|
||||
.routes(routes!(library::report_provider_running))
|
||||
.routes(routes!(library::get_library_art))
|
||||
.routes(routes!(stats::stats_capture_start))
|
||||
.routes(routes!(stats::stats_capture_stop))
|
||||
|
||||
@@ -250,6 +250,10 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
|
||||
(&Method::DELETE, "/api/v1/library/custom/{}"),
|
||||
(&Method::PUT, "/api/v1/library/provider/{}"),
|
||||
(&Method::DELETE, "/api/v1/library/provider/{}"),
|
||||
// Liveness reporting for a provider's OWN titles. No new authority: the host maps the
|
||||
// report through the catalog, so a plugin can only ever speak about entries it published,
|
||||
// and the worst a defective one can do to someone else's session is nothing at all.
|
||||
(&Method::PUT, "/api/v1/library/provider/{}/running"),
|
||||
// Stats / telemetry.
|
||||
(&Method::POST, "/api/v1/stats/capture/start"),
|
||||
(&Method::POST, "/api/v1/stats/capture/stop"),
|
||||
|
||||
@@ -23,13 +23,16 @@ pub(crate) struct Health {
|
||||
abi_version: u32,
|
||||
}
|
||||
|
||||
/// Host identity and advertised capabilities (static for the life of the process).
|
||||
/// Host identity and advertised capabilities (static for the life of the process, except
|
||||
/// `local_ip`).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct HostInfo {
|
||||
hostname: String,
|
||||
/// Stable per-host id (persisted across restarts), matched on pairing.
|
||||
uniqueid: String,
|
||||
/// Best-effort primary LAN IP.
|
||||
/// Best-effort primary LAN IP, read fresh on every request — a host that started before its
|
||||
/// network did (cold boot) reports `127.0.0.1` only until it actually has an address, and a
|
||||
/// host that moves networks reports the new one. Poll it rather than caching it.
|
||||
local_ip: String,
|
||||
/// `punktfunk-host` crate version.
|
||||
version: String,
|
||||
@@ -324,7 +327,7 @@ pub(crate) async fn get_host_info(State(st): State<Arc<MgmtState>>) -> Json<Host
|
||||
Json(HostInfo {
|
||||
hostname: h.hostname.clone(),
|
||||
uniqueid: h.uniqueid.clone(),
|
||||
local_ip: h.local_ip.to_string(),
|
||||
local_ip: h.local_ip().to_string(),
|
||||
version: env!("PUNKTFUNK_VERSION").into(),
|
||||
abi_version: punktfunk_core::ABI_VERSION,
|
||||
app_version: APP_VERSION.into(),
|
||||
|
||||
@@ -607,12 +607,130 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
|
||||
if removed > 0 {
|
||||
tracing::info!(provider, removed, "library provider entries removed");
|
||||
}
|
||||
// Its entries are gone, so its opinions about them are meaningless — and a lease must
|
||||
// never be held open by a provider that no longer exists.
|
||||
crate::runstate::forget(&provider);
|
||||
Json(ProviderRemoved { removed }).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One running title in a provider's liveness report.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct RunningTitle {
|
||||
/// The provider's own stable id for the title — the same key its reconcile payload uses.
|
||||
pub external_id: String,
|
||||
/// The process id the provider started for it, when it knows one. Optional, and never trusted
|
||||
/// as a bare number: the host re-resolves it and pins it to its start time before it is ever
|
||||
/// signalled, so a stale or recycled pid simply contributes nothing.
|
||||
#[serde(default)]
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// Request body for `reportProviderRunning`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct ProviderRunningInput {
|
||||
/// Every title of this provider's that is running **right now**. The full set, not a delta:
|
||||
/// anything absent from it is reported as stopped.
|
||||
#[serde(default)]
|
||||
pub running: Vec<RunningTitle>,
|
||||
}
|
||||
|
||||
/// The result of a liveness report.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct ProviderRunningAccepted {
|
||||
/// How many reported titles matched an entry this provider currently publishes.
|
||||
matched: usize,
|
||||
/// How many were ignored because no such entry exists (a report that raced a reconcile).
|
||||
unknown: usize,
|
||||
/// Seconds this report stays authoritative without being restated — re-report inside it while
|
||||
/// anything is running.
|
||||
ttl_s: u64,
|
||||
}
|
||||
|
||||
/// Report which of a provider's titles are running
|
||||
///
|
||||
/// The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to
|
||||
/// recognize* a title's process, this one says *it is running now* (design §9,
|
||||
/// [`crate::runstate`]). For a provider that starts games itself and knows when they stop —
|
||||
/// Playnite tracks every launch and fires an event on both edges — this is a fact the host would
|
||||
/// otherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated
|
||||
/// game, a manually added one) could not derive at all.
|
||||
///
|
||||
/// Declarative and idempotent, like the reconcile: the body is the provider's **complete** running
|
||||
/// set, so a missed event, a plugin restart or an install mid-game all self-correct on the next
|
||||
/// report rather than drifting.
|
||||
///
|
||||
/// The report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a
|
||||
/// live provider to keep a streaming session open for a game the host cannot see: a plugin that
|
||||
/// dies with a game running stops counting shortly after, and the host falls back to process
|
||||
/// scanning exactly as it does without one. Re-report on every change **and** on a timer well
|
||||
/// inside the window.
|
||||
///
|
||||
/// Titles the provider does not currently publish are ignored (counted in `unknown`), not an error:
|
||||
/// a report may legitimately race its own reconcile.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/library/provider/{provider}/running",
|
||||
tag = "library",
|
||||
operation_id = "reportProviderRunning",
|
||||
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
|
||||
request_body = ProviderRunningInput,
|
||||
responses(
|
||||
(status = OK, description = "The report was accepted", body = ProviderRunningAccepted),
|
||||
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn report_provider_running(
|
||||
Path(provider): Path<String>,
|
||||
ApiJson(input): ApiJson<ProviderRunningInput>,
|
||||
) -> Response {
|
||||
if let Err(e) = crate::library::validate_provider_name(&provider) {
|
||||
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||
}
|
||||
// Resolve the provider's own keys to the ids the rest of the host uses. A plugin knows its
|
||||
// titles by `external_id`; a lease knows them by the library id the catalog assigned
|
||||
// (`playnite:<guid>`), and only the catalog can map between the two — which is also what makes
|
||||
// this authorization-safe, since a provider can only ever speak about entries it published.
|
||||
let mine: Vec<(String, String)> = crate::library::load_custom()
|
||||
.into_iter()
|
||||
.filter(|e| e.provider.as_deref() == Some(provider.as_str()))
|
||||
.filter_map(|e| {
|
||||
let external = e.external_id.clone()?;
|
||||
Some((external, crate::library::library_id_for(&e)))
|
||||
})
|
||||
.collect();
|
||||
let owned: std::collections::HashSet<String> = mine.iter().map(|(_, id)| id.clone()).collect();
|
||||
|
||||
let mut running = std::collections::HashMap::new();
|
||||
let mut unknown = 0usize;
|
||||
for t in &input.running {
|
||||
match mine.iter().find(|(external, _)| *external == t.external_id) {
|
||||
Some((_, id)) => {
|
||||
running.insert(id.clone(), t.pid);
|
||||
}
|
||||
None => unknown += 1,
|
||||
}
|
||||
}
|
||||
let matched = running.len();
|
||||
tracing::debug!(
|
||||
provider,
|
||||
owned = owned.len(),
|
||||
matched,
|
||||
unknown,
|
||||
"provider liveness report"
|
||||
);
|
||||
crate::runstate::report(&provider, owned, running);
|
||||
Json(ProviderRunningAccepted {
|
||||
matched,
|
||||
unknown,
|
||||
ttl_s: crate::runstate::REPORT_TTL.as_secs(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Fetch one cover-art image for a library entry
|
||||
///
|
||||
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
|
||||
|
||||
@@ -47,7 +47,6 @@ use axum::body::Body;
|
||||
use axum::http::StatusCode;
|
||||
use http_body_util::BodyExt;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::atomic::Ordering;
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -73,7 +72,6 @@ fn test_state() -> Arc<AppState> {
|
||||
let host = Host {
|
||||
hostname: "test-host".into(),
|
||||
uniqueid: "deadbeef".into(),
|
||||
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
os_chain: "linux/arch/steamos".into(),
|
||||
@@ -1440,6 +1438,16 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
|
||||
("DELETE", "/api/v1/library/custom/{id}", true, false),
|
||||
("PUT", "/api/v1/library/provider/{provider}", true, false),
|
||||
("DELETE", "/api/v1/library/provider/{provider}", true, false),
|
||||
// Liveness for a provider's own titles: the plugin lane's, like the reconcile beside it,
|
||||
// and for the same reason — the host maps the report through the catalog, so a provider can
|
||||
// only ever speak about entries it published. Never the cert lane: a streaming client has
|
||||
// no titles of its own to report on.
|
||||
(
|
||||
"PUT",
|
||||
"/api/v1/library/provider/{provider}/running",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
// ---- stats.
|
||||
("POST", "/api/v1/stats/capture/start", true, false),
|
||||
("POST", "/api/v1/stats/capture/stop", true, false),
|
||||
@@ -2935,3 +2943,54 @@ async fn provider_reconcile_validation() {
|
||||
let (s, _) = send(&app, del).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// Liveness reporting: the provider id is validated like every other provider write, and a title
|
||||
/// the provider does not publish is *counted*, not refused.
|
||||
///
|
||||
/// That tolerance is the point. A report races its own reconcile by construction — a game can start
|
||||
/// before the entry that describes it has landed — and 400-ing the whole report over one unknown id
|
||||
/// would throw away the liveness of every other running title, which is precisely the failure the
|
||||
/// launcher-tile 400 taught us to avoid (`sanitize_launcher_entries`). The developer's real catalog
|
||||
/// is not touched here, so every id in this test is `unknown` by construction — which is exactly
|
||||
/// the case being pinned.
|
||||
#[tokio::test]
|
||||
async fn provider_running_report_validation() {
|
||||
let app = test_app(test_state(), None);
|
||||
let put = |provider: &str, body: serde_json::Value| {
|
||||
axum::http::Request::put(format!("/api/v1/library/provider/{provider}/running"))
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let (s, json) = send(&app, put("manual", serde_json::json!({"running": []}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
assert!(json["error"].as_str().unwrap().contains("reserved"));
|
||||
let (s, _) = send(&app, put("Bad%2FName", serde_json::json!({"running": []}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
|
||||
// An unreported provider is a legitimate report of "nothing is running".
|
||||
let (s, json) = send(&app, put("playnite", serde_json::json!({"running": []}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(json["matched"], 0);
|
||||
assert_eq!(json["unknown"], 0);
|
||||
assert!(json["ttl_s"].as_u64().unwrap() > 0);
|
||||
|
||||
// An id this provider does not publish is ignored, not an error.
|
||||
let (s, json) = send(
|
||||
&app,
|
||||
put(
|
||||
"playnite",
|
||||
serde_json::json!({"running": [{"external_id": "no-such-title", "pid": 4242}]}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(json["matched"], 0);
|
||||
assert_eq!(json["unknown"], 1);
|
||||
|
||||
// A report leaves no opinion behind about a title nobody published, so nothing this test did
|
||||
// can hold a real lease open.
|
||||
assert!(!crate::runstate::speaks_for(Some("playnite:no-such-title")));
|
||||
crate::runstate::forget("playnite");
|
||||
}
|
||||
|
||||
@@ -48,8 +48,10 @@ mod compositor;
|
||||
use compositor::resolve_compositor;
|
||||
|
||||
/// Virtual-gamepad backend resolution (plan §W1); `serve_session` + the `Pads` state machine reach
|
||||
/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here.
|
||||
mod gamepad;
|
||||
/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here. Crate-visible because the choice of
|
||||
/// Windows Xbox backend (`windows_xbox_hid`) is not the native plane's alone — the GameStream plane
|
||||
/// presents the same virtual pad and has to make the same choice, from one definition.
|
||||
pub(crate) mod gamepad;
|
||||
use gamepad::{resolve_gamepad, resolve_pad_kind, route_decision};
|
||||
|
||||
/// The SPAKE2 pairing ceremony (plan §W1); `serve_session` dispatches a PairRequest connection here.
|
||||
@@ -154,9 +156,33 @@ pub struct Punktfunk1Options {
|
||||
/// the client's reported address, no hole-punch"; `false` (random port, or a busy fixed port) means
|
||||
/// "hole-punch". The socket is held from the handshake through streaming — no drop-then-rebind
|
||||
/// window in which a concurrent session could steal a fixed port.
|
||||
fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSocket, bool)> {
|
||||
///
|
||||
/// `local_ip` is the address the client's QUIC connection was RECEIVED on (`Connection::local_ip`),
|
||||
/// and binding to it is load-bearing on a multi-homed host. The client's data socket is
|
||||
/// `connect`ed to the host IP it dialed, so its kernel accepts video only from THAT source
|
||||
/// address; a wildcard bind here lets the routing table pick the egress interface independently of
|
||||
/// the one the control plane arrived on, and the two differ whenever a host has two paths to the
|
||||
/// client — Ethernet and Wi-Fi both up on the same LAN is the everyday case. Every video datagram
|
||||
/// is then dropped by the client's kernel before userspace: nothing counts it, `loss_ppm` stays 0
|
||||
/// (no packets, no gaps), the hole-punch still arrives so the host logs `punched=true`, and the
|
||||
/// control plane — which quinn pins to the right local address — stays perfectly healthy. That is
|
||||
/// the "connects fine, black screen forever" shape with every gauge green, and it is invisible on
|
||||
/// both ends. `None` (platform can't report it) or a bind failure falls back to the wildcard.
|
||||
fn bind_data_socket(
|
||||
data_port: Option<u16>,
|
||||
local_ip: Option<std::net::IpAddr>,
|
||||
) -> std::io::Result<(std::net::UdpSocket, bool)> {
|
||||
// An IPv4-mapped v6 local address (dual-stack endpoint) must be unmapped before it can bind a
|
||||
// socket that will `connect` to a v4 peer — the families have to match.
|
||||
let local_ip = local_ip.map(|ip| match ip {
|
||||
std::net::IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, std::net::IpAddr::V4),
|
||||
v4 => v4,
|
||||
});
|
||||
let wildcard = |ip: Option<std::net::IpAddr>| {
|
||||
ip.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED))
|
||||
};
|
||||
if let Some(p) = data_port.filter(|p| *p != 0) {
|
||||
match std::net::UdpSocket::bind(("0.0.0.0", p)) {
|
||||
match std::net::UdpSocket::bind((wildcard(local_ip), p)) {
|
||||
Ok(sock) => return Ok((sock, true)),
|
||||
Err(e) => tracing::warn!(
|
||||
data_port = p,
|
||||
@@ -166,7 +192,23 @@ fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSoc
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
|
||||
match std::net::UdpSocket::bind((wildcard(local_ip), 0)) {
|
||||
Ok(sock) => Ok((sock, false)),
|
||||
// The control plane arrived on this address moments ago, so a failure here means it just
|
||||
// went away (an adapter dropped mid-handshake). The wildcard still reaches a client the
|
||||
// routing table can route to — degraded, not dead — so take it and say why.
|
||||
Err(e) if local_ip.is_some() => {
|
||||
tracing::warn!(
|
||||
local_ip = ?local_ip,
|
||||
error = %e,
|
||||
"could not bind the data plane to the address the control connection arrived on \
|
||||
— falling back to the wildcard. On a multi-homed host video may now egress from \
|
||||
a different interface than the client dialed, which it silently drops."
|
||||
);
|
||||
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
|
||||
@@ -363,7 +405,6 @@ pub(crate) async fn serve(
|
||||
match crate::gamestream::Host::detect() {
|
||||
Ok(h) => crate::discovery::advertise_native(
|
||||
&h.hostname,
|
||||
h.local_ip,
|
||||
opts.port,
|
||||
&fingerprint_hex(&fingerprint),
|
||||
opts.require_pairing,
|
||||
@@ -1402,6 +1443,12 @@ async fn serve_session(
|
||||
// evidence (a refusal without the score left a 23-minute floor-pinned field session with no
|
||||
// trace of why).
|
||||
let cadence_behind_score = Arc::new(AtomicU32::new(0));
|
||||
// Delivery truth, control task → data plane: the packet count the client reports having
|
||||
// received all session (`u32::MAX` until a client new enough to answer sends one). The data
|
||||
// plane needs it to tell a clean link from a dead one — `loss_ppm = 0` means both — before it
|
||||
// blames the client for a stream that never reached it.
|
||||
let client_packets_received = Arc::new(AtomicU32::new(u32::MAX));
|
||||
let client_packets_received_ctl = client_packets_received.clone();
|
||||
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
||||
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
|
||||
@@ -1533,6 +1580,7 @@ async fn serve_session(
|
||||
encoder_ceiling_kbps.clone(),
|
||||
cadence_degraded.clone(),
|
||||
cadence_behind_score.clone(),
|
||||
client_packets_received_ctl,
|
||||
fec_target_ctl,
|
||||
phase_ctl_control,
|
||||
reconfig_tx,
|
||||
@@ -2048,6 +2096,10 @@ async fn serve_session(
|
||||
// stages ride the same per-session trace; resizes write their totals into the shared slot.
|
||||
let bringup_dp = bringup.clone();
|
||||
let resize_ms_dp = resize_ms.clone();
|
||||
// The address the control connection arrived on, for the data plane's source-address check
|
||||
// below — the one comparison that distinguishes "the client is filtering our video" from
|
||||
// "the video never left". Captured here because the send loop runs on a blocking thread.
|
||||
let control_local_ip = conn.local_ip();
|
||||
let result: Result<()> = async {
|
||||
let stream_thread = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
|
||||
@@ -2082,15 +2134,64 @@ async fn serve_session(
|
||||
}
|
||||
};
|
||||
bringup_dp.mark("punch_done");
|
||||
// Post-`connect`, `local_addr` reports the source address the kernel will actually
|
||||
// stamp on every video datagram — the number that has to match the host IP the client
|
||||
// dialed, because its data socket is connected and its kernel drops anything else
|
||||
// before userspace. Logged unconditionally: a black-screen report is unanswerable
|
||||
// without it (this session's showed only the port).
|
||||
let local = transport.local_addr().ok();
|
||||
tracing::info!(
|
||||
%client_udp,
|
||||
udp_port,
|
||||
direct,
|
||||
punched,
|
||||
local = ?local,
|
||||
"data plane bound (direct=true → fixed --data-port, streaming to the reported \
|
||||
address with no hole-punch; else punched=true → the client's observed source, \
|
||||
false → no punch seen, the reported address)"
|
||||
);
|
||||
// A video source address that isn't the one the control plane arrived on means the
|
||||
// client will discard every datagram we send, however healthy this end looks.
|
||||
if let (Some(l), Some(c)) = (local.map(|a| a.ip()), control_local_ip) {
|
||||
let c = match c {
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
v6.to_ipv4_mapped().map_or(c, std::net::IpAddr::V4)
|
||||
}
|
||||
v4 => v4,
|
||||
};
|
||||
if !l.is_unspecified() && l != c {
|
||||
tracing::warn!(
|
||||
video_source_ip = %l,
|
||||
control_local_ip = %c,
|
||||
"the video data plane egresses from a DIFFERENT host address than the one \
|
||||
this client connected to — its data socket is connected to the address it \
|
||||
dialed, so its kernel drops every video datagram before userspace: black \
|
||||
screen, zero reported loss, healthy control plane. Usual cause is two \
|
||||
live paths to the client (Ethernet and Wi-Fi both up on the same LAN, or \
|
||||
a VPN/overlay adapter claiming the route)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// A punch that never arrives is not a routine fallback — it is the fingerprint of a
|
||||
// data port the client cannot reach INBOUND, and every client punches (5/s for the
|
||||
// first three seconds, then every two). Video then goes to an address the client only
|
||||
// CLAIMED, unverified, and if anything on the path needed the flow opened client-first
|
||||
// it silently goes nowhere: black picture, healthy control plane, no error anywhere.
|
||||
// On Windows the usual cause is a firewall rule that opens fixed ports only, while
|
||||
// this port is ephemeral and different every session (fixed by the program-scoped rule
|
||||
// `service install` now adds — an install predating it still has the old rules).
|
||||
// `direct` skips the punch by operator choice, so it is not a failure there.
|
||||
if !direct && !punched {
|
||||
tracing::warn!(
|
||||
%client_udp,
|
||||
udp_port,
|
||||
"no hole-punch reached this host's data port — inbound UDP to it looks \
|
||||
BLOCKED, so video is being sent to the address the client reported without \
|
||||
any confirmed return path. If the picture stays black while the session is \
|
||||
otherwise healthy, this line is the reason: allow inbound UDP for the host \
|
||||
executable (any port), or pin --data-port and open that one"
|
||||
);
|
||||
}
|
||||
let mut session = Session::new(cfg, Box::new(transport))
|
||||
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
||||
match source {
|
||||
@@ -2125,6 +2226,7 @@ async fn serve_session(
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
cadence_behind_score,
|
||||
client_packets_received,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
chroma,
|
||||
@@ -2485,7 +2587,7 @@ mod tests {
|
||||
// No fixed port (and the explicit-0 alias) → a random ephemeral port, and NOT direct: the
|
||||
// caller hole-punches.
|
||||
for req in [None, Some(0)] {
|
||||
let (sock, direct) = bind_data_socket(req).expect("bind random data socket");
|
||||
let (sock, direct) = bind_data_socket(req, None).expect("bind random data socket");
|
||||
assert!(!direct, "req={req:?} must hole-punch, not stream direct");
|
||||
assert_ne!(sock.local_addr().unwrap().port(), 0);
|
||||
}
|
||||
@@ -2502,13 +2604,14 @@ mod tests {
|
||||
.port();
|
||||
|
||||
// A free fixed port binds exactly it, in DIRECT mode (no hole-punch).
|
||||
let (held, direct) = bind_data_socket(Some(free)).expect("bind fixed data socket");
|
||||
let (held, direct) = bind_data_socket(Some(free), None).expect("bind fixed data socket");
|
||||
assert!(direct, "a fixed --data-port must stream direct");
|
||||
assert_eq!(held.local_addr().unwrap().port(), free);
|
||||
|
||||
// While it's held, a second session on the same fixed port can't bind it → it must fall
|
||||
// back to a random port + hole-punch rather than fail (so concurrency never regresses).
|
||||
let (fallback, direct2) = bind_data_socket(Some(free)).expect("busy fixed port falls back");
|
||||
let (fallback, direct2) =
|
||||
bind_data_socket(Some(free), None).expect("busy fixed port falls back");
|
||||
assert!(!direct2, "a busy fixed port must fall back to hole-punch");
|
||||
assert_ne!(
|
||||
fallback.local_addr().unwrap().port(),
|
||||
@@ -2517,6 +2620,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The multi-homed black screen: video must egress from the address the client's control
|
||||
/// connection arrived on, because the client's data socket is connected to the host address it
|
||||
/// dialed and its kernel drops every datagram from any other source — silently, before
|
||||
/// userspace, so nothing on either end counts it. A wildcard bind here lets the routing table
|
||||
/// choose a different interface whenever the host has two paths to the client.
|
||||
#[test]
|
||||
fn data_socket_binds_the_address_the_control_plane_arrived_on() {
|
||||
let loopback = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
|
||||
let (sock, direct) =
|
||||
bind_data_socket(None, Some(loopback)).expect("bind pinned data socket");
|
||||
assert!(!direct);
|
||||
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
|
||||
|
||||
// An IPv4-mapped v6 local address (a dual-stack QUIC endpoint reports one) has to be
|
||||
// unmapped, or the socket binds v6 and can never `connect` to the v4 client.
|
||||
let mapped = std::net::IpAddr::V6(std::net::Ipv4Addr::LOCALHOST.to_ipv6_mapped());
|
||||
let (sock, _) = bind_data_socket(None, Some(mapped)).expect("bind mapped data socket");
|
||||
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
|
||||
|
||||
// No reported local address (platform can't say) keeps the old wildcard behaviour.
|
||||
let (sock, _) = bind_data_socket(None, None).expect("bind wildcard data socket");
|
||||
assert!(sock.local_addr().unwrap().ip().is_unspecified());
|
||||
}
|
||||
|
||||
/// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value in
|
||||
/// `punktfunk_core::input::gamepad` — the single source both the punktfunk/1 native wire and the
|
||||
/// GameStream/Limelight wire read from (they are one and the same). Renumbering a bit in core
|
||||
|
||||
@@ -30,6 +30,10 @@ pub(super) async fn run(
|
||||
encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||
cadence_degraded: Arc<AtomicBool>,
|
||||
cadence_behind_score: Arc<AtomicU32>,
|
||||
// Delivery truth, published from every `DeliveryReport` for the data plane's stall diagnosis:
|
||||
// the packets the client says it has received all session (`u32::MAX` = a client too old to
|
||||
// send one, the pre-seeded value).
|
||||
client_packets_received: Arc<AtomicU32>,
|
||||
fec_target_ctl: Arc<AtomicU8>,
|
||||
// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode loop's
|
||||
// controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
|
||||
@@ -162,6 +166,16 @@ pub(super) async fn run(
|
||||
if rfi_tx.send((req.first_frame, req.last_frame)).is_err() {
|
||||
break; // data plane gone
|
||||
}
|
||||
} else if let Ok(rep) = punktfunk_core::quic::DeliveryReport::decode(&msg) {
|
||||
// What the client has actually RECEIVED — published unconditionally, because it
|
||||
// is what lets the data plane read `loss_ppm = 0` correctly and must survive
|
||||
// both the `adaptive_fec` opt-out and a pinned FEC percentage (a host with
|
||||
// PUNKTFUNK_FEC_PCT set is exactly as blind to a dead data plane otherwise).
|
||||
// Saturated into the u32 bridge; the value only ever matters near zero.
|
||||
client_packets_received.store(
|
||||
rep.packets_received.min(u32::MAX as u64 - 1) as u32,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
} else if let Ok(rep) = LossReport::decode(&msg) {
|
||||
// Adaptive FEC: size recovery to the loss the client is seeing. The data-plane
|
||||
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
|
||||
|
||||
@@ -363,8 +363,13 @@ fn degrade_xbox_identity(chosen: GamepadPref) -> GamepadPref {
|
||||
///
|
||||
/// The two backends are mutually exclusive per pad by construction (one match arm or the other) —
|
||||
/// presenting both would hand a game two controllers for one pair of hands.
|
||||
///
|
||||
/// Read by BOTH input planes. The native plane branches on it in `Pads::handle`; the GameStream
|
||||
/// plane in `gamestream::control::SessionPads`. It was `pub(super)` while only the native plane
|
||||
/// consulted it, and that is exactly how Moonlight sessions spent two releases on the XUSB pad
|
||||
/// after this default flipped — the knob was unreachable from the module that needed it.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn windows_xbox_hid() -> bool {
|
||||
pub(crate) fn windows_xbox_hid() -> bool {
|
||||
match std::env::var("PUNKTFUNK_XBOX_BACKEND") {
|
||||
Ok(v) if v.trim().eq_ignore_ascii_case("xusb") => false,
|
||||
// Anything else — unset, empty, "hid", or a typo — takes the default. A misspelled opt-out
|
||||
|
||||
@@ -780,7 +780,9 @@ pub(super) async fn negotiate(
|
||||
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
|
||||
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
|
||||
// no punch-wait); otherwise a random ephemeral port + hole-punch.
|
||||
let (data_sock, direct) = bind_data_socket(data_port)?;
|
||||
// Bound to the address THIS connection arrived on, not the wildcard: the client only accepts
|
||||
// video from the host IP it dialed (see `bind_data_socket`).
|
||||
let (data_sock, direct) = bind_data_socket(data_port, conn.local_ip())?;
|
||||
let udp_port = data_sock.local_addr()?.port();
|
||||
|
||||
// The session's video geometry (see the `shard_payload` field below). Resolved before the
|
||||
|
||||
@@ -1319,6 +1319,14 @@ pub(super) struct SessionContext {
|
||||
/// of what held it there — the score is the missing discriminator between "the detector's
|
||||
/// budget is wrong" and "this encoder genuinely can't hold cadence").
|
||||
pub(super) cadence_behind_score: Arc<AtomicU32>,
|
||||
/// Data-plane packets the CLIENT says it has received all session, from the latest
|
||||
/// [`punktfunk_core::quic::DeliveryReport`] ([`u32::MAX`] = a client too old to send one).
|
||||
///
|
||||
/// The one signal that distinguishes "the link is clean" from "nothing is arriving": both look
|
||||
/// like `loss_ppm = 0`, because loss is a ratio over the packets that DID arrive. Read by the
|
||||
/// keyframe-cadence diagnosis below, which without it accuses the client of being too slow for
|
||||
/// a stream it has never received a byte of.
|
||||
pub(super) client_packets_received: Arc<AtomicU32>,
|
||||
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
|
||||
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
|
||||
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
|
||||
@@ -1598,6 +1606,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
cadence_behind_score,
|
||||
client_packets_received,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
|
||||
@@ -3006,16 +3015,73 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// subsystems while the real chain was: client refused the codec → demoted to
|
||||
// a slower decode rung → could not sustain the rate → standing queue.
|
||||
// Perfect periodicity argues FOR a software cooldown, not against it.
|
||||
if matches_client_flush_cadence(period) {
|
||||
tracing::warn!(
|
||||
let client_rx = client_packets_received.load(Ordering::Relaxed);
|
||||
// The client has TOLD us it has received nothing all session (a v1 client
|
||||
// leaves the `u32::MAX` seed, so this only fires on an explicit zero). That
|
||||
// outranks both cadence verdicts below, which are about a client drowning in
|
||||
// frames — the opposite failure, and indistinguishable by period alone because
|
||||
// a client that got no picture re-asks on its own no-video timer at very
|
||||
// nearly the same spacing. Diagnosing this as "too slow" cost a 2026-08-20
|
||||
// field investigation days: the host was blameless-looking (`sent` climbing,
|
||||
// `loss_ppm = 0`, FEC decayed to the floor) while not one byte of video ever
|
||||
// reached the client.
|
||||
if client_rx == 0 {
|
||||
tracing::error!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
"client keyframe recoveries match the client's jump-to-live cooldown \
|
||||
— the CLIENT cannot sustain the stream and is shedding a standing \
|
||||
receive queue (check its log for 'receive backlog stopped draining' \
|
||||
with queue_depth, and for a decode rung that demoted); a slower \
|
||||
decode path or a link below the bitrate does this, and it is NOT a \
|
||||
host display disturbance"
|
||||
frames_sent = sent,
|
||||
"THE VIDEO DATA PLANE IS NOT REACHING THE CLIENT — it reports 0 \
|
||||
packets received all session while this host has sent the frames \
|
||||
counted here, so the picture is black and every keyframe we force is \
|
||||
wasted. The control plane is healthy (this report arrived on it), so \
|
||||
the session looks alive: audio, input and the library keep working. \
|
||||
READ THE 'data plane bound' LINE ABOVE — it says which leg failed, \
|
||||
and this line cannot. `punched=false`: the client's hole-punch never \
|
||||
arrived, so inbound UDP to this host's per-session data port is \
|
||||
blocked — open it (the ports are ephemeral, so the rule must be \
|
||||
program-scoped, not port-scoped). `punched=true`: inbound is FINE and \
|
||||
the failure is on the return leg — compare that line's `local=` \
|
||||
source address against the host address this client dialed, because \
|
||||
its data socket is connected and its kernel silently drops video from \
|
||||
any other source. If those match, the datagrams left this host \
|
||||
correctly and the client either never received them (a hop on the \
|
||||
path) or received them and could not open them: this counter is \
|
||||
incremented AFTER decrypt and replay checks, so a session whose every \
|
||||
datagram failed to open reports exactly this same zero"
|
||||
);
|
||||
} else if matches_client_recovery_cooldown(period) {
|
||||
if client_rx == u32::MAX {
|
||||
// This client predates the delivery count, so the period alone has to
|
||||
// carry the verdict — and it CANNOT: both client cooldowns live in this
|
||||
// band and they mean opposite things. Say so instead of picking one.
|
||||
// The old confident wording sent a field investigation after the
|
||||
// decoder for days while the real fault was that nothing arrived.
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
frames_sent = sent,
|
||||
"client keyframe recoveries land on a client software cooldown, \
|
||||
but this client is too old to report whether any video reached \
|
||||
it — so this is EITHER a client that cannot sustain the stream \
|
||||
and is shedding a standing receive queue, OR a client that has \
|
||||
received nothing at all and is re-asking on its no-video timer. \
|
||||
They are opposite faults; the host cannot tell them apart from \
|
||||
the period. Its log does: 'receive backlog stopped draining' \
|
||||
(with queue_depth) means the first, 'no video received … into \
|
||||
the session' means the second. Upgrading the client makes this \
|
||||
line decide on its own"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
client_packets_received = client_rx,
|
||||
"client keyframe recoveries match the client's jump-to-live \
|
||||
cooldown, and it confirms video IS arriving — the CLIENT cannot \
|
||||
sustain the stream and is shedding a standing receive queue \
|
||||
(check its log for 'receive backlog stopped draining' with \
|
||||
queue_depth, and for a decode rung that demoted); a slower \
|
||||
decode path or a link below the bitrate does this, and it is NOT \
|
||||
a host display disturbance"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
@@ -4191,6 +4257,26 @@ fn matches_client_flush_cadence(period: std::time::Duration) -> bool {
|
||||
period.abs_diff(flush) < flush / 10
|
||||
}
|
||||
|
||||
/// The client's OTHER re-ask cooldown: it has received no video whatsoever and is asking for a
|
||||
/// keyframe on its no-video timer. Kept separate from [`matches_client_flush_cadence`] because the
|
||||
/// two describe opposite faults — drowning in frames versus receiving none — and only the client's
|
||||
/// reported delivery count can say which. Both are host-side-irrelevant either way: a fixed
|
||||
/// software cooldown is never the periodic *disturbance* the metronomic branch reports.
|
||||
///
|
||||
/// Compared against the SHARED constant, never a copy of the number — the same discipline
|
||||
/// [`matches_client_flush_cadence`] follows, and the one that was missing when the two cooldowns
|
||||
/// were both 2000 ms and the host could not even tell that it was guessing.
|
||||
fn matches_client_no_video_cadence(period: std::time::Duration) -> bool {
|
||||
let no_video = punktfunk_core::client::NO_VIDEO_RETRY;
|
||||
period.abs_diff(no_video) < no_video / 10
|
||||
}
|
||||
|
||||
/// Either client cooldown — the band in which a period tells us about the CLIENT's software, not
|
||||
/// about anything physical on this host.
|
||||
fn matches_client_recovery_cooldown(period: std::time::Duration) -> bool {
|
||||
matches_client_flush_cadence(period) || matches_client_no_video_cadence(period)
|
||||
}
|
||||
|
||||
/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval).
|
||||
/// Dropping the capturer tears down the PipeWire stream and the virtual output with it.
|
||||
type Pipeline = (
|
||||
@@ -5068,6 +5154,29 @@ mod tests {
|
||||
assert!(!matches_client_flush_cadence(std::time::Duration::ZERO));
|
||||
}
|
||||
|
||||
/// The two client cooldowns must stay TELLABLE APART by period, and both must stay out of the
|
||||
/// display-disturbance branch. While they were both 2000 ms a black-screen field case (nothing
|
||||
/// ever reached the client) was reported as "the client cannot sustain the stream" — the exact
|
||||
/// opposite fault — because the periods were identical and the host guessed.
|
||||
#[test]
|
||||
fn the_two_client_cooldowns_are_distinguishable_and_both_excluded_from_display_blame() {
|
||||
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
|
||||
let no_video = punktfunk_core::client::NO_VIDEO_RETRY;
|
||||
assert_ne!(
|
||||
flush, no_video,
|
||||
"identical cooldowns make the host's verdict a coin flip"
|
||||
);
|
||||
// Neither may fall inside the other's ±10% band, or the period stops discriminating.
|
||||
assert!(!matches_client_flush_cadence(no_video));
|
||||
assert!(!matches_client_no_video_cadence(flush));
|
||||
// Both are client software cooldowns: never the metronomic display-disturbance branch.
|
||||
assert!(matches_client_recovery_cooldown(flush));
|
||||
assert!(matches_client_recovery_cooldown(no_video));
|
||||
// A real periodic disturbance still reaches that branch.
|
||||
assert!(!matches_client_recovery_cooldown(flush * 3));
|
||||
assert!(!matches_client_recovery_cooldown(std::time::Duration::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
|
||||
const DEGRADE: u32 = 10;
|
||||
@@ -5345,6 +5454,17 @@ mod tests {
|
||||
"spawn gamescope (is it installed? `apt install gamescope`)"
|
||||
));
|
||||
assert!(is_permanent_build_error("virtual displays require Linux"));
|
||||
// The ONE KWin refusal that must stay retryable: pf-vdisplay repaired the box (it enabled
|
||||
// the output KWin created disabled, which KWin persists), so the next attempt is not the
|
||||
// same attempt. That path deliberately reports WITHOUT the `KWin virtual output failed`
|
||||
// prefix above — if it ever regains it, the retry that consumes the repair stops running
|
||||
// and the repair is dead code.
|
||||
assert!(!is_permanent_build_error(
|
||||
"create virtual output: KWin created the virtual output disabled and refused to \
|
||||
stream it (stream_virtual_output failed: Não foi possível encontrar saída); enabled \
|
||||
it over output management (head Virtual-punktfunk-a1b2) — the retry picks up the \
|
||||
configuration KWin just persisted"
|
||||
));
|
||||
// Transient: negotiation/timeout races — exactly what backoff is for.
|
||||
assert!(!is_permanent_build_error(
|
||||
"first frame: no PipeWire frame within 10s (node 42): format negotiation never completed"
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! What a provider plugin **says** is running — the one liveness signal the host cannot work out
|
||||
//! for itself.
|
||||
//!
|
||||
//! [`crate::procscan`] answers "is this game running" by looking at the process table, and
|
||||
//! [`crate::gamelease`] turns that into a session lifetime. That works because most stores leave
|
||||
//! something recognizable behind: an install directory, an executable, a Steam reaper. Some do not,
|
||||
//! and one store in particular *already knows the answer*: Playnite starts the game itself, tracks
|
||||
//! it with the mode the person configured (process, directory, original-process), and fires an
|
||||
//! event on both edges — carrying the pid it started. Every bit of that was being thrown away, and
|
||||
//! the host was left re-deriving a worse version of it by scanning.
|
||||
//!
|
||||
//! So this is the inbound half of [`crate::library::DetectHint`]. That one is *static* ("here is
|
||||
//! how to recognize my title's process"); this one is *live* ("that title is running right now, and
|
||||
//! here is its pid"). A provider PUTs its full running set; the host keeps it here; the lease
|
||||
//! watcher consults it.
|
||||
//!
|
||||
//! ### Why the whole set, and why a TTL
|
||||
//!
|
||||
//! The wire is declarative — the same shape as the library reconcile, for the same reason. A
|
||||
//! provider that missed an event, restarted, or was installed mid-game converges on its next PUT
|
||||
//! instead of drifting forever; there is no per-event delta to lose.
|
||||
//!
|
||||
//! And a report **expires**. A plugin that dies with a game running would otherwise leave a claim
|
||||
//! that is true today and a lie tomorrow — and unlike Steam's registry flag (which
|
||||
//! [`crate::procscan::running_hint`] must treat as merely a bounded veto because Steam leaves it
|
||||
//! set on any unclean exit) this claim is allowed to *keep a session alive on its own*. That is
|
||||
//! only safe while something is actively restating it, so a report older than [`REPORT_TTL`] stops
|
||||
//! counting and the host falls back to scanning, exactly as it does today. The provider's side of
|
||||
//! that bargain is to re-PUT well inside the window while anything is running.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a provider's report stays authoritative without being restated.
|
||||
///
|
||||
/// Generous enough that a plugin refreshing every 30s survives a slow reconcile or a paused runner,
|
||||
/// short enough that a *dead* plugin stops vetoing a session end within a couple of minutes. The
|
||||
/// cost of expiring too early is the pre-existing behaviour (scan-only); the cost of never expiring
|
||||
/// is a session that can never end on its own, which is the bug this whole area exists to kill.
|
||||
pub const REPORT_TTL: Duration = Duration::from_secs(90);
|
||||
|
||||
/// What a provider says about one of its titles.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Liveness {
|
||||
/// Whether the provider lists this title as running right now.
|
||||
pub running: bool,
|
||||
/// The pid the provider started for it, when it knows one. Never trusted as a bare number —
|
||||
/// every use re-verifies it through [`crate::procscan`], which pins it to its start time.
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// One provider's most recent report.
|
||||
struct Report {
|
||||
/// When it landed — the TTL clock.
|
||||
at: Instant,
|
||||
/// Every library id this provider speaks for. What makes "not in `running`" mean *not running*
|
||||
/// rather than *no opinion*: without it an omitted title is indistinguishable from a title
|
||||
/// belonging to some other provider entirely.
|
||||
owned: HashSet<String>,
|
||||
/// The subset that is running, each with the pid the provider started (when it has one).
|
||||
running: HashMap<String, Option<u32>>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn fresh(&self) -> bool {
|
||||
self.at.elapsed() < REPORT_TTL
|
||||
}
|
||||
}
|
||||
|
||||
fn table() -> MutexGuard<'static, HashMap<String, Report>> {
|
||||
static TABLE: OnceLock<Mutex<HashMap<String, Report>>> = OnceLock::new();
|
||||
TABLE
|
||||
.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Record a provider's report, replacing whatever it said before.
|
||||
///
|
||||
/// `owned` is every library id the provider currently publishes; `running` is the subset that is
|
||||
/// running, keyed the same way, valued by pid where one is known.
|
||||
pub fn report(provider: &str, owned: HashSet<String>, running: HashMap<String, Option<u32>>) {
|
||||
table().insert(
|
||||
provider.to_string(),
|
||||
Report {
|
||||
at: Instant::now(),
|
||||
owned,
|
||||
running,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Forget everything a provider said — its entries are gone, so its opinions are meaningless.
|
||||
pub fn forget(provider: &str) {
|
||||
table().remove(provider);
|
||||
}
|
||||
|
||||
/// What a *fresh* provider says about this library id, or `None` when none speaks for it.
|
||||
///
|
||||
/// `None` is the answer for every title on a host with no reporting plugin, which is what keeps
|
||||
/// this entirely inert until someone opts in.
|
||||
pub fn opinion(app_id: &str) -> Option<Liveness> {
|
||||
let table = table();
|
||||
table
|
||||
.values()
|
||||
.filter(|r| r.fresh())
|
||||
.find(|r| r.owned.contains(app_id))
|
||||
.map(|r| match r.running.get(app_id) {
|
||||
Some(pid) => Liveness {
|
||||
running: true,
|
||||
pid: *pid,
|
||||
},
|
||||
None => Liveness {
|
||||
running: false,
|
||||
pid: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any fresh provider reports liveness for this title at all — regardless of what it
|
||||
/// currently says.
|
||||
///
|
||||
/// Asked once, when a lease opens: a title whose provider will tell us when it stops is trackable
|
||||
/// even with no detect signals whatsoever, which is the whole point (see
|
||||
/// [`crate::gamelease::LeaseKind::Reported`]).
|
||||
pub fn speaks_for(app_id: Option<&str>) -> bool {
|
||||
app_id.is_some_and(|id| opinion(id).is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn owned(ids: &[&str]) -> HashSet<String> {
|
||||
ids.iter().map(|s| (*s).to_string()).collect()
|
||||
}
|
||||
|
||||
fn running(ids: &[(&str, Option<u32>)]) -> HashMap<String, Option<u32>> {
|
||||
ids.iter().map(|(s, p)| ((*s).to_string(), *p)).collect()
|
||||
}
|
||||
|
||||
// The table is process-global and these tests run in parallel, so each takes a provider id and
|
||||
// app ids only it uses, and cleans up only its own row. An earlier draft shared the id
|
||||
// `playnite` and cleared the whole table between cases, which made the three of them flip each
|
||||
// other's answers depending on scheduling — the same shape as `mgmt`'s `local_summary` race.
|
||||
|
||||
/// The three answers, and the distinction the whole module turns on: a title its provider omits
|
||||
/// is *not running*, while a title nobody speaks for has *no opinion*. Conflating them would
|
||||
/// make every unreported game on the box look like it had just quit.
|
||||
#[test]
|
||||
fn omitted_is_not_running_but_unknown_is_no_opinion() {
|
||||
report(
|
||||
"answers-test",
|
||||
owned(&["answers:a", "answers:b"]),
|
||||
running(&[("answers:a", Some(4242))]),
|
||||
);
|
||||
assert_eq!(
|
||||
opinion("answers:a"),
|
||||
Some(Liveness {
|
||||
running: true,
|
||||
pid: Some(4242)
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
opinion("answers:b"),
|
||||
Some(Liveness {
|
||||
running: false,
|
||||
pid: None
|
||||
})
|
||||
);
|
||||
assert_eq!(opinion("answers:never-published"), None);
|
||||
assert!(speaks_for(Some("answers:b")));
|
||||
assert!(!speaks_for(Some("answers:never-published")));
|
||||
assert!(!speaks_for(None));
|
||||
forget("answers-test");
|
||||
}
|
||||
|
||||
/// A report replaces its predecessor wholesale. The set is the message: a title that dropped out
|
||||
/// of it has stopped, and carrying the old entry forward would be exactly the stuck-running
|
||||
/// state this exists to prevent.
|
||||
#[test]
|
||||
fn a_report_replaces_the_previous_one() {
|
||||
report(
|
||||
"replace-test",
|
||||
owned(&["replace:a"]),
|
||||
running(&[("replace:a", None)]),
|
||||
);
|
||||
report("replace-test", owned(&["replace:a"]), running(&[]));
|
||||
assert_eq!(
|
||||
opinion("replace:a"),
|
||||
Some(Liveness {
|
||||
running: false,
|
||||
pid: None
|
||||
})
|
||||
);
|
||||
forget("replace-test");
|
||||
assert_eq!(opinion("replace:a"), None);
|
||||
}
|
||||
|
||||
/// A stale report stops counting — the bound that makes it safe to let a plugin's claim hold a
|
||||
/// session open. Seeded with an aged timestamp rather than by sleeping for 90 seconds.
|
||||
#[test]
|
||||
fn a_stale_report_has_no_opinion() {
|
||||
table().insert(
|
||||
"stale-test".to_string(),
|
||||
Report {
|
||||
at: Instant::now() - REPORT_TTL - Duration::from_secs(1),
|
||||
owned: owned(&["stale:a"]),
|
||||
running: running(&[("stale:a", Some(7))]),
|
||||
},
|
||||
);
|
||||
assert_eq!(opinion("stale:a"), None);
|
||||
assert!(!speaks_for(Some("stale:a")));
|
||||
forget("stale-test");
|
||||
}
|
||||
}
|
||||
@@ -770,8 +770,24 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
// (security-review 2026-08-05 H-3). Same host, same certificate, different port — which is
|
||||
// what makes it a different origin to the browser while staying same-site for the session
|
||||
// cookie. Without this rule, plugin interfaces simply do not load from another device.
|
||||
// Both rules are scoped to the bundled bun binary that actually listens on them, not left
|
||||
// open to any program: a port-only `dir=in action=allow` rule admits whatever binds the port
|
||||
// first, needs no elevation to do so, and suppresses the Windows prompt that would otherwise
|
||||
// be the only way in (see `service::fw_add_rule_args`). The console child is
|
||||
// `<app>/bun/bun.exe` — the same path `service.rs`'s supervisor spawns — so the rule follows
|
||||
// it. If that binary isn't there, fall back to the port-only rule rather than leaving the
|
||||
// console unreachable, and say which happened.
|
||||
let fw_profile =
|
||||
crate::service::firewall_profile_arg(crate::service::allow_public_network(args)?);
|
||||
let bun = app_dir.join("bun").join("bun.exe");
|
||||
let program = bun.exists().then_some(bun.as_path());
|
||||
if program.is_none() {
|
||||
eprintln!(
|
||||
"warning: {} not found — the console firewall rules stay open to any program on those \
|
||||
ports instead of only the console",
|
||||
bun.display()
|
||||
);
|
||||
}
|
||||
for (name, port) in [
|
||||
("Punktfunk web console (TCP 47992)", "47992"),
|
||||
("Punktfunk plugin UIs (TCP 47993)", "47993"),
|
||||
@@ -786,21 +802,13 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
&format!("name={name}"),
|
||||
],
|
||||
);
|
||||
if !run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
"advfirewall",
|
||||
"firewall",
|
||||
"add",
|
||||
"rule",
|
||||
&format!("name={name}"),
|
||||
"dir=in",
|
||||
"action=allow",
|
||||
"protocol=TCP",
|
||||
&format!("localport={port}"),
|
||||
fw_profile,
|
||||
],
|
||||
) {
|
||||
if !crate::service::run_netsh(&crate::service::fw_add_rule_args(
|
||||
name,
|
||||
"TCP",
|
||||
Some(port),
|
||||
program,
|
||||
fw_profile,
|
||||
)) {
|
||||
eprintln!("warning: could not add the firewall rule for TCP {port}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1550,14 +1550,79 @@ pub(crate) fn allow_public_network(args: &[String]) -> Result<bool> {
|
||||
Ok(fw_public_marker().exists())
|
||||
}
|
||||
|
||||
/// Build the `netsh advfirewall firewall add rule` argument vector for one inbound allow rule.
|
||||
///
|
||||
/// `program` is the whole point of this helper existing. A `dir=in action=allow` rule carrying only
|
||||
/// `localport=` admits **any process on the machine** on those ports, and binding a high port on
|
||||
/// Windows needs no elevation — so such a rule is a standing hole that any unprivileged program can
|
||||
/// step into simply by binding first, and it does so *silently*, because our rule is exactly what
|
||||
/// suppresses the "Allow this app to communicate on…" prompt Windows would otherwise raise (that
|
||||
/// prompt is the UAC gate; without a matching rule there is no way in without one). Naming the
|
||||
/// owning executable keeps the ports open for punktfunk and no one else. Reported by a user on
|
||||
/// 2026-08-21, and correct: the fixed rules were the last any-program ones we shipped.
|
||||
///
|
||||
/// `ports` stays alongside it rather than being replaced by it — program AND port is strictly
|
||||
/// tighter than either alone, and it is only ever dropped where the port genuinely cannot be known
|
||||
/// in advance ([`add_data_plane_firewall_rule`], whose port is ephemeral per session).
|
||||
///
|
||||
/// `None` for `program` reproduces the old any-program rule, and every caller falls back to it
|
||||
/// rather than skipping the rule when it cannot resolve its executable: a looser rule still streams,
|
||||
/// no rule at all is a black screen.
|
||||
pub(crate) fn fw_add_rule_args(
|
||||
name: &str,
|
||||
proto: &str,
|
||||
ports: Option<&str>,
|
||||
program: Option<&std::path::Path>,
|
||||
profile: &str,
|
||||
) -> Vec<String> {
|
||||
let mut args: Vec<String> = ["advfirewall", "firewall", "add", "rule"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
args.push(format!("name={name}"));
|
||||
args.push("dir=in".into());
|
||||
args.push("action=allow".into());
|
||||
args.push(format!("protocol={proto}"));
|
||||
if let Some(p) = ports {
|
||||
args.push(format!("localport={p}"));
|
||||
}
|
||||
if let Some(exe) = program {
|
||||
args.push(format!("program={}", exe.display()));
|
||||
}
|
||||
args.push(profile.to_string());
|
||||
args
|
||||
}
|
||||
|
||||
/// [`run_quiet`] for an arg vector built by [`fw_add_rule_args`].
|
||||
pub(crate) fn run_netsh(args: &[String]) -> bool {
|
||||
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
run_quiet("netsh", &borrowed)
|
||||
}
|
||||
|
||||
/// Inbound firewall rules for the streaming + mgmt ports (best-effort; logs but never fails the
|
||||
/// install). Scoped by [`firewall_profile_arg`]: Domain + Private by default, all profiles when
|
||||
/// `allow_public`. TCP 47990 is deliberate: `serve` binds the mgmt/library REST API to all interfaces
|
||||
/// so paired clients can browse the game library over mTLS, and off-loopback `mgmt::require_auth`
|
||||
/// exposes only the read-only status/library allowlist to a paired client cert — the bearer-token
|
||||
/// admin surface stays loopback-only regardless of the bind — so opening it adds no admin exposure.
|
||||
/// `allow_public`, and — since 2026-08-21 — to this host executable, so the ports below are open to
|
||||
/// punktfunk rather than to anything on the machine that binds them first (see
|
||||
/// [`fw_add_rule_args`]). TCP 47990 is deliberate: `serve` binds the mgmt/library REST API to all
|
||||
/// interfaces so paired clients can browse the game library over mTLS, and off-loopback
|
||||
/// `mgmt::require_auth` exposes only the read-only status/library allowlist to a paired client cert
|
||||
/// — the bearer-token admin surface stays loopback-only regardless of the bind — so opening it adds
|
||||
/// no admin exposure.
|
||||
fn add_firewall_rules(allow_public: bool) {
|
||||
let profile = firewall_profile_arg(allow_public);
|
||||
// Resolved once and shared with the data-plane rule below. `service install` re-runs this whole
|
||||
// remove-then-add on every upgrade, so a path recorded here cannot go stale behind a moved
|
||||
// install — which is what previously argued for leaving these rules unscoped.
|
||||
let exe = match std::env::current_exe() {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"warning: could not resolve the host executable path ({e}) — the rules below stay \
|
||||
open to any program on those ports, and the per-session data-plane rule is skipped"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
// (name suffix, protocol, ports). 47990 = mgmt/library (LAN = read-only, paired-cert only); the
|
||||
// rest are the GameStream (47984/47989/48010, 47998-48010) + native (9777) + mDNS (5353) ports.
|
||||
let rules = [
|
||||
@@ -1566,27 +1631,35 @@ fn add_firewall_rules(allow_public: bool) {
|
||||
];
|
||||
for (suffix, proto, ports) in rules {
|
||||
let name = format!("Punktfunk {suffix}");
|
||||
let ok = run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
"advfirewall",
|
||||
"firewall",
|
||||
"add",
|
||||
"rule",
|
||||
&format!("name={name}"),
|
||||
"dir=in",
|
||||
"action=allow",
|
||||
&format!("protocol={proto}"),
|
||||
&format!("localport={ports}"),
|
||||
profile,
|
||||
],
|
||||
);
|
||||
let ok = run_netsh(&fw_add_rule_args(
|
||||
&name,
|
||||
proto,
|
||||
Some(ports),
|
||||
exe.as_deref(),
|
||||
profile,
|
||||
));
|
||||
if ok {
|
||||
println!("Firewall rule added: {name} ({ports}) [{profile}]");
|
||||
let scope = match &exe {
|
||||
Some(p) => format!(" for {}", p.display()),
|
||||
None => String::new(),
|
||||
};
|
||||
println!("Firewall rule added: {name} ({ports}{scope}) [{profile}]");
|
||||
} else {
|
||||
eprintln!("warning: could not add firewall rule '{name}' (add it manually if needed)");
|
||||
}
|
||||
}
|
||||
add_data_plane_firewall_rule(profile, exe.as_deref());
|
||||
// 5353 is now ours alone. Anything else on this machine that answered mDNS through the old
|
||||
// any-program rule needs its own — say so, because it is the one externally visible change.
|
||||
// Only when the scoping actually happened: with no exe path these rules are still wide open,
|
||||
// and claiming otherwise in installer output is worse than saying nothing.
|
||||
if exe.is_some() {
|
||||
println!(
|
||||
"Note: these rules are scoped to the punktfunk host executable, so they no longer open \
|
||||
those ports to every program on this machine. Another mDNS/GameStream application \
|
||||
that relied on punktfunk's rules to be reachable now needs a rule of its own."
|
||||
);
|
||||
}
|
||||
if !allow_public {
|
||||
println!(
|
||||
"Note: streaming ports are open on Private/Domain networks only. On a network Windows \
|
||||
@@ -1596,7 +1669,69 @@ fn add_firewall_rules(allow_public: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule name for the program-scoped data-plane rule (see [`add_data_plane_firewall_rule`]).
|
||||
const FW_DATA_PLANE_RULE: &str = "Punktfunk UDP (data plane)";
|
||||
|
||||
/// Inbound UDP for the host executable itself, at **any** local port.
|
||||
///
|
||||
/// The media data plane binds an EPHEMERAL port per session (`0.0.0.0:0`, reported to the client in
|
||||
/// the Welcome), so no `localport=` rule can cover it — the port-scoped rules above open the fixed
|
||||
/// control/GameStream/mDNS ports and nothing else. Without this, Windows Firewall drops the client's
|
||||
/// hole-punch (`PUNCH_MAGIC` → the host's data port) on EVERY session: that is what `punched=false`
|
||||
/// on the host's "data plane bound" line means. The punch then never opens the return path, video
|
||||
/// falls back to blind-sending at the address the client merely *reported*, and the moment anything
|
||||
/// on the path needs the flow opened client-first the stream goes black while the control plane
|
||||
/// stays healthy — no reconnect, no error, just a session that never shows a picture.
|
||||
///
|
||||
/// Program-scoped rather than a pinned port: it covers whatever port the session picks, needs no
|
||||
/// second rule when the range moves, and cannot collide with another host (a pinned data port in
|
||||
/// 47998-48010 would land on Sunshine/Apollo's GameStream range). This rule is the pattern the
|
||||
/// fixed-port rules above now follow too — it is only the `localport=` they keep and this one
|
||||
/// cannot have.
|
||||
///
|
||||
/// `exe` is resolved once by the caller and shared; `None` means it could not be resolved, and this
|
||||
/// rule is skipped rather than widened, because a program-less "any inbound UDP on any port" rule is
|
||||
/// not a looser version of this — it is an open host.
|
||||
fn add_data_plane_firewall_rule(profile: &str, exe: Option<&std::path::Path>) {
|
||||
let Some(exe) = exe else {
|
||||
eprintln!(
|
||||
"warning: no host executable path — skipping the data-plane firewall rule; streams may \
|
||||
show a black picture behind a healthy connection on networks that need the client's \
|
||||
hole-punch to open the path"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let ok = run_netsh(&fw_add_rule_args(
|
||||
FW_DATA_PLANE_RULE,
|
||||
"UDP",
|
||||
None,
|
||||
Some(exe),
|
||||
profile,
|
||||
));
|
||||
if ok {
|
||||
println!(
|
||||
"Firewall rule added: {FW_DATA_PLANE_RULE} (any UDP port for {}) [{profile}]",
|
||||
exe.display()
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"warning: could not add firewall rule '{FW_DATA_PLANE_RULE}' — the per-session video \
|
||||
data port stays closed to inbound, so the client's hole-punch cannot reach it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_firewall_rules() {
|
||||
let _ = run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
"advfirewall",
|
||||
"firewall",
|
||||
"delete",
|
||||
"rule",
|
||||
&format!("name={FW_DATA_PLANE_RULE}"),
|
||||
],
|
||||
);
|
||||
for suffix in ["TCP", "UDP"] {
|
||||
// Capital P is the brand; netsh matches a rule name case-INSENSITIVELY, so this still
|
||||
// reaps the lowercase rules every release up to 0.22.1 created — no orphans on upgrade.
|
||||
@@ -1803,3 +1938,55 @@ fn maybe_boot_loop_rollback(restarts: u32, attempted: &mut bool) {
|
||||
Err(e) => tracing::error!(error = %e, "failed to spawn the rollback installer"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod firewall_tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
/// Every fixed-port rule must carry BOTH `program=` and `localport=`. Dropping the program
|
||||
/// scope is the regression that matters: the rule still works, streaming still works, and the
|
||||
/// only visible difference is that any unprivileged process on the machine can bind those
|
||||
/// ports and be reachable from the LAN without ever raising a Windows prompt.
|
||||
#[test]
|
||||
fn fixed_port_rules_are_scoped_to_the_program_and_the_ports() {
|
||||
let exe = Path::new(r"C:\Program Files\Punktfunk\punktfunk-host.exe");
|
||||
let args = fw_add_rule_args(
|
||||
"Punktfunk UDP",
|
||||
"UDP",
|
||||
Some("47998-48010,9777,5353"),
|
||||
Some(exe),
|
||||
"profile=domain,private",
|
||||
);
|
||||
assert!(args.contains(&format!("program={}", exe.display())));
|
||||
assert!(args.contains(&"localport=47998-48010,9777,5353".to_string()));
|
||||
assert!(args.contains(&"dir=in".to_string()));
|
||||
assert!(args.contains(&"action=allow".to_string()));
|
||||
assert!(args.contains(&"profile=domain,private".to_string()));
|
||||
assert_eq!(&args[..4], &["advfirewall", "firewall", "add", "rule"]);
|
||||
}
|
||||
|
||||
/// The data plane is the one rule that legitimately has no port: its socket binds `0.0.0.0:0`
|
||||
/// per session. It must therefore never lose its program scope — a program-less "any inbound
|
||||
/// UDP on any port" rule is not a looser version of this rule, it is an open host.
|
||||
#[test]
|
||||
fn the_data_plane_rule_has_a_program_but_no_port() {
|
||||
let exe = Path::new(r"C:\Program Files\Punktfunk\punktfunk-host.exe");
|
||||
let args = fw_add_rule_args(FW_DATA_PLANE_RULE, "UDP", None, Some(exe), "profile=any");
|
||||
assert!(args.contains(&format!("program={}", exe.display())));
|
||||
assert!(
|
||||
!args.iter().any(|a| a.starts_with("localport=")),
|
||||
"the per-session data port is ephemeral — pinning one would close the others"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unresolvable executable falls back to the old any-program rule rather than to no rule:
|
||||
/// a looser rule still streams, a missing one is a black screen. Pinned so the fallback stays
|
||||
/// deliberate rather than becoming an accident.
|
||||
#[test]
|
||||
fn a_missing_program_falls_back_to_the_port_only_rule() {
|
||||
let args = fw_add_rule_args("Punktfunk TCP", "TCP", Some("47990"), None, "profile=any");
|
||||
assert!(!args.iter().any(|a| a.starts_with("program=")));
|
||||
assert!(args.contains(&"localport=47990".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -185,11 +185,11 @@
|
||||
"id": "windows-client",
|
||||
"name": "Windows client",
|
||||
"installs": "client",
|
||||
"packageManager": "msix",
|
||||
"packageManager": "installer",
|
||||
"docs": "/docs/install-client#windows",
|
||||
"install": [
|
||||
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix",
|
||||
"Add-AppxPackage .\\punktfunk-client-windows_x64.msix"
|
||||
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-setup_x64.exe",
|
||||
".\\punktfunk-client-setup_x64.exe"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,7 +32,8 @@ track per machine; switching is a one-line change.
|
||||
| **pacman** (Arch host/client) | `[punktfunk-canary]` repo section | `[punktfunk]` (`Server = …/api/packages/unom/arch/$repo/$arch`) |
|
||||
| **Flatpak** (client) | `flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref` | `…/io.unom.Punktfunk.flatpakref` |
|
||||
| **Decky** (Steam Deck) | install-from-URL `…/generic/punktfunk-decky/canary/punktfunk.zip` | `…/punktfunk-decky/latest/punktfunk.zip` |
|
||||
| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page |
|
||||
| **Windows client** (installer) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-setup_x64.exe` | `…/latest/…` + the release page |
|
||||
| **Windows client** (MSIX / portable zip) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` (or `…_x64-portable.zip`) | `…/latest/…` + the release page |
|
||||
| **Windows host** (installer) | `…/generic/punktfunk-host-windows/canary/punktfunk-host-setup.exe` | `…/latest/…` + the release page |
|
||||
| **Windows host** (winget) | — *(stable only)* | `winget install unom.PunktfunkHost` / `winget upgrade unom.PunktfunkHost`, after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest` |
|
||||
| **Android** | Play **Internal testing** (invite-only) + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** (production) + the release page |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user